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"
+
+ 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.Tools
-
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."
+ }
+ ]
+}
+ POST /api/lookup/attack
{
+ "attackTable": "broadsword",
+ "armorType": "AT10",
+ "roll": 111,
+ "criticalRoll": 72
+}
+ 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.
+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.
+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.
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.
+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.
+Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.
+Loading table list...
+ } + else if (!referenceData.CriticalTables.Any()) + { +No critical tables are available yet.
+ } + else + { +@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) + { +Pick another roll band, variant, or severity to inspect a stored result.
+Loading diagnostics...
+ } + else if (!string.IsNullOrWhiteSpace(diagnosticsError)) + { +@diagnosticsError
+ } + else if (diagnosticsModel is not null) + { +