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.
## 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`?
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.
- 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 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.
- 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
@@ -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.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.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.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, 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.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. |

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>
@if (IsCampaignDataLoading)
{

View File

@@ -1,5 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages.HomeControls;
@@ -7,6 +8,37 @@ namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
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]
public bool IsCampaignDataLoading { get; set; }

View File

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

View File

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

View File

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

View File

@@ -145,7 +145,6 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign = null;
CampaignLog = [];
SelectedCharacterId = null;
SelectedSkillId = null;
ConnectionState = "offline";
return;
}
@@ -157,7 +156,6 @@ public partial class Workspace : IAsyncDisposable
SelectedCampaign = await ApiClient.RequestAsync<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
CampaignLog = (await ApiClient.RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{campaignId}/log")).ToList();
SyncSelectedCharacter();
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
@@ -226,6 +224,7 @@ public partial class Workspace : IAsyncDisposable
private async Task SwitchScreenAsync(string screen)
{
CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play";
IsScreenMenuOpen = false;
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 RefreshCampaignScopeAsync();
await SyncStateEventsAsync();
IsScreenMenuOpen = false;
}
private async Task OnCampaignCreatedAsync(Guid campaignId)
@@ -327,7 +327,6 @@ public partial class Workspace : IAsyncDisposable
private async Task SelectCharacterAsync(Guid characterId)
{
SelectedCharacterId = characterId;
SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
}
@@ -367,25 +366,37 @@ public partial class Workspace : IAsyncDisposable
SetStatus("Skill created.", false);
}
private async Task OnSkillUpdatedAsync(Guid skillId)
private async Task OnSkillUpdatedAsync(Guid _)
{
SelectedSkillId = skillId;
await RefreshCampaignScopeAsync();
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;
}
IsMutating = true;
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();
SetStatus("Roll recorded.", false);
@@ -407,11 +418,6 @@ public partial class Workspace : IAsyncDisposable
return Task.CompletedTask;
}
private void SelectSkill(Guid skillId)
{
SelectedSkillId = skillId;
}
private bool CanEditSkill(SkillSummary skill)
{
if (SelectedCampaign is null)
@@ -526,21 +532,6 @@ public partial class Workspace : IAsyncDisposable
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)
{
if (User is not null && ownerUserId == User.Id)
@@ -624,7 +615,6 @@ public partial class Workspace : IAsyncDisposable
Campaigns = [];
CampaignLog = [];
SelectedCharacterId = null;
SelectedSkillId = null;
LastRoll = null;
ShowCreateCharacterModal = false;
ShowEditCharacterModal = false;
@@ -646,6 +636,11 @@ public partial class Workspace : IAsyncDisposable
LiveAnnouncement = message;
}
private void ToggleScreenMenu()
{
IsScreenMenuOpen = !IsScreenMenuOpen;
}
[Inject]
private IJSRuntime JS { get; set; } = null!;
@@ -660,7 +655,6 @@ public partial class Workspace : IAsyncDisposable
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> Rulesets { get; set; } = [];
private Guid? SelectedCharacterId { get; set; }
private Guid? SelectedSkillId { get; set; }
private RollResult? LastRoll { get; set; }
private string RollVisibility { get; set; } = "public";
@@ -674,6 +668,7 @@ public partial class Workspace : IAsyncDisposable
private string MobilePanel { get; set; } = "character";
private string ConnectionState { get; set; } = "offline";
private string LiveAnnouncement { get; set; } = string.Empty;
private bool IsScreenMenuOpen { get; set; }
private bool ShowCreateCharacterModal { get; set; }
private bool ShowEditCharacterModal { get; set; }
@@ -694,12 +689,6 @@ public partial class Workspace : IAsyncDisposable
private CharacterSummary? SelectedCharacter =>
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 =>
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 IsManagementScreen => !IsPlayScreen;
private string CurrentScreenLabel => IsPlayScreen ? "Play" : "Campaign Management";
private string ConnectionStateLabel => ConnectionState switch
{

View File

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

View File

@@ -81,32 +81,35 @@ h3 {
position: sticky;
top: 0;
z-index: 10;
display: grid;
gap: 0.75rem;
grid-template-columns: 1.1fr 1.2fr 1fr;
display: flex;
background: linear-gradient(120deg, #f1e4c9, #efe0bf);
border: 1px solid var(--card-border);
border-radius: 0.8rem;
padding: 0.85rem;
padding: 0.5rem 0.7rem;
backdrop-filter: blur(6px);
}
.header-group {
display: grid;
gap: 0.2rem;
.header-row {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
flex-wrap: wrap;
}
.brand h1 {
.header-row h1 {
margin: 0;
font-size: 1.15rem;
}
.brand p,
.context p {
.header-identity,
.header-campaign {
margin: 0;
white-space: nowrap;
}
.controls {
justify-items: end;
.header-campaign {
color: var(--muted);
}
.switch-group,
@@ -121,7 +124,7 @@ h3 {
background: color-mix(in srgb, var(--card) 94%, #ffffff 6%);
border: 1px solid var(--card-border);
border-radius: 0.8rem;
padding: 0.85rem;
padding: 0.7rem;
display: grid;
gap: 0.75rem;
}
@@ -216,7 +219,8 @@ select:focus-visible {
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
justify-content: flex-start;
flex-wrap: wrap;
gap: 0.75rem;
}
@@ -231,6 +235,10 @@ select:focus-visible {
height: 100%;
}
.app-play .play-screen > * {
min-height: 0;
}
.management-screen {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -273,18 +281,18 @@ select:focus-visible {
font-size: 0.82rem;
}
.character-sheet,
.skills-section,
.last-roll {
border: 1px dashed #a89066;
border-radius: 0.65rem;
padding: 0.65rem;
padding: 0.55rem;
display: grid;
gap: 0.4rem;
gap: 0.45rem;
}
.app-play .character-panel {
overflow: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.log-panel {
@@ -302,25 +310,146 @@ select:focus-visible {
}
.skill-item {
display: flex;
justify-content: space-between;
align-items: center;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.6rem;
background: #f8f1df;
background: #f6ebd3;
color: var(--text);
border: 1px solid #b39f79;
border-radius: 0.5rem;
padding: 0.45rem 0.55rem;
}
.skill-item.active {
border-color: #7b5a1f;
background: #ecdfc2;
}
.roll-panel {
.skill-details {
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;
}
.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 {
font-size: 1.8rem;
font-weight: 800;
@@ -572,7 +701,6 @@ select:focus-visible {
@media (max-width: 1023px) {
.workspace-header {
position: static;
grid-template-columns: 1fr;
}
.play-screen {
@@ -595,6 +723,12 @@ select:focus-visible {
grid-template-columns: 1fr;
}
.header-row h1,
.header-identity,
.header-campaign {
white-space: normal;
}
.mobile-bottom-nav {
display: flex;
}