Refactor Home UI controls and add dice to campaign log entries

This commit is contained in:
2026-02-26 09:22:29 +01:00
parent 96238a9341
commit 2d1bf9b9b7
20 changed files with 774 additions and 255 deletions

6
FAQ.md
View File

@@ -62,4 +62,8 @@ d6 skills now store two explicit options:
- `wildDice`: number of wild dice for the skill - `wildDice`: number of wild dice for the skill
- `allowFumble`: whether wild dice rolling `1` can trigger fumble removal - `allowFumble`: whether wild dice rolling `1` can trigger fumble removal
Roll responses also include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total. Roll responses and campaign log entries include per-die state flags (`crit`, `fumble`, `wild`, `removed`, `added`) so the frontend can render the full die-by-die outcome, not just the final total.
## How is the active character chosen in the Play screen?
There is no separate activate button in Play. The selected character in the character picker is treated as active context and the UI syncs that choice to the backend for owned characters.

View File

@@ -15,8 +15,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 | 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, activate action, 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, 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.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), 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. |
| 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. |

View File

@@ -26,7 +26,8 @@ Backend:
Frontend: Frontend:
- `RpgRoller/Components/`: Blazor root app, routes, layout and page components - `RpgRoller/Components/`: Blazor root app, routes, layout and page components
- `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens - `RpgRoller/Components/Pages/Home.razor(.cs)`: page composition + app state orchestration
- `RpgRoller/Components/Pages/HomeControls/`: play-screen UI controls extracted from `Home.razor` to reduce churn
- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor
- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens - `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens
@@ -106,10 +107,10 @@ dotnet dotnet-ef migrations add <MigrationName> --project RpgRoller/RpgRoller.cs
- registration, login, logout - registration, login, logout
- play screen and campaign management screen switch - play screen and campaign management screen switch
- campaign creation and selection - campaign creation and selection
- character create/edit/activate via modal forms - character create/edit via modal forms, with picker selection treated as active character context
- skill create/edit via modal forms including d6 wild dice + allow-fumble controls - skill create/edit via modal forms including d6 wild dice + allow-fumble controls
- public/private rolling and campaign log viewing - public/private rolling and campaign log viewing
- die-state visualization in Last Roll (critical, fumble, wild, removed, added) - die-state visualization in Last Roll and Campaign Log (critical, fumble, wild, removed, added)
- responsive play UX: - responsive play UX:
- desktop two-column (character + log) - desktop two-column (character + log)
- tablet/mobile panel switching with bottom tab bar (`Character` / `Log`) - tablet/mobile panel switching with bottom tab bar (`Character` / `Log`)

View File

@@ -60,13 +60,16 @@ public sealed class RollVisibilityApiTests : ApiTestBase
var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log"); var gmLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(gmClient, $"/api/campaigns/{campaign.Id}/log");
Assert.Equal(2, gmLog.Count); Assert.Equal(2, gmLog.Count);
Assert.All(gmLog, entry => Assert.NotEmpty(entry.Dice));
var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log"); var playerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(playerClient, $"/api/campaigns/{campaign.Id}/log");
Assert.Equal(2, playerLog.Count); Assert.Equal(2, playerLog.Count);
Assert.All(playerLog, entry => Assert.NotEmpty(entry.Dice));
var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(observerClient, $"/api/campaigns/{campaign.Id}/log"); var observerLog = await GetAsync<IReadOnlyList<CampaignLogEntry>>(observerClient, $"/api/campaigns/{campaign.Id}/log");
Assert.Single(observerLog); Assert.Single(observerLog);
Assert.Equal("public", observerLog[0].Visibility); Assert.Equal("public", observerLog[0].Visibility);
Assert.NotEmpty(observerLog[0].Dice);
await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider"); await RegisterAsync(outsiderClient, "outsider", "Password123", "Outsider");
await LoginAsync(outsiderClient, "outsider", "Password123"); await LoginAsync(outsiderClient, "outsider", "Password123");

View File

@@ -32,7 +32,7 @@ public sealed class HostingCoverageTests
} }
[Fact] [Fact]
public void SqliteSchemaUpgrader_MigratesLegacySkillsSchema() public void SqliteSchemaUpgrader_MigratesLegacySchema()
{ {
var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-legacy-upgrade-{Guid.NewGuid():N}.db"); var dbPath = Path.Combine(Path.GetTempPath(), $"rpgroller-legacy-upgrade-{Guid.NewGuid():N}.db");
var connectionString = $"Data Source={dbPath}"; var connectionString = $"Data Source={dbPath}";
@@ -119,6 +119,17 @@ public sealed class HostingCoverageTests
Assert.Contains("WildDice", columns); Assert.Contains("WildDice", columns);
Assert.Contains("AllowFumble", columns); Assert.Contains("AllowFumble", columns);
using var rollTableInfoCommand = verifyConnection.CreateCommand();
rollTableInfoCommand.CommandText = "PRAGMA table_info('RollLogEntries');";
using var rollTableInfoReader = rollTableInfoCommand.ExecuteReader();
var rollColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (rollTableInfoReader.Read())
{
rollColumns.Add(rollTableInfoReader.GetString(1));
}
Assert.Contains("Dice", rollColumns);
using var historyCommand = verifyConnection.CreateCommand(); using var historyCommand = verifyConnection.CreateCommand();
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';"; historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar()); var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
@@ -128,5 +139,10 @@ public sealed class HostingCoverageTests
modelSyncHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226090000_ModelSync';"; modelSyncHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226090000_ModelSync';";
var modelSyncHistoryCount = Convert.ToInt32(modelSyncHistoryCommand.ExecuteScalar()); var modelSyncHistoryCount = Convert.ToInt32(modelSyncHistoryCommand.ExecuteScalar());
Assert.Equal(1, modelSyncHistoryCount); Assert.Equal(1, modelSyncHistoryCount);
using var rollDiceHistoryCommand = verifyConnection.CreateCommand();
rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';";
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
Assert.Equal(1, rollDiceHistoryCount);
} }
} }

View File

@@ -65,6 +65,8 @@ public sealed class ServiceSkillRollTests
Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count); Assert.Equal(2, ServiceTestSupport.GetValue(ownerLog).Count);
Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count); Assert.Equal(2, ServiceTestSupport.GetValue(gmLog).Count);
Assert.False(outsiderLog.Succeeded); Assert.False(outsiderLog.Succeeded);
Assert.All(ServiceTestSupport.GetValue(ownerLog), entry => Assert.NotEmpty(entry.Dice));
Assert.All(ServiceTestSupport.GetValue(gmLog), entry => Assert.NotEmpty(entry.Dice));
var version = service.GetCampaignVersion(ownerSession, campaign.Id); var version = service.GetCampaignVersion(ownerSession, campaign.Id);
var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid()); var missingVersion = service.GetCampaignVersion(ownerSession, Guid.NewGuid());

View File

@@ -1,7 +1,8 @@
@page "/" @page "/"
@implements IAsyncDisposable @implements IAsyncDisposable
@using RpgRoller.Components.Pages.HomeControls
<div class="rr-app"> <div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p> <p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@if (!IsInitialized) @if (!IsInitialized)
@@ -122,130 +123,39 @@
@if (CurrentScreen == "play") @if (CurrentScreen == "play")
{ {
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")"> <main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<section class="card character-panel"> <CharacterPanel
<div class="section-head"><h2>Character Context</h2></div> IsCampaignDataLoading="IsCampaignDataLoading"
@if (IsCampaignDataLoading) SelectedCampaign="SelectedCampaign"
{ SelectedCharacterId="SelectedCharacterId"
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div> SelectedCharacter="SelectedCharacter"
} IsMutating="IsMutating"
else if (SelectedCampaign is null) SelectedCharacterSkills="SelectedCharacterSkills"
{ SelectedSkillId="SelectedSkillId"
<p class="empty">No campaign selected. Choose one in Campaign Management.</p> SelectedSkill="SelectedSkill"
} RollVisibility="RollVisibility"
else if (SelectedCampaign.Characters.Count == 0) RollVisibilityChanged="OnRollVisibilityChanged"
{ LastRoll="LastRoll"
<p class="empty">No characters in this campaign yet.</p> OwnerLabel="OwnerLabel"
} SkillDefinitionLabel="SkillDefinitionLabel"
else CanEditCharacter="CanEditCharacter"
{ CanEditSkill="CanEditSkill"
<div class="character-picker" role="tablist" aria-label="Character picker"> CanRollSkill="CanRollSkill"
@foreach (var character in SelectedCampaign.Characters) CharacterSelected="SelectCharacterAsync"
{ SkillSelected="SelectSkill"
var isSelectedCharacter = SelectedCharacterId == character.Id; EditCharacterRequested="OpenEditCharacterModal"
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => SelectCharacter(character.Id)"> CreateSkillRequested="OpenCreateSkillModal"
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span> EditSkillRequested="OpenEditSkillModal"
<span class="icon-tab-text">@character.Name</span> RollRequested="RollSelectedSkillAsync" />
</button>
}
</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>
@if (SelectedCharacter.Id == ActiveCharacterId)
{
<span class="badge active">Active</span>
}
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(SelectedCharacter))" @onclick="() => OpenEditCharacterModal(SelectedCharacter)">Edit Character</button>
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(SelectedCharacter))" @onclick="() => ActivateCharacterAsync(SelectedCharacter.Id)">Activate 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</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
{
<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="() => SelectSkill(skill.Id)">
<strong>@skill.Name</strong><span>@SkillDefinitionLabel(skill)</span>
</button>
}
</div>
}
<form class="roll-panel" @onsubmit="RollSelectedSkillAsync" @onsubmit:preventDefault>
<label for="roll-visibility">Visibility</label>
<select id="roll-visibility" @bind="RollVisibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit" disabled="@(IsMutating || SelectedSkill is null || !CanRollSkill(SelectedSkill))">Roll Skill</button>
</form>
</article>
}
}
<article class="last-roll">
<h3>Last Roll</h3>
@if (LastRoll is null)
{
<p class="empty">No roll yet.</p>
}
else
{
<p class="roll-total">@LastRoll.Result</p>
@if (LastRoll.Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="Rolled dice">
@foreach (var die in LastRoll.Dice)
{
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
}
</div>
}
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
}
</article>
</section>
<aside class="card log-panel"> <CampaignLogPanel
<div class="section-head"><h2>Campaign Log</h2></div> IsCampaignDataLoading="IsCampaignDataLoading"
@if (IsCampaignDataLoading) CampaignLog="CampaignLog"
{ RollerLabel="RollerLabel"
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line"></div><div class="skeleton-line short"></div></div> SkillLabel="SkillLabel"
} CharacterLabel="CharacterLabel"
else if (CampaignLog.Count == 0) LogEntryCssClass="LogEntryCssClass"
{ VisibilityLabel="VisibilityLabel"
<p class="empty">No log entries yet.</p> VisibilityBadgeCssClass="VisibilityBadgeCssClass" />
}
else
{
<ul class="log-list">
@foreach (var entry in CampaignLog)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
</li>
}
</ul>
}
</aside>
</main> </main>
<nav class="mobile-bottom-nav" aria-label="Play panel selector"> <nav class="mobile-bottom-nav" aria-label="Play panel selector">
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button> <button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)" @onclick="SetMobilePanelCharacterAsync">Character</button>
@@ -337,7 +247,6 @@
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div> <div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions"> <div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button> <button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
<button type="button" disabled="@(IsMutating || !CanActivateCharacter(character))" @onclick="() => ActivateCharacterAsync(character.Id)">Activate</button>
</div> </div>
</li> </li>
} }

View File

@@ -78,7 +78,7 @@ public partial class Home
private string? SelectedCampaignName => SelectedCampaign?.Name; private string? SelectedCampaignName => SelectedCampaign?.Name;
private CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId); private CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId);
private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId); private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId);
private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name; 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; private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id;
private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
@@ -103,6 +103,8 @@ public partial class Home
_ => "offline" _ => "offline"
}; };
private string AppCssClass => User is not null && CurrentScreen == "play" ? "rr-app app-play" : "rr-app";
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
HasInteractiveRenderStarted = true; HasInteractiveRenderStarted = true;
@@ -264,6 +266,7 @@ public partial class Home
CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{SelectedCampaignId.Value}/log")).ToList(); CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("GET", $"/api/campaigns/{SelectedCampaignId.Value}/log")).ToList();
SyncSelectedCharacter(); SyncSelectedCharacter();
SyncSelectedSkill(); SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
} }
catch (ApiRequestException ex) when (ex.StatusCode == 401) catch (ApiRequestException ex) when (ex.StatusCode == 401)
{ {
@@ -619,27 +622,6 @@ public partial class Home
} }
} }
private async Task ActivateCharacterAsync(Guid characterId)
{
IsMutating = true;
try
{
await RequestWithoutPayloadAsync("POST", $"/api/characters/{characterId}/activate");
ActiveCharacterId = characterId;
SelectedCharacterId = characterId;
await RefreshCampaignScopeAsync();
SetStatus("Active character updated.", false);
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
finally
{
IsMutating = false;
}
}
private void OpenCreateSkillModal() private void OpenCreateSkillModal()
{ {
SkillForm.Name = string.Empty; SkillForm.Name = string.Empty;
@@ -808,10 +790,17 @@ public partial class Home
} }
} }
private void SelectCharacter(Guid characterId) private Task OnRollVisibilityChanged(string visibility)
{
RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
return Task.CompletedTask;
}
private async Task SelectCharacterAsync(Guid characterId)
{ {
SelectedCharacterId = characterId; SelectedCharacterId = characterId;
SyncSelectedSkill(); SyncSelectedSkill();
await EnsureSelectedCharacterActiveAsync();
} }
private void SelectSkill(Guid skillId) private void SelectSkill(Guid skillId)
@@ -829,6 +818,35 @@ public partial class Home
return User is not null && character.OwnerUserId == User.Id; return User is not null && character.OwnerUserId == User.Id;
} }
private async Task EnsureSelectedCharacterActiveAsync()
{
if (!SelectedCharacterId.HasValue || SelectedCampaign is null)
{
return;
}
var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character))
{
return;
}
if (ActiveCharacterId == character.Id)
{
return;
}
try
{
await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate");
ActiveCharacterId = character.Id;
}
catch (ApiRequestException ex)
{
SetStatus(ex.Message, true);
}
}
private bool CanEditSkill(SkillSummary skill) private bool CanEditSkill(SkillSummary skill)
{ {
if (SelectedCampaign is null) if (SelectedCampaign is null)
@@ -1033,82 +1051,6 @@ public partial class Home
return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}"; return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}";
} }
private static string RollDieGlyph(int roll)
{
return roll switch
{
1 => "\u2680",
2 => "\u2681",
3 => "\u2682",
4 => "\u2683",
5 => "\u2684",
6 => "\u2685",
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
private string RollerLabel(CampaignLogEntry entry) private string RollerLabel(CampaignLogEntry entry)
{ {
if (User is not null && entry.RollerUserId == User.Id) if (User is not null && entry.RollerUserId == User.Id)
@@ -1184,22 +1126,6 @@ public partial class Home
return "private-generic"; return "private-generic";
} }
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
private void ClearAuthenticatedState() private void ClearAuthenticatedState()
{ {
User = null; User = null;

View File

@@ -0,0 +1,56 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<aside 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)
{
<li class="log-entry @LogEntryCssClass(entry)">
<p><strong>@RollerLabel(entry)</strong> rolled <strong>@SkillLabel(entry.SkillId)</strong> with <strong>@CharacterLabel(entry.CharacterId)</strong></p>
<p class="roll-total inline">@entry.Result</p>
<RollDiceStrip Dice="entry.Dice" AriaLabel="Log roll dice" />
<p>@entry.Breakdown</p>
<p class="log-meta"><span class="badge @VisibilityBadgeCssClass(entry)">@VisibilityLabel(entry)</span> <time title="@entry.TimestampUtc.ToString("O")">@entry.TimestampUtc.ToLocalTime().ToString("g")</time></p>
</li>
}
</ul>
}
</aside>
@code {
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public IReadOnlyList<CampaignLogEntry> CampaignLog { get; set; } = [];
[Parameter]
public Func<CampaignLogEntry, string> RollerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> SkillLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<Guid, string> CharacterLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> LogEntryCssClass { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CampaignLogEntry, string> VisibilityBadgeCssClass { get; set; } = _ => string.Empty;
}

View File

@@ -0,0 +1,186 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<section class="card character-panel">
<div class="section-head"><h2>Character Context</h2></div>
@if (IsCampaignDataLoading)
{
<div class="skeleton-stack"><div class="skeleton-line"></div><div class="skeleton-line short"></div><div class="skeleton-line"></div></div>
}
else if (SelectedCampaign is null)
{
<p class="empty">No campaign selected. Choose one in Campaign Management.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<div class="character-picker" role="tablist" aria-label="Character picker">
@foreach (var character in SelectedCampaign.Characters)
{
var isSelectedCharacter = SelectedCharacterId == character.Id;
<button type="button" class="icon-tab @(isSelectedCharacter ? "active" : string.Empty)" aria-label="@character.Name" @onclick="() => CharacterSelected.InvokeAsync(character.Id)">
<span class="icon-tab-glyph">@InitialsFor(character.Name)</span>
<span class="icon-tab-text">@character.Name</span>
</button>
}
</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="CreateSkillRequested">Create Skill</button>
<button type="button" disabled="@(IsMutating || SelectedSkill is null || !CanEditSkill(SelectedSkill))" @onclick="EditSkillRequested">Edit Skill</button>
</div>
</div>
@if (SelectedCharacterSkills.Count == 0)
{
<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>
</button>
}
</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>
</form>
</article>
}
}
<article class="last-roll">
<h3>Last Roll</h3>
@if (LastRoll is null)
{
<p class="empty">No roll yet.</p>
}
else
{
<p class="roll-total">@LastRoll.Result</p>
<RollDiceStrip Dice="LastRoll.Dice" AriaLabel="Last roll dice" />
<p>@LastRoll.Breakdown</p>
<p><span class="badge @(LastRoll.Visibility == "private" ? "private-self" : "public")">@LastRoll.Visibility</span> <time title="@LastRoll.TimestampUtc.ToString("O")">@LastRoll.TimestampUtc.ToLocalTime().ToString("g")</time></p>
}
</article>
</section>
@code {
[Parameter]
public bool IsCampaignDataLoading { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public Guid? SelectedCharacterId { get; set; }
[Parameter]
public CharacterSummary? SelectedCharacter { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public IReadOnlyList<SkillSummary> SelectedCharacterSkills { get; set; } = [];
[Parameter]
public Guid? SelectedSkillId { get; set; }
[Parameter]
public SkillSummary? SelectedSkill { get; set; }
[Parameter]
public string RollVisibility { get; set; } = "public";
[Parameter]
public EventCallback<string> RollVisibilityChanged { get; set; }
[Parameter]
public RollResult? LastRoll { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<SkillSummary, string> SkillDefinitionLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanEditSkill { get; set; } = _ => false;
[Parameter]
public Func<SkillSummary, bool> CanRollSkill { get; set; } = _ => false;
[Parameter]
public EventCallback<Guid> CharacterSelected { get; set; }
[Parameter]
public EventCallback<Guid> SkillSelected { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
[Parameter]
public EventCallback CreateSkillRequested { get; set; }
[Parameter]
public EventCallback EditSkillRequested { get; set; }
[Parameter]
public EventCallback RollRequested { get; set; }
private async Task OnRollVisibilityChangedAsync(ChangeEventArgs args)
{
var selectedVisibility = args.Value?.ToString() ?? "public";
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task OnRollSubmitAsync()
{
await RollRequested.InvokeAsync();
}
private static string InitialsFor(string value)
{
var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (words.Length == 0)
{
return "?";
}
if (words.Length == 1)
{
return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant();
}
return string.Concat(words[0][0], words[1][0]).ToUpperInvariant();
}
}

View File

@@ -0,0 +1,97 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@if (Dice.Count > 0)
{
<div class="roll-dice-strip" aria-label="@AriaLabel">
@foreach (var die in Dice)
{
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
}
</div>
}
@code {
[Parameter]
public IReadOnlyList<RollDieResult> Dice { get; set; } = [];
[Parameter]
public string AriaLabel { get; set; } = "Rolled dice";
private static string RollDieGlyph(int roll)
{
return roll switch
{
1 => "\u2680",
2 => "\u2681",
3 => "\u2682",
4 => "\u2683",
5 => "\u2684",
6 => "\u2685",
_ => roll.ToString()
};
}
private static string RollDieCssClass(RollDieResult die)
{
var classes = new List<string> { "die-chip" };
if (die.Wild)
{
classes.Add("wild");
}
if (die.Crit)
{
classes.Add("crit");
}
if (die.Fumble)
{
classes.Add("fumble");
}
if (die.Removed)
{
classes.Add("removed");
}
if (die.Added)
{
classes.Add("added");
}
return string.Join(" ", classes);
}
private static string RollDieTitle(RollDieResult die)
{
var labels = new List<string> { $"Roll {die.Roll}" };
if (die.Wild)
{
labels.Add("wild");
}
if (die.Crit)
{
labels.Add("critical");
}
if (die.Fumble)
{
labels.Add("fumble");
}
if (die.Removed)
{
labels.Add("removed");
}
if (die.Added)
{
labels.Add("added");
}
return string.Join(", ", labels);
}
}

View File

@@ -52,4 +52,5 @@ public sealed record CampaignLogEntry(
string Visibility, string Visibility,
int Result, int Result,
string Breakdown, string Breakdown,
IReadOnlyList<RollDieResult> Dice,
DateTimeOffset TimestampUtc); DateTimeOffset TimestampUtc);

View File

@@ -69,6 +69,7 @@ public sealed class RpgRollerDbContext : DbContext
entity.HasKey(x => x.Id); entity.HasKey(x => x.Id);
entity.Property(x => x.Visibility).HasConversion<string>().IsRequired(); entity.Property(x => x.Visibility).HasConversion<string>().IsRequired();
entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256); entity.Property(x => x.Breakdown).IsRequired().HasMaxLength(256);
entity.Property(x => x.Dice).IsRequired();
entity.Property(x => x.TimestampUtc).IsRequired(); entity.Property(x => x.TimestampUtc).IsRequired();
entity.HasIndex(x => x.CampaignId); entity.HasIndex(x => x.CampaignId);
entity.HasIndex(x => x.RollerUserId); entity.HasIndex(x => x.RollerUserId);

View File

@@ -66,6 +66,7 @@ public sealed class RollLogEntry
public required RollVisibility Visibility { get; init; } public required RollVisibility Visibility { get; init; }
public required int Result { get; init; } public required int Result { get; init; }
public required string Breakdown { get; init; } public required string Breakdown { get; init; }
public required string Dice { get; init; }
public required DateTimeOffset TimestampUtc { get; init; } public required DateTimeOffset TimestampUtc { get; init; }
} }

View File

@@ -0,0 +1,216 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using RpgRoller.Data;
#nullable disable
namespace RpgRoller.Migrations
{
[DbContext(typeof(RpgRollerDbContext))]
[Migration("20260226100000_AddRollLogDice")]
partial class AddRollLogDice
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("RpgRoller.Domain.Campaign", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GmUserId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Ruleset")
.IsRequired()
.HasColumnType("TEXT");
b.Property<long>("Version")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("GmUserId");
b.ToTable("Campaigns");
});
modelBuilder.Entity("RpgRoller.Domain.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("OwnerUserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("OwnerUserId");
b.ToTable("Characters");
});
modelBuilder.Entity("RpgRoller.Domain.RollLogEntry", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Breakdown")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid>("CampaignId")
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result")
.HasColumnType("INTEGER");
b.Property<Guid>("RollerUserId")
.HasColumnType("TEXT");
b.Property<Guid>("SkillId")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("TimestampUtc")
.HasColumnType("TEXT");
b.Property<string>("Visibility")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CampaignId");
b.HasIndex("CharacterId");
b.HasIndex("RollerUserId");
b.HasIndex("SkillId");
b.ToTable("RollLogEntries");
});
modelBuilder.Entity("RpgRoller.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AllowFumble")
.HasColumnType("INTEGER");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("DiceRollDefinition")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<int>("WildDice")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.ToTable("Skills");
});
modelBuilder.Entity("RpgRoller.Domain.UserAccount", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ActiveCharacterId")
.HasColumnType("TEXT");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("UsernameNormalized")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UsernameNormalized")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("RpgRoller.Domain.UserSession", b =>
{
b.Property<string>("Token")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Token");
b.HasIndex("UserId");
b.ToTable("Sessions");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace RpgRoller.Migrations
{
/// <inheritdoc />
public partial class AddRollLogDice : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Dice",
table: "RollLogEntries",
type: "TEXT",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Dice",
table: "RollLogEntries");
}
}
}

View File

@@ -88,6 +88,10 @@ namespace RpgRoller.Migrations
b.Property<Guid>("CharacterId") b.Property<Guid>("CharacterId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Dice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Result") b.Property<int>("Result")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RpgRoller.Contracts; using RpgRoller.Contracts;
using RpgRoller.Data; using RpgRoller.Data;
using RpgRoller.Domain; using RpgRoller.Domain;
@@ -8,6 +9,7 @@ namespace RpgRoller.Services;
public sealed class GameService : IGameService public sealed class GameService : IGameService
{ {
private static readonly JsonSerializerOptions DiceJsonOptions = new(JsonSerializerDefaults.Web);
private readonly object m_Gate = new(); private readonly object m_Gate = new();
private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory; private readonly IDbContextFactory<RpgRollerDbContext> m_DbContextFactory;
private readonly IPasswordHasher<UserAccount> m_PasswordHasher; private readonly IPasswordHasher<UserAccount> m_PasswordHasher;
@@ -550,6 +552,7 @@ public sealed class GameService : IGameService
Visibility = parsedVisibility.Value, Visibility = parsedVisibility.Value,
Result = roll.Total, Result = roll.Total,
Breakdown = roll.Breakdown, Breakdown = roll.Breakdown,
Dice = SerializeDice(roll.Dice),
TimestampUtc = DateTimeOffset.UtcNow TimestampUtc = DateTimeOffset.UtcNow
}; };
@@ -810,6 +813,8 @@ public sealed class GameService : IGameService
private static CampaignLogEntry ToLogEntry(RollLogEntry entry) private static CampaignLogEntry ToLogEntry(RollLogEntry entry)
{ {
var dice = DeserializeDice(entry.Dice);
return new CampaignLogEntry( return new CampaignLogEntry(
entry.Id, entry.Id,
entry.CampaignId, entry.CampaignId,
@@ -819,9 +824,32 @@ public sealed class GameService : IGameService
entry.Visibility == RollVisibility.Public ? "public" : "private", entry.Visibility == RollVisibility.Public ? "public" : "private",
entry.Result, entry.Result,
entry.Breakdown, entry.Breakdown,
dice,
entry.TimestampUtc); entry.TimestampUtc);
} }
private static string SerializeDice(IReadOnlyList<RollDieResult> dice)
{
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static IReadOnlyList<RollDieResult> DeserializeDice(string serializedDice)
{
if (string.IsNullOrWhiteSpace(serializedDice))
{
return [];
}
try
{
return JsonSerializer.Deserialize<IReadOnlyList<RollDieResult>>(serializedDice, DiceJsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign) private bool CanEditCharacterLocked(Guid actorUserId, Character character, Campaign campaign)
{ {
return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId; return character.OwnerUserId == actorUserId || campaign.GmUserId == actorUserId;
@@ -1063,6 +1091,7 @@ public sealed class GameService : IGameService
Visibility = entry.Visibility, Visibility = entry.Visibility,
Result = entry.Result, Result = entry.Result,
Breakdown = entry.Breakdown, Breakdown = entry.Breakdown,
Dice = entry.Dice,
TimestampUtc = entry.TimestampUtc TimestampUtc = entry.TimestampUtc
}; };
} }

View File

@@ -23,6 +23,7 @@ html,
body { body {
margin: 0; margin: 0;
min-height: 100%; min-height: 100%;
height: 100%;
} }
body { body {
@@ -47,6 +48,12 @@ h3 {
padding: 1rem 1rem 4.5rem; padding: 1rem 1rem 4.5rem;
} }
.rr-app.app-play {
height: 100dvh;
overflow: hidden;
padding-bottom: 1rem;
}
.loading-shell, .loading-shell,
.auth-shell { .auth-shell {
display: grid; display: grid;
@@ -65,6 +72,12 @@ h3 {
gap: 1rem; gap: 1rem;
} }
.app-play .workspace-shell {
height: 100%;
min-height: 0;
grid-template-rows: auto auto minmax(0, 1fr);
}
.workspace-header { .workspace-header {
position: sticky; position: sticky;
top: 0; top: 0;
@@ -212,6 +225,11 @@ select:focus-visible {
display: grid; display: grid;
grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr); grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr);
gap: 1rem; gap: 1rem;
min-height: 0;
}
.app-play .play-screen {
height: 100%;
} }
.management-screen { .management-screen {
@@ -266,6 +284,19 @@ select:focus-visible {
gap: 0.4rem; gap: 0.4rem;
} }
.app-play .character-panel {
overflow: hidden;
}
.log-panel {
min-height: 0;
}
.app-play .log-panel {
overflow-y: auto;
overscroll-behavior: contain;
}
.skill-list { .skill-list {
display: grid; display: grid;
gap: 0.35rem; gap: 0.35rem;
@@ -297,6 +328,10 @@ select:focus-visible {
margin: 0; margin: 0;
} }
.roll-total.inline {
font-size: 1.2rem;
}
.roll-dice-strip { .roll-dice-strip {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -564,4 +599,8 @@ select:focus-visible {
.mobile-bottom-nav { .mobile-bottom-nav {
display: flex; display: flex;
} }
.rr-app.app-play {
padding-bottom: 4.25rem;
}
} }

15
UX.md
View File

@@ -20,7 +20,7 @@ In scope for this UX:
- Two authenticated screens: - Two authenticated screens:
- `Play` screen for character sheet and log - `Play` screen for character sheet and log
- `Campaign Management` screen for campaign details and management actions - `Campaign Management` screen for campaign details and management actions
- Character create/edit/activate - Character create/edit with picker-selected active context
- Skill create/edit - Skill create/edit
- Skill rolling with public/private visibility - Skill rolling with public/private visibility
- Campaign log with live updates - Campaign log with live updates
@@ -135,7 +135,7 @@ Campaign details and management controls are excluded from this screen.
3. Open `Campaign Management` 3. Open `Campaign Management`
4. Create campaign with ruleset 4. Create campaign with ruleset
5. Create or move character into campaign 5. Create or move character into campaign
6. Activate character 6. Select character in picker (selection becomes active context)
7. Switch to `Play` 7. Switch to `Play`
8. Create skill 8. Create skill
9. Roll skill (public/private) 9. Roll skill (public/private)
@@ -147,7 +147,7 @@ Campaign details and management controls are excluded from this screen.
2. Open `Campaign Management` 2. Open `Campaign Management`
3. Select accessible campaign 3. Select accessible campaign
4. Create character in that campaign 4. Create character in that campaign
5. Activate character 5. Select character in picker (selection becomes active context)
6. Switch to `Play` 6. Switch to `Play`
7. Add or edit skills 7. Add or edit skills
8. Roll and view log with visibility rules 8. Roll and view log with visibility rules
@@ -235,12 +235,11 @@ Shows selected character details:
- Character name - Character name
- Owner - Owner
- Campaign affiliation - Campaign affiliation
- Active badge if it matches session active character - Active badge shown for the currently selected picker character
Actions: Actions:
- `Edit Character` button -> opens modal form - `Edit Character` button -> opens modal form
- `Activate Character` button (enabled only when permitted)
### Skills and Roll Commands ### Skills and Roll Commands
@@ -272,6 +271,7 @@ Shows chronological roll entries with:
- Roller identity - Roller identity
- Character + skill - Character + skill
- Result and breakdown - Result and breakdown
- Die-by-die visualization with states: `critical`, `fumble`, `wild`, `removed`, `added`
- Visibility badge - Visibility badge
- Timestamp - Timestamp
@@ -339,7 +339,7 @@ Server error mapping:
| `campaign_not_found`, `character_not_found`, `skill_not_found` | Non-blocking panel alert + auto-refresh relevant list | | `campaign_not_found`, `character_not_found`, `skill_not_found` | Non-blocking panel alert + auto-refresh relevant list |
| `forbidden` | Explain permission rule and remove unavailable actions | | `forbidden` | Explain permission rule and remove unavailable actions |
| `unauthorized` | Clear session and return to auth view with message | | `unauthorized` | Clear session and return to auth view with message |
| `no_active_character` | Show CTA to activate a character | | `no_active_character` | Prompt user to choose a character in picker and auto-sync active context |
## 11. Empty, Loading, and Disabled States ## 11. Empty, Loading, and Disabled States
@@ -388,7 +388,6 @@ Loading patterns:
- Use plain language and short action labels. - Use plain language and short action labels.
- Keep critical actions explicit: - Keep critical actions explicit:
- "Create Campaign" - "Create Campaign"
- "Activate Character"
- "Roll Skill" - "Roll Skill"
- Error tone: corrective and direct. - Error tone: corrective and direct.
- Date/time: local display with ISO tooltip. - Date/time: local display with ISO tooltip.
@@ -412,7 +411,7 @@ Loading patterns:
|---|---| |---|---|
| User management | Auth view with register/login/logout, secure failure messaging | | User management | Auth view with register/login/logout, secure failure messaging |
| Campaign management | Dedicated `Campaign Management` screen with ruleset assignment and details | | Campaign management | Dedicated `Campaign Management` screen with ruleset assignment and details |
| Character management | Character picker + modal edit/create/activate flows | | Character management | Character picker + modal edit/create flows with selected-character active context |
| Active character context | Persistent header context and activation CTA | | Active character context | Persistent header context and activation CTA |
| Skill management | Skill create/edit modals + ruleset-aware validation feedback | | Skill management | Skill create/edit modals + ruleset-aware validation feedback |
| Dice rolling | Visibility toggle + deterministic result display in `Play` screen | | Dice rolling | Visibility toggle + deterministic result display in `Play` screen |