Detach custom roll panel from log feed

This commit is contained in:
2026-04-04 20:08:44 +02:00
parent 9e6e6fe8c7
commit 22ee512cb7
4 changed files with 93 additions and 73 deletions

View File

@@ -67,7 +67,7 @@ Gameplay capabilities now include:
- Rolemaster create/edit forms now keep the expression authoritative, show generic Rolemaster syntax help, and reveal `FumbleRange` only when the expression is an open-ended percentile roll
- Rolemaster roll execution now supports generic standard Rolemaster rolls (`NdS+x`, with implicit count `1` for `dS`) plus open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange`; low-end trigger rolls are shown for auditability but do not count toward the total
- Compact campaign-log summaries stay dense for Rolemaster rolls, while lazy-loaded roll detail includes ordered die metadata for each open-ended follow-up step
- Play screen campaign logs now include a bottom-mounted custom roll composer that records arbitrary expressions against the selected character without creating a skill; invalid expressions stay inline on the field with tooltip/error styling instead of using error toasts
- Play screen campaign logs now include a detached bottom custom-roll panel below the scrollable log feed; it records arbitrary expressions against the selected character without creating a skill, and invalid expressions stay inline on the field with tooltip/error styling instead of using error toasts
- Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete
## Prerequisites

View File

@@ -1,80 +1,83 @@
<aside @ref="LogPanelRef" class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
}
else if (CampaignLog.Count == 0)
{
<p class="empty">No log entries yet.</p>
}
else
{
<ul class="log-list">
@foreach (var entry in CampaignLog)
{
var isExpanded = ExpandedRollId == entry.RollId;
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
<button type="button"
class="log-entry-toggle"
aria-expanded="@isExpanded"
@onclick="() => ToggleRollDetailRequested.InvokeAsync(entry.RollId)">
<span class="log-entry-main">
<span class="log-entry-copy">
<span class="log-entry-actor">@entry.RollerLabel</span>
<span class="log-entry-action">rolled</span>
<span class="log-entry-skill">@entry.SkillName</span>
<span class="log-entry-action">with</span>
<span class="log-entry-character">@entry.CharacterName</span>
<div @ref="LogFeedRef" class="log-panel-feed">
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack">
<div class="skeleton-line"></div>
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
}
else if (CampaignLog.Count == 0)
{
<p class="empty">No log entries yet.</p>
}
else
{
<ul class="log-list">
@foreach (var entry in CampaignLog)
{
var isExpanded = ExpandedRollId == entry.RollId;
<li class="log-entry @LogEntryCssClass(entry, isExpanded, FreshRollId == entry.RollId)">
<button type="button"
class="log-entry-toggle"
aria-expanded="@isExpanded"
@onclick="() => ToggleRollDetailRequested.InvokeAsync(entry.RollId)">
<span class="log-entry-main">
<span class="log-entry-copy">
<span class="log-entry-actor">@entry.RollerLabel</span>
<span class="log-entry-action">rolled</span>
<span class="log-entry-skill">@entry.SkillName</span>
<span class="log-entry-action">with</span>
<span class="log-entry-character">@entry.CharacterName</span>
</span>
<span class="roll-total inline">@entry.Result</span>
</span>
<span class="roll-total inline">@entry.Result</span>
</span>
@if (HasSummary(entry))
@if (HasSummary(entry))
{
<span class="log-summary-row">
@foreach (var badge in GetEventBadges(entry))
{
<span class="log-event-badge @badge.Tone">@badge.Label</span>
}
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
{
<span class="log-summary-text">@entry.SummaryText</span>
}
</span>
}
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
</span>
</button>
@if (isExpanded)
{
<span class="log-summary-row">
@foreach (var badge in GetEventBadges(entry))
<div class="log-detail">
@if (IsRollDetailLoading(entry.RollId))
{
<span class="log-event-badge @badge.Tone">@badge.Label</span>
<p class="muted">Loading roll detail...</p>
}
@if (!string.IsNullOrWhiteSpace(entry.SummaryText))
else if (!string.IsNullOrWhiteSpace(GetRollDetailError(entry.RollId)))
{
<span class="log-summary-text">@entry.SummaryText</span>
<p class="field-error">@GetRollDetailError(entry.RollId)</p>
}
</span>
else if (ResolveRollDetail(entry.RollId) is { } detail)
{
<RollDiceStrip Dice="detail.Dice" AriaLabel="Log roll dice"/>
<p>@detail.Breakdown</p>
}
</div>
}
<span class="log-meta"><span class="badge @entry.VisibilityStyle">@entry.VisibilityLabel</span>
<time
title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time>
</span>
</button>
@if (isExpanded)
{
<div class="log-detail">
@if (IsRollDetailLoading(entry.RollId))
{
<p class="muted">Loading roll detail...</p>
}
else if (!string.IsNullOrWhiteSpace(GetRollDetailError(entry.RollId)))
{
<p class="field-error">@GetRollDetailError(entry.RollId)</p>
}
else if (ResolveRollDetail(entry.RollId) is { } detail)
{
<RollDiceStrip Dice="detail.Dice" AriaLabel="Log roll dice"/>
<p>@detail.Breakdown</p>
}
</div>
}
</li>
}
</ul>
}
</li>
}
</ul>
}
</div>
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
<section class="custom-roll-panel" aria-label="Custom roll panel">
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
<div class="custom-roll-composer-head">
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
<span class="muted">@CustomRollStatusText</span>
@@ -98,5 +101,6 @@
{
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
}
</form>
</form>
</section>
</aside>

View File

@@ -23,7 +23,7 @@ public partial class CampaignLogPanel
{
try
{
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogPanelRef);
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogFeedRef);
}
catch (JSDisconnectedException)
{
@@ -44,6 +44,7 @@ public partial class CampaignLogPanel
private RpgRollerApiClient ApiClient { get; set; } = null!;
private ElementReference LogPanelRef { get; set; }
private ElementReference LogFeedRef { get; set; }
private ElementReference CustomRollInputRef { get; set; }
private int LastRenderedLogCount { get; set; }
private Guid? LastRenderedLogRollId { get; set; }

View File

@@ -336,12 +336,21 @@ select:focus-visible {
min-height: 0;
display: grid;
gap: 1rem;
align-content: start;
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
}
.app-play .log-panel {
overflow: hidden;
}
.log-panel-feed {
min-height: 0;
display: grid;
align-content: start;
overflow-y: auto;
overscroll-behavior: contain;
padding-right: 0.15rem;
}
.skill-list {
@@ -618,11 +627,17 @@ select:focus-visible {
gap: 0.7rem;
}
.custom-roll-panel {
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%);
background: color-mix(in srgb, var(--card) 88%, #ffffff 12%);
border-radius: 0.95rem;
padding: 0.85rem 0.9rem 0.9rem;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.45);
}
.custom-roll-composer {
display: grid;
gap: 0.45rem;
padding-top: 0.2rem;
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%);
}
.custom-roll-composer-head {