Overhaul workspace UX for denser play workflow

This commit is contained in:
2026-02-26 11:53:36 +01:00
parent e7114d8798
commit c3aa0d4e88
10 changed files with 355 additions and 160 deletions

14
FAQ.md
View File

@@ -77,6 +77,20 @@ 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 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.
## How do I create, edit, and roll skills in the Play column now?
Skills now use inline row chip actions:
- `✎` chip on each skill row to edit that skill
- `⚄` chip on each skill row to roll immediately
- a final `+` dummy row styled like a skill row to create a new skill
Roll visibility remains controlled in the skills header row.
## Why is auth form state kept in `AuthSection` instead of `Home`? ## Why is auth form state kept in `AuthSection` instead of `Home`?
Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only. Auth inputs, validation, and submit workflows are transient UI concerns, so they now live in `AuthSection`. `Home` keeps shared session/workspace state and cross-control refresh/orchestration only.

View File

@@ -10,8 +10,11 @@ Tracking against `UX.md` tasks and decisions.
- Home was simplified to a minimal gateway (`Loading` / `Anonymous` / `Workspace`) in a single `Home.razor.cs` class. - Home was simplified to a minimal gateway (`Loading` / `Anonymous` / `Workspace`) in a single `Home.razor.cs` class.
- The authenticated workspace shell/state/behavior was moved to `Components/Pages/Workspace.razor`. - The authenticated workspace shell/state/behavior was moved to `Components/Pages/Workspace.razor`.
- 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.
- 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.
- Campaign log now auto-scrolls to the newest entry when new entries arrive.
## UX Checklist ## UX Checklist
@@ -19,8 +22,8 @@ Tracking against `UX.md` tasks and decisions.
|---|---|---| |---|---|---|
| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. | | 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. |
| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. | | 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. |
| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. | | 9.3 Shared authenticated header | Implemented | Compact single-row header with user/campaign/connection context, hamburger menu screen switch, and link-style logout. |
| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, selected-tab-as-active behavior, skill list, d6 skill options (wild/fumble), roll controls, and die-state visualized last roll card. | | 9.4 Play screen character column | Implemented | Character icon tabs, merged character+skills header row, modal edit/create flows, inline per-skill edit/roll chips, d6 skill options (wild/fumble), and die-state visualized last roll card. |
| 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/summary, create form, details card, character management actions with modal edit pattern. |
| 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. |

View File

@@ -1,4 +1,4 @@
<aside class="card log-panel"> <aside @ref="LogPanelRef" class="card log-panel">
<div class="section-head"><h2>Campaign Log</h2></div> <div class="section-head"><h2>Campaign Log</h2></div>
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {

View File

@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Contracts; using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls; namespace RpgRoller.Components.Pages.HomeControls;
@@ -7,6 +8,37 @@ namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]
public partial class CampaignLogPanel public partial class CampaignLogPanel
{ {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (IsCampaignDataLoading || CampaignLog.Count == 0)
{
LastRenderedLogCount = CampaignLog.Count;
return;
}
if (firstRender || CampaignLog.Count > LastRenderedLogCount)
{
try
{
await JS.InvokeVoidAsync("rpgRollerApi.scrollElementToBottom", LogPanelRef);
}
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (ex.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase))
{
}
}
LastRenderedLogCount = CampaignLog.Count;
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
private ElementReference LogPanelRef { get; set; }
private int LastRenderedLogCount { get; set; }
[Parameter] [Parameter]
public bool IsCampaignDataLoading { get; set; } public bool IsCampaignDataLoading { get; set; }
@@ -30,4 +62,4 @@ public partial class CampaignLogPanel
[Parameter] [Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty; public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
} }

View File

@@ -1,5 +1,4 @@
<section class="card character-panel"> <section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading) @if (IsCampaignDataLoading)
{ {
<div class="skeleton-stack"> <div class="skeleton-stack">
@@ -31,27 +30,25 @@
</div> </div>
@if (SelectedCharacter is not null) @if (SelectedCharacter is not null)
{ {
<article class="character-sheet">
<h3>@SelectedCharacter.Name</h3>
<p>Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)</p>
<p>Campaign: @SelectedCampaign.Name</p>
<span class="badge active">Active</span>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">Edit Character
</button>
</div>
</article>
<article class="skills-section"> <article class="skills-section">
<div class="section-head"> <div class="section-head">
<h3>Skills</h3> <h3 class="skills-heading">@SelectedCharacter.Name <span
<div class="inline-actions"> class="muted">| Owner: @OwnerLabel(SelectedCharacter.OwnerUserId) | Campaign: @SelectedCampaign.Name</span>
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" </h3>
@onclick="OpenCreateSkillModal">Create Skill <div class="chip-toolbar">
</button> <label class="visibility-control" for="roll-visibility">Visibility</label>
<button type="button" <select id="roll-visibility" value="@RollVisibility" @onchange="OnRollVisibilityChangedAsync">
disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" <option value="public">Public</option>
@onclick="OpenEditSkillModal">Edit Skill <option value="private">Private</option>
</select>
<button
type="button"
class="chip-button"
title="Edit character"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="() => EditCharacterRequested.InvokeAsync(SelectedCharacter)">
<span aria-hidden="true">✎</span>
<span class="sr-only">Edit character</span>
</button> </button>
</div> </div>
</div> </div>
@@ -59,29 +56,49 @@
{ {
<p class="empty">No skills for this character yet.</p> <p class="empty">No skills for this character yet.</p>
} }
else <div class="skill-list">
{ @foreach (var skill in SelectedCharacterSkills)
<div class="skill-list"> {
@foreach (var skill in SelectedCharacterSkills) <div class="skill-item">
{ <div class="skill-details">
var isSelectedSkill = SelectedSkillId == skill.Id; <strong>@skill.Name</strong>
<button type="button" class="skill-item @(isSelectedSkill ? "active" : string.Empty)" <span>@SkillDefinitionLabel(skill)</span>
@onclick="() => SkillSelected.InvokeAsync(skill.Id)"> </div>
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span> <div class="skill-chip-actions">
</button> <button
} type="button"
</div> class="chip-button"
} title="Edit skill"
<form class="roll-panel" @onsubmit="OnRollSubmitAsync" @onsubmit:preventDefault> disabled="@(IsMutating || !CanEditSkill(skill))"
<label for="roll-visibility">Visibility</label> @onclick="() => OpenEditSkillModal(skill)">
<select id="roll-visibility" value="@RollVisibility" @onchange="OnRollVisibilityChangedAsync"> <span aria-hidden="true">✎</span>
<option value="public">Public</option> <span class="sr-only">Edit @skill.Name</span>
<option value="private">Private</option> </button>
</select> <button
<button type="submit" type="button"
disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill class="chip-button"
title="Roll skill"
disabled="@(IsMutating || !CanRollSkill(skill))"
@onclick="() => RollSkillAsync(skill.Id)">
<span aria-hidden="true">⚄</span>
<span class="sr-only">Roll @skill.Name</span>
</button>
</div>
</div>
}
<button
type="button"
class="skill-item create-skill-item"
disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))"
@onclick="OpenCreateSkillModal">
<span class="skill-create-icon" aria-hidden="true">+</span>
<span>Add skill</span>
</button> </button>
</form> </div>
@if (SelectedCharacterSkills.Count > 0)
{
<p class="muted">Use skill chips to edit or roll directly.</p>
}
</article> </article>
} }
} }

View File

@@ -21,18 +21,15 @@ public partial class CharacterPanel
ShowCreateSkillModal = true; ShowCreateSkillModal = true;
} }
private void OpenEditSkillModal() private void OpenEditSkillModal(SkillSummary skill)
{ {
if (SelectedSkill is null) EditingSkillId = skill.Id;
return;
EditingSkillId = SelectedSkill.Id;
EditSkillInitialModel = new() EditSkillInitialModel = new()
{ {
Name = SelectedSkill.Name, Name = skill.Name,
DiceRollDefinition = SelectedSkill.DiceRollDefinition, DiceRollDefinition = skill.DiceRollDefinition,
WildDice = SelectedSkill.WildDice, WildDice = skill.WildDice,
AllowFumble = SelectedSkill.AllowFumble AllowFumble = skill.AllowFumble
}; };
EditSkillFormVersion++; EditSkillFormVersion++;
@@ -64,9 +61,9 @@ public partial class CharacterPanel
await RollVisibilityChanged.InvokeAsync(selectedVisibility); await RollVisibilityChanged.InvokeAsync(selectedVisibility);
} }
private async Task OnRollSubmitAsync() private async Task RollSkillAsync(Guid skillId)
{ {
await RollRequested.InvokeAsync(); await RollRequested.InvokeAsync(skillId);
} }
private static string InitialsFor(string value) private static string InitialsFor(string value)
@@ -107,12 +104,6 @@ public partial class CharacterPanel
[Parameter] [Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = []; public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public Guid? SelectedSkillId { get; set; }
[Parameter]
public SkillSummary? SelectedSkill { get; set; }
[Parameter] [Parameter]
public bool IsD6 { get; set; } public bool IsD6 { get; set; }
@@ -143,9 +134,6 @@ public partial class CharacterPanel
[Parameter] [Parameter]
public EventCallback<Guid> CharacterSelected { get; set; } public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<Guid> SkillSelected { get; set; }
[Parameter] [Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; } public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
@@ -156,5 +144,5 @@ public partial class CharacterPanel
public EventCallback<Guid> SkillUpdated { get; set; } public EventCallback<Guid> SkillUpdated { get; set; }
[Parameter] [Parameter]
public EventCallback RollRequested { get; set; } public EventCallback<Guid> RollRequested { get; set; }
} }

View File

@@ -15,36 +15,47 @@
<div class="workspace-shell"> <div class="workspace-shell">
<header class="workspace-header"> <header class="workspace-header">
<div class="header-group brand"> <div class="header-row">
<h1>RpgRoller</h1> <h1>RpgRoller</h1>
<p>Tabletop utility cockpit</p>
</div>
<div class="header-group context">
@if (User is null) @if (User is null)
{ {
<p><strong>Loading user...</strong></p> <p class="header-identity"><strong>Loading user...</strong></p>
} }
else else
{ {
<p><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p> <p class="header-identity"><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
} }
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
</div>
<div class="header-group controls">
<p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p> <p class="connection @ConnectionStateCssClass">@ConnectionStateLabel</p>
<div class="switch-group" role="tablist" aria-label="Screen selector"> <p class="header-campaign">Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
<button type="button" class="switch @(CurrentScreen == "play" ? "active" : string.Empty)" <div class="header-menu-wrap">
@onclick="SwitchToPlayAsync">Play <button
</button> id="screen-menu-button"
<button type="button" class="switch @(CurrentScreen == "management" ? "active" : string.Empty)" type="button"
@onclick="SwitchToManagementAsync">Campaign Management class="menu-toggle"
aria-haspopup="true"
aria-expanded="@IsScreenMenuOpen"
aria-controls="screen-menu"
@onclick="ToggleScreenMenu">
<span aria-hidden="true">☰</span>
<span class="menu-toggle-label">@CurrentScreenLabel</span>
</button> </button>
@if (IsScreenMenuOpen)
{
<div id="screen-menu" class="screen-menu" role="menu" aria-labelledby="screen-menu-button">
<button type="button"
class="menu-item @(IsPlayScreen ? "active" : string.Empty)"
role="menuitem"
@onclick="SwitchToPlayAsync">Play
</button>
<button type="button"
class="menu-item @(IsManagementScreen ? "active" : string.Empty)"
role="menuitem"
@onclick="SwitchToManagementAsync">Campaign Management
</button>
</div>
}
</div> </div>
<div class="header-actions"> <a href="" class="logout-link" @onclick:preventDefault="true" @onclick="LogoutAsync">Logout</a>
<button type="button" @onclick="ManualRefreshAsync" disabled="@IsMutating">Refresh</button>
<button type="button" class="ghost" @onclick="LogoutAsync" disabled="@IsMutating">Logout</button>
</div>
</div> </div>
</header> </header>
@@ -63,8 +74,6 @@
SelectedCharacter="SelectedCharacter" SelectedCharacter="SelectedCharacter"
IsMutating="IsMutating" IsMutating="IsMutating"
SelectedCharacterSkills="SelectedCharacterSkills" SelectedCharacterSkills="SelectedCharacterSkills"
SelectedSkillId="SelectedSkillId"
SelectedSkill="SelectedSkill"
IsD6="IsSelectedCampaignD6" IsD6="IsSelectedCampaignD6"
RollVisibility="RollVisibility" RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged" RollVisibilityChanged="OnRollVisibilityChanged"
@@ -75,11 +84,10 @@
CanEditSkill="CanEditSkill" CanEditSkill="CanEditSkill"
CanRollSkill="CanRollSkill" CanRollSkill="CanRollSkill"
CharacterSelected="SelectCharacterAsync" CharacterSelected="SelectCharacterAsync"
SkillSelected="SelectSkill"
EditCharacterRequested="OpenEditCharacterModal" EditCharacterRequested="OpenEditCharacterModal"
SkillCreated="OnSkillCreatedAsync" SkillCreated="OnSkillCreatedAsync"
SkillUpdated="OnSkillUpdatedAsync" SkillUpdated="OnSkillUpdatedAsync"
RollRequested="RollSelectedSkillAsync"/> RollRequested="RollSkillAsync"/>
<CampaignLogPanel <CampaignLogPanel
IsCampaignDataLoading="IsCampaignDataLoading" IsCampaignDataLoading="IsCampaignDataLoading"

View File

@@ -145,7 +145,6 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign = null; SelectedCampaign = null;
CampaignLog = []; CampaignLog = [];
SelectedCharacterId = null; SelectedCharacterId = null;
SelectedSkillId = null;
ConnectionState = "offline"; ConnectionState = "offline";
return; return;
} }
@@ -157,7 +156,6 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign = await ApiClient.RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}"); SelectedCampaign = await ApiClient.RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList(); CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList();
SyncSelectedCharacter(); SyncSelectedCharacter();
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
@@ -226,6 +224,7 @@ public partial class Workspace : IAsyncDisposable
private async Task SwitchScreenAsync(string screen) private async Task SwitchScreenAsync(string screen)
{ {
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play"; CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
IsScreenMenuOpen = false;
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen);
} }
@@ -264,6 +263,7 @@ public partial class Workspace : IAsyncDisposable
await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString());
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
await SyncStateEventsAsync(); await SyncStateEventsAsync();
IsScreenMenuOpen = false;
} }
private async Task OnCampaignCreatedAsync(Guid campaignId) private async Task OnCampaignCreatedAsync(Guid campaignId)
@@ -327,7 +327,6 @@ public partial class Workspace : IAsyncDisposable
private async Task SelectCharacterAsync(Guid characterId) private async Task SelectCharacterAsync(Guid characterId)
{ {
SelectedCharacterId = characterId; SelectedCharacterId = characterId;
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync(); await EnsureSelectedCharacterActiveAsync();
} }
@@ -367,25 +366,37 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Skill created.", false); SetStatus("Skill created.", false);
} }
private async Task OnSkillUpdatedAsync(Guid skillId) private async Task OnSkillUpdatedAsync(Guid _)
{ {
SelectedSkillId = skillId;
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Skill updated.", false); SetStatus("Skill updated.", false);
} }
private async Task RollSelectedSkillAsync() private async Task RollSkillAsync(Guid skillId)
{ {
if (SelectedSkill is null) if (SelectedCampaign is null)
{ {
SetStatus("Select a skill to roll.", true); SetStatus("No campaign selected.", true);
return;
}
var selectedSkill = SelectedCampaign.Skills.FirstOrDefault(skill => skill.Id == skillId);
if (selectedSkill is null)
{
SetStatus("Skill is no longer available. Refresh campaign data.", true);
return;
}
if (!CanRollSkill(selectedSkill))
{
SetStatus("You are not allowed to roll this skill.", true);
return; return;
} }
IsMutating = true; IsMutating = true;
try try
{ {
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility)); LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{selectedSkill.Id}/roll", new RollSkillRequest(RollVisibility));
await RefreshCampaignScopeAsync(); await RefreshCampaignScopeAsync();
SetStatus("Roll recorded.", false); SetStatus("Roll recorded.", false);
@@ -407,11 +418,6 @@ public partial class Workspace : IAsyncDisposable
return Task.CompletedTask; return Task.CompletedTask;
} }
private void SelectSkill(Guid skillId)
{
SelectedSkillId = skillId;
}
private bool CanEditSkill(SkillSummary skill) private bool CanEditSkill(SkillSummary skill)
{ {
if (SelectedCampaign is null) if (SelectedCampaign is null)
@@ -526,21 +532,6 @@ public partial class Workspace : IAsyncDisposable
SelectedCharacterId = SelectedCampaign.Characters[0].Id; SelectedCharacterId = SelectedCampaign.Characters[0].Id;
} }
private void SyncSelectedSkill()
{
var skills = SelectedCharacterSkills;
if (skills.Count == 0)
{
SelectedSkillId = null;
return;
}
if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value))
return;
SelectedSkillId = skills[0].Id;
}
private string OwnerLabel(Guid ownerUserId) private string OwnerLabel(Guid ownerUserId)
{ {
if (User is not null && ownerUserId == User.Id) if (User is not null && ownerUserId == User.Id)
@@ -624,7 +615,6 @@ public partial class Workspace : IAsyncDisposable
Campaigns = []; Campaigns = [];
CampaignLog = []; CampaignLog = [];
SelectedCharacterId = null; SelectedCharacterId = null;
SelectedSkillId = null;
LastRoll = null; LastRoll = null;
ShowCreateCharacterModal = false; ShowCreateCharacterModal = false;
ShowEditCharacterModal = false; ShowEditCharacterModal = false;
@@ -646,6 +636,11 @@ public partial class Workspace : IAsyncDisposable
LiveAnnouncement = message; LiveAnnouncement = message;
} }
private void ToggleScreenMenu()
{
IsScreenMenuOpen = !IsScreenMenuOpen;
}
[Inject] [Inject]
private IJSRuntime JS { get; set; } = null!; private IJSRuntime JS { get; set; } = null!;
@@ -660,7 +655,6 @@ public partial class Workspace : IAsyncDisposable
private List<CampaignLogEntry> CampaignLog { get; set; } = []; private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = []; private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; } private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; } private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public"; private string RollVisibility { get; set; } = "public";
@@ -674,6 +668,7 @@ public partial class Workspace : IAsyncDisposable
private string MobilePanel { get; set; } = "character"; private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline"; private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty; private string LiveAnnouncement { get; set; } = string.Empty;
private bool IsScreenMenuOpen { get; set; }
private bool ShowCreateCharacterModal { get; set; } private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; } private bool ShowEditCharacterModal { get; set; }
@@ -694,12 +689,6 @@ public partial class Workspace : IAsyncDisposable
private CharacterSummary? SelectedCharacter => private CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId); SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill =>
SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName =>
SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId)?.Name;
private bool IsCurrentUserGm => private bool IsCurrentUserGm =>
SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
@@ -711,6 +700,7 @@ public partial class Workspace : IAsyncDisposable
private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
private bool IsManagementScreen => !IsPlayScreen; private bool IsManagementScreen => !IsPlayScreen;
private string CurrentScreenLabel => IsPlayScreen ? "Play" : "Campaign Management";
private string ConnectionStateLabel => ConnectionState switch private string ConnectionStateLabel => ConnectionState switch
{ {
@@ -731,4 +721,4 @@ 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";
} }

View File

@@ -179,11 +179,20 @@ window.rpgRollerApi = (() => {
connectStateStream(); connectStateStream();
} }
function scrollElementToBottom(element) {
if (!element) {
return;
}
element.scrollTop = element.scrollHeight;
}
return { return {
request, request,
getSessionValue, getSessionValue,
setSessionValue, setSessionValue,
startStateEvents, startStateEvents,
stopStateEvents stopStateEvents,
scrollElementToBottom
}; };
})(); })();

View File

@@ -81,32 +81,35 @@ h3 {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
display: grid; display: flex;
gap: 0.75rem;
grid-template-columns: 1.1fr 1.2fr 1fr;
background: linear-gradient(120deg, #f1e4c9, #efe0bf); background: linear-gradient(120deg, #f1e4c9, #efe0bf);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.85rem; padding: 0.5rem 0.7rem;
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
} }
.header-group { .header-row {
display: grid; display: flex;
gap: 0.2rem; align-items: center;
gap: 0.6rem;
width: 100%;
flex-wrap: wrap;
} }
.brand h1 { .header-row h1 {
margin: 0; margin: 0;
font-size: 1.15rem;
} }
.brand p, .header-identity,
.context p { .header-campaign {
margin: 0; margin: 0;
white-space: nowrap;
} }
.controls { .header-campaign {
justify-items: end; color: var(--muted);
} }
.switch-group, .switch-group,
@@ -121,7 +124,7 @@ h3 {
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%); background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 0.8rem; border-radius: 0.8rem;
padding: 0.85rem; padding: 0.7rem;
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -216,7 +219,8 @@ select:focus-visible {
.section-head { .section-head {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-start;
flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
} }
@@ -231,6 +235,10 @@ select:focus-visible {
height: 100%; height: 100%;
} }
.app-play .play-screen > * {
min-height: 0;
}
.management-screen { .management-screen {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -273,18 +281,18 @@ select:focus-visible {
font-size: 0.82rem; font-size: 0.82rem;
} }
.character-sheet,
.skills-section, .skills-section,
.last-roll { .last-roll {
border: 1px dashed #a89066; border: 1px dashed #a89066;
border-radius: 0.65rem; border-radius: 0.65rem;
padding: 0.65rem; padding: 0.55rem;
display: grid; display: grid;
gap: 0.4rem; gap: 0.45rem;
} }
.app-play .character-panel { .app-play .character-panel {
overflow: hidden; overflow-y: auto;
overscroll-behavior: contain;
} }
.log-panel { .log-panel {
@@ -302,25 +310,146 @@ select:focus-visible {
} }
.skill-item { .skill-item {
display: flex; display: grid;
justify-content: space-between; grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 0.6rem; gap: 0.6rem;
background: #f8f1df; background: #f6ebd3;
color: var(--text); color: var(--text);
border: 1px solid #b39f79; border: 1px solid #b39f79;
border-radius: 0.5rem;
padding: 0.45rem 0.55rem;
} }
.skill-item.active { .skill-details {
border-color: #7b5a1f;
background: #ecdfc2;
}
.roll-panel {
display: grid; display: grid;
gap: 0.1rem;
min-width: 0;
}
.skill-details span {
color: var(--muted);
font-size: 0.88rem;
}
.skill-chip-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.chip-button {
min-width: 2rem;
height: 2rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 999px;
background: transparent;
color: var(--text);
border-color: #8e7b57;
}
.create-skill-item {
grid-template-columns: auto 1fr;
align-items: center;
justify-items: start;
background: #f9f2e2;
}
.skill-create-icon {
width: 1.45rem;
height: 1.45rem;
border: 1px solid #8e7b57;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
.skills-heading {
margin: 0;
font-size: 1rem;
}
.chip-toolbar {
display: inline-flex;
align-items: center;
gap: 0.4rem; gap: 0.4rem;
} }
.visibility-control {
font-size: 0.85rem;
color: var(--muted);
}
.chip-toolbar select {
width: auto;
padding: 0.2rem 0.35rem;
}
.header-menu-wrap {
position: relative;
}
.menu-toggle {
background: transparent;
color: var(--text);
border-color: #8e7b57;
display: inline-flex;
gap: 0.4rem;
align-items: center;
}
.menu-toggle-label {
font-size: 0.9rem;
}
.screen-menu {
position: absolute;
right: 0;
top: calc(100% + 0.3rem);
z-index: 40;
min-width: 14.5rem;
padding: 0.35rem;
background: #fff8ea;
border: 1px solid var(--card-border);
border-radius: 0.55rem;
display: grid;
gap: 0.3rem;
box-shadow: 0 8px 16px rgba(34, 24, 9, 0.2);
}
.menu-item {
width: 100%;
text-align: left;
background: transparent;
color: var(--text);
border-color: #8e7b57;
}
.menu-item.active {
background: #ecd8ae;
border-color: #9a7f43;
}
.logout-link {
color: var(--accent-2);
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.logout-link:hover {
color: #6b2419;
}
.logout-link:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
}
.roll-total { .roll-total {
font-size: 1.8rem; font-size: 1.8rem;
font-weight: 800; font-weight: 800;
@@ -572,7 +701,6 @@ select:focus-visible {
@media (max-width: 1023px) { @media (max-width: 1023px) {
.workspace-header { .workspace-header {
position: static; position: static;
grid-template-columns: 1fr;
} }
.play-screen { .play-screen {
@@ -595,6 +723,12 @@ select:focus-visible {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.header-row h1,
.header-identity,
.header-campaign {
white-space: normal;
}
.mobile-bottom-nav { .mobile-bottom-nav {
display: flex; display: flex;
} }