Harden shell and tables interactions

This commit is contained in:
2026-04-12 22:38:18 +02:00
parent 222abc155b
commit 0dd1f42fac
18 changed files with 299 additions and 664 deletions

View File

@@ -30,7 +30,7 @@ It is intentionally implementation-focused:
- Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12`
- Current focus: `Phase 7`
- Current focus: `Post-Phase 7 review`
- Document mode: living plan and progress log
### Progress Log
@@ -79,6 +79,7 @@ It is intentionally implementation-focused:
| 2026-04-12 | Phase 4 | Completed | Replaced the placeholder `/curation` route with a real queue-first workspace, added queue scope and context persistence, moved browse-to-curation handoff out of `Tables`, and preserved diagnostics and full-editor escape hatches without keeping queue work on the reference page. |
| 2026-04-12 | Phase 5 | Completed | Consolidated the existing tooling routes into a coherent `Tools` workspace with a real hub, shared tooling page frame, preserved-context exits from diagnostics back into `Tables` and `Curation`, and a grouped API reference surface. |
| 2026-04-12 | Phase 6 | Completed | Reframed `/` as `Play`, replaced the old symmetric dashboard treatment with a resolver-first layout, aligned the page to the shared shell and token system, and added result-to-`Tables` deep links from both direct and attack-driven critical outcomes. |
| 2026-04-12 | Phase 7 | Completed | Hardened keyboard/focus behavior across shell and table workflows, raised compact controls to the 44px accessibility target, tightened sticky-shell scroll offsets, added explicit deep-link serializer coverage, removed obsolete old-shell and old-`/tables` component paths, and cleaned up the final route/shell documentation. |
### Lessons Learned
@@ -212,12 +213,12 @@ Create the implementation foundation so the visual overhaul does not start with
| App host | `src/RolemasterDb.App/Components/App.razor` | document shell, fonts, global CSS/script includes | keep as the document root; only update fonts and global assets during Phase 1 |
| Router | `src/RolemasterDb.App/Components/Routes.razor` | route resolution and layout selection | keep single router; add compatibility route components instead of special middleware redirects |
| App shell | `src/RolemasterDb.App/Components/Layout/MainLayout.razor` | current sidebar layout and page body host | replace with the new top app bar shell and mobile bottom nav |
| Primary nav | `src/RolemasterDb.App/Components/Layout/NavMenu.razor` | implementation-bucket navigation | retire after the new shell lands; replace with destination navigation primitives |
| Primary nav | `src/RolemasterDb.App/Components/Shell/ShellPrimaryNav.razor` | destination-based shell navigation | keep as the canonical top and mobile nav surface |
| Home page | `src/RolemasterDb.App/Components/Pages/Home.razor` | live lookup flow | keep behavior, later restyle and reframe as `Play` |
| Tables page | `src/RolemasterDb.App/Components/Pages/Tables.razor` | table selection, table rendering, persisted selection, editor launch, curation queue work | split into page shell, selection state, index rail, context bar, table canvas, inspector, and action services |
| Diagnostics page | `src/RolemasterDb.App/Components/Pages/Diagnostics.razor` | engineering inspection of a selected cell | move under `Tools` and reuse shared table-selection state |
| API page | `src/RolemasterDb.App/Components/Pages/Api.razor` | static API docs page | move under `Tools` with updated framing |
| Shared editor and curation UI | `src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor`, `src/RolemasterDb.App/Components/Shared/CriticalCellCurationDialog.razor` | full editing and quick curation interactions | preserve behavior; change launch points and page ownership |
| Shared editor and curation UI | `src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor`, `src/RolemasterDb.App/Components/Curation/CurationWorkspace.razor` | full editing and queue-first quick curation interactions | preserve behavior while keeping repeated curation work on the dedicated `/curation` surface |
| Lookup and table contracts | `src/RolemasterDb.App/Features/LookupContracts.cs` | frontend-facing DTOs for tables, lookups, editing, and diagnostics | preserve contracts; build frontend state and deep links around them |
### Behaviors To Preserve
@@ -226,7 +227,7 @@ Create the implementation foundation so the visual overhaul does not start with
| --- | --- | --- | --- |
| Selected table persistence | `Tables.razor` local storage key `rolemaster.tables.selectedTable` | shared per-destination context persistence service | move out of page code during Phase 2 |
| Full cell editor flow | `Tables.razor` + `CriticalCellEditorDialog.razor` | stable editor entry from inspector, curation, and tools | editor remains modal for now |
| Quick curation flow and save-next behavior | `Tables.razor` + `CriticalCellCurationDialog.razor` | dedicated `Curation` workflow with reused quick-parse components | queue logic moves off the reference page |
| Quick curation flow and save-next behavior | `Curation.razor` + `CurationWorkspace.razor` | dedicated `Curation` workflow with reused quick-parse components | queue logic is no longer carried by the reference page |
| Diagnostics selection model | `Diagnostics.razor` | shared table-position selector model used by `Tools` | existing selection order is a good starting point |
| Attack and direct critical lookup flows | `Home.razor` | `Play` page behaviors | preserve contracts and calculation behavior |
| Result preview components | `CriticalLookupResultCard.razor`, `CompactCriticalCell.razor`, related shared components | reusable reference and inspector content | keep and adapt instead of rewriting presentation logic from scratch |
@@ -589,7 +590,7 @@ Turn `/tables` into the canonical reference surface for reading and inspecting c
- The route already exists at `Components/Pages/Curation.razor`, but it is still a placeholder panel with no queue, no selection state, and no working-lane layout.
- The active curation implementation currently lives inside `Components/Pages/Tables.razor` through page-local state such as `OpenCellCurationAsync`, `MarkCellCuratedAsync`, `LoadCurationCellAsync`, `ReparseCurationCellAsync`, and `FindNextUncuratedResultId`.
- The current curation UI is rendered by `Components/Shared/CriticalCellCurationDialog.razor`, which already contains the two most valuable pieces of the eventual Phase 4 surface: side-by-side parsed preview and source image, plus an inline quick-parse mode.
- The current queue-first curation UI now lives in `Components/Curation/CurationWorkspace.razor`, which keeps the two most valuable pieces of the old dialog flow: side-by-side parsed preview and source image, plus an inline quick-parse mode.
- The current `next uncurated` behavior only walks the already loaded cells of the selected table. It does not yet operate across queue scopes such as all tables or the pinned set.
- The full editor path already exists and should remain available, but the present implementation still depends on opening curation from the `Tables` browsing flow rather than from a dedicated repeated-action workflow.
@@ -677,7 +678,7 @@ Create a dedicated queue-first curation workflow so repair work is fast and does
### Phase 4 Implementation Notes
- The lowest-risk migration path is to keep `CriticalCellCurationDialog` as the behavioral baseline, extract any queue and save helpers that are currently trapped in `Tables.razor`, and then rehost that workflow inside new `Components/Curation` surfaces.
- The lowest-risk migration path was to keep the old dialog behavior as the baseline, extract any queue and save helpers that were trapped in `Tables.razor`, and then rehost that workflow inside `Components/Curation` surfaces.
- The current page-local `FindNextUncuratedResultId` logic is the clearest seam for early extraction. Once that becomes a shared queue helper, the same save-and-advance contract can back both the dedicated `Curation` page and any temporary compatibility entry points from `Tables`.
- `Curation` should reuse the existing deep-link grammar rather than inventing a second identifier model. The distinction should be workflow mode and queue scope, not a parallel object-addressing scheme.
- The implementation should keep normal curation modal-free on desktop and mobile. Full edit can still use a focused drawer or sheet, but the queue lane itself should be a stable page surface.
@@ -770,6 +771,26 @@ Make the default landing experience feel like part of the same product and conne
## Phase 7: Hardening, Accessibility, Performance, And Rollout Cleanup
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P7.1` | Completed | Shared tabs, shell overlays, and the table index rail now expose stronger keyboard reachability and visible active state. |
| `P7.2` | Completed | Focus treatment was normalized across shell navigation, compact controls, table index actions, and interactive result surfaces. |
| `P7.3` | Completed | Accent, warning, success, and tooling emphasis continue to use distinct semantic color ramps in both themes after the final focus and hit-target pass. |
| `P7.4` | Completed | Sticky shell/context-bar and contained-scroll behavior were verified and tightened for the final desktop/mobile layout. |
| `P7.5` | Completed | Final shell scroll offsets now reserve space for sticky shell chrome so content is not obscured during navigation or restored context jumps. |
| `P7.6` | Completed | Shared table-context URL round-trip coverage now explicitly exercises the full serializer field set, including queue scope. |
| `P7.7` | Completed | The per-destination restore model established earlier remained the final persisted-context path and was reverified during the hardening pass. |
| `P7.8` | Completed | `TablesCanvas` now caches active row/column/group state and roll-jump context instead of recomputing those hot-path values throughout the render loop. |
| `P7.9` | Completed | Virtualization was not introduced because profiling did not justify the extra sticky-grid complexity. |
| `P7.10` | Completed | Obsolete old-shell and old-`/tables` components were removed from the active codebase, including the retired inspector and pre-queue curation dialog path. |
| `P7.11` | Completed | The implementation plan now reflects the final route map, shell model, and surviving component boundaries. |
### Goal
Stabilize the overhaul and ensure the final UX matches the bible under real usage conditions.

View File

@@ -1,31 +0,0 @@
<div class="nav-menu-shell">
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler')?.click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Lookup Desk
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="api">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> API Surface
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="tables">
<span class="bi bi-table" aria-hidden="true"></span> Critical Tables
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="diagnostics">
<span class="bi bi-tools" aria-hidden="true"></span> Diagnostics
</NavLink>
</div>
</nav>
</div>
</div>

View File

@@ -1,98 +0,0 @@
.nav-menu-shell {
position: relative;
min-height: 3.5rem;
}
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(226, 195, 128, 0.22);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 244, 218, 0.82%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.06);
}
.navbar-toggler:checked {
background-color: rgba(226, 195, 128, 0.3);
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='%23fce7bc' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.98rem;
padding-bottom: 0.35rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
font-family: "Source Sans 3", "Segoe UI", sans-serif;
color: #f3ddbc;
background: none;
border: none;
border-radius: 12px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
padding: 0 1rem;
}
.nav-item ::deep a.active {
background: linear-gradient(135deg, rgba(230, 195, 126, 0.22), rgba(255, 245, 225, 0.1));
color: #fff7e2;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255, 248, 234, 0.08);
color: #fff7e2;
}
.nav-scrollable {
display: none;
padding: 3.5rem 0 0.5rem;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-menu-shell {
min-height: 0;
}
.nav-scrollable {
display: block;
height: 100vh;
overflow-y: auto;
padding: 1rem 0;
}
}

View File

@@ -2,14 +2,17 @@
@foreach (var item in Items)
{
var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal);
var tabIndex = isSelected || (!HasSelectedValue && string.Equals(item.Value, FirstEnabledValue, StringComparison.Ordinal)) ? 0 : -1;
<button
type="button"
class="segmented-tabs-button @(isSelected ? "is-selected" : null)"
role="tab"
aria-selected="@isSelected"
tabindex="@tabIndex"
disabled="@item.IsDisabled"
@onclick="() => SelectAsync(item.Value)">
@onclick="() => SelectAsync(item.Value)"
@onkeydown="args => HandleKeyDownAsync(args, item.Value)">
<span>@item.Label</span>
@if (!string.IsNullOrWhiteSpace(item.Badge))
{
@@ -20,6 +23,7 @@
</div>
@code {
[Parameter]
public IReadOnlyList<SegmentedTabItem> Items { get; set; } = [];
@@ -35,11 +39,47 @@
[Parameter]
public string? CssClass { get; set; }
private bool HasSelectedValue =>
!string.IsNullOrWhiteSpace(SelectedValue) && Items.Any(item => !item.IsDisabled && string.Equals(item.Value, SelectedValue, StringComparison.Ordinal));
private string? FirstEnabledValue =>
Items.FirstOrDefault(item => !item.IsDisabled)?.Value;
private string BuildCssClass() =>
string.IsNullOrWhiteSpace(CssClass)
? "segmented-tabs"
: $"segmented-tabs {CssClass}";
string.IsNullOrWhiteSpace(CssClass) ? "segmented-tabs" : $"segmented-tabs {CssClass}";
private Task SelectAsync(string value) =>
SelectedValueChanged.InvokeAsync(value);
}
private async Task HandleKeyDownAsync(KeyboardEventArgs args, string currentValue)
{
var enabledItems = Items.Where(item => !item.IsDisabled).ToList();
if (enabledItems.Count == 0)
{
return;
}
var currentIndex = enabledItems.FindIndex(item => string.Equals(item.Value, currentValue, StringComparison.Ordinal));
if (currentIndex < 0)
{
currentIndex = 0;
}
var nextValue = args.Key switch
{
"ArrowRight" or "ArrowDown" => enabledItems[Math.Min(currentIndex + 1, enabledItems.Count - 1)].Value,
"ArrowLeft" or "ArrowUp" => enabledItems[Math.Max(currentIndex - 1, 0)].Value,
"Home" => enabledItems[0].Value,
"End" => enabledItems[^1].Value,
_ => null
};
if (string.IsNullOrWhiteSpace(nextValue))
{
return;
}
await SelectAsync(nextValue);
}
}

View File

@@ -1,77 +0,0 @@
@if (IsOpen)
{
<button
type="button"
class="surface-drawer-backdrop"
aria-label="@CloseLabel"
@onclick="HandleCloseAsync"></button>
<aside class="@BuildCssClass()" aria-label="@AriaLabel">
@if (!string.IsNullOrWhiteSpace(Title) || HeaderContent is not null || OnClose.HasDelegate)
{
<header class="surface-drawer-header">
<div>
@if (!string.IsNullOrWhiteSpace(Title))
{
<strong class="surface-drawer-title">@Title</strong>
}
</div>
<div class="surface-drawer-header-actions">
@HeaderContent
@if (OnClose.HasDelegate)
{
<button type="button" class="btn btn-link" @onclick="HandleCloseAsync">Close</button>
}
</div>
</header>
}
<div class="surface-drawer-body">
@ChildContent
</div>
</aside>
}
@code {
[Parameter]
public bool IsOpen { get; set; }
[Parameter]
public string Placement { get; set; } = "end";
[Parameter]
public string AriaLabel { get; set; } = "Drawer";
[Parameter]
public string CloseLabel { get; set; } = "Close panel";
[Parameter]
public string? Title { get; set; }
[Parameter]
public RenderFragment? HeaderContent { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public EventCallback OnClose { get; set; }
[Parameter]
public string? CssClass { get; set; }
private string BuildCssClass()
{
var classes = new List<string> { "surface-drawer", $"is-{Placement.Trim().ToLowerInvariant()}" };
if (!string.IsNullOrWhiteSpace(CssClass))
{
classes.Add(CssClass);
}
return string.Join(' ', classes);
}
private Task HandleCloseAsync() =>
OnClose.InvokeAsync();
}

View File

@@ -1,168 +0,0 @@
@using Microsoft.JSInterop
@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Features
@implements IAsyncDisposable
@inject IJSRuntime JSRuntime
<div class="critical-editor-backdrop"
@onpointerdown="HandleBackdropPointerDown"
@onpointerup="HandleBackdropPointerUp"
@onpointercancel="HandleBackdropPointerCancel">
<div class="critical-editor-dialog critical-curation-dialog"
@onpointerdown="HandleDialogPointerDown"
@onpointerup="HandleDialogPointerUp"
@onpointercancel="HandleDialogPointerCancel"
@onpointerdown:stopPropagation="true"
@onpointerup:stopPropagation="true"
@onpointercancel:stopPropagation="true">
<header class="critical-editor-header">
<div>
<h3 class="panel-title">Curate Result Card</h3>
@if (Model is not null)
{
<p class="muted critical-editor-meta">
<strong>@Model.TableName</strong>
<span> · Column <strong>@BuildColumnDisplayText(Model)</strong></span>
<span> · Roll band <strong>@Model.RollBand</strong></span>
</p>
}
</div>
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
</header>
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
<CurationWorkspace
Model="Model"
IsLoading="IsLoading"
IsSaving="IsSaving"
IsReparsing="IsReparsing"
IsQuickParseMode="IsQuickParseMode"
ErrorMessage="@ErrorMessage"
QuickParseErrorMessage="@QuickParseErrorMessage"
LegendEntries="LegendEntries"
PrimaryActionLabel="Mark as Curated"
SecondaryActionLabel="Edit"
SecondaryActionCssClass="btn btn-link"
OnPrimaryAction="OnMarkCurated"
OnSecondaryAction="OnEdit"
OnEnterQuickParse="OnEnterQuickParse"
OnCancelQuickParse="OnCancelQuickParse"
OnReparse="OnReparse"/>
</div>
</div>
</div>
@code {
[Parameter, EditorRequired]
public CriticalCellEditorModel? Model { get; set; }
[Parameter]
public bool IsLoading { get; set; }
[Parameter]
public bool IsSaving { get; set; }
[Parameter]
public bool IsReparsing { get; set; }
[Parameter]
public bool IsQuickParseMode { get; set; }
[Parameter]
public string? ErrorMessage { get; set; }
[Parameter]
public string? QuickParseErrorMessage { get; set; }
[Parameter]
public IReadOnlyList<CriticalTableLegendEntry>? LegendEntries { get; set; }
[Parameter, EditorRequired]
public EventCallback OnClose { get; set; }
[Parameter, EditorRequired]
public EventCallback OnMarkCurated { get; set; }
[Parameter, EditorRequired]
public EventCallback OnEdit { get; set; }
[Parameter, EditorRequired]
public EventCallback OnEnterQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnCancelQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnReparse { get; set; }
private IJSObjectReference? jsModule;
private bool isBackdropPointerDown;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./components/shared/critical-cell-editor-dialog.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll");
}
}
public async ValueTask DisposeAsync()
{
if (jsModule is null)
{
return;
}
try
{
await jsModule.InvokeVoidAsync("unlockBackgroundScroll");
await jsModule.DisposeAsync();
}
catch (JSDisconnectedException)
{
}
}
private void HandleBackdropPointerDown()
{
isBackdropPointerDown = true;
}
private async Task HandleBackdropPointerUp()
{
if (!isBackdropPointerDown)
{
return;
}
isBackdropPointerDown = false;
await OnClose.InvokeAsync();
}
private void HandleBackdropPointerCancel()
{
isBackdropPointerDown = false;
}
private void HandleDialogPointerDown()
{
isBackdropPointerDown = false;
}
private void HandleDialogPointerUp()
{
isBackdropPointerDown = false;
}
private void HandleDialogPointerCancel()
{
isBackdropPointerDown = false;
}
private static string BuildColumnDisplayText(CriticalCellEditorModel model) =>
string.IsNullOrWhiteSpace(model.GroupLabel) ? model.ColumnLabel : $"{model.GroupLabel} / {model.ColumnLabel}";
}

View File

@@ -26,7 +26,7 @@
<nav class="app-shell-header-nav" aria-label="Primary">
@if (PrimaryNavContent is null)
{
<ShellPrimaryNav />
<ShellPrimaryNav/>
}
else
{
@@ -45,6 +45,7 @@
<button
type="button"
class="app-shell-menu-toggle"
aria-haspopup="dialog"
aria-expanded="@isNavMenuOpen"
aria-controls="app-shell-drawer"
@onclick="ToggleNavMenu">
@@ -65,7 +66,7 @@
}
</header>
<ShellOmniboxPalette />
<ShellOmniboxPalette/>
<main id="app-main" class="app-shell-main">
<div class="content-shell">
@@ -79,26 +80,28 @@
type="button"
class="app-shell-drawer-backdrop"
aria-label="Close navigation"
@onclick="CloseNavMenu"></button>
@onclick="CloseNavMenu">
</button>
<aside id="app-shell-drawer" class="app-shell-drawer" aria-label="Primary navigation">
<aside id="app-shell-drawer" class="app-shell-drawer" role="dialog" aria-modal="true" aria-label="Primary navigation" @onkeydown="HandleNavMenuKeyDownAsync">
<div class="app-shell-drawer-header">
<strong>Navigate</strong>
<button type="button" class="app-shell-drawer-close" @onclick="CloseNavMenu">
<button type="button" class="app-shell-drawer-close" @ref="navMenuCloseButton" @onclick="CloseNavMenu">
Close
</button>
</div>
<ShellPrimaryNav />
<ShellPrimaryNav/>
</aside>
}
<nav class="app-shell-mobile-nav" aria-label="Primary">
<ShellPrimaryNav IsBottomNav="true" />
<ShellPrimaryNav IsBottomNav="true"/>
</nav>
</div>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
@@ -118,20 +121,35 @@
public RenderFragment? UtilityContent { get; set; }
private bool isNavMenuOpen;
private ElementReference navMenuCloseButton;
private bool shouldFocusNavMenuCloseButton;
protected override void OnInitialized()
{
NavigationManager.LocationChanged += HandleLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!shouldFocusNavMenuCloseButton || !isNavMenuOpen)
{
return;
}
shouldFocusNavMenuCloseButton = false;
await navMenuCloseButton.FocusAsync();
}
private void ToggleNavMenu()
{
isNavMenuOpen = !isNavMenuOpen;
shouldFocusNavMenuCloseButton = isNavMenuOpen;
}
private void CloseNavMenu()
{
isNavMenuOpen = false;
shouldFocusNavMenuCloseButton = false;
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
@@ -144,4 +162,15 @@
{
NavigationManager.LocationChanged -= HandleLocationChanged;
}
}
private Task HandleNavMenuKeyDownAsync(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
CloseNavMenu();
}
return Task.CompletedTask;
}
}

View File

@@ -24,6 +24,11 @@
top: 1rem;
}
.skip-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.app-shell-header {
position: sticky;
top: 0;
@@ -95,6 +100,7 @@
.app-shell-header-omnibox {
min-width: 0;
overflow: hidden;
}
.app-shell-header-actions {
@@ -127,6 +133,12 @@
background: currentColor;
}
.app-shell-menu-toggle:focus-visible,
.app-shell-drawer-close:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.app-shell-shortcuts {
margin-top: 0.75rem;
}
@@ -135,6 +147,7 @@
flex: 1 1 auto;
min-width: 0;
padding: 1rem 0 5.75rem;
scroll-margin-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
}
.content-shell {
@@ -197,7 +210,7 @@
border-radius: 999px;
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
color: var(--text-primary);
min-height: 2.5rem;
min-height: 2.75rem;
padding: 0.45rem 0.85rem;
}
@@ -212,7 +225,7 @@
}
.app-shell-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
}

View File

@@ -14,10 +14,12 @@
type="button"
class="shell-omnibox-backdrop"
aria-label="Close search panel"
@onclick="CloseAsync"></button>
@onclick="CloseAsync">
</button>
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-label="Search tables and commands">
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-labelledby="shell-omnibox-title" @onkeydown="HandlePaletteKeyDownAsync">
<header class="shell-omnibox-header">
<h2 id="shell-omnibox-title" class="visually-hidden">Search tables and commands</h2>
<label class="shell-omnibox-search">
<span class="visually-hidden">Search tables or commands</span>
<input
@@ -26,7 +28,7 @@
placeholder="Search tables or type /"
@bind="query"
@bind:event="oninput"
@onkeydown="HandleInputKeyDownAsync" />
@onkeydown="HandleInputKeyDownAsync"/>
</label>
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
@@ -139,6 +141,7 @@
}
@code {
private static readonly IReadOnlyList<ShellOmniboxCommand> Commands =
[
new("/tables", "Reference", "Open the reference tables surface.", "/tables"),
@@ -155,37 +158,19 @@
private string query = string.Empty;
private bool HasResults =>
MatchingCommands.Count > 0 ||
MatchingPinned.Count > 0 ||
MatchingRecent.Count > 0 ||
MatchingTables.Count > 0;
MatchingCommands.Count > 0 || MatchingPinned.Count > 0 || MatchingRecent.Count > 0 || MatchingTables.Count > 0;
private IReadOnlyList<CriticalTableReference> MatchingTables =>
referenceData?.CriticalTables
.Where(MatchesTableQuery)
.Take(8)
.ToList()
?? [];
referenceData?.CriticalTables.Where(MatchesTableQuery).Take(8).ToList() ?? [];
private IReadOnlyList<PinnedTableEntry> MatchingPinned =>
PinnedTablesState.Items
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
.Take(6)
.ToList();
PinnedTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
private IReadOnlyList<RecentTableEntry> MatchingRecent =>
RecentTablesState.Items
.Where(item => MatchesText(item.Label, item.Family, item.Slug))
.Take(6)
.ToList();
RecentTablesState.Items.Where(item => MatchesText(item.Label, item.Family, item.Slug)).Take(6).ToList();
private IReadOnlyList<ShellOmniboxCommand> MatchingCommands =>
Commands
.Where(command => QueryStartsCommandMode()
? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase)
: MatchesText(command.Shortcut, command.Label, command.Description))
.Take(5)
.ToList();
Commands.Where(command => QueryStartsCommandMode() ? command.Shortcut.Contains(query.Trim(), StringComparison.OrdinalIgnoreCase) : MatchesText(command.Shortcut, command.Label, command.Description)).Take(5).ToList();
protected override void OnInitialized()
{
@@ -274,11 +259,12 @@
}
}
private Task HandlePaletteKeyDownAsync(KeyboardEventArgs args) =>
string.Equals(args.Key, "Escape", StringComparison.Ordinal) ? CloseAsync() : Task.CompletedTask;
private async Task OpenTableAsync(string tableSlug)
{
var snapshot = new TableContextSnapshot(
TableSlug: tableSlug,
Mode: TableContextMode.Reference);
var snapshot = new TableContextSnapshot(TableSlug: tableSlug, Mode: TableContextMode.Reference);
await TableContextState.PersistAsync("tables", snapshot);
ShellOmniboxState.Close();
@@ -303,11 +289,10 @@
return true;
}
return values.Any(value =>
!string.IsNullOrWhiteSpace(value) &&
value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
return values.Any(value => !string.IsNullOrWhiteSpace(value) && value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
}
private bool QueryStartsCommandMode() =>
query.TrimStart().StartsWith("/", StringComparison.Ordinal);
}
}

View File

@@ -3,6 +3,8 @@
display: inline-flex;
align-items: center;
justify-content: flex-start;
min-width: 0;
max-width: 100%;
min-height: 2.75rem;
padding: 0.65rem 0.9rem;
border: 1px solid var(--border-default);
@@ -13,6 +15,9 @@
}
.shell-omnibox-trigger-label {
display: block;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -24,6 +24,11 @@
transform: translateY(-1px);
}
.shell-primary-nav-link:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.shell-primary-nav-link.active {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent-1) 84%, var(--surface-2));

View File

@@ -42,7 +42,7 @@
@onkeydown="args => HandleCellKeyDown(args, cell)">
<div class="critical-table-cell-shell">
<div class="critical-table-cell-actions">
@if (string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal))
@if (isReferenceMode)
{
<StatusIndicator Tone="@(cell.IsCurated ? "success" : "warning")" CssClass="tables-cell-status-indicator"/>
}
@@ -92,6 +92,11 @@
private readonly List<CriticalGroupReference> visibleGroups = new();
private readonly List<CriticalColumnReference> visibleColumns = new();
private string gridTemplateStyle = string.Empty;
private bool isReferenceMode;
private string? activeRollBand;
private string? activeColumnKey;
private string? activeGroupKey;
private string? rollJumpBandLabel;
[Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!;
@@ -123,6 +128,11 @@
displayColumns.Clear();
visibleGroups.Clear();
visibleColumns.Clear();
isReferenceMode = string.Equals(CurrentMode, TablesReferenceMode.Reference, StringComparison.Ordinal);
rollJumpBandLabel = ResolveRollJumpBandLabel();
activeRollBand = !string.IsNullOrWhiteSpace(SelectedCell?.RollBand) ? SelectedCell.RollBand : rollJumpBandLabel;
activeColumnKey = !string.IsNullOrWhiteSpace(SelectedCell?.ColumnKey) ? SelectedCell.ColumnKey : (!string.IsNullOrWhiteSpace(SelectedColumnKey) ? SelectedColumnKey : null);
activeGroupKey = !string.IsNullOrWhiteSpace(SelectedCell?.GroupKey) ? SelectedCell.GroupKey : (!string.IsNullOrWhiteSpace(SelectedGroupKey) ? SelectedGroupKey : null);
foreach (var cell in Detail.Cells)
{
@@ -197,15 +207,6 @@
_ => true
};
private string? ActiveRollBand =>
!string.IsNullOrWhiteSpace(SelectedCell?.RollBand) ? SelectedCell.RollBand : ResolveRollJumpBandLabel();
private string? ActiveColumnKey =>
!string.IsNullOrWhiteSpace(SelectedCell?.ColumnKey) ? SelectedCell.ColumnKey : (!string.IsNullOrWhiteSpace(SelectedColumnKey) ? SelectedColumnKey : null);
private string? ActiveGroupKey =>
!string.IsNullOrWhiteSpace(SelectedCell?.GroupKey) ? SelectedCell.GroupKey : (!string.IsNullOrWhiteSpace(SelectedGroupKey) ? SelectedGroupKey : null);
private string BuildGridCssClass()
{
var classes = new List<string>
@@ -224,7 +225,7 @@
"critical-table-grid-header-cell",
"critical-table-grid-group-header"
};
if (string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
if (string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
@@ -240,12 +241,12 @@
"critical-table-grid-column-header"
};
if (string.Equals(columnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
if (string.Equals(columnKey, activeColumnKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-column");
}
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
@@ -260,12 +261,12 @@
"critical-table-grid-header-cell",
"critical-table-grid-roll-band"
};
if (string.Equals(rollBandLabel, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
if (string.Equals(rollBandLabel, activeRollBand, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-row");
}
if (string.Equals(rollBandLabel, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
if (string.Equals(rollBandLabel, rollJumpBandLabel, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-roll-target");
}
@@ -281,17 +282,17 @@
cell.IsCurated ? "is-curated" : "needs-curation"
};
if (string.Equals(cell.RollBand, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
if (string.Equals(cell.RollBand, activeRollBand, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-row");
}
if (string.Equals(cell.ColumnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
if (string.Equals(cell.ColumnKey, activeColumnKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-column");
}
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, activeGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
@@ -301,7 +302,7 @@
classes.Add("is-selected-cell");
}
if (string.Equals(cell.RollBand, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
if (string.Equals(cell.RollBand, rollJumpBandLabel, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-roll-target");
}

View File

@@ -1,9 +1,9 @@
<section class="tables-index-rail" aria-labelledby="tables-index-heading">
<section class="tables-index-rail" aria-labelledby="tables-index-heading" tabindex="0" @onkeydown="HandleRailKeyDown">
<div class="tables-index-rail-header">
<h2 id="tables-index-heading" class="tables-index-title">Table Index</h2>
</div>
<div class="tables-index-controls" @onkeydown="HandleRailKeyDown">
<div class="tables-index-controls">
<label class="tables-index-search-label" for="tables-index-search">Search tables</label>
<input
id="tables-index-search"
@@ -11,7 +11,8 @@
type="search"
placeholder="Search tables"
value="@searchText"
@oninput="HandleSearchInput"/>
@oninput="HandleSearchInput"
@onkeydown:stopPropagation="true"/>
@if (familyFilters.Count > 1)
{
@@ -40,7 +41,7 @@
<h3>Pinned</h3>
<span class="tables-index-section-count">@pinnedTables.Count</span>
</div>
<div class="tables-index-list" role="listbox" aria-label="Pinned critical tables">
<div class="tables-index-list" aria-label="Pinned critical tables">
@foreach (var table in pinnedTables)
{
@RenderTableOption(table)
@@ -56,7 +57,7 @@
<h3>Recent</h3>
<span class="tables-index-section-count">@recentTables.Count</span>
</div>
<div class="tables-index-list" role="listbox" aria-label="Recent critical tables">
<div class="tables-index-list" aria-label="Recent critical tables">
@foreach (var table in recentTables)
{
@RenderTableOption(table)
@@ -77,7 +78,7 @@
}
else
{
<div class="tables-index-list" role="listbox" aria-label="Critical tables">
<div class="tables-index-list" aria-label="Critical tables">
@foreach (var table in filteredTables)
{
@RenderTableOption(table)
@@ -250,6 +251,18 @@
return;
}
if (string.Equals(args.Key, "Home", StringComparison.Ordinal))
{
MoveActiveOptionToBoundary(useLast: false);
return;
}
if (string.Equals(args.Key, "End", StringComparison.Ordinal))
{
MoveActiveOptionToBoundary(useLast: true);
return;
}
if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug))
{
_ = OnSelectTable.InvokeAsync(activeOptionSlug);
@@ -275,6 +288,17 @@
activeOptionSlug = keyboardOptions[nextIndex].Key;
}
private void MoveActiveOptionToBoundary(bool useLast)
{
if (keyboardOptions.Count == 0)
{
activeOptionSlug = null;
return;
}
activeOptionSlug = useLast ? keyboardOptions[^1].Key : keyboardOptions[0].Key;
}
private void EnsureActiveOption()
{
if (keyboardOptions.Count == 0)
@@ -326,8 +350,7 @@
private RenderFragment RenderTableOption(CriticalTableReference table) => @<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
aria-current="@(string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase) ? "true" : null)"
class="table-index-option @GetTableOptionCssClass(table)"
@onfocus="() => SetActiveOption(table.Key)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">

View File

@@ -1,14 +0,0 @@
<aside class="tables-inspector" aria-label="Selected result inspector">
<TablesInspectorContent SelectedCellDetail="SelectedCellDetail" OnEdit="OnEdit" OnCurate="OnCurate" />
</aside>
@code {
[Parameter]
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnCurate { get; set; }
}

View File

@@ -1,62 +0,0 @@
@if (SelectedCellDetail is null)
{
<InspectorSection Title="Inspector" Description="Select a result in the table to inspect its details here.">
<p class="tables-inspector-empty">Choose a cell to see its roll band, severity, and readable result without leaving the grid.</p>
</InspectorSection>
}
else
{
var cell = SelectedCellDetail;
<InspectorSection Title="Selected Result" Description="Read the selected cell and its context without opening a modal.">
<div class="tables-inspector-summary">
<div>
<p class="tables-inspector-kicker">Roll band</p>
<strong>@cell.RollBand</strong>
</div>
<div>
<p class="tables-inspector-kicker">Severity</p>
<strong>@cell.ColumnLabel</strong>
</div>
@if (!string.IsNullOrWhiteSpace(cell.GroupLabel))
{
<div>
<p class="tables-inspector-kicker">Variant</p>
<strong>@cell.GroupLabel</strong>
</div>
}
<div>
<p class="tables-inspector-kicker">Status</p>
<StatusChip Tone="@(cell.IsCurated ? "success" : "warning")">
@(cell.IsCurated ? "Curated" : "Needs Curation")
</StatusChip>
</div>
</div>
</InspectorSection>
<InspectorSection Title="Result" Description="The selected critical result stays readable while you browse the grid.">
<div class="tables-inspector-actions">
@if (!cell.IsCurated)
{
<button type="button" class="btn btn-secondary" @onclick="OnCurate">Open curation</button>
}
<button type="button" class="btn btn-primary" @onclick="OnEdit">Open editor</button>
</div>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</InspectorSection>
}
@code {
[Parameter]
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnCurate { get; set; }
}

View File

@@ -1,35 +0,0 @@
@if (SelectedCellDetail is not null)
{
<div class="tables-inspector-sheet" role="dialog" aria-modal="true" aria-label="Selected result inspector">
<button type="button" class="tables-inspector-sheet-backdrop" @onclick="OnClose"></button>
<section class="tables-inspector-sheet-panel">
<div class="tables-inspector-sheet-handle" aria-hidden="true"></div>
<header class="tables-inspector-sheet-header">
<div>
<p class="tables-page-eyebrow">Selected Result</p>
<h2 class="panel-title">Mobile Inspector</h2>
</div>
<button type="button" class="btn btn-link" @onclick="OnClose">Close</button>
</header>
<div class="tables-inspector-sheet-body">
<TablesInspectorContent SelectedCellDetail="SelectedCellDetail" OnEdit="OnEdit" OnCurate="OnCurate" />
</div>
</section>
</div>
}
@code {
[Parameter]
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
[Parameter]
public EventCallback OnClose { get; set; }
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnCurate { get; set; }
}

View File

@@ -199,6 +199,10 @@ html, body {
font-weight: 400;
}
html {
scroll-padding-top: calc(var(--shell-header-height, 5.75rem) + 1rem);
}
body {
margin: 0;
}
@@ -262,6 +266,13 @@ textarea {
font: inherit;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--control-height);
}
code,
pre,
.code-block {
@@ -577,9 +588,11 @@ select.input-shell {
height: var(--control-height);
}
.input-shell:focus {
.input-shell:focus,
.input-shell:focus-visible {
outline: 2px solid var(--focus-ring);
border-color: var(--focus-border);
outline-offset: 1px;
}
.action-row {
@@ -592,6 +605,7 @@ select.input-shell {
.btn-ritual {
border: none;
border-radius: 999px;
min-height: var(--control-height);
padding: 0.8rem 1.15rem;
background: linear-gradient(135deg, var(--accent-4), var(--accent-5));
color: var(--text-on-accent);
@@ -603,9 +617,18 @@ select.input-shell {
background: linear-gradient(135deg, #c38a4d, #8f5a2f);
}
.btn-ritual:focus-visible,
.roll-button:focus-visible,
.play-action-link:focus-visible,
.shell-omnibox-close:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.roll-button {
flex: 0 0 auto;
min-width: 4.5rem;
min-height: var(--control-height);
border-radius: 14px;
border: 1px solid var(--button-secondary-border);
background: var(--button-secondary-bg);
@@ -1083,6 +1106,7 @@ select.input-shell {
align-items: center;
justify-content: center;
gap: 0.55rem;
min-width: 0;
min-height: 2.75rem;
padding: 0.7rem 1rem;
border-radius: 999px;
@@ -1121,8 +1145,9 @@ select.input-shell {
.segmented-tabs-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-height: 2.25rem;
min-height: 2.75rem;
padding: 0.4rem 0.85rem;
border: none;
border-radius: 999px;
@@ -1130,6 +1155,11 @@ select.input-shell {
color: var(--text-muted);
}
.segmented-tabs-button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.segmented-tabs-button.is-selected {
background: var(--surface-elevated);
color: var(--text-strong);
@@ -1454,6 +1484,11 @@ select.input-shell {
scrollbar-gutter: stable;
}
.tables-index-rail:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 3px;
}
.tables-index-rail-header {
display: grid;
gap: 0.35rem;
@@ -1487,12 +1522,16 @@ select.input-shell {
}
.tables-family-filter {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
border: 1px solid rgba(127, 96, 55, 0.2);
border-radius: 999px;
background: rgba(255, 250, 242, 0.86);
color: var(--ink-soft);
font-size: 0.8rem;
padding: 0.28rem 0.6rem;
padding: 0.35rem 0.8rem;
transition: border-color 0.16s ease, background-color 0.16s ease, color 0.16s ease;
}
@@ -1501,7 +1540,8 @@ select.input-shell {
border-color: rgba(184, 121, 59, 0.3);
background: rgba(255, 244, 228, 0.94);
color: var(--ink);
outline: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.tables-family-filter.is-selected {
@@ -1570,7 +1610,8 @@ select.input-shell {
.table-index-option:focus-visible {
border-color: rgba(184, 121, 59, 0.28);
background: rgba(255, 247, 235, 0.94);
outline: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.table-index-option.is-selected {
@@ -1649,46 +1690,6 @@ select.input-shell {
backdrop-filter: blur(12px);
}
.tables-reference-inspector-shell {
position: sticky;
top: calc(var(--shell-header-height) + 1rem);
}
.tables-inspector {
display: grid;
gap: 0.85rem;
}
.tables-inspector-sheet {
display: none;
}
.tables-inspector-empty {
margin: 0;
color: var(--ink-soft);
}
.tables-inspector-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.tables-inspector-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.tables-inspector-kicker {
margin: 0 0 0.2rem;
color: var(--ink-soft);
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.table-shell {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
@@ -1777,6 +1778,10 @@ select.input-shell {
}
.tables-context-filter-chip {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
border: 1px solid rgba(127, 96, 55, 0.2);
border-radius: 999px;
background: rgba(255, 246, 233, 0.88);
@@ -1790,7 +1795,8 @@ select.input-shell {
.tables-context-filter-chip:focus-visible {
border-color: rgba(184, 121, 59, 0.34);
background: rgba(255, 239, 214, 0.96);
outline: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.table-browser-reading-hint {
@@ -1810,62 +1816,11 @@ select.input-shell {
display: block;
}
.tables-reference-inspector-shell {
display: none;
}
.tables-selection-menu {
right: 0.75rem;
left: 0.75rem;
justify-content: flex-end;
}
.tables-inspector-sheet {
position: fixed;
inset: 0;
display: grid;
align-items: end;
z-index: 60;
}
.tables-inspector-sheet-backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(17, 18, 19, 0.38);
padding: 0;
}
.tables-inspector-sheet-panel {
position: relative;
display: grid;
gap: 0.85rem;
max-height: min(78vh, 42rem);
padding: 0.85rem 1rem 1rem;
border-radius: 24px 24px 0 0;
background: var(--surface-card-strong);
border: 1px solid rgba(127, 96, 55, 0.18);
box-shadow: 0 -12px 32px rgba(18, 14, 9, 0.18);
}
.tables-inspector-sheet-handle {
width: 3.2rem;
height: 0.32rem;
margin: 0 auto;
border-radius: 999px;
background: rgba(127, 96, 55, 0.22);
}
.tables-inspector-sheet-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.85rem;
}
.tables-inspector-sheet-body {
overflow: auto;
}
}
@media (min-width: 1024px) {
@@ -1988,7 +1943,7 @@ select.input-shell {
}
.critical-table-cell:focus-visible {
outline: 2px solid rgba(13, 148, 136, 0.45);
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}

View File

@@ -0,0 +1,43 @@
using RolemasterDb.App.Frontend.AppState;
namespace RolemasterDb.ImportTool.Tests;
public sealed class TableContextUrlSerializerTests
{
private readonly TableContextUrlSerializer serializer = new();
[Fact]
public void Build_relative_uri_round_trips_all_supported_context_fields()
{
var snapshot = new TableContextSnapshot(TableSlug: "void", GroupKey: "alpha", ColumnKey: "E", RollBand: "16-20", RollJump: 18, ResultId: 11617, Mode: TableContextMode.Curation, QueueScope: "pinned");
var uri = serializer.BuildRelativeUri("/curation", snapshot);
var roundTripped = serializer.Parse(new Uri(new Uri("https://example.test"), uri).AbsoluteUri);
Assert.Equal("/curation?table=void&group=alpha&column=E&rollBand=16-20&roll=18&result=11617&mode=curation&scope=pinned", uri);
Assert.Equal(snapshot, roundTripped);
}
[Fact]
public void Build_relative_uri_omits_empty_context_values()
{
var snapshot = new TableContextSnapshot(TableSlug: "slash", GroupKey: "", ColumnKey: null, RollBand: "01-05", RollJump: null, ResultId: null, Mode: TableContextMode.Reference, QueueScope: " ");
var uri = serializer.BuildRelativeUri("/tables", snapshot);
Assert.Equal("/tables?table=slash&rollBand=01-05&mode=reference", uri);
}
[Fact]
public void Parse_treats_unknown_mode_as_null_without_losing_other_context()
{
var snapshot = serializer.Parse("https://example.test/tools/diagnostics?table=arcane-nether&column=C&rollBand=06-10&result=9748&mode=unknown&scope=selected-table");
Assert.Equal("arcane-nether", snapshot.TableSlug);
Assert.Equal("C", snapshot.ColumnKey);
Assert.Equal("06-10", snapshot.RollBand);
Assert.Equal(9748, snapshot.ResultId);
Assert.Null(snapshot.Mode);
Assert.Equal("selected-table", snapshot.QueueScope);
}
}