diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index b4df18d..79fdbb5 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -55,6 +55,7 @@ It is intentionally implementation-focused: | 2026-03-21 | P2.3 | Completed | Added a shared `RecentTablesState` service backed by browser storage, centralized the recents storage key, and started recording successful table visits from the `Tables` page so later omnibox and rail work has real shared data. | | 2026-03-21 | P2.4 | Completed | Added a shared `PinnedTablesState` service with browser persistence, centralized the pin storage key, initialized pin state in the `Tables` page, and added a first live pin/unpin action plus pinned status chips so later omnibox and navigation work have real saved pins to consume. | | 2026-03-21 | P2.5 | Completed | Added shared table-context URL types and a serializer, registered the serializer centrally, and started round-tripping table context through `/tables` and `/tools/diagnostics` so direct links restore the selected table and diagnostic cell context instead of relying only on local storage defaults. | +| 2026-03-21 | P2.7 | Completed | Added shared frontend primitives for app-bar actions, chips, segmented tabs, drawers, inspector sections, and status indicators, wired the shell omnibox trigger onto the new app-bar action primitive, and switched the new pinned-table labels in `Tables` to the shared status-chip primitive so later page work can build on reusable building blocks instead of fresh ad hoc markup. | | 2026-03-21 | P2.8 | Completed | Added a shared `TableContextState` service on top of browser storage and the URL serializer, moved the `Tables` page off page-local table selection persistence, and switched diagnostics to the same restore/persist/build-URI flow so table context logic now lives in shared frontend state instead of being reinvented per page. | ### Lessons Learned @@ -81,6 +82,7 @@ It is intentionally implementation-focused: - Shared pinned-state services also need one live writer early. A minimal pin/unpin affordance in the current `Tables` page is enough to validate persistence before the larger navigation surfaces consume pins. - URL serializers only pay off when wired into real pages. Using the shared serializer in both `Tables` and diagnostics now gives the project one verified round-trip path before the larger table-context service lands. - The serializer alone is not the reusable boundary. The maintainable seam is a shared context-state helper that owns restore, persist, normalization, and URI-building conventions while pages keep only workflow-specific selection logic. +- Primitive extraction lands best when one or two live consumers adopt the new components immediately. That keeps the foundation honest without forcing a broad page rewrite just to validate the abstraction. ## Target Outcomes @@ -374,7 +376,7 @@ Establish the shared shell, tokens, typography, and theme system that every dest | `P2.4` | Completed | Pinned tables now persist through a shared app-state service and can already be toggled from the `Tables` page. | | `P2.5` | Completed | Shared table-context URLs now parse and serialize through one helper and are consumed by `/tables` and `/tools/diagnostics`. | | `P2.6` | Pending | The shell omnibox is still a placeholder trigger. | -| `P2.7` | Pending | Shared primitives for chips, tabs, drawers, and inspector sections are not extracted yet. | +| `P2.7` | Completed | Shared primitives for chips, tabs, drawers, inspector sections, app-bar actions, and status indicators now exist in reusable components. | | `P2.8` | Completed | Table-context restore, persistence, normalization, and URI building now flow through one shared state service used by `Tables` and diagnostics. | ### Goal diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 7a0b763..0d94695 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -33,7 +33,7 @@ @if (PinnedTablesState.IsPinned(selected.Key)) { - Pinned + Pinned } @($"{selected.CurationPercentage}%") @@ -59,7 +59,7 @@ @if (PinnedTablesState.IsPinned(table.Key)) { - Pinned + Pinned } @($"{table.CurationPercentage}%") diff --git a/src/RolemasterDb.App/Components/Primitives/AppBarActionButton.razor b/src/RolemasterDb.App/Components/Primitives/AppBarActionButton.razor new file mode 100644 index 0000000..d6b7a9e --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/AppBarActionButton.razor @@ -0,0 +1,37 @@ + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public EventCallback OnClick { get; set; } + + [Parameter] + public string Type { get; set; } = "button"; + + [Parameter] + public string Title { get; set; } = string.Empty; + + [Parameter] + public string? AriaLabel { get; set; } + + [Parameter] + public bool Disabled { get; set; } + + [Parameter] + public string? CssClass { get; set; } + + private string BuildCssClass() => + string.IsNullOrWhiteSpace(CssClass) + ? "app-bar-action-button" + : $"app-bar-action-button {CssClass}"; +} diff --git a/src/RolemasterDb.App/Components/Primitives/InspectorSection.razor b/src/RolemasterDb.App/Components/Primitives/InspectorSection.razor new file mode 100644 index 0000000..ca59a48 --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/InspectorSection.razor @@ -0,0 +1,46 @@ +
+ @if (!string.IsNullOrWhiteSpace(Title) || HeaderContent is not null) + { +
+
+ @if (!string.IsNullOrWhiteSpace(Title)) + { +

@Title

+ } + + @if (!string.IsNullOrWhiteSpace(Description)) + { +

@Description

+ } +
+ + @HeaderContent +
+ } + +
+ @ChildContent +
+
+ +@code { + [Parameter] + public string? Title { get; set; } + + [Parameter] + public string? Description { get; set; } + + [Parameter] + public RenderFragment? HeaderContent { get; set; } + + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string? CssClass { get; set; } + + private string BuildCssClass() => + string.IsNullOrWhiteSpace(CssClass) + ? "inspector-section" + : $"inspector-section {CssClass}"; +} diff --git a/src/RolemasterDb.App/Components/Primitives/SegmentedTabItem.cs b/src/RolemasterDb.App/Components/Primitives/SegmentedTabItem.cs new file mode 100644 index 0000000..33d6c8c --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/SegmentedTabItem.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Components.Primitives; + +public sealed record SegmentedTabItem( + string Value, + string Label, + string? Badge = null, + bool IsDisabled = false); diff --git a/src/RolemasterDb.App/Components/Primitives/SegmentedTabs.razor b/src/RolemasterDb.App/Components/Primitives/SegmentedTabs.razor new file mode 100644 index 0000000..e7e18be --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/SegmentedTabs.razor @@ -0,0 +1,45 @@ +
+ @foreach (var item in Items) + { + var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal); + + + } +
+ +@code { + [Parameter] + public IReadOnlyList Items { get; set; } = []; + + [Parameter] + public string? SelectedValue { get; set; } + + [Parameter] + public EventCallback SelectedValueChanged { get; set; } + + [Parameter] + public string AriaLabel { get; set; } = "Options"; + + [Parameter] + public string? CssClass { get; set; } + + private string BuildCssClass() => + string.IsNullOrWhiteSpace(CssClass) + ? "segmented-tabs" + : $"segmented-tabs {CssClass}"; + + private Task SelectAsync(string value) => + SelectedValueChanged.InvokeAsync(value); +} diff --git a/src/RolemasterDb.App/Components/Primitives/StatusChip.razor b/src/RolemasterDb.App/Components/Primitives/StatusChip.razor new file mode 100644 index 0000000..1eb47fb --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/StatusChip.razor @@ -0,0 +1,25 @@ + + @ChildContent + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string Tone { get; set; } = "neutral"; + + [Parameter] + public string? CssClass { get; set; } + + private string BuildCssClass() + { + var classes = new List { "ui-status-chip", $"is-{Tone.Trim().ToLowerInvariant()}" }; + if (!string.IsNullOrWhiteSpace(CssClass)) + { + classes.Add(CssClass); + } + + return string.Join(' ', classes); + } +} diff --git a/src/RolemasterDb.App/Components/Primitives/StatusIndicator.razor b/src/RolemasterDb.App/Components/Primitives/StatusIndicator.razor new file mode 100644 index 0000000..589852e --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/StatusIndicator.razor @@ -0,0 +1,29 @@ + + + @if (ChildContent is not null) + { + @ChildContent + } + + +@code { + [Parameter] + public RenderFragment? ChildContent { get; set; } + + [Parameter] + public string Tone { get; set; } = "neutral"; + + [Parameter] + public string? CssClass { get; set; } + + private string BuildCssClass() + { + var classes = new List { "ui-status-indicator", $"is-{Tone.Trim().ToLowerInvariant()}" }; + if (!string.IsNullOrWhiteSpace(CssClass)) + { + classes.Add(CssClass); + } + + return string.Join(' ', classes); + } +} diff --git a/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor b/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor new file mode 100644 index 0000000..b9d7268 --- /dev/null +++ b/src/RolemasterDb.App/Components/Primitives/SurfaceDrawer.razor @@ -0,0 +1,77 @@ +@if (IsOpen) +{ + + + +} + +@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 { "surface-drawer", $"is-{Placement.Trim().ToLowerInvariant()}" }; + if (!string.IsNullOrWhiteSpace(CssClass)) + { + classes.Add(CssClass); + } + + return string.Join(' ', classes); + } + + private Task HandleCloseAsync() => + OnClose.InvokeAsync(); +} diff --git a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor index 4fe555f..918c808 100644 --- a/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor +++ b/src/RolemasterDb.App/Components/Shell/ShellOmniboxTrigger.razor @@ -1,3 +1,3 @@ - + diff --git a/src/RolemasterDb.App/Components/_Imports.razor b/src/RolemasterDb.App/Components/_Imports.razor index 1ac4419..1c0af0e 100644 --- a/src/RolemasterDb.App/Components/_Imports.razor +++ b/src/RolemasterDb.App/Components/_Imports.razor @@ -11,6 +11,7 @@ @using RolemasterDb.App @using RolemasterDb.App.Components @using RolemasterDb.App.Components.Layout +@using RolemasterDb.App.Components.Primitives @using RolemasterDb.App.Components.Shell @using RolemasterDb.App.Components.Shared @using RolemasterDb.App.Components.Tools diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index fce926b..2aa0794 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -721,6 +721,234 @@ pre, color: var(--ink-soft); } +.ui-status-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 999px; + padding: 0.3rem 0.68rem; + border: 1px solid var(--border-subtle); + background: var(--surface-chip); + color: var(--text-muted); + font-size: 0.75rem; + line-height: 1; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.ui-status-chip.is-accent { + border-color: color-mix(in srgb, var(--accent-4) 42%, var(--border-subtle)); + background: color-mix(in srgb, var(--accent-3) 16%, var(--surface-chip)); + color: var(--accent-5); +} + +.ui-status-chip.is-success { + border-color: color-mix(in srgb, var(--success-4) 36%, var(--border-subtle)); + background: color-mix(in srgb, var(--success-3) 14%, var(--surface-chip)); + color: var(--success-5); +} + +.ui-status-chip.is-warning { + border-color: color-mix(in srgb, var(--warning-4) 36%, var(--border-subtle)); + background: color-mix(in srgb, var(--warning-3) 14%, var(--surface-chip)); + color: var(--warning-5); +} + +.ui-status-chip.is-danger { + border-color: color-mix(in srgb, var(--danger-4) 36%, var(--border-subtle)); + background: color-mix(in srgb, var(--danger-3) 14%, var(--surface-chip)); + color: var(--danger-5); +} + +.ui-status-chip.is-info { + border-color: color-mix(in srgb, var(--info-4) 36%, var(--border-subtle)); + background: color-mix(in srgb, var(--info-3) 14%, var(--surface-chip)); + color: var(--info-5); +} + +.ui-status-indicator { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--text-muted); +} + +.ui-status-indicator-dot { + width: 0.6rem; + height: 0.6rem; + border-radius: 999px; + background: var(--border-strong); + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 12%, transparent); +} + +.ui-status-indicator.is-accent { + color: var(--accent-5); +} + +.ui-status-indicator.is-success { + color: var(--success-5); +} + +.ui-status-indicator.is-warning { + color: var(--warning-5); +} + +.ui-status-indicator.is-danger { + color: var(--danger-5); +} + +.ui-status-indicator.is-info { + color: var(--info-5); +} + +.app-bar-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.55rem; + min-height: 2.75rem; + padding: 0.7rem 1rem; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-raised) 86%, transparent); + color: var(--text-strong); + transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease; +} + +.app-bar-action-button:hover:not(:disabled) { + border-color: var(--border-strong); + background: var(--surface-elevated); +} + +.app-bar-action-button:disabled { + opacity: 0.78; + cursor: not-allowed; +} + +.segmented-tabs { + display: inline-flex; + align-items: stretch; + gap: 0.25rem; + padding: 0.25rem; + border-radius: 999px; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-raised) 82%, transparent); +} + +.segmented-tabs-button { + display: inline-flex; + align-items: center; + gap: 0.45rem; + min-height: 2.25rem; + padding: 0.4rem 0.85rem; + border: none; + border-radius: 999px; + background: transparent; + color: var(--text-muted); +} + +.segmented-tabs-button.is-selected { + background: var(--surface-elevated); + color: var(--text-strong); + box-shadow: 0 10px 24px -18px var(--shadow-strong); +} + +.segmented-tabs-badge { + font-size: 0.75rem; + color: var(--text-soft); +} + +.inspector-section { + display: grid; + gap: 1rem; + padding: 1rem 1.1rem; + border-radius: 1rem; + border: 1px solid var(--border-subtle); + background: color-mix(in srgb, var(--surface-raised) 88%, transparent); + box-shadow: 0 16px 36px -32px var(--shadow-strong); +} + +.inspector-section-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; +} + +.inspector-section-title { + margin: 0; + font-size: 1rem; +} + +.inspector-section-copy { + margin: 0.2rem 0 0; + color: var(--text-muted); +} + +.inspector-section-body { + display: grid; + gap: 0.85rem; +} + +.surface-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 90; + border: none; + background: rgba(15, 17, 23, 0.48); +} + +.surface-drawer { + position: fixed; + z-index: 95; + display: grid; + gap: 1rem; + border: 1px solid var(--border-subtle); + background: var(--surface-elevated); + box-shadow: 0 24px 54px -28px var(--shadow-strong); +} + +.surface-drawer.is-end { + top: calc(var(--shell-header-height) + 1rem); + right: 1rem; + bottom: 1rem; + width: min(24rem, calc(100vw - 2rem)); + border-radius: 1.25rem; +} + +.surface-drawer.is-bottom { + right: 0.75rem; + bottom: calc(var(--shell-mobile-nav-height) + 0.75rem); + left: 0.75rem; + max-height: min(70vh, 36rem); + border-radius: 1.25rem 1.25rem 0 0; +} + +.surface-drawer-header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1rem 0; +} + +.surface-drawer-title { + font-size: 1rem; +} + +.surface-drawer-header-actions { + display: inline-flex; + align-items: center; + gap: 0.75rem; +} + +.surface-drawer-body { + display: grid; + gap: 1rem; + padding: 0 1rem 1rem; + overflow: auto; +} + .details-block { margin-top: 0.85rem; }