diff --git a/docs/tables_frontend_overhaul_implementation_plan.md b/docs/tables_frontend_overhaul_implementation_plan.md index 09d7931..7774256 100644 --- a/docs/tables_frontend_overhaul_implementation_plan.md +++ b/docs/tables_frontend_overhaul_implementation_plan.md @@ -50,6 +50,7 @@ It is intentionally implementation-focused: | 2026-03-21 | Post-P1 fix 2 | Completed | Replaced the most visible light-only surface and control colors with theme-aware tokens so switching between `Light`, `Dark`, and `System` produces a clear visual change. | | 2026-03-21 | Post-P1 fix 3 | Completed | Restored layout-level shell interactivity by rendering routed content in `InteractiveServer` mode, which re-enabled shell event handlers such as the hamburger menu and theme selector. | | 2026-03-21 | Post-P1 fix 4 | Completed | Added early theme bootstrapping in `App.razor` and `theme.js` so the stored mode is applied before hydration and remains visible after refresh. | +| 2026-03-21 | P2.1 | Completed | Added canonical `/tools/diagnostics` and `/tools/api` routes with dedicated tooling page components, extracted the diagnostics and API content into shared tool components, and updated the `Tools` landing page to link to the new route structure. | ### Lessons Learned @@ -69,6 +70,7 @@ It is intentionally implementation-focused: - Theme infrastructure is not enough on its own. Any surface that keeps hardcoded light values will make the theme switch feel broken even when the selector logic is correct. - In Blazor Web Apps, page-level render modes do not automatically make layout-level controls interactive in the way this shell expects. The routed shell itself needs an interactive render boundary. - Persisted theme state should be applied before Blazor hydrates, not only after layout initialization. Otherwise refresh can look broken even when storage writes succeed. +- For route migration in Blazor, extracting the destination UI into shared components keeps canonical routes and temporary compatibility routes from drifting while the redirect phase is still pending. ## Target Outcomes @@ -348,6 +350,23 @@ Establish the shared shell, tokens, typography, and theme system that every dest ## Phase 2: Shared Navigation, Search, And State Infrastructure +### Status + +`In progress` + +### Task Progress + +| Task | Status | Notes | +| --- | --- | --- | +| `P2.1` | Completed | Canonical `Tools` child routes now exist, backed by dedicated route pages and shared tooling content components. | +| `P2.2` | Pending | Old `/diagnostics` and `/api` routes still need to become compatibility forwards. | +| `P2.3` | Pending | Recent tables state has not been introduced yet. | +| `P2.4` | Pending | Pinned tables state has not been introduced yet. | +| `P2.5` | Pending | Table-context URL parsing and serialization still needs a shared model. | +| `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.8` | Pending | Table-selection logic still lives inside individual pages. | + ### Goal Build the shared interaction infrastructure needed by multiple destinations before page-specific UI work deepens. diff --git a/src/RolemasterDb.App/Components/Pages/ToolRoutes/Api.razor b/src/RolemasterDb.App/Components/Pages/ToolRoutes/Api.razor new file mode 100644 index 0000000..d9f5547 --- /dev/null +++ b/src/RolemasterDb.App/Components/Pages/ToolRoutes/Api.razor @@ -0,0 +1,5 @@ +@page "/tools/api" + +API Surface + + diff --git a/src/RolemasterDb.App/Components/Pages/ToolRoutes/Diagnostics.razor b/src/RolemasterDb.App/Components/Pages/ToolRoutes/Diagnostics.razor new file mode 100644 index 0000000..9aaf493 --- /dev/null +++ b/src/RolemasterDb.App/Components/Pages/ToolRoutes/Diagnostics.razor @@ -0,0 +1,6 @@ +@page "/tools/diagnostics" +@rendermode InteractiveServer + +Diagnostics + + diff --git a/src/RolemasterDb.App/Components/Pages/Tools.razor b/src/RolemasterDb.App/Components/Pages/Tools.razor index d3e5f03..6cce8e6 100644 --- a/src/RolemasterDb.App/Components/Pages/Tools.razor +++ b/src/RolemasterDb.App/Components/Pages/Tools.razor @@ -4,10 +4,10 @@

Tools

-

Diagnostics and API documentation move under this destination in later phases. The landing page is in place now so the shell can navigate with the target destination model.

+

Diagnostics and API documentation now live under the `Tools` destination so engineering workflows stay reachable without polluting player-facing navigation.

- Open diagnostics - Open API docs + Open diagnostics + Open API docs
diff --git a/src/RolemasterDb.App/Components/Tools/ApiPageContent.razor b/src/RolemasterDb.App/Components/Tools/ApiPageContent.razor new file mode 100644 index 0000000..c36a76c --- /dev/null +++ b/src/RolemasterDb.App/Components/Tools/ApiPageContent.razor @@ -0,0 +1,143 @@ +
+
+

Reference data

+

GET /api/reference-data

+
{
+  "attackTables": [
+    {
+      "key": "broadsword",
+      "label": "Broadsword",
+      "attackKind": "melee",
+      "fumbleMinRoll": 1,
+      "fumbleMaxRoll": 2
+    }
+  ],
+  "criticalTables": [
+    {
+      "key": "mana",
+      "label": "Mana Critical Strike Table",
+      "family": "standard",
+      "sourceDocument": "Mana.pdf",
+      "notes": "Imported from PDF XML extraction."
+    }
+  ]
+}
+
+ +
+

Attack lookup

+

POST /api/lookup/attack

+
{
+  "attackTable": "broadsword",
+  "armorType": "AT10",
+  "roll": 111,
+  "criticalRoll": 72
+}
+
+ +
+

Critical lookup

+

POST /api/lookup/critical

+
{
+  "criticalType": "mana",
+  "column": "E",
+  "roll": 100,
+  "group": null
+}
+

Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.

+
+ +
+

Cell editor load

+

GET /api/tables/critical/{slug}/cells/{resultId}

+
{
+  "resultId": 412,
+  "tableSlug": "slash",
+  "tableName": "Slash Critical Strike Table",
+  "rollBand": "66-70",
+  "groupKey": null,
+  "columnKey": "C",
+  "isCurated": false,
+  "sourcePageNumber": 1,
+  "sourceImageUrl": "/api/tables/critical/slash/cells/412/source-image",
+  "rawCellText": "Original imported full cell text",
+  "descriptionText": "Current curated prose",
+  "rawAffixText": "+8H - 2S",
+  "parseStatus": "verified",
+  "parsedJson": "{\"version\":1,\"isDescriptionOverridden\":false,\"isRawAffixTextOverridden\":false,\"areEffectsOverridden\":false,\"areBranchesOverridden\":false,\"effects\":[],\"branches\":[]}",
+  "isDescriptionOverridden": false,
+  "isRawAffixTextOverridden": false,
+  "areEffectsOverridden": false,
+  "areBranchesOverridden": false,
+  "validationMessages": [],
+  "effects": [],
+  "branches": []
+}
+

Use this to retrieve the full editable result graph for one critical-table cell, including nested branches, normalized effects, and review notes for unresolved quick-parse tokens.

+
+ +
+

Cell source image

+

GET /api/tables/critical/{slug}/cells/{resultId}/source-image

+

Streams the importer-generated PNG crop for the current critical cell. Returns 404 when the row has no stored crop or the artifact is missing.

+
+ +
+

Cell re-parse

+

POST /api/tables/critical/{slug}/cells/{resultId}/reparse

+
{
+  "currentState": {
+    "rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside.",
+    "descriptionText": "Curated prose",
+    "rawAffixText": "+8H",
+    "parseStatus": "partial",
+    "parsedJson": "{}",
+    "isCurated": false,
+    "isDescriptionOverridden": true,
+    "isRawAffixTextOverridden": false,
+    "areEffectsOverridden": false,
+    "areBranchesOverridden": false,
+    "effects": [],
+    "branches": []
+  }
+}
+

Re-runs the shared single-cell parser, merges the generated result with the current override state, and returns the refreshed editor payload without saving changes. Unknown or partially parsed tokens are surfaced explicitly in the returned review data.

+
+ +
+

Cell editor save

+

PUT /api/tables/critical/{slug}/cells/{resultId}

+
{
+  "rawCellText": "Corrected imported text",
+    "descriptionText": "Rewritten prose after manual review",
+  "rawAffixText": "+10H - must parry 2 rnds",
+  "parseStatus": "manually_curated",
+  "parsedJson": "{\"reviewed\":true}",
+  "isCurated": true,
+  "isDescriptionOverridden": true,
+  "isRawAffixTextOverridden": false,
+  "areEffectsOverridden": false,
+  "areBranchesOverridden": false,
+  "effects": [
+    {
+      "effectCode": "direct_hits",
+      "target": null,
+      "valueInteger": 10,
+      "valueDecimal": null,
+      "valueExpression": null,
+      "durationRounds": null,
+      "perRound": null,
+      "modifier": null,
+      "bodyPart": null,
+      "isPermanent": false,
+      "sourceType": "symbol",
+      "sourceText": "+10H",
+      "originKey": "base:direct_hits:1",
+      "isOverridden": true
+    }
+  ],
+  "branches": []
+}
+

The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.

+
+
diff --git a/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor new file mode 100644 index 0000000..f89d924 --- /dev/null +++ b/src/RolemasterDb.App/Components/Tools/DiagnosticsPageContent.razor @@ -0,0 +1,328 @@ +@using System +@using System.Collections.Generic +@using System.Linq +@inject LookupService LookupService + +
+
+
+

Critical Cell Diagnostics

+

Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.

+
+
+ + @if (referenceData is null) + { +

Loading table list...

+ } + else if (!referenceData.CriticalTables.Any()) + { +

No critical tables are available yet.

+ } + else + { +
+
+ + +
+ +
+ + +
+ + @if (tableDetail is { Groups.Count: > 0 }) + { +
+ + +
+ } + +
+ + +
+
+ + @if (!string.IsNullOrWhiteSpace(detailError)) + { +

@detailError

+ } + else if (tableDetail is null) + { +

The selected table could not be loaded.

+ } + else if (!tableDetail.Cells.Any()) + { +

The selected table has no filled cells to inspect.

+ } + else if (selectedCell is null) + { +
+
+
+ No Filled Cell At This Position +

Pick another roll band, variant, or severity to inspect a stored result.

+
+
+
+ } + else + { +
+ Inspecting + @tableDetail.DisplayName + · Roll band @selectedCell.RollBand + · Severity @selectedCell.ColumnLabel + @if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel)) + { + · Variant @selectedCell.GroupLabel + } + · Result ID @selectedCell.ResultId +
+ + @if (isDiagnosticsLoading) + { +

Loading diagnostics...

+ } + else if (!string.IsNullOrWhiteSpace(diagnosticsError)) + { +

@diagnosticsError

+ } + else if (diagnosticsModel is not null) + { + + } + } + } +
+ +@code { + private LookupReferenceData? referenceData; + private CriticalTableDetail? tableDetail; + private CriticalTableCellDetail? selectedCell; + private CriticalCellEditorModel? diagnosticsModel; + private string selectedTableSlug = string.Empty; + private string selectedRollBand = string.Empty; + private string selectedColumnKey = string.Empty; + private string? selectedGroupKey; + private bool isDetailLoading; + private bool isDiagnosticsLoading; + private string? detailError; + private string? diagnosticsError; + + private bool isBusy => isDetailLoading || isDiagnosticsLoading; + + protected override async Task OnInitializedAsync() + { + referenceData = await LookupService.GetReferenceDataAsync(); + selectedTableSlug = referenceData.CriticalTables.FirstOrDefault()?.Key ?? string.Empty; + await LoadTableDetailAsync(); + } + + private async Task HandleTableChanged(ChangeEventArgs args) + { + selectedTableSlug = args.Value?.ToString() ?? string.Empty; + await LoadTableDetailAsync(); + } + + private async Task HandleRollBandChanged(ChangeEventArgs args) + { + selectedRollBand = args.Value?.ToString() ?? string.Empty; + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task HandleGroupChanged(ChangeEventArgs args) + { + selectedGroupKey = NormalizeOptionalText(args.Value?.ToString()); + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task HandleColumnChanged(ChangeEventArgs args) + { + selectedColumnKey = args.Value?.ToString() ?? string.Empty; + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + + private async Task LoadTableDetailAsync() + { + if (string.IsNullOrWhiteSpace(selectedTableSlug)) + { + tableDetail = null; + selectedCell = null; + diagnosticsModel = null; + return; + } + + isDetailLoading = true; + detailError = null; + diagnosticsError = null; + diagnosticsModel = null; + selectedCell = null; + + try + { + tableDetail = await LookupService.GetCriticalTableAsync(selectedTableSlug); + if (tableDetail is null) + { + detailError = "The selected table could not be loaded."; + return; + } + + SetDefaultSelection(tableDetail); + ResolveSelectedCell(); + await LoadSelectedCellDiagnosticsAsync(); + } + catch (Exception exception) + { + detailError = exception.Message; + tableDetail = null; + selectedCell = null; + diagnosticsModel = null; + } + finally + { + isDetailLoading = false; + } + } + + private void SetDefaultSelection(CriticalTableDetail detail) + { + if (!detail.Cells.Any()) + { + selectedRollBand = detail.RollBands.FirstOrDefault()?.Label ?? string.Empty; + selectedColumnKey = detail.Columns.FirstOrDefault()?.Key ?? string.Empty; + selectedGroupKey = detail.Groups.FirstOrDefault()?.Key; + return; + } + + var rollOrder = detail.RollBands + .Select((rollBand, index) => new { rollBand.Label, index }) + .ToDictionary(item => item.Label, item => item.index, StringComparer.Ordinal); + var columnOrder = detail.Columns + .Select((column, index) => new { column.Key, index }) + .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + var groupOrder = detail.Groups + .Select((group, index) => new { group.Key, index }) + .ToDictionary(item => item.Key, item => item.index, StringComparer.Ordinal); + + var firstCell = detail.Cells + .OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)) + .ThenBy(cell => + { + if (cell.GroupKey is null) + { + return -1; + } + + return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue); + }) + .ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)) + .First(); + + selectedRollBand = firstCell.RollBand; + selectedColumnKey = firstCell.ColumnKey; + selectedGroupKey = firstCell.GroupKey; + } + + private void ResolveSelectedCell() + { + if (tableDetail is null) + { + selectedCell = null; + return; + } + + selectedCell = tableDetail.Cells.FirstOrDefault(cell => + string.Equals(cell.RollBand, selectedRollBand, StringComparison.Ordinal) && + string.Equals(cell.ColumnKey, selectedColumnKey, StringComparison.Ordinal) && + string.Equals(cell.GroupKey ?? string.Empty, selectedGroupKey ?? string.Empty, StringComparison.Ordinal)); + } + + private async Task LoadSelectedCellDiagnosticsAsync() + { + diagnosticsError = null; + diagnosticsModel = null; + + if (selectedCell is null || string.IsNullOrWhiteSpace(selectedTableSlug)) + { + return; + } + + isDiagnosticsLoading = true; + + try + { + var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, selectedCell.ResultId); + if (response is null) + { + diagnosticsError = "The selected cell could not be loaded."; + return; + } + + diagnosticsModel = CriticalCellEditorModel.FromResponse(response); + } + catch (Exception exception) + { + diagnosticsError = exception.Message; + } + finally + { + isDiagnosticsLoading = false; + } + } + + private static string? NormalizeOptionalText(string? value) => + string.IsNullOrWhiteSpace(value) ? null : value.Trim(); +} diff --git a/src/RolemasterDb.App/Components/_Imports.razor b/src/RolemasterDb.App/Components/_Imports.razor index 883310b..1ac4419 100644 --- a/src/RolemasterDb.App/Components/_Imports.razor +++ b/src/RolemasterDb.App/Components/_Imports.razor @@ -13,3 +13,4 @@ @using RolemasterDb.App.Components.Layout @using RolemasterDb.App.Components.Shell @using RolemasterDb.App.Components.Shared +@using RolemasterDb.App.Components.Tools