Refactor management UX and move workspace status to toasts

This commit is contained in:
2026-02-26 12:38:19 +01:00
parent 017fc37b1d
commit 15c046bcac
7 changed files with 214 additions and 75 deletions

8
FAQ.md
View File

@@ -77,6 +77,10 @@ Authenticated application state and behavior were moved into `Components/Pages/W
`Workspace` initializes authenticated session data after the first render (`OnAfterRenderAsync`). During that first render pass, the header now intentionally shows a null-safe fallback label instead of dereferencing user fields before `/api/me` has been loaded. `Workspace` initializes authenticated session data after the first render (`OnAfterRenderAsync`). During that first render pass, the header now intentionally shows a null-safe fallback label instead of dereferencing user fields before `/api/me` has been loaded.
## Where did workspace success/error messages go?
Inline status message rows in the workspace were replaced with timed toast notifications. This keeps the main layout compact while still surfacing operation outcomes.
## Where did Play/Campaign Management switching move? ## Where did Play/Campaign Management switching move?
Screen switching is now inside the header hamburger menu. The menu exposes `Play` and `Campaign Management` options while keeping the top bar compact. Screen switching is now inside the header hamburger menu. The menu exposes `Play` and `Campaign Management` options while keeping the top bar compact.
@@ -110,3 +114,7 @@ Auth inputs, validation, and submit workflows are transient UI concerns, so they
## Why are there `.razor.cs` files next to Razor components? ## Why are there `.razor.cs` files next to Razor components?
Component behavior was moved out of inline `@code` blocks into code-behind classes so `.razor` files stay markup-focused while state, parameters, handlers, and injected services live in typed C# files. Component behavior was moved out of inline `@code` blocks into code-behind classes so `.razor` files stay markup-focused while state, parameters, handlers, and injected services live in typed C# files.
## How do I add campaigns and characters in Campaign Management now?
Campaign creation is launched from a compact `Add campaign` row button that opens a modal form. Character management is directly below the campaign card and uses per-row edit chips plus an `Add character` row button for create flow.

View File

@@ -12,6 +12,7 @@ Tracking against `UX.md` tasks and decisions.
- Workspace header user identity rendering is now null-safe during first render (`Loading user...` fallback until `/api/me` loads). - Workspace header user identity rendering is now null-safe during first render (`Loading user...` fallback until `/api/me` loads).
- Workspace header was compacted into a single horizontal row with hamburger menu screen switching and link-style logout. - Workspace header was compacted into a single horizontal row with hamburger menu screen switching and link-style logout.
- Header alignment was tightened so connection status occupies the growing middle cell and the hamburger menu remains pinned to the right edge. - Header alignment was tightened so connection status occupies the growing middle cell and the hamburger menu remains pinned to the right edge.
- Workspace status/error feedback moved from inline messages to timed toast notifications.
- Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh. - Concern controls now own their local form state and mutation workflows; the workspace host handles shared cross-control state refresh.
- Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together). - Skill create/edit flow is now owned by `CharacterPanel` (where characters and their skills are presented together).
- Skill interactions are now row-local chip actions (edit/roll) with an inline dummy `+` row for create-skill. - Skill interactions are now row-local chip actions (edit/roll) with an inline dummy `+` row for create-skill.
@@ -29,7 +30,7 @@ Tracking against `UX.md` tasks and decisions.
| 9.3 Shared authenticated header | Implemented | Compact single-row header with user/campaign context, growing connection-status cell, right-aligned hamburger screen switch, and link-style logout. | | 9.3 Shared authenticated header | Implemented | Compact single-row header with user/campaign context, growing connection-status cell, right-aligned hamburger screen switch, and link-style logout. |
| 9.4 Play screen character column | Implemented | Compact character picker, merged character+skills header row, modal edit/create flows, inline per-skill edit/roll chips, d6 skill options (wild/fumble), and no separate last-roll panel. | | 9.4 Play screen character column | Implemented | Compact character picker, merged character+skills header row, modal edit/create flows, inline per-skill edit/roll chips, d6 skill options (wild/fumble), and no separate last-roll panel. |
| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), per-entry dice visualization with die-state flags, local time + ISO tooltip. | | 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), per-entry dice visualization with die-state flags, local time + ISO tooltip. |
| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. | | 9.6 Campaign management screen | Implemented | Campaign selector and details are merged into one card, campaign create moved behind an inline `Add campaign` row opening a modal, and character management sits beneath with chip-style edit actions plus an inline `Add character` row. |
| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. | | 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. |
| 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. | | 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. |
| 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. | | 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. |

View File

@@ -1,6 +1,8 @@
<main class="management-screen"> <main class="management-screen">
<section class="card"> <section class="card">
<h2>Campaign Selector</h2> <div class="section-head">
<h2>Campaign</h2>
</div>
@if (Campaigns.Count == 0) @if (Campaigns.Count == 0)
{ {
<p class="empty">No campaigns yet.</p> <p class="empty">No campaigns yet.</p>
@@ -11,47 +13,11 @@
<select id="campaign-select" @onchange="CampaignSelectionChanged"> <select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns) @foreach (var campaign in Campaigns)
{ {
<option value="@campaign.Id" <option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)
</option>
} }
</select> </select>
} }
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
</section>
<section class="card">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
{
<p class="form-error">@CampaignState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit"
disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
</form>
</section>
<section class="card">
<h2>Campaign Details</h2>
@if (SelectedCampaign is null) @if (SelectedCampaign is null)
{ {
<p class="empty">No campaign selected.</p> <p class="empty">No campaign selected.</p>
@@ -60,44 +26,102 @@
{ {
<p>Name: <strong>@SelectedCampaign.Name</strong></p> <p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p> <p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span <p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p> <p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
} }
<button type="button"
class="add-row-button"
disabled="@(IsMutating || IsCreatingCampaign)"
@onclick="OpenCreateCampaignModal">
<span class="add-row-icon" aria-hidden="true">+</span>
<span>Add campaign</span>
</button>
</section> </section>
<section class="card"> <section class="card">
<div class="section-head"> <div class="section-head">
<h2>Character Management</h2> <h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || IsCreatingCampaign || SelectedCampaign is null)"
@onclick="CreateCharacterRequested">Create Character
</button>
</div> </div>
@if (SelectedCampaign is null) @if (SelectedCampaign is null)
{ {
<p class="empty">Select a campaign first.</p> <p class="empty">Select a campaign first.</p>
} }
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else else
{ {
<ul class="management-list"> @if (SelectedCampaign.Characters.Count == 0)
@foreach (var character in SelectedCampaign.Characters) {
{ <p class="empty">No characters in this campaign yet.</p>
<li> }
<div><strong>@character.Name</strong> else
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div> {
<div class="inline-actions"> <ul class="management-list">
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div>
<strong>@character.Name</strong>
<p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p>
</div>
<button type="button" <button type="button"
class="chip-button"
title="Edit character"
disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))" disabled="@(IsMutating || IsCreatingCampaign || !CanEditCharacter(character))"
@onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit @onclick="() => EditCharacterRequested.InvokeAsync(character)">
<span aria-hidden="true">✎</span>
<span class="sr-only">Edit @character.Name</span>
</button> </button>
</div> </li>
</li> }
} </ul>
</ul> }
<button type="button"
class="add-row-button"
disabled="@(IsMutating || IsCreatingCampaign)"
@onclick="CreateCharacterRequested">
<span class="add-row-icon" aria-hidden="true">+</span>
<span>Add character</span>
</button>
} }
</section> </section>
</main> </main>
@if (ShowCreateCampaignModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create Campaign">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
{
<p class="form-error">@CampaignState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitCreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput"/>
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsCreatingCampaign)">@(IsCreatingCampaign ? "Creating..." : "Create Campaign")</button>
<button type="button" class="ghost" disabled="@(IsMutating || IsCreatingCampaign)" @onclick="CloseCreateCampaignModal">Cancel</button>
</div>
</form>
</section>
</div>
}

View File

@@ -13,6 +13,22 @@ public partial class CampaignManagementPanel
CampaignState.Model.RulesetId = Rulesets[0].Id; CampaignState.Model.RulesetId = Rulesets[0].Id;
} }
private void OpenCreateCampaignModal()
{
CampaignState.Model.Name = string.Empty;
if (string.IsNullOrWhiteSpace(CampaignState.Model.RulesetId) && Rulesets.Count > 0)
CampaignState.Model.RulesetId = Rulesets[0].Id;
CampaignState.ResetValidation();
ShowCreateCampaignModal = true;
}
private void CloseCreateCampaignModal()
{
CampaignState.ResetValidation();
ShowCreateCampaignModal = false;
}
private async Task SubmitCreateCampaignAsync() private async Task SubmitCreateCampaignAsync()
{ {
CampaignState.ResetValidation(); CampaignState.ResetValidation();
@@ -35,6 +51,7 @@ public partial class CampaignManagementPanel
var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId)); var campaign = await ApiClient.RequestAsync<CampaignSummary>("POST", "/api/campaigns", new CreateCampaignRequest(CampaignState.Model.Name.Trim(), CampaignState.Model.RulesetId));
CampaignState.Model.Name = string.Empty; CampaignState.Model.Name = string.Empty;
ShowCreateCampaignModal = false;
await CampaignCreated.InvokeAsync(campaign.Id); await CampaignCreated.InvokeAsync(campaign.Id);
} }
catch (ApiRequestException ex) catch (ApiRequestException ex)
@@ -52,6 +69,7 @@ public partial class CampaignManagementPanel
private FormState<CampaignFormModel> CampaignState { get; } = new(); private FormState<CampaignFormModel> CampaignState { get; } = new();
private bool IsCreatingCampaign { get; set; } private bool IsCreatingCampaign { get; set; }
private bool ShowCreateCampaignModal { get; set; }
[Parameter] [Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = []; public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
@@ -59,9 +77,6 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public Guid? SelectedCampaignId { get; set; } public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter] [Parameter]
public CampaignDetails? SelectedCampaign { get; set; } public CampaignDetails? SelectedCampaign { get; set; }
@@ -88,4 +103,4 @@ public partial class CampaignManagementPanel
[Parameter] [Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
} }

View File

@@ -60,11 +60,6 @@
</div> </div>
</header> </header>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
@if (IsPlayScreen) @if (IsPlayScreen)
{ {
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")"> <main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
@@ -114,7 +109,6 @@
<CampaignManagementPanel <CampaignManagementPanel
Campaigns="Campaigns" Campaigns="Campaigns"
SelectedCampaignId="SelectedCampaignId" SelectedCampaignId="SelectedCampaignId"
SelectedCampaignName="SelectedCampaignName"
SelectedCampaign="SelectedCampaign" SelectedCampaign="SelectedCampaign"
Rulesets="Rulesets" Rulesets="Rulesets"
IsMutating="IsMutating" IsMutating="IsMutating"
@@ -126,6 +120,18 @@
EditCharacterRequested="OpenEditCharacterModal"/> EditCharacterRequested="OpenEditCharacterModal"/>
} }
</div> </div>
@if (Toasts.Count > 0)
{
<div class="toast-stack" aria-live="polite" aria-atomic="false">
@foreach (var toast in Toasts)
{
<div class="toast @(toast.IsError ? "error" : "success")" role="status">
<p>@toast.Message</p>
</div>
}
</div>
}
</div> </div>
<CharacterFormModal <CharacterFormModal

View File

@@ -622,12 +622,12 @@ public partial class Workspace : IAsyncDisposable
EditCharacterInitialModel = new(); EditCharacterInitialModel = new();
CreateCharacterFormVersion = 0; CreateCharacterFormVersion = 0;
EditCharacterFormVersion = 0; EditCharacterFormVersion = 0;
Toasts.Clear();
} }
private void SetStatus(string message, bool isError) private void SetStatus(string message, bool isError)
{ {
StatusMessage = message; AddToast(message, isError);
StatusIsError = isError;
Announce(message); Announce(message);
} }
@@ -636,6 +636,29 @@ public partial class Workspace : IAsyncDisposable
LiveAnnouncement = message; LiveAnnouncement = message;
} }
private void AddToast(string message, bool isError)
{
var toastId = Guid.NewGuid();
Toasts.Add(new WorkspaceToast(toastId, message, isError));
_ = DismissToastLaterAsync(toastId);
}
private async Task DismissToastLaterAsync(Guid toastId)
{
await Task.Delay(ToastDurationMs);
if (Toasts.RemoveAll(toast => toast.Id == toastId) == 0)
return;
try
{
await InvokeAsync(StateHasChanged);
}
catch (ObjectDisposedException)
{
}
}
private void ToggleScreenMenu() private void ToggleScreenMenu()
{ {
IsScreenMenuOpen = !IsScreenMenuOpen; IsScreenMenuOpen = !IsScreenMenuOpen;
@@ -662,8 +685,7 @@ public partial class Workspace : IAsyncDisposable
private bool IsCampaignDataLoading { get; set; } private bool IsCampaignDataLoading { get; set; }
private bool HasHealthIssue { get; set; } private bool HasHealthIssue { get; set; }
private string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; private string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
private string? StatusMessage { get; set; } private List<WorkspaceToast> Toasts { get; } = [];
private bool StatusIsError { get; set; }
private string CurrentScreen { get; set; } = "play"; private string CurrentScreen { get; set; } = "play";
private string MobilePanel { get; set; } = "character"; private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline"; private string ConnectionState { get; set; } = "offline";
@@ -721,4 +743,7 @@ public partial class Workspace : IAsyncDisposable
private const string ScreenSessionKey = "screen"; private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign"; private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel"; private const string MobilePanelSessionKey = "play-panel";
private const int ToastDurationMs = 3200;
private sealed record WorkspaceToast(Guid Id, string Message, bool IsError);
} }

View File

@@ -250,7 +250,7 @@ select:focus-visible {
.management-screen { .management-screen {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 1rem; gap: 1rem;
} }
@@ -666,6 +666,31 @@ select:focus-visible {
padding: 0.5rem; padding: 0.5rem;
} }
.management-list li p {
margin: 0;
}
.add-row-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
align-self: flex-start;
background: #f9f2e2;
color: var(--text);
border: 1px solid #b39f79;
}
.add-row-icon {
width: 1.2rem;
height: 1.2rem;
border: 1px solid #8e7b57;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.modal-overlay { .modal-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -699,6 +724,41 @@ select:focus-visible {
border-top: 1px solid var(--card-border); border-top: 1px solid var(--card-border);
} }
.toast-stack {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 60;
display: grid;
gap: 0.45rem;
width: min(22rem, calc(100vw - 2rem));
}
.toast {
border-radius: 0.6rem;
border: 1px solid;
padding: 0.55rem 0.7rem;
box-shadow: 0 6px 14px rgba(34, 24, 9, 0.22);
backdrop-filter: blur(4px);
}
.toast p {
margin: 0;
font-weight: 700;
}
.toast.success {
background: #e8f7e8;
border-color: #78a978;
color: #1f5425;
}
.toast.error {
background: #ffe9e5;
border-color: #bb6e62;
color: #7f2015;
}
.sr-only { .sr-only {
position: absolute; position: absolute;
width: 1px; width: 1px;