Compare commits

45 Commits

Author SHA1 Message Date
0c3e10a5ca updated agents 2026-04-12 01:19:01 +02:00
f625cdae0d Consolidate tools and fix static assets 2026-04-12 01:18:26 +02:00
7843073d13 Implement queue-first curation workflow 2026-04-12 00:45:50 +02:00
752593fa62 Fix styling 2026-04-12 00:20:57 +02:00
b1bad734b9 Expand Phase 4 curation plan 2026-04-12 00:19:52 +02:00
66cf050be0 Contain tables viewport and critical results 2026-04-12 00:03:38 +02:00
6719967907 Tighten tables layout and grid behavior 2026-04-11 23:45:09 +02:00
c892a6d07a updated db and agents 2026-04-11 23:21:50 +02:00
790eef5665 Prevent tables grid from losing columns 2026-03-21 16:24:58 +01:00
7a0ce00429 Harden tables selection interactions 2026-03-21 15:20:05 +01:00
fb6e5a4e86 Move tables actions into inspector 2026-03-21 15:18:42 +01:00
662640e5bd Soften default tables reference chrome 2026-03-21 15:16:55 +01:00
6a5d024029 Make tables legend an on-demand surface 2026-03-21 15:15:28 +01:00
0daef1f769 Add mobile tables inspector sheet 2026-03-21 15:13:36 +01:00
9841a5c097 Add desktop tables inspector 2026-03-21 15:11:55 +01:00
9cfb9ac364 Quiet resting cells in tables canvas 2026-03-21 15:10:06 +01:00
ae582367d6 Enhance tables canvas reading states 2026-03-21 15:08:39 +01:00
7a5568f77c Add sticky tables context controls 2026-03-21 15:04:56 +01:00
88018e047e Add searchable table index rail behaviors 2026-03-21 14:59:09 +01:00
bed85b9778 Add permanent table index rail layout 2026-03-21 14:56:36 +01:00
338842dba9 Split Tables page into focused components 2026-03-21 14:53:47 +01:00
5e8a129666 Fix omnibox overlay hosting 2026-03-21 14:39:55 +01:00
e965a944b6 Rebuild shell omnibox as command palette 2026-03-21 14:33:42 +01:00
49e8528dc6 Fix shell omnibox drawer layout 2026-03-21 14:25:56 +01:00
4134d84b9d Add shell omnibox foundation 2026-03-21 14:12:43 +01:00
ef3dd950ce Add shared frontend primitive components 2026-03-21 14:08:37 +01:00
bf19374558 Add shared table context state 2026-03-21 14:04:34 +01:00
aa0639ef66 Add table context URL serializer 2026-03-21 14:00:41 +01:00
58df648bd5 Add shared pinned tables state 2026-03-21 13:57:16 +01:00
40b01d707f Add shared recent tables state 2026-03-21 13:53:56 +01:00
8b2984e7bd Add tools route compatibility redirects 2026-03-21 13:51:37 +01:00
354c376f1d Add canonical tools child routes 2026-03-21 13:48:54 +01:00
fa805f3d75 Bootstrap persisted theme before hydration 2026-03-21 13:40:19 +01:00
b63fbae957 Enable interactive shell event handling 2026-03-21 13:39:32 +01:00
52585dd3e7 Strengthen visible theme mode styling 2026-03-21 13:34:24 +01:00
4934c39f9f Add tablet navigation drawer to shell 2026-03-21 13:32:44 +01:00
a7bacbabfc Style tooling surfaces within shared shell 2026-03-21 13:22:33 +01:00
8b34851010 Add shell accessibility landmarks 2026-03-21 13:20:25 +01:00
15a2b0825a Add shell slots and destination utilities 2026-03-21 13:19:11 +01:00
b3c846d1ef Replace sidebar with responsive app shell 2026-03-21 13:16:19 +01:00
4f1ef770c7 Persist frontend theme preference 2026-03-21 13:13:26 +01:00
0b7cc846e7 Add frontend theme mode scaffolding 2026-03-21 13:10:42 +01:00
d3b7819df3 Update frontend typography system 2026-03-21 13:09:07 +01:00
26591424d0 Add semantic frontend design tokens 2026-03-21 13:07:57 +01:00
62322bb620 Document phase 0 frontend overhaul baseline 2026-03-21 13:01:27 +01:00
76 changed files with 6944 additions and 1504 deletions

View File

@@ -16,14 +16,22 @@ These tool paths should be used instead of any entry in the PATH environment var
- Keep changes as small as possible, design solutions that achieve the goals with minimal churn. - Keep changes as small as possible, design solutions that achieve the goals with minimal churn.
- Always place each newly created class into its own file. The file name must match the class name. - Always place each newly created class into its own file. The file name must match the class name.
- When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation. - When asked to begin working on a task, create a detailed implementation plan first, present the plan to the user, and ask for approval before beginning with the actual implementation.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied. - When an task is finished, perform a code review to evaluate if the change is clean and maintainable with high software engineering standards. Iterate on the code and repeat the review process until satisfied.
- After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF. - After the implementation is finished, verify all changed files, and run `python D:\Code\crlf.py $file1 $file2 ...` only for files you recognize, in order to normalize all line endings of all touched files to CRLF.
- If there's documnentation present, always keep it updated. - If there's documnentation present, always keep it updated.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `jb cleanupcode --build=False '$file1' '$file2' ...` for every file you touched.
- After every frontend change, verify the results using an ephemeral playwright.
### Git ### Git
- Never change the .gitignore file without consent. - Never change the .gitignore file without consent.
- At the end perform a git commit with a one-liner summary. - Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
- When multiple commits are necessary, pause after every commit and ask the user to give a command to proceed.
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
- Never use `git restore`, `git checkout --`, reset commands, or equivalent rollback actions to discard local changes unless the user explicitly asks for that exact rollback.
### PowerShell ### PowerShell

View File

@@ -26,6 +26,101 @@ It is intentionally implementation-focused:
- treat route and UI changes as frontend/presentation refactors unless an additive backend endpoint is explicitly approved later - treat route and UI changes as frontend/presentation refactors unless an additive backend endpoint is explicitly approved later
- keep changes incremental and shippable by phase - keep changes incremental and shippable by phase
## Working Status
- Branch: `frontend/tables-overhaul`
- Last updated: `2026-04-12`
- Current focus: `Phase 6`
- Document mode: living plan and progress log
### Progress Log
| Date | Phase | Status | Notes |
| --- | --- | --- | --- |
| 2026-03-21 | Phase 0 | Completed | Created overhaul branch, audited the current frontend, and locked the route map, component boundaries, migration path, and shared-state ownership. |
| 2026-03-21 | P1.1 | Completed | Replaced the legacy token root with semantic background, surface, text, border, focus, shadow, and semantic accent ramps while keeping compatibility aliases for incremental migration. |
| 2026-03-21 | P1.2 | Completed | Switched the app to Fraunces, IBM Plex Sans, and IBM Plex Mono with distinct display, body, UI, and code font roles instead of one shared heading font. |
| 2026-03-21 | P1.3 | Completed | Added explicit light, dark, and system theme modes in the token layer and introduced a scoped `ThemeState` service for later shell controls. |
| 2026-03-21 | P1.4 | Completed | Added shared browser-storage wrappers, persisted theme mode in `localStorage`, and initialize/apply theme state from the layout on interactive render. |
| 2026-03-21 | P1.5 | Completed | Replaced the permanent sidebar layout with a sticky top app shell and mobile bottom navigation backed by dedicated shell components. |
| 2026-03-21 | P1.6 | Completed | Added explicit shell slots for nav, omnibox, shortcuts, and utilities; switched shell navigation to `Play`, `Tables`, `Curation`, and `Tools`; and wired the first live theme control into the shell. |
| 2026-03-21 | P1.7 | Completed | Added a shell-level skip link and tightened the top-level header, navigation, and main landmarks around the new shell structure. |
| 2026-03-21 | P1.8 | Completed | Introduced a cooler tooling emphasis for `Tools`, diagnostics, and API surfaces, and styled the `Tools` destination as distinct without splitting the shell. |
| 2026-03-21 | Post-P1 fix 1 | Completed | Closed the 768px-1023px navigation gap by adding a shell hamburger menu and drawer so primary navigation never disappears at tablet widths. |
| 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. |
| 2026-03-21 | P2.2 | Completed | Replaced the old `/diagnostics` and `/api` pages with lightweight compatibility routes that reuse a shared redirect component, preserve query strings and fragments, and replace browser history while forwarding into the canonical `Tools` routes. |
| 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.6 | Completed | Replaced the dead shell omnibox trigger with a live drawer-backed omnibox foundation that loads critical tables on demand, filters table search results, surfaces pinned and recent table sections from shared state, and exposes slash-command navigation for the core destinations and tooling routes. |
| 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. |
| 2026-03-21 | Post-P2 fix 1 | Completed | Fixed the shell omnibox drawer regression by adding explicit shell offset variables, constraining drawer/body scrolling, and giving the omnibox its own backdrop geometry so the flyout opens within the visible viewport instead of collapsing into invalid top/bottom positioning. |
| 2026-03-21 | Post-P2 fix 2 | Completed | Rebuilt the shell omnibox as a dedicated command palette instead of a repurposed drawer, with shell-owned overlay markup, explicit viewport-safe geometry, autofocus, Escape and navigation close behavior, and a stable scrollable result body. |
| 2026-03-21 | Post-P2 fix 3 | Completed | Moved omnibox overlay ownership from the header subtree into `AppShell` itself via a shared omnibox state service and a top-level palette host, which restored full-screen backdrop coverage and reliable outside-click close behavior. |
| 2026-03-21 | P3.1 | Completed | Split `Tables.razor` into focused table components for the selector header, context bar, canvas, and legend while leaving loading, deep-link synchronization, and dialog state in the page host. |
| 2026-03-21 | P3.2 | Completed | Replaced the floating table picker with a permanent left-rail layout, converted the old selector component into a real page header, and kept the current selection flow intact inside the new reference frame. |
| 2026-03-21 | P3.3 | Completed | Added rail search, family filters, pinned and recent sections, curated status chips, and keyboard up/down plus Enter handling on top of the new permanent table index rail. |
| 2026-03-21 | P3.4 | Completed | Added a sticky context bar with reference-mode tabs, variant and severity selectors, roll-jump state, and active filter chips, then wired those controls into page state and canvas filtering. |
| 2026-03-21 | P3.5 | Completed | Reworked the canvas with sticky headers, a sticky roll-band column, row and column emphasis driven by selection and roll-jump state, selected-cell treatment, and a comfortable/dense density toggle. |
| 2026-03-21 | P3.6 | Completed | Removed the always-visible cell button stack from resting cells, leaving status-only hints by default and limiting compact edit/curation buttons to the currently selected cell. |
| 2026-03-21 | P3.7 | Completed | Added a dedicated desktop inspector column that reads from the shared selected-cell state and keeps the selected result readable beside the grid. |
| 2026-03-21 | P3.8 | Completed | Reused the inspector body inside a mobile bottom sheet with its own backdrop and close affordance so touch users keep the same selection-driven inspector model as desktop. |
| 2026-03-21 | P3.9 | Completed | Pulled legend/help out of the always-on canvas component and turned it into an explicitly toggled secondary surface controlled from the context bar. |
| 2026-03-21 | P3.10 | Completed | Softened the default `Reference` mode by replacing repeated curation wording in resting cells with subtle status indicators and by simplifying the top-level guidance copy. |
| 2026-03-21 | P3.11 | Completed | Moved the live editor and curation entry points into the shared inspector content and removed the last remaining grid-owned action buttons. |
| 2026-03-21 | P3.12 | Completed | Added keyboard-selectable cells, visible focus treatment, and selection normalization so changing filters or modes cannot leave the inspector pointing at hidden cells. |
| 2026-03-21 | Post-P3 fix 1 | Completed | Added a defensive visible-column fallback in the table canvas and tightened view-state normalization so a stale severity filter cannot collapse the grid to roll bands only. |
| 2026-04-11 | Post-P3 fix 2 | Completed | Simplified `/tables` by removing static prose and context controls, dropped the redundant selected-result inspector in favor of a floating action menu, and moved the canvas onto its own scroll region so sticky headers layer correctly beneath the context bar. |
| 2026-04-12 | Phase 4 planning | Planned | Expanded the `Curation` phase from a route placeholder into a concrete migration plan that moves queue-first curation out of `Tables` and into a dedicated workflow surface. |
| 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. |
### Lessons Learned
- The current app shell is simple enough that the redesign can land incrementally in `MainLayout.razor` without changing the hosting model in `Program.cs`.
- The current `Tables.razor` page already contains three different concerns: reference browsing, curation queue work, and full editing. Splitting by workflow is the highest-value maintainability move.
- Existing contracts in `LookupContracts.cs` already contain the identifiers needed for deep links and cross-surface navigation: table slug, group key, column key, roll band, and result id.
- Diagnostics already behaves like a separate workflow and should be moved under `Tools` early, before the `Tables` page is rewritten further.
- `localStorage` access is currently page-local and ad hoc. Theme, recents, pins, and table context need one shared storage boundary before more UI work starts.
- The old typography setup coupled display and utility text under a single token. The new shell work needs separate display and UI font roles to avoid decorative type in controls.
- Theme mode selection can be prepared independently of persistence. Splitting those concerns keeps the theme CSS and the storage wiring reviewable.
- Shared UI state events in Blazor should stay synchronous unless the event contract is async-aware. Layout refresh can be triggered safely with `InvokeAsync(StateHasChanged)` from a synchronous handler.
- Extracting shell markup into dedicated components is lower-risk than continuing to evolve `MainLayout.razor` directly. It isolates responsive frame work from page content and keeps later nav changes localized.
- Once a Razor component exposes multiple named `RenderFragment` parameters, the page body must be passed explicitly through `<ChildContent>`. That pattern is now the baseline for shell composition here.
- Accessibility work is cheaper when the shell owns the landmarks. Adding skip links and nav/main structure at the shell layer avoids repeating that work page by page.
- Tooling can feel distinct through cooler surfaces and labeling alone. A separate app shell is unnecessary and would undermine the shared-product goal.
- Responsive shell design needs an explicit tablet state, not just desktop and phone states. The original breakpoints left a navigation dead zone between the top nav and bottom nav layouts.
- 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.
- Compatibility routes should stay declarative and shared. A single redirect component is enough for route forwarding and avoids copy-pasted `NavigationManager` lifecycle logic in page files.
- Shared browser-backed state becomes more useful once one real page writes to it immediately. Recording recents from `Tables` now keeps later omnibox work from being blocked on synthetic placeholder data.
- 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.
- The omnibox foundation does not need the full final interaction model to be useful. A drawer with real table search, real pinned/recent data, and a small slash-command set is enough to validate the shell surface before Phase 3 builds deeper index and inspector flows on top of it.
- Shared overlay primitives should not depend on undeclared layout variables. If a drawer needs shell offsets, the shell must define them explicitly and overlay-specific backdrops should be adjustable instead of assuming full-screen dimming is always correct.
- A command palette is not just a styled drawer. It needs shell-owned geometry, predictable focus behavior, and a bounded scroll region; treating it as a generic side panel led directly to the layout regressions found in Phase 2.
- Backdrop and outside-click behavior depend on overlay ownership as much as CSS. If the trigger owns the overlay inside a sticky header subtree, fixed-position assumptions can break; shell-level overlays should be rendered by the shell, not by individual header controls.
- The `Tables` rewrite is safer when orchestration and rendering are separated early. Keeping loading, persistence, and dialog state in the page host while extracting render-only components makes later layout and interaction changes much lower risk.
- The `Tables` navigation model needs its own persistent geometry before advanced behaviors land. Converting the selector to a real rail first keeps later search and keyboard work from being tangled up with another structural rewrite.
- Rail keyboard behavior is easiest to maintain when it works from one deduplicated option order, even if the UI shows multiple sections. Keeping one internal option list avoids separate arrow-key state per section.
- The context bar controls should own one shared view-state model before the canvas gets more visual treatment. Wiring the filters into the host page now avoids a second refactor when row, column, and cell emphasis land.
- Canvas emphasis becomes maintainable once selection, roll-jump, and density are all fed through one explicit state model. That lets the grid respond to context without hiding selection logic inside CSS-only heuristics.
- Resting-cell quietness should be enforced structurally, not only visually. Showing actions only for the selected cell prevents future CSS regressions from reintroducing button clutter across the whole grid.
- The inspector should be its own sibling surface in the page layout, not nested inside the table shell. That keeps the content reusable for both desktop and the later mobile sheet without coupling it to canvas markup.
- The inspector content itself should be shared independently of its container. Once the body is separated from the desktop column chrome, the mobile bottom sheet can reuse it with almost no behavioral drift.
- Help content should not stay embedded in the canvas component once the grid becomes the main task surface. Moving legend rendering back to the page host makes it easier to demote, reposition, or merge with future help surfaces.
- Hiding maintenance noise in default reference mode is mostly a copy and chrome problem, not a routing or data problem. Replacing repeated curation words with quieter indicators goes further than adding yet another toggle.
- Once the inspector owns the action entry points, the grid should stop carrying legacy button styles and callbacks. Removing dead grid-action code immediately keeps the browse-first model from drifting back toward edit-first behavior.
- Filtered table rendering needs a non-empty fallback set for structural axes such as columns. Even when the host page tries to normalize stale filter state, the canvas should never assume a filtered axis remains populated.
## Target Outcomes ## Target Outcomes
The overhaul is complete when the app behaves as one coherent product with four clear destination families: The overhaul is complete when the app behaves as one coherent product with four clear destination families:
@@ -67,6 +162,10 @@ Recommended order:
## Phase 0: Discovery And Technical Baseline ## Phase 0: Discovery And Technical Baseline
### Status
`Completed`
### Goal ### Goal
Create the implementation foundation so the visual overhaul does not start with uncontrolled edits in page files. Create the implementation foundation so the visual overhaul does not start with uncontrolled edits in page files.
@@ -105,8 +204,132 @@ Create the implementation foundation so the visual overhaul does not start with
- no unresolved structural ambiguity remains around shell, routes, state ownership, or shared primitives - no unresolved structural ambiguity remains around shell, routes, state ownership, or shared primitives
### Current Repo Audit
| Area | Current owner | Current responsibility | Phase 0 decision |
| --- | --- | --- | --- |
| 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 |
| 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 |
| 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
| Behavior | Current source | Preserve as | Notes |
| --- | --- | --- | --- |
| 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 |
| 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 |
### Resolved Route Map
| Surface | Target route | Compatibility handling | Owner page/component |
| --- | --- | --- | --- |
| Play | `/` | none | `Pages/Home.razor` until renamed later |
| Tables | `/tables` | none | `Pages/Tables.razor`, later split into page components under `Components/Tables` |
| Curation | `/curation` | none | new page introduced in Phase 4 |
| Tools landing | `/tools` | none | new page introduced in Phase 5 |
| Diagnostics | `/tools/diagnostics` | keep `/diagnostics` as compatibility page that forwards to `/tools/diagnostics` | new page in `Pages/Tools/Diagnostics.razor` |
| API docs | `/tools/api` | keep `/api` as compatibility page that forwards to `/tools/api` | new page in `Pages/Tools/Api.razor` |
### Route Compatibility Strategy
- Use lightweight compatibility pages instead of server-side redirects so route behavior stays inside the Blazor app.
- Compatibility pages should preserve query string values and replace browser history when forwarding, so old bookmarks do not create noisy back-stack entries.
- New deep-link work should target the destination routes only. Compatibility pages are temporary migration aids and should not gain new UI.
### Target Frontend Structure
| Area | Planned location | Responsibility |
| --- | --- | --- |
| Shell components | `src/RolemasterDb.App/Components/Shell` | app frame, top app bar, bottom nav, destination nav, shell actions |
| Shared app state | `src/RolemasterDb.App/Frontend/AppState` | theme, recents, pins, selected context, URL serialization, storage access |
| Shared primitives | `src/RolemasterDb.App/Components/Primitives` | chips, tabs, buttons, drawers, inspector sections, empty states |
| Tables-specific components | `src/RolemasterDb.App/Components/Tables` | index rail, context bar, table canvas, inspector, state adapters |
| Curation-specific components | `src/RolemasterDb.App/Components/Curation` | queue layout, quick parse surface, save-and-advance actions |
| Tools-specific components | `src/RolemasterDb.App/Components/Tools` | diagnostics inspector, API docs framing, context links |
| Play-specific components | `src/RolemasterDb.App/Components/Play` | lookup forms, result rail, deep-link actions |
### Shared State Strategy
| Concern | Planned owner | Persistence | Consumers |
| --- | --- | --- | --- |
| Theme mode | `ThemeState` service | `localStorage` | shell, all pages |
| Recent tables | `RecentTablesState` service | `localStorage` | shell omnibox, tables rail, curation |
| Pinned tables | `PinnedTablesState` service | `localStorage` | shell shortcuts, tables rail, curation |
| Selected table context | `TableContextState` service | URL first, `localStorage` fallback per destination | tables, curation, tools |
| Table deep-link parsing and serialization | `TableContextUrlSerializer` helper | URL only | tables, curation, tools, play deep links |
| Local storage access | `BrowserStorageService` | wrapper around JS interop | all frontend state services |
### State Ownership Rules
- URL state is the source of truth for sharable context.
- Local storage is only for preferences and last-used convenience state.
- Page components should consume state services and emit intents. They should not own persistence logic directly.
- Editor and curation dialog transient state stays local to the workflow component that opened it.
### Shared Primitive Boundaries
| Primitive | First consumer | Notes |
| --- | --- | --- |
| App bar and destination nav | shell | replaces `NavMenu.razor` |
| Bottom nav | shell | mobile-only persistent destination nav |
| Omnibox trigger and panel | shell, tables | foundation in Phase 2; full search later |
| Chip and status pill primitives | shell, tables, curation, tools | replace ad hoc chip styling in `app.css` |
| Tabs and segmented controls | tables, tools | mode switching and filtered views |
| Drawer and bottom sheet primitives | tables, curation | inspector on small screens |
| Inspector section cards | tables, tools, curation | unify right-rail and sheet layout |
### Migration Path
1. Land the new shell and theme infrastructure in `MainLayout.razor` and `wwwroot/app.css` without changing page internals.
2. Add shared state and compatibility routes before splitting the largest pages.
3. Move diagnostics and API docs under `Tools` early to reduce noise in the main navigation.
4. Split `Tables.razor` into composable pieces while preserving existing editor and curation behaviors.
5. Extract the dedicated `Curation` workflow once the shared selector and table context code already exists.
6. Restyle and reconnect `Play` after shell, deep links, and shared primitives are stable.
### Phase 0 Exit Criteria
- the route map above is treated as implementation truth unless a later change is explicitly recorded here
- page, shell, and shared-state ownership are defined strongly enough to begin Phase 1 without structural rework
- compatibility handling for `/diagnostics` and `/api` is decided
- preserved behaviors are identified so later phases do not regress them accidentally
### Next Implementation Slice
- Start Phase 1 with the shell and design-token foundation in `MainLayout.razor`, `App.razor`, and `wwwroot/app.css`.
- Introduce the shared theme state and browser storage wrapper before rewriting destination pages.
- Replace the current sidebar navigation first so later page work lands inside the target shell instead of the legacy layout.
## Phase 1: Design System And Application Shell ## Phase 1: Design System And Application Shell
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P1.1` | Completed | Semantic token layer landed in `wwwroot/app.css` with compatibility aliases to keep existing pages stable. |
| `P1.2` | Completed | Font loading now uses Fraunces, IBM Plex Sans, and IBM Plex Mono with explicit role-based tokens. |
| `P1.3` | Completed | Explicit light, dark, and system modes now exist in CSS, backed by a scoped `ThemeState` service. |
| `P1.4` | Completed | Theme mode now persists through a shared storage service and is applied from the layout during interactive startup. |
| `P1.5` | Completed | The sidebar is gone; pages now render inside a sticky top-shell with a mobile bottom nav. |
| `P1.6` | Completed | The shell now has explicit nav, omnibox, shortcut, and utility slots, plus a live theme selector and destination-model navigation. |
| `P1.7` | Completed | The shell now exposes a skip link and explicit header/nav/main landmarks. |
| `P1.8` | Completed | Tooling surfaces and the `Tools` nav item now use a cooler emphasis without leaving the shared shell system. |
### Goal ### Goal
Establish the shared shell, tokens, typography, and theme system that every destination will inherit. Establish the shared shell, tokens, typography, and theme system that every destination will inherit.
@@ -168,8 +391,31 @@ Establish the shared shell, tokens, typography, and theme system that every dest
- primary navigation shows `Play`, `Tables`, `Curation`, and `Tools` - primary navigation shows `Play`, `Tables`, `Curation`, and `Tools`
- the app has a stable theme system and global spacing/typography rules - the app has a stable theme system and global spacing/typography rules
### Phase 1 Exit Notes
- The app now has a semantic token system, explicit typography roles, theme modes with persistence, and a responsive shell.
- The shell already uses the destination model `Play`, `Tables`, `Curation`, and `Tools`, even though deeper route migration remains a Phase 2 concern.
- The next implementation focus is Phase 2: shared route compatibility, recents, pins, omnibox foundations, and deep-link infrastructure.
## Phase 2: Shared Navigation, Search, And State Infrastructure ## Phase 2: Shared Navigation, Search, And State Infrastructure
### Status
`Completed`
### 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` | Completed | Old `/diagnostics` and `/api` routes now forward into the canonical `Tools` routes with shared redirect behavior. |
| `P2.3` | Completed | Recent critical-table visits now persist through a shared app-state service and are recorded from the `Tables` page. |
| `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` | Completed | The shell omnibox now opens a live drawer with table search, pinned tables, recent tables, and slash-command navigation. |
| `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 ### Goal
Build the shared interaction infrastructure needed by multiple destinations before page-specific UI work deepens. Build the shared interaction infrastructure needed by multiple destinations before page-specific UI work deepens.
@@ -226,6 +472,27 @@ Build the shared interaction infrastructure needed by multiple destinations befo
## Phase 3: `Tables` Reference Experience ## Phase 3: `Tables` Reference Experience
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P3.1` | Completed | `Tables.razor` now acts as the stateful host while selector/header/canvas/legend rendering lives in dedicated `Components/Tables` components. |
| `P3.2` | Completed | The old dropdown picker is gone; `/tables` now uses a permanent left rail and a real page header while keeping the current selection flow intact. |
| `P3.3` | Completed | The rail now supports search-as-you-type, family filters, pinned and recent sections, curated status chips, and a deduplicated arrow/Enter keyboard path. |
| `P3.4` | Completed | The table surface now has a sticky context bar with mode tabs, variant/severity focus, roll-jump state, and active filter chips wired into host-page view state. |
| `P3.5` | Completed | The canvas now supports sticky headers and roll bands, row and column emphasis from selection and roll-jump state, selected-cell treatment, and a comfortable/dense density toggle. |
| `P3.6` | Completed | Resting cells now show only status hints; compact edit/curation buttons appear only for the selected cell. |
| `P3.7` | Completed | Desktop now has a dedicated inspector column driven by the shared selected-cell state instead of forcing result reading back into the grid alone. |
| `P3.8` | Completed | Mobile now uses a bottom-sheet inspector that reuses the same selected-cell content as the desktop inspector column. |
| `P3.9` | Completed | Legend/help is now on-demand and controlled from the context bar instead of always rendering below the canvas. |
| `P3.10` | Completed | Default `Reference` mode now uses quieter status indicators and calmer guidance copy so the page reads less like a maintenance surface. |
| `P3.11` | Completed | Full editor and curation entry points now live in the shared inspector content instead of the grid itself. |
| `P3.12` | Completed | Cell selection now works by click, tap, and keyboard, and view changes clear stale selection instead of leaving the inspector out of sync with the visible grid. |
### Goal ### Goal
Turn `/tables` into the canonical reference surface for reading and inspecting critical tables quickly. Turn `/tables` into the canonical reference surface for reading and inspecting critical tables quickly.
@@ -298,50 +565,146 @@ Turn `/tables` into the canonical reference surface for reading and inspecting c
## Phase 4: `Curation` Workflow Surface ## Phase 4: `Curation` Workflow Surface
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P4.1` | Completed | The placeholder route was replaced with an interactive `Curation` page host that owns queue scope, queue item, loading, quick-parse, and save-and-advance state. |
| `P4.2` | Completed | `Curation` now restores and persists context through the shared table-context infrastructure and reuses the same deep-link object identifiers as `Tables` and diagnostics. |
| `P4.3` | Completed | The new queue surface supports `All tables`, `Selected table`, and `Pinned set` scopes. |
| `P4.4` | Completed | Queue ordering and next-item resolution now live in dedicated frontend curation helpers instead of inside `Tables.razor`. |
| `P4.5` | Completed | The page now presents a stable queue-first split workspace with queue summary, source image, parsed preview, inline quick parse, and fixed primary actions. |
| `P4.6` | Completed | The existing load, reparse, mark-curated, and save-and-advance mechanics were rehosted into `Curation` with minimal backend churn. |
| `P4.7` | Completed | Full editor access remains available from the curation lane, while `Quick parse` and `Mark curated and continue` are the primary fast path. |
| `P4.8` | Completed | Save-and-advance keeps the user in the same scope and walks the queue forward without reopening context. |
| `P4.9` | Completed | Normal save flow stays on the primary accent action hierarchy and does not borrow warning treatment. |
| `P4.10` | Completed | Diagnostics remain out of the default curation lane and are linked contextually through `Tools` instead of embedded inline. |
### Current Baseline
- 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 `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.
### Goal ### Goal
Create a dedicated queue-first curation workflow so repair work is fast and does not pollute the reference experience. Create a dedicated queue-first curation workflow so repair work is fast and does not pollute the reference experience.
### Tasks ### Tasks
- `P4.1` Create the new `/curation` page and route. - `P4.1` Replace the placeholder `/curation` route body with a real stateful page host:
- `P4.2` Reuse shared table/context selection patterns from Phase 2 and Phase 3. - keep the existing route and shell navigation entry point
- own queue scope, current queue item, quick-parse mode, loading states, and save-and-advance transitions in the page host
- keep rendering concerns in focused `Components/Curation` children rather than rebuilding another monolithic page
- `P4.2` Reuse shared table/context selection patterns from Phase 2 and Phase 3:
- build on `TableContextState`, shared URL serialization, and the existing table identifiers already used by `Tables` and diagnostics
- preserve table, group, column, roll band, result id, and curation mode context where useful
- keep curation selection deterministic and bookmarkable rather than page-local only
- `P4.3` Define queue scopes: - `P4.3` Define queue scopes:
- all tables - all tables
- selected table - selected table
- pinned set - pinned set
- `P4.4` Build a stable queue-first layout with: - `P4.4` Build one normalized queue descriptor and selection pipeline behind those scopes:
- selected scope value
- optional selected table slug when the scope is table-specific
- current queue item identity
- queue ordering rules
- next-item resolution that is independent from the `Tables` page
- `P4.5` Build a stable queue-first layout with:
- current queue item summary - current queue item summary
- queue scope controls
- next-item affordance in a fixed, predictable location
- source image - source image
- parsed preview - parsed preview
- quick parse area - quick parse area
- compact queue metadata such as table, group, column, roll band, and curated state
- save-and-advance actions - save-and-advance actions
- `P4.5` Move the current “next uncurated” logic into the dedicated workflow surface. - `P4.6` Extract and reuse the current curation mechanics from `Tables.razor` with minimal churn:
- `P4.6` Keep full editor access available, but make quick parse and mark curated the fast path. - loading the current result card for curation
- `P4.7` Ensure save-and-advance keeps the user in the same workflow lane without reopening context. - quick parse and reparse
- `P4.8` Use warning styling only for disruptive repair actions, not for normal save flow. - mark curated
- `P4.9` Hide developer-only diagnostics from the normal curation workflow. - save and advance
- open full editor for edge cases
- `P4.7` Keep full editor access available, but make quick parse and mark curated the fast path:
- quick parse should stay inline in the curation workspace
- full edit should remain one action away without becoming the default
- avoid modal-on-modal or browse-surface detours for normal work
- `P4.8` Ensure save-and-advance keeps the user in the same workflow lane without reopening context:
- preserve queue scope and ordering after save
- advance immediately when another queue item exists
- surface a clear terminal state when the current queue is exhausted
- `P4.9` Use warning styling only for disruptive repair actions, not for normal save flow:
- `Mark curated` and `Save and continue` remain strong-accent primary actions
- warning styling is reserved for unusual or risky repair paths, not ordinary progression
- `P4.10` Hide developer-only diagnostics from the normal curation workflow:
- keep raw JSON, parser provenance, and engineering detail out of the default curation lane
- provide links into `Tools` when deep inspection is needed instead of embedding diagnostics directly in `Curation`
### Deliverables ### Deliverables
- `/curation` page - stateful `/curation` page host
- queue-first layout - shared queue-scope and next-item selection helpers
- queue-first split layout components
- integrated save-and-advance flow - integrated save-and-advance flow
- quick parse in-context workflow - inline quick-parse workflow
- preserved full-editor handoff from the curation lane
- context-preserving links between `Curation`, `Tables`, and `Tools`
### Acceptance Criteria ### Acceptance Criteria
- a curator can move from one uncurated cell to the next with one primary action after save - a curator can move from one uncurated cell to the next with one primary action after save
- a curator can start from `All tables`, `Selected table`, or `Pinned set` without losing the repeated-action workflow
- source and parsed result are visible side by side on wide screens - source and parsed result are visible side by side on wide screens
- quick parse can be completed without opening the full editor for common cases - quick parse can be completed without opening the full editor for common cases
- full editor remains available as a secondary path for complex corrections
- normal curation work does not require stacked dialogs or re-entering queue context after every save
- the queue can be exhausted gracefully without dropping the user into an ambiguous empty state
- diagnostics detail is not embedded by default in the curation workspace
- `Tables` no longer carries the primary queue-work burden - `Tables` no longer carries the primary queue-work burden
### Definition Of Done ### Definition Of Done
- curation is a dedicated workflow page with a stable repeated interaction loop - curation is a dedicated workflow page with a stable repeated interaction loop
- `Tables` remains browse-first after the queue workflow moves out
- queue scope, item context, and save-and-advance behavior are implemented once and not duplicated between `Tables` and `Curation`
### 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 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.
- Verification for the implementation phase should include:
- build success
- save-and-advance through at least one real uncurated item
- quick parse round-trip in the queue lane
- queue exhaustion behavior
- context-preserving navigation back to `Tables` and into `Tools` when needed
## Phase 5: `Tools` Consolidation ## Phase 5: `Tools` Consolidation
### Status
`Completed`
### Task Progress
| Task | Status | Notes |
| --- | --- | --- |
| `P5.1` | Completed | The thin `Tools` panel was replaced with a real hub page presenting diagnostics and API docs as destination cards. |
| `P5.2` | Completed | Diagnostics remained on the canonical `/tools/diagnostics` route established earlier. |
| `P5.3` | Completed | API docs remained on the canonical `/tools/api` route established earlier. |
| `P5.4` | Completed | Tooling pages now reuse a shared tooling frame and the same table-context URI patterns already used by `Tables`, `Curation`, and diagnostics. |
| `P5.5` | Completed | Diagnostics now exposes preserved-context links back into `Tables` and `Curation`, plus a stable return path to the `Tools` hub. |
| `P5.6` | Completed | Deep inspection remains isolated to tooling surfaces; browse and curation flows still link outward instead of embedding engineering detail inline. |
| `P5.7` | Completed | The tooling pages now share a stronger cool-slate documentation/workbench treatment instead of ad hoc panel stacks. |
### Goal ### Goal
Separate diagnostic and developer tooling from player-facing flows without losing deep-link usefulness. Separate diagnostic and developer tooling from player-facing flows without losing deep-link usefulness.
@@ -530,3 +893,9 @@ Start with a narrow but high-leverage slice:
5. extraction of `/tables` shell pieces without yet rewriting every detail of the canvas 5. extraction of `/tables` shell pieces without yet rewriting every detail of the canvas
This sequence reduces risk because it establishes the shared infrastructure before the most complex page rewrite. This sequence reduces risk because it establishes the shared infrastructure before the most complex page rewrite.
## Post-Plan Adjustments
- keep `/tables` in a contained-height desktop layout so the rail and table canvas own their own scrolling instead of pushing document scroll
- let `Reading help` consume part of the table-shell height budget instead of extending the page
- preserve the simplified floating cell actions and avoid reintroducing a persistent inspector column

View File

@@ -8,19 +8,20 @@
<ResourcePreloader/> <ResourcePreloader/>
<link rel="preconnect" href="https://fonts.googleapis.com"/> <link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Domine:wght@400&family=Source%20Sans%203:wght@400;500&display=swap" /> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"/>
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]"/> <link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]"/>
<link rel="stylesheet" href="@Assets["app.css"]"/> <link rel="stylesheet" href="@Assets["app.css"]"/>
<link rel="stylesheet" href="@Assets["RolemasterDb.App.styles.css"]"/> <link rel="stylesheet" href="@Assets["RolemasterDb.App.styles.css"]"/>
<script src="@Assets["theme.js"]"></script>
<script>window.rolemasterTheme?.init("rolemaster.theme.mode");</script>
<ImportMap/> <ImportMap/>
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<HeadOutlet/> <HeadOutlet/>
</head> </head>
<body> <body>
<Routes /> <Routes @rendermode="InteractiveServer"/>
<ReconnectModal/> <ReconnectModal/>
<script src="@Assets["tables.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
</body> </body>

View File

@@ -0,0 +1,99 @@
@using RolemasterDb.App.Frontend.Curation
<div class="critical-editor-card curation-queue-bar">
<div class="curation-queue-bar-header">
<div class="curation-queue-heading">
<h1 class="panel-title">Curation</h1>
<span class="muted">Queue-first repair workspace</span>
</div>
<div class="curation-queue-links">
@if (!string.IsNullOrWhiteSpace(TablesHref))
{
<a class="btn btn-secondary" href="@TablesHref">Open tables</a>
}
@if (!string.IsNullOrWhiteSpace(DiagnosticsHref))
{
<a class="btn btn-secondary" href="@DiagnosticsHref">Open diagnostics</a>
}
</div>
</div>
<SegmentedTabs
Items="ScopeItems"
SelectedValue="SelectedScope"
SelectedValueChanged="SelectedScopeChanged"
AriaLabel="Curation queue scope"
CssClass="curation-scope-tabs"/>
@if (string.Equals(SelectedScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
<div class="field-shell curation-table-select">
<label for="curation-table-select">Table scope</label>
<select
id="curation-table-select"
class="input-shell"
value="@SelectedTableSlug"
@onchange="HandleTableChanged"
disabled="@IsBusy">
@foreach (var table in Tables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
}
@if (CurrentItem is not null)
{
<div class="curation-queue-summary">
<StatusChip Tone="warning">Needs curation</StatusChip>
<strong>@CurrentItem.TableName</strong>
<span>Roll band <strong>@CurrentItem.RollBand</strong></span>
<span>Severity <strong>@CurrentItem.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(CurrentItem.GroupLabel))
{
<span>Variant <strong>@CurrentItem.GroupLabel</strong></span>
}
<span>Result ID <strong>@CurrentItem.ResultId</strong></span>
</div>
}
</div>
@code {
[Parameter]
public IReadOnlyList<SegmentedTabItem> ScopeItems { get; set; } = [];
[Parameter]
public string SelectedScope { get; set; } = CurationQueueScopes.AllTables;
[Parameter, EditorRequired]
public EventCallback<string> SelectedScopeChanged { get; set; }
[Parameter]
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = [];
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter, EditorRequired]
public EventCallback<string> SelectedTableSlugChanged { get; set; }
[Parameter]
public CurationQueueItem? CurrentItem { get; set; }
[Parameter]
public bool IsBusy { get; set; }
[Parameter]
public string? TablesHref { get; set; }
[Parameter]
public string? DiagnosticsHref { get; set; }
private Task HandleTableChanged(ChangeEventArgs args) =>
SelectedTableSlugChanged.InvokeAsync(args.Value?.ToString() ?? string.Empty);
}

View File

@@ -0,0 +1,27 @@
<div class="critical-editor-card curation-empty-state">
<div>
<h2 class="panel-title">@Title</h2>
<p class="muted">@Message</p>
</div>
@if (!string.IsNullOrWhiteSpace(ActionHref) && !string.IsNullOrWhiteSpace(ActionLabel))
{
<a class="btn btn-secondary" href="@ActionHref">@ActionLabel</a>
}
</div>
@code {
[Parameter]
public string Title { get; set; } = "Queue empty";
[Parameter]
public string Message { get; set; } = string.Empty;
[Parameter]
public string? ActionHref { get; set; }
[Parameter]
public string? ActionLabel { get; set; }
}

View File

@@ -0,0 +1,248 @@
@using System.Collections.Generic
@using System.Linq
@using Microsoft.AspNetCore.Components.Web
@if (IsLoading)
{
<div class="curation-workspace-shell">
<p class="muted" role="status">@LoadingMessage</p>
</div>
}
else if (Model is null)
{
<div class="curation-workspace-shell">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
}
else
{
<p class="muted">@EmptyMessage</p>
}
</div>
}
else
{
<div class="curation-workspace-shell">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error" role="alert">@GetVisibleErrorMessage()</p>
}
<div class="critical-curation-grid">
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (IsQuickParseMode)
{
<CriticalCellQuickParseEditor
@ref="quickParseEditor"
Model="Model"
IsDisabled="@(IsSaving || IsReparsing)"
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea"
OnReparse="OnReparse"/>
}
else
{
<button
type="button"
class="critical-curation-preview-button"
@onclick="OnEnterQuickParse"
disabled="@(IsSaving || IsReparsing)">
<CompactCriticalCell
Description="@Model.DescriptionText"
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)"/>
</button>
}
</div>
<div class="critical-editor-card critical-curation-source-card">
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
{
<img
class="critical-curation-source-image"
src="@Model.SourceImageUrl"
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)"/>
}
else
{
<div class="critical-curation-source-empty">
<p class="muted">No source image is available for this cell yet.</p>
</div>
}
</div>
</div>
@if (!IsQuickParseMode)
{
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
{
<div class="critical-curation-legend">
@foreach (var entry in usedLegendEntries)
{
<div class="critical-curation-legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<strong>@entry.Label</strong>
</div>
}
</div>
}
}
<div class="curation-workspace-actions">
@if (IsQuickParseMode)
{
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
<button
type="button"
class="btn-ritual"
@onclick="HandleReparseClickAsync"
@onclick:stopPropagation="true"
@onclick:preventDefault="true"
disabled="@(IsSaving || IsReparsing)">
@(IsReparsing ? ReparseBusyLabel : ReparseActionLabel)
</button>
}
else
{
@if (ShowSecondaryAction)
{
<button type="button" class="@SecondaryActionCssClass" @onclick="OnSecondaryAction" disabled="@(IsSaving || IsReparsing)">
@SecondaryActionLabel
</button>
}
@if (ShowPrimaryAction)
{
<button type="button" class="@PrimaryActionCssClass" @onclick="OnPrimaryAction" disabled="@(IsSaving || IsReparsing)">
@(IsSaving ? PrimaryBusyLabel : PrimaryActionLabel)
</button>
}
}
</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]
public string LoadingMessage { get; set; } = "Loading curation preview...";
[Parameter]
public string EmptyMessage { get; set; } = "No curation preview is available.";
[Parameter]
public string PrimaryActionLabel { get; set; } = "Mark curated";
[Parameter]
public string PrimaryBusyLabel { get; set; } = "Saving...";
[Parameter]
public string PrimaryActionCssClass { get; set; } = "btn-ritual";
[Parameter]
public bool ShowPrimaryAction { get; set; } = true;
[Parameter]
public string SecondaryActionLabel { get; set; } = "Open full editor";
[Parameter]
public string SecondaryActionCssClass { get; set; } = "btn btn-secondary";
[Parameter]
public bool ShowSecondaryAction { get; set; } = true;
[Parameter]
public string ReparseActionLabel { get; set; } = "Parse";
[Parameter]
public string ReparseBusyLabel { get; set; } = "Parsing...";
[Parameter, EditorRequired]
public EventCallback OnPrimaryAction { get; set; }
[Parameter, EditorRequired]
public EventCallback OnSecondaryAction { get; set; }
[Parameter, EditorRequired]
public EventCallback OnEnterQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnCancelQuickParse { get; set; }
[Parameter, EditorRequired]
public EventCallback OnReparse { get; set; }
private CriticalCellQuickParseEditor? quickParseEditor;
private bool shouldFocusQuickParseEditor;
private bool wasQuickParseMode;
protected override void OnParametersSet()
{
if (IsQuickParseMode && !wasQuickParseMode)
{
shouldFocusQuickParseEditor = true;
}
wasQuickParseMode = IsQuickParseMode;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (shouldFocusQuickParseEditor && quickParseEditor is not null)
{
shouldFocusQuickParseEditor = false;
await quickParseEditor.FocusAsync();
}
}
private static IReadOnlyList<CriticalTableLegendEntry> GetUsedLegendEntries(CriticalCellEditorModel model, IReadOnlyList<CriticalTableLegendEntry>? legendEntries)
{
if (legendEntries is null || legendEntries.Count == 0)
{
return [];
}
var usedEffectCodes = model.Effects.Select(effect => effect.EffectCode).Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode))).Where(effectCode => !string.IsNullOrWhiteSpace(effectCode)).ToHashSet(StringComparer.OrdinalIgnoreCase);
return legendEntries.Where(entry => usedEffectCodes.Contains(entry.EffectCode)).ToList();
}
private async Task HandleReparseClickAsync(MouseEventArgs _)
{
if (quickParseEditor is not null)
{
await quickParseEditor.ReparseAsync();
return;
}
await OnReparse.InvokeAsync();
}
private string? GetVisibleErrorMessage() =>
IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage) ? QuickParseErrorMessage : ErrorMessage;
}

View File

@@ -1,19 +1,43 @@
@inherits LayoutComponentBase @inherits LayoutComponentBase
@implements IDisposable
@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
<div class="page"> <AppShell>
<div class="sidebar"> <OmniboxContent>
<NavMenu /> <ShellOmniboxTrigger />
</div> </OmniboxContent>
<UtilityContent>
<main> <ShellThemeSwitcher />
<article class="content-shell"> </UtilityContent>
<ChildContent>
@Body @Body
</article> </ChildContent>
</main> </AppShell>
</div>
<div id="blazor-error-ui" data-nosnippet> <div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred. An unhandled error has occurred.
<a href="." class="reload">Reload</a> <a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span> <span class="dismiss">🗙</span>
</div> </div>
@code {
protected override void OnInitialized()
{
ThemeState.Changed += HandleThemeChanged;
}
protected override Task OnAfterRenderAsync(bool firstRender) =>
firstRender
? ThemeState.InitializeAsync()
: Task.CompletedTask;
private void HandleThemeChanged()
{
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
ThemeState.Changed -= HandleThemeChanged;
}
}

View File

@@ -1,38 +1,3 @@
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
}
main {
flex: 1;
min-width: 0;
}
.sidebar {
background:
radial-gradient(circle at top, rgba(196, 167, 107, 0.28), transparent 35%),
linear-gradient(180deg, #24130d 0%, #3c2415 46%, #130d0b 100%);
border-right: 1px solid rgba(196, 167, 107, 0.2);
}
.content-shell {
padding: 1.5rem;
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 290px;
height: 100vh;
position: sticky;
top: 0;
}
}
#blazor-error-ui { #blazor-error-ui {
color-scheme: light only; color-scheme: light only;
background: #682e24; background: #682e24;

View File

@@ -1,4 +1,4 @@
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script> <script type="module" src="@Assets["components/layout/reconnect-modal.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet> <dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container"> <div class="components-reconnect-container">

View File

@@ -2,146 +2,4 @@
<PageTitle>API Surface</PageTitle> <PageTitle>API Surface</PageTitle>
<div class="api-grid"> <CompatibilityRouteRedirect TargetPath="/tools/api" />
<section class="panel">
<h2 class="panel-title">Reference data</h2>
<p class="panel-copy"><code>GET /api/reference-data</code></p>
<pre class="code-block">{
"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."
}
]
}</pre>
</section>
<section class="panel">
<h2 class="panel-title">Attack lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/attack</code></p>
<pre class="code-block">{
"attackTable": "broadsword",
"armorType": "AT10",
"roll": 111,
"criticalRoll": 72
}</pre>
</section>
<section class="panel">
<h2 class="panel-title">Critical lookup</h2>
<p class="panel-copy"><code>POST /api/lookup/critical</code></p>
<pre class="code-block">{
"criticalType": "mana",
"column": "E",
"roll": 100,
"group": null
}</pre>
<p class="panel-copy">Response now includes table metadata, roll-band bounds, raw imported cell text, parse status, and parsed JSON alongside the gameplay description.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell editor load</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
"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": []
}</pre>
<p class="panel-copy">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.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell source image</h2>
<p class="panel-copy"><code>GET /api/tables/critical/{slug}/cells/{resultId}/source-image</code></p>
<p class="panel-copy">Streams the importer-generated PNG crop for the current critical cell. Returns <code>404</code> when the row has no stored crop or the artifact is missing.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
<pre class="code-block">{
"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": []
}
}</pre>
<p class="panel-copy">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.</p>
</section>
<section class="panel">
<h2 class="panel-title">Cell editor save</h2>
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
<pre class="code-block">{
"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": []
}</pre>
<p class="panel-copy">The save endpoint replaces the stored base result, branch rows, and effect rows for that cell with the submitted curated payload.</p>
</section>
</div>

View File

@@ -0,0 +1,616 @@
@page "/curation"
@rendermode InteractiveServer
@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<PageTitle>Curation</PageTitle>
<section class="panel curation-page">
@if (referenceData is null)
{
<div class="critical-editor-card nested">
<span class="muted" role="status">Loading curation queue...</span>
</div>
}
else if (!hasInitializedContext)
{
<div class="critical-editor-card nested">
<span class="muted" role="status">Restoring curation context...</span>
</div>
}
else
{
<CurationQueueBar
ScopeItems="BuildScopeItems()"
SelectedScope="selectedQueueScope"
SelectedScopeChanged="HandleQueueScopeChangedAsync"
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
SelectedTableSlugChanged="HandleSelectedTableChangedAsync"
CurrentItem="currentQueueItem"
IsBusy="IsBusy"
TablesHref="@BuildTablesUri()"
DiagnosticsHref="@BuildDiagnosticsUri()"/>
@if (!string.IsNullOrWhiteSpace(pageError))
{
<p class="error-text critical-editor-error" role="alert">@pageError</p>
}
else if (currentQueueItem is null)
{
<CurationQueueEmptyState
Title="@BuildEmptyStateTitle()"
Message="@BuildEmptyStateMessage()"
ActionHref="@BuildTablesUri()"
ActionLabel="Open tables"/>
}
else
{
<div class="critical-editor-card curation-workspace-frame">
<CurationWorkspace
Model="curationModel"
IsLoading="isCurationLoading"
IsSaving="isCurationSaving"
IsReparsing="isCurationReparsing"
IsQuickParseMode="isCurationQuickParseMode"
ErrorMessage="@curationError"
QuickParseErrorMessage="@curationQuickParseError"
LegendEntries="@(currentTableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
PrimaryActionLabel="Mark curated and continue"
SecondaryActionLabel="Open full editor"
OnPrimaryAction="MarkCellCuratedAsync"
OnSecondaryAction="OpenCellEditorAsync"
OnEnterQuickParse="EnterCurationQuickParseMode"
OnCancelQuickParse="CancelCurationQuickParseMode"
OnReparse="ReparseCurationCellAsync"/>
</div>
}
}
</section>
@if (isEditorOpen)
{
<CriticalCellEditorDialog
@key="editorModel"
Model="editorModel"
ComparisonBaseline="editorComparisonBaselineModel"
IsLoading="isEditorLoading"
IsReparsing="isEditorReparsing"
IsSaving="isEditorSaving"
LoadErrorMessage="@editorLoadError"
ReparseErrorMessage="@editorReparseError"
SaveErrorMessage="@editorSaveError"
OnClose="CloseCellEditorAsync"
OnReparse="ReparseCellEditorAsync"
OnSave="SaveCellEditorAsync"/>
}
@code {
private const string ContextDestination = "curation";
private readonly Dictionary<string, CriticalTableDetail?> tableDetailCache = new(StringComparer.OrdinalIgnoreCase);
private LookupReferenceData? referenceData;
private string selectedQueueScope = CurationQueueScopes.AllTables;
private string selectedTableSlug = string.Empty;
private bool hasInitializedContext;
private bool isCurationLoading;
private bool isCurationSaving;
private bool isCurationReparsing;
private bool isCurationQuickParseMode;
private string? pageError;
private string? curationError;
private string? curationQuickParseError;
private CurationQueueItem? currentQueueItem;
private CriticalTableDetail? currentTableDetail;
private CriticalCellEditorModel? curationModel;
private bool isEditorOpen;
private bool isEditorLoading;
private bool isEditorReparsing;
private bool isEditorSaving;
private string? editorLoadError;
private string? editorReparseError;
private string? editorSaveError;
private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel;
private bool IsBusy =>
isCurationLoading || isCurationSaving || isCurationReparsing || isEditorLoading || isEditorReparsing || isEditorSaving;
protected override async Task OnInitializedAsync()
{
referenceData = await LookupService.GetReferenceDataAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0)
{
return;
}
await PinnedTablesState.InitializeAsync();
var initialContext = await TableContextState.RestoreAsync(NavigationManager.Uri, ContextDestination, referenceData.CriticalTables, TableContextMode.Curation);
selectedQueueScope = CurationQueueScopes.Normalize(initialContext.QueueScope);
selectedTableSlug = initialContext.TableSlug ?? string.Empty;
hasInitializedContext = true;
await LoadQueueAsync(initialContext);
await InvokeAsync(StateHasChanged);
}
private async Task HandleQueueScopeChangedAsync(string scope)
{
selectedQueueScope = CurationQueueScopes.Normalize(scope);
await LoadQueueAsync();
}
private async Task HandleSelectedTableChangedAsync(string tableSlug)
{
selectedTableSlug = tableSlug;
await LoadQueueAsync();
}
private async Task LoadQueueAsync(TableContextSnapshot? preferredContext = null)
{
isCurationLoading = true;
pageError = null;
try
{
var (detail, item) = await ResolveQueueItemAsync(preferredContext);
await ApplyResolvedQueueItemAsync(detail, item, "The selected cell could not be loaded for curation.");
}
catch (Exception exception)
{
currentTableDetail = null;
currentQueueItem = null;
curationModel = null;
curationError = null;
curationQuickParseError = null;
pageError = exception.Message;
}
finally
{
isCurationLoading = false;
}
}
private async Task ApplyResolvedQueueItemAsync(CriticalTableDetail? detail, CurationQueueItem? item, string loadFailureMessage)
{
currentTableDetail = detail;
currentQueueItem = item;
curationModel = null;
curationError = null;
curationQuickParseError = null;
isCurationQuickParseMode = false;
if (item is not null)
{
selectedTableSlug = item.TableSlug;
var response = await LookupService.GetCriticalCellEditorAsync(item.TableSlug, item.ResultId);
if (response is null)
{
curationError = loadFailureMessage;
}
else
{
curationModel = CriticalCellEditorModel.FromResponse(response);
}
}
await PersistAndSyncCurationContextAsync();
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveQueueItemAsync(TableContextSnapshot? preferredContext = null)
{
var candidateSlugs = GetCandidateTableSlugs();
if (candidateSlugs.Count == 0)
{
return (null, null);
}
foreach (var slug in OrderCandidatesForRestore(candidateSlugs, preferredContext?.TableSlug))
{
var detail = await GetTableDetailAsync(slug);
if (detail is null)
{
continue;
}
if (preferredContext is not null && string.Equals(slug, preferredContext.TableSlug, StringComparison.OrdinalIgnoreCase) && CurationQueueResolver.FindCell(detail, preferredContext) is { IsCurated: false } preferredCell)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, preferredCell));
}
if (CurationQueueResolver.FindFirstUncurated(detail) is { } firstCell)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, firstCell));
}
if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
return (detail, null);
}
}
return (null, null);
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveNextQueueItemAsync()
{
if (currentQueueItem is null)
{
return await ResolveQueueItemAsync();
}
var candidateSlugs = GetCandidateTableSlugs();
var currentTableIndex = candidateSlugs.FindIndex(slug => string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
if (currentTableIndex < 0)
{
return await ResolveQueueItemAsync();
}
for (var index = currentTableIndex; index < candidateSlugs.Count; index++)
{
var slug = candidateSlugs[index];
var detail = await GetTableDetailAsync(slug, forceRefresh: string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase));
if (detail is null)
{
continue;
}
CriticalTableCellDetail? nextCell = string.Equals(slug, currentQueueItem.TableSlug, StringComparison.OrdinalIgnoreCase) ? CurationQueueResolver.FindNextUncurated(detail, currentQueueItem.ResultId) : CurationQueueResolver.FindFirstUncurated(detail);
if (nextCell is not null)
{
return (detail, CurationQueueResolver.CreateQueueItem(detail, nextCell));
}
if (string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal))
{
return (detail, null);
}
}
return (null, null);
}
private async Task<(CriticalTableDetail? Detail, CurationQueueItem? Item)> ResolveCurrentQueueItemAsync()
{
if (currentQueueItem is null)
{
return (null, null);
}
var detail = await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
if (detail is null)
{
return (null, null);
}
var cell = detail.Cells.FirstOrDefault(item => item.ResultId == currentQueueItem.ResultId);
return cell is null ? (detail, null) : (detail, CurationQueueResolver.CreateQueueItem(detail, cell));
}
private async Task<CriticalTableDetail?> GetTableDetailAsync(string slug, bool forceRefresh = false)
{
if (!forceRefresh && tableDetailCache.TryGetValue(slug, out var cachedDetail))
{
return cachedDetail;
}
var detail = await LookupService.GetCriticalTableAsync(slug);
tableDetailCache[slug] = detail;
return detail;
}
private List<string> GetCandidateTableSlugs()
{
if (referenceData?.CriticalTables.Count is not > 0)
{
return [];
}
return selectedQueueScope switch
{
CurationQueueScopes.SelectedTable when !string.IsNullOrWhiteSpace(selectedTableSlug) => [TableContextState.ResolveTableSlug(referenceData.CriticalTables, selectedTableSlug)],
CurationQueueScopes.PinnedSet => referenceData.CriticalTables.Where(table => PinnedTablesState.IsPinned(table.Key)).Select(table => table.Key).ToList(),
_ => referenceData.CriticalTables.Select(table => table.Key).ToList()
};
}
private static IReadOnlyList<string> OrderCandidatesForRestore(IReadOnlyList<string> candidates, string? preferredSlug)
{
if (string.IsNullOrWhiteSpace(preferredSlug))
{
return candidates;
}
var ordered = new List<string>(candidates.Count);
foreach (var slug in candidates)
{
if (string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
{
ordered.Add(slug);
break;
}
}
foreach (var slug in candidates)
{
if (!string.Equals(slug, preferredSlug, StringComparison.OrdinalIgnoreCase))
{
ordered.Add(slug);
}
}
return ordered;
}
private Task EnterCurationQuickParseMode()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = true;
return Task.CompletedTask;
}
private Task CancelCurationQuickParseMode()
{
if (isCurationReparsing)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = false;
return Task.CompletedTask;
}
private async Task ReparseCurationCellAsync()
{
if (curationModel is null || currentQueueItem is null)
{
return;
}
isCurationReparsing = true;
curationQuickParseError = null;
try
{
var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
if (response is null)
{
curationQuickParseError = "The selected cell could not be re-parsed.";
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
isCurationQuickParseMode = false;
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
curationQuickParseError = exception.Message;
}
finally
{
isCurationReparsing = false;
}
}
private async Task MarkCellCuratedAsync()
{
if (curationModel is null || currentQueueItem is null)
{
return;
}
isCurationSaving = true;
curationError = null;
try
{
curationModel.IsCurated = true;
var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, curationModel.ToRequest());
if (response is null)
{
curationError = "The selected cell could not be marked curated.";
return;
}
await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
var (detail, item) = await ResolveNextQueueItemAsync();
await ApplyResolvedQueueItemAsync(detail, item, "The next cell could not be loaded for curation.");
}
catch (Exception exception)
{
curationError = exception.Message;
}
finally
{
isCurationSaving = false;
}
}
private Task OpenCellEditorAsync()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editorComparisonBaselineModel = null;
editorModel = curationModel.Clone();
isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false;
isEditorOpen = true;
return Task.CompletedTask;
}
private async Task CloseCellEditorAsync()
{
isEditorOpen = false;
isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false;
editorLoadError = null;
editorReparseError = null;
editorSaveError = null;
editorModel = null;
editorComparisonBaselineModel = null;
await InvokeAsync(StateHasChanged);
}
private async Task ReparseCellEditorAsync()
{
if (editorModel is null || currentQueueItem is null)
{
return;
}
isEditorReparsing = true;
editorReparseError = null;
try
{
var comparisonBaseline = editorModel.Clone();
var response = await LookupService.ReparseCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
if (response is null)
{
editorReparseError = "The selected cell could not be re-parsed.";
return;
}
editorComparisonBaselineModel = comparisonBaseline;
editorModel = CriticalCellEditorModel.FromResponse(response);
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
editorReparseError = exception.Message;
}
finally
{
isEditorReparsing = false;
}
}
private async Task SaveCellEditorAsync()
{
if (editorModel is null || currentQueueItem is null)
{
return;
}
isEditorSaving = true;
editorSaveError = null;
try
{
var response = await LookupService.UpdateCriticalCellAsync(currentQueueItem.TableSlug, currentQueueItem.ResultId, editorModel.ToRequest());
if (response is null)
{
editorSaveError = "The selected cell could not be saved.";
return;
}
await GetTableDetailAsync(currentQueueItem.TableSlug, forceRefresh: true);
await CloseCellEditorAsync();
if (response.IsCurated)
{
var (nextDetail, nextItem) = await ResolveNextQueueItemAsync();
await ApplyResolvedQueueItemAsync(nextDetail, nextItem, "The next cell could not be loaded for curation.");
}
else
{
var (detail, item) = await ResolveCurrentQueueItemAsync();
await ApplyResolvedQueueItemAsync(detail, item, "The edited cell could not be reloaded for curation.");
}
}
catch (Exception exception)
{
editorSaveError = exception.Message;
}
finally
{
isEditorSaving = false;
}
}
private async Task PersistAndSyncCurationContextAsync()
{
var snapshot = BuildCurrentCurationContext();
await TableContextState.PersistAsync(ContextDestination, snapshot);
var targetUri = TableContextState.BuildUri("/curation", snapshot);
if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal))
{
return;
}
NavigationManager.NavigateTo(targetUri, replace: true);
}
private TableContextSnapshot BuildCurrentCurationContext() =>
new(TableSlug: string.Equals(selectedQueueScope, CurationQueueScopes.SelectedTable, StringComparison.Ordinal) ? selectedTableSlug : currentQueueItem?.TableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Curation, QueueScope: selectedQueueScope);
private string BuildTablesUri()
{
var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem?.TableSlug ?? selectedTableSlug, GroupKey: currentQueueItem?.GroupKey, ColumnKey: currentQueueItem?.ColumnKey, RollBand: currentQueueItem?.RollBand, ResultId: currentQueueItem?.ResultId, Mode: TableContextMode.Reference);
return TableContextState.BuildUri("/tables", snapshot);
}
private string? BuildDiagnosticsUri()
{
if (currentQueueItem is null)
{
return null;
}
var snapshot = new TableContextSnapshot(TableSlug: currentQueueItem.TableSlug, GroupKey: currentQueueItem.GroupKey, ColumnKey: currentQueueItem.ColumnKey, RollBand: currentQueueItem.RollBand, ResultId: currentQueueItem.ResultId, Mode: TableContextMode.Diagnostics);
return TableContextState.BuildUri("/tools/diagnostics", snapshot);
}
private IReadOnlyList<SegmentedTabItem> BuildScopeItems() =>
[
new(CurationQueueScopes.AllTables, "All tables"),
new(CurationQueueScopes.SelectedTable, "Selected table"),
new(CurationQueueScopes.PinnedSet, "Pinned set", PinnedTablesState.Items.Count == 0 ? null : PinnedTablesState.Items.Count.ToString())
];
private string BuildEmptyStateTitle() =>
selectedQueueScope switch
{
CurationQueueScopes.SelectedTable => "This table has no remaining queue items",
CurationQueueScopes.PinnedSet => "Pinned queue is empty",
_ => "Curation queue complete"
};
private string BuildEmptyStateMessage() =>
selectedQueueScope switch
{
CurationQueueScopes.SelectedTable => "Choose another table scope or return to reference mode to inspect already curated entries.",
CurationQueueScopes.PinnedSet when PinnedTablesState.Items.Count == 0 => "Pin one or more tables first, then return to the pinned queue lane.",
CurationQueueScopes.PinnedSet => "Every uncurated result in the pinned set has already been reviewed.",
_ => "No uncurated results remain in the current queue scope."
};
}

View File

@@ -1,332 +1,5 @@
@page "/diagnostics" @page "/diagnostics"
@rendermode InteractiveServer
@using System
@using System.Collections.Generic
@using System.Linq
@inject LookupService LookupService
<PageTitle>Diagnostics</PageTitle> <PageTitle>Diagnostics</PageTitle>
<section class="panel diagnostics-page"> <CompatibilityRouteRedirect TargetPath="/tools/diagnostics" />
<header class="diagnostics-page-header">
<div>
<h2 class="panel-title">Critical Cell Diagnostics</h2>
<p class="panel-copy">Engineering-only parser metadata, provenance, and payload inspection live here instead of inside the normal curation modal.</p>
</div>
</header>
@if (referenceData is null)
{
<p class="muted">Loading table list...</p>
}
else if (!referenceData.CriticalTables.Any())
{
<p class="muted">No critical tables are available yet.</p>
}
else
{
<div class="diagnostics-selector-grid">
<div class="field-shell">
<label for="diagnostics-table-select">Table</label>
<select
id="diagnostics-table-select"
class="input-shell"
value="@selectedTableSlug"
@onchange="HandleTableChanged"
disabled="@isBusy">
@foreach (var table in referenceData.CriticalTables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="diagnostics-roll-band-select">Roll Band</label>
<select
id="diagnostics-roll-band-select"
class="input-shell"
value="@selectedRollBand"
@onchange="HandleRollBandChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.RollBands.Any())">
@if (tableDetail is not null)
{
@foreach (var rollBand in tableDetail.RollBands)
{
<option value="@rollBand.Label">@rollBand.Label</option>
}
}
</select>
</div>
@if (tableDetail is { Groups.Count: > 0 })
{
<div class="field-shell">
<label for="diagnostics-group-select">Variant</label>
<select
id="diagnostics-group-select"
class="input-shell"
value="@selectedGroupKey"
@onchange="HandleGroupChanged"
disabled="@isBusy">
@foreach (var group in tableDetail.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</div>
}
<div class="field-shell">
<label for="diagnostics-column-select">Severity</label>
<select
id="diagnostics-column-select"
class="input-shell"
value="@selectedColumnKey"
@onchange="HandleColumnChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.Columns.Any())">
@if (tableDetail is not null)
{
@foreach (var column in tableDetail.Columns)
{
<option value="@column.Key">@column.Label</option>
}
}
</select>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(detailError))
{
<p class="error-text">@detailError</p>
}
else if (tableDetail is null)
{
<p class="muted">The selected table could not be loaded.</p>
}
else if (!tableDetail.Cells.Any())
{
<p class="muted">The selected table has no filled cells to inspect.</p>
}
else if (selectedCell is null)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>No Filled Cell At This Position</strong>
<p class="muted critical-editor-inline-copy">Pick another roll band, variant, or severity to inspect a stored result.</p>
</div>
</div>
</div>
}
else
{
<div class="diagnostics-selection-summary">
<strong>Inspecting</strong>
<span>@tableDetail.DisplayName</span>
<span>· Roll band <strong>@selectedCell.RollBand</strong></span>
<span>· Severity <strong>@selectedCell.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel))
{
<span>· Variant <strong>@selectedCell.GroupLabel</strong></span>
}
<span>· Result ID <strong>@selectedCell.ResultId</strong></span>
</div>
@if (isDiagnosticsLoading)
{
<p class="muted">Loading diagnostics...</p>
}
else if (!string.IsNullOrWhiteSpace(diagnosticsError))
{
<p class="error-text">@diagnosticsError</p>
}
else if (diagnosticsModel is not null)
{
<CriticalCellEngineeringDiagnostics Model="diagnosticsModel" />
}
}
}
</section>
@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();
}

View File

@@ -1,66 +1,18 @@
@page "/tables" @page "/tables"
@rendermode InteractiveServer @rendermode InteractiveServer
@using System @using System
@using System.Collections.Generic
@using System.Diagnostics.CodeAnalysis
@using System.Linq @using System.Linq
@inject IJSRuntime JSRuntime @using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService @inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<PageTitle>Critical Tables</PageTitle> <PageTitle>Critical Tables</PageTitle>
<section class="panel tables-page"> <section class="panel tables-page">
<div class="table-browser-toolbar"> <TablesPageHeader/>
<div class="table-selector">
<label id="critical-table-selector-label">Table</label>
<div class="table-select-shell">
<button
type="button"
class="input-shell table-select-trigger"
aria-haspopup="listbox"
aria-expanded="@isTableMenuOpen"
aria-labelledby="critical-table-selector-label critical-table-selector-value"
@onclick="ToggleTableMenu"
disabled="@IsTableSelectionDisabled">
<span class="table-select-trigger-copy">
<span id="critical-table-selector-value" class="table-select-trigger-title">@GetSelectedTableLabel()</span>
</span>
@if (SelectedTableReference is { } selected)
{
<span class="table-select-trigger-chips">
<span class="chip">@($"{selected.CurationPercentage}%")</span>
</span>
}
</button>
@if (isTableMenuOpen && referenceData is not null)
{
<button type="button" class="table-selector-backdrop" @onclick="CloseTableMenu" aria-label="Close table selector"></button>
<div class="table-select-menu" role="listbox" aria-labelledby="critical-table-selector-label">
@foreach (var table in referenceData.CriticalTables)
{
<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-select-option @GetTableOptionCssClass(table)"
@onclick="() => SelectTableAsync(table.Key)">
<span class="table-select-option-main">
<strong class="table-select-option-title">@table.Label</strong>
</span>
<span class="table-select-option-chips">
<span class="chip">@($"{table.CurationPercentage}%")</span>
</span>
</button>
}
</div>
}
</div>
</div>
<p class="table-browser-toolbar-copy">Choose a table, then read from the roll band on the left across to the result you need.</p>
</div>
@if (referenceData is null) @if (referenceData is null)
{ {
@@ -70,6 +22,24 @@
{ {
<p class="muted">No critical tables are available yet.</p> <p class="muted">No critical tables are available yet.</p>
} }
else
{
<div class="tables-reference-layout">
<aside class="tables-reference-rail">
<TablesIndexRail
Tables="referenceData.CriticalTables"
SelectedTableSlug="selectedTableSlug"
PinnedTableSlugs="PinnedTablesState.Items.Select(item => item.Slug).ToArray()"
RecentTableSlugs="RecentTablesState.Items.Select(item => item.Slug).ToArray()"
IsPinned="PinnedTablesState.IsPinned"
OnSelectTable="SelectTableAsync"/>
</aside>
<div class="tables-reference-main">
@if (!hasResolvedStoredTableSelection)
{
<p class="muted">Restoring table context...</p>
}
else if (isDetailLoading) else if (isDetailLoading)
{ {
<p class="muted">Loading the selected table...</p> <p class="muted">Loading the selected table...</p>
@@ -84,114 +54,40 @@
} }
else if (tableDetail is { } detail) else if (tableDetail is { } detail)
{ {
var readingHint = detail.Groups.Count > 0
? "Find the roll band on the left, then read across to the group and severity you need."
: "Find the roll band on the left, then read across to the severity you need.";
<div class="table-shell"> <div class="table-shell">
<header class="table-browser-header"> <TablesContextBar
<div> Detail="detail"
<h2 class="panel-title">@detail.DisplayName</h2> IsPinned="PinnedTablesState.IsPinned(detail.Slug)"
<p class="table-browser-reading-hint">@readingHint</p> OnTogglePin="TogglePinnedTableAsync"
IsLegendOpen="isLegendOpen"
OnToggleLegend="ToggleLegend"/>
<TablesCanvas
Detail="detail"
CurrentMode="referenceMode"
SelectedGroupKey="selectedGroupKey"
SelectedColumnKey="selectedColumnKey"
RollJumpValue="rollJumpValue"
DensityMode="densityMode"
SelectedCell="selectedCell"
OnSelectCell="SelectCell"/>
@if (isLegendOpen)
{
<TablesLegend LegendEntries="@(detail.Legend ?? Array.Empty<CriticalTableLegendEntry>())"/>
}
</div> </div>
<p class="table-browser-edit-hint">Use the curation action or edit action on any filled result.</p>
</header>
@{
var displayColumns = GetDisplayColumns(detail);
var gridTemplateStyle = BuildGridTemplateStyle(detail);
}
<div class="table-scroll">
<div class="critical-table-grid" role="group" aria-label="@detail.DisplayName" style="@gridTemplateStyle">
@if (detail.Groups.Count > 0)
{
<div class="critical-table-grid-header-cell critical-table-grid-corner" aria-hidden="true"></div>
@foreach (var group in detail.Groups)
{
<div
class="critical-table-grid-header-cell critical-table-grid-group-header"
style="@BuildColumnSpanStyle(detail.Columns.Count)">
<span>@group.Label</span>
</div>
}
}
<div class="critical-table-grid-header-cell critical-table-grid-roll-band-header" aria-hidden="true"></div>
@foreach (var displayColumn in displayColumns)
{
<div class="critical-table-grid-header-cell critical-table-grid-column-header">
<span>@displayColumn.ColumnLabel</span>
</div>
}
@foreach (var rollBand in detail.RollBands)
{
<div class="critical-table-grid-header-cell critical-table-grid-roll-band">@rollBand.Label</div>
@foreach (var displayColumn in displayColumns)
{
@if (TryGetCell(rollBand.Label, displayColumn.GroupKey, displayColumn.ColumnKey, out var cell))
{
@RenderCriticalTableCell(cell)
}
else
{
@RenderEmptyCriticalTableCell()
}
}
} }
</div> </div>
</div> </div>
@{ <TablesSelectionMenu
var legendEntries = detail.Legend ?? Array.Empty<CriticalTableLegendEntry>(); SelectedCellDetail="SelectedCellDetail"
} OnEdit="OpenSelectedCellEditorAsync"
OnCurate="OpenSelectedCellCurationAsync"/>
@if (legendEntries.Count > 0)
{
<div class="critical-legend">
<div class="critical-legend-header">
<h4>Reading help</h4>
<p class="muted">These symbols show the effects attached to a result at a glance.</p>
</div>
<div class="legend-grid">
@foreach (var entry in legendEntries)
{
<div class="legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<div>
<strong>@entry.Label</strong>
<span class="muted">@entry.Description</span>
</div>
</div>
}
</div>
</div>
}
</div>
} }
</section> </section>
@if (isCurationOpen)
{
<CriticalCellCurationDialog
@key="curationModel"
Model="curationModel"
IsLoading="isCurationLoading"
IsSaving="isCurationSaving"
IsReparsing="isCurationReparsing"
IsQuickParseMode="isCurationQuickParseMode"
ErrorMessage="@curationError"
QuickParseErrorMessage="@curationQuickParseError"
LegendEntries="@(tableDetail?.Legend ?? Array.Empty<CriticalTableLegendEntry>())"
OnClose="CloseCellCurationAsync"
OnMarkCurated="MarkCellCuratedAsync"
OnEdit="OpenEditorFromCurationAsync"
OnEnterQuickParse="EnterCurationQuickParseMode"
OnCancelQuickParse="CancelCurationQuickParseMode"
OnReparse="ReparseCurationCellAsync" />
}
@if (isEditorOpen) @if (isEditorOpen)
{ {
<CriticalCellEditorDialog <CriticalCellEditorDialog
@@ -210,15 +106,12 @@
} }
@code { @code {
private const string SelectedTableStorageKey = "rolemaster.tables.selectedTable"; private const string ContextDestination = "tables";
private LookupReferenceData? referenceData; private LookupReferenceData? referenceData;
private CriticalTableDetail? tableDetail; private CriticalTableDetail? tableDetail;
private string selectedTableSlug = string.Empty; private string selectedTableSlug = string.Empty;
private bool isDetailLoading; private bool isDetailLoading;
private bool isReferenceDataLoading = true;
private string? detailError; private string? detailError;
private Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail>? cellIndex;
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
private bool isEditorOpen; private bool isEditorOpen;
private bool isEditorLoading; private bool isEditorLoading;
private bool isEditorReparsing; private bool isEditorReparsing;
@@ -229,47 +122,31 @@
private int? editingResultId; private int? editingResultId;
private CriticalCellEditorModel? editorModel; private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel; private CriticalCellEditorModel? editorComparisonBaselineModel;
private bool isCurationOpen; private string referenceMode = TablesReferenceMode.Reference;
private bool isCurationLoading; private string selectedGroupKey = string.Empty;
private bool isCurationSaving; private string selectedColumnKey = string.Empty;
private bool isCurationReparsing; private string rollJumpValue = string.Empty;
private bool isCurationQuickParseMode; private string densityMode = TablesDensityMode.Comfortable;
private string? curationError; private TablesCellSelection? selectedCell;
private string? curationQuickParseError; private bool isLegendOpen;
private int? curatingResultId;
private CriticalCellEditorModel? curationModel;
private bool isTableMenuOpen;
private bool hasResolvedStoredTableSelection; private bool hasResolvedStoredTableSelection;
private CriticalTableReference? SelectedTableReference => private CriticalTableReference? SelectedTableReference =>
referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)); referenceData?.CriticalTables.FirstOrDefault(item => string.Equals(item.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase));
private CriticalTableCellDetail? SelectedCellDetail =>
selectedCell is null ? null : tableDetail?.Cells.FirstOrDefault(cell => cell.ResultId == selectedCell.ResultId);
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
referenceData = await LookupService.GetReferenceDataAsync(); referenceData = await LookupService.GetReferenceDataAsync();
isReferenceDataLoading = false;
}
private void ToggleTableMenu()
{
if (IsTableSelectionDisabled)
{
return;
}
isTableMenuOpen = !isTableMenuOpen;
}
private void CloseTableMenu()
{
isTableMenuOpen = false;
} }
private async Task SelectTableAsync(string tableSlug) private async Task SelectTableAsync(string tableSlug)
{ {
selectedTableSlug = tableSlug; selectedTableSlug = tableSlug;
isTableMenuOpen = false;
await PersistSelectedTableAsync(tableSlug);
await LoadTableDetailAsync(); await LoadTableDetailAsync();
await PersistAndSyncTableContextAsync();
} }
private async Task LoadTableDetailAsync() private async Task LoadTableDetailAsync()
@@ -277,14 +154,12 @@
if (string.IsNullOrWhiteSpace(selectedTableSlug)) if (string.IsNullOrWhiteSpace(selectedTableSlug))
{ {
tableDetail = null; tableDetail = null;
cellIndex = null;
return; return;
} }
isDetailLoading = true; isDetailLoading = true;
detailError = null; detailError = null;
tableDetail = null; tableDetail = null;
cellIndex = null;
try try
{ {
@@ -292,76 +167,51 @@
if (tableDetail is null) if (tableDetail is null)
{ {
detailError = "The selected table could not be loaded."; detailError = "The selected table could not be loaded.";
NormalizeViewStateForCurrentDetail();
return;
} }
await RecordRecentTableVisitAsync();
NormalizeViewStateForCurrentDetail();
} }
catch (Exception exception) catch (Exception exception)
{ {
detailError = exception.Message; detailError = exception.Message;
NormalizeViewStateForCurrentDetail();
} }
finally finally
{ {
isDetailLoading = false; isDetailLoading = false;
BuildCellIndex();
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender)
{
await RecentTablesState.InitializeAsync();
await PinnedTablesState.InitializeAsync();
}
if (!hasResolvedStoredTableSelection && referenceData?.CriticalTables.Count > 0) if (!hasResolvedStoredTableSelection && referenceData?.CriticalTables.Count > 0)
{ {
try var initialContext = await TableContextState.RestoreAsync(NavigationManager.Uri, ContextDestination, referenceData.CriticalTables, RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
{
var storedTableSlug = await JSRuntime.InvokeAsync<string?>("localStorage.getItem", SelectedTableStorageKey);
hasResolvedStoredTableSelection = true; hasResolvedStoredTableSelection = true;
var resolvedTableSlug = ResolveSelectedTableSlug(storedTableSlug); var resolvedTableSlug = initialContext.TableSlug ?? string.Empty;
if (string.IsNullOrWhiteSpace(selectedTableSlug) || if (string.IsNullOrWhiteSpace(selectedTableSlug) || !string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase))
!string.Equals(resolvedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase))
{ {
selectedTableSlug = resolvedTableSlug; selectedTableSlug = resolvedTableSlug;
await LoadTableDetailAsync(); await LoadTableDetailAsync();
await PersistSelectedTableAsync(selectedTableSlug); await PersistAndSyncTableContextAsync();
await InvokeAsync(StateHasChanged); await InvokeAsync(StateHasChanged);
return; return;
} }
if (!string.Equals(storedTableSlug, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) await PersistAndSyncTableContextAsync();
{
await PersistSelectedTableAsync(selectedTableSlug);
} }
} }
catch (InvalidOperationException)
{
// During prerender localStorage is unavailable. Retry after interactive render.
}
}
}
private void BuildCellIndex()
{
if (tableDetail?.Cells is null)
{
cellIndex = null;
return;
}
cellIndex = new Dictionary<(string, string?, string), CriticalTableCellDetail>();
foreach (var cell in tableDetail.Cells)
{
cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell;
}
}
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, [NotNullWhen(true)] out CriticalTableCellDetail? cell)
{
if (cellIndex is null)
{
cell = null;
return false;
}
return cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
}
private async Task OpenCellEditorAsync(int resultId) private async Task OpenCellEditorAsync(int resultId)
{ {
@@ -404,238 +254,6 @@
} }
} }
private async Task OpenCellCurationAsync(int resultId)
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
isCurationSaving = false;
isCurationReparsing = false;
isCurationQuickParseMode = false;
isCurationOpen = true;
await LoadCurationCellAsync(resultId, "The selected cell could not be loaded for curation.");
}
private async Task CloseCellCurationAsync()
{
isCurationOpen = false;
isCurationLoading = false;
isCurationSaving = false;
isCurationReparsing = false;
isCurationQuickParseMode = false;
curationError = null;
curationQuickParseError = null;
curatingResultId = null;
curationModel = null;
await InvokeAsync(StateHasChanged);
}
private async Task MarkCellCuratedAsync()
{
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
{
return;
}
isCurationSaving = true;
curationError = null;
try
{
curationModel.IsCurated = true;
var response = await LookupService.UpdateCriticalCellAsync(selectedTableSlug, curatingResultId.Value, curationModel.ToRequest());
if (response is null)
{
curationError = "The selected cell could not be marked curated.";
return;
}
await LoadTableDetailAsync();
var nextResultId = FindNextUncuratedResultId(curatingResultId.Value);
if (nextResultId is null)
{
await CloseCellCurationAsync();
return;
}
await LoadCurationCellAsync(nextResultId.Value, "The next cell could not be loaded for curation.");
}
catch (Exception exception)
{
curationError = exception.Message;
}
finally
{
isCurationSaving = false;
}
}
private async Task OpenEditorFromCurationAsync()
{
if (curatingResultId is null)
{
return;
}
var resultId = curatingResultId.Value;
await CloseCellCurationAsync();
await OpenCellEditorAsync(resultId);
}
private Task EnterCurationQuickParseMode()
{
if (curationModel is null)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = true;
return Task.CompletedTask;
}
private Task CancelCurationQuickParseMode()
{
if (isCurationReparsing)
{
return Task.CompletedTask;
}
curationQuickParseError = null;
isCurationQuickParseMode = false;
return Task.CompletedTask;
}
private async Task LoadCurationCellAsync(int resultId, string loadFailureMessage)
{
curationError = null;
curationQuickParseError = null;
curationModel = null;
curatingResultId = resultId;
isCurationLoading = true;
isCurationQuickParseMode = false;
isCurationReparsing = false;
try
{
var response = await LookupService.GetCriticalCellEditorAsync(selectedTableSlug, resultId);
if (response is null)
{
curationError = loadFailureMessage;
curationModel = null;
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
}
catch (Exception exception)
{
curationError = exception.Message;
curationModel = null;
}
finally
{
isCurationLoading = false;
}
}
private async Task ReparseCurationCellAsync()
{
if (curationModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || curatingResultId is null)
{
return;
}
isCurationReparsing = true;
curationQuickParseError = null;
try
{
var response = await ReparseCriticalCellAsync(curationModel, curatingResultId.Value);
if (response is null)
{
curationQuickParseError = "The selected cell could not be re-parsed.";
return;
}
curationModel = CriticalCellEditorModel.FromResponse(response);
isCurationQuickParseMode = false;
await InvokeAsync(StateHasChanged);
}
catch (Exception exception)
{
curationQuickParseError = exception.Message;
}
finally
{
isCurationReparsing = false;
}
}
private int? FindNextUncuratedResultId(int currentResultId)
{
if (tableDetail?.Cells is null || tableDetail.Cells.Count == 0)
{
return null;
}
var orderedCells = tableDetail.Cells
.OrderBy(cell => GetGroupSortOrder(cell.GroupKey))
.ThenBy(cell => GetColumnSortOrder(cell.ColumnKey))
.ThenBy(cell => GetRollBandSortOrder(cell.RollBand))
.ToList();
var currentIndex = orderedCells.FindIndex(cell => cell.ResultId == currentResultId);
for (var index = currentIndex + 1; index < orderedCells.Count; index++)
{
if (!orderedCells[index].IsCurated)
{
return orderedCells[index].ResultId;
}
}
return null;
}
private int GetGroupSortOrder(string? groupKey)
{
if (tableDetail is null || string.IsNullOrWhiteSpace(groupKey))
{
return 0;
}
return tableDetail.Groups
.FirstOrDefault(group => string.Equals(group.Key, groupKey, StringComparison.OrdinalIgnoreCase))
?.SortOrder ?? int.MaxValue;
}
private int GetColumnSortOrder(string columnKey)
{
if (tableDetail is null)
{
return int.MaxValue;
}
return tableDetail.Columns
.FirstOrDefault(column => string.Equals(column.Key, columnKey, StringComparison.OrdinalIgnoreCase))
?.SortOrder ?? int.MaxValue;
}
private int GetRollBandSortOrder(string rollBandLabel)
{
if (tableDetail is null)
{
return int.MaxValue;
}
return tableDetail.RollBands
.FirstOrDefault(rollBand => string.Equals(rollBand.Label, rollBandLabel, StringComparison.OrdinalIgnoreCase))
?.SortOrder ?? int.MaxValue;
}
private async Task CloseCellEditorAsync() private async Task CloseCellEditorAsync()
{ {
isEditorOpen = false; isEditorOpen = false;
@@ -720,103 +338,167 @@
} }
} }
private static string GetCellCssClass(CriticalTableCellDetail cell) => private Task TogglePinnedTableAsync()
cell.IsCurated
? "critical-table-cell is-curated"
: "critical-table-cell needs-curation";
private static IReadOnlyList<(string? GroupKey, string ColumnKey, string ColumnLabel)> GetDisplayColumns(CriticalTableDetail detail)
{ {
if (detail.Groups.Count == 0) if (SelectedTableReference is not { } selectedTable)
{ {
return detail.Columns return Task.CompletedTask;
.Select(column => ((string?)null, column.Key, column.Label))
.ToList();
} }
return detail.Groups return PinnedTablesState.ToggleAsync(selectedTable.Key, selectedTable.Label, selectedTable.Family, selectedTable.CurationPercentage);
.SelectMany(group => detail.Columns.Select(column => ((string?)group.Key, column.Key, column.Label)))
.ToList();
} }
private static string BuildGridTemplateStyle(CriticalTableDetail detail) private Task RecordRecentTableVisitAsync()
{ {
var dataColumnCount = detail.Columns.Count * Math.Max(detail.Groups.Count, 1); if (SelectedTableReference is not { } selectedTable)
return $"grid-template-columns: max-content repeat({dataColumnCount}, minmax(0, 1fr));"; {
return Task.CompletedTask;
} }
private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};"; return RecentTablesState.RecordVisitAsync(selectedTable.Key, selectedTable.Label, selectedTable.Family, selectedTable.CurationPercentage);
}
private string GetSelectedTableLabel() => private async Task PersistAndSyncTableContextAsync()
SelectedTableReference?.Label ?? "Select a table";
private string ResolveSelectedTableSlug(string? storedTableSlug)
{ {
if (referenceData is null || referenceData.CriticalTables.Count == 0) var snapshot = BuildCurrentTableContext();
await TableContextState.PersistAsync(ContextDestination, snapshot);
var targetUri = TableContextState.BuildUri("/tables", snapshot);
if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal))
{
return;
}
NavigationManager.NavigateTo(targetUri, replace: true);
}
private RolemasterDb.App.Frontend.AppState.TableContextSnapshot BuildCurrentTableContext() =>
new(TableSlug: selectedTableSlug, Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
private void SelectCell(TablesCellSelection selection)
{
if (tableDetail?.Cells.Any(cell => cell.ResultId == selection.ResultId) != true)
{
selectedCell = null;
return;
}
selectedCell = selection;
}
private Task OpenSelectedCellEditorAsync() =>
selectedCell is null ? Task.CompletedTask : OpenCellEditorAsync(selectedCell.ResultId);
private async Task OpenSelectedCellCurationAsync()
{
if (SelectedCellDetail is not { } cellDetail || string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
var snapshot = new TableContextSnapshot(TableSlug: selectedTableSlug, GroupKey: cellDetail.GroupKey, ColumnKey: cellDetail.ColumnKey, RollBand: cellDetail.RollBand, ResultId: cellDetail.ResultId, Mode: TableContextMode.Curation, QueueScope: CurationQueueScopes.SelectedTable);
await TableContextState.PersistAsync("curation", snapshot);
NavigationManager.NavigateTo(TableContextState.BuildUri("/curation", snapshot));
}
private Task ToggleLegend()
{
isLegendOpen = !isLegendOpen;
return Task.CompletedTask;
}
private void NormalizeViewStateForCurrentDetail()
{
referenceMode = NormalizeMode(referenceMode);
if (tableDetail is null)
{
selectedGroupKey = string.Empty;
selectedColumnKey = string.Empty;
rollJumpValue = string.Empty;
densityMode = NormalizeDensityMode(densityMode);
selectedCell = null;
return;
}
if (tableDetail.Groups.All(group => !string.Equals(group.Key, selectedGroupKey, StringComparison.OrdinalIgnoreCase)))
{
selectedGroupKey = string.Empty;
}
var matchingColumns = tableDetail.Columns.Where(column => string.IsNullOrWhiteSpace(selectedColumnKey) || string.Equals(column.Key, selectedColumnKey, StringComparison.OrdinalIgnoreCase)).ToList();
if (matchingColumns.Count == 0)
{
selectedColumnKey = string.Empty;
}
rollJumpValue = NormalizeRollInput(rollJumpValue);
densityMode = NormalizeDensityMode(densityMode);
if (selectedCell is not null && tableDetail.Cells.All(cell => cell.ResultId != selectedCell.ResultId))
{
selectedCell = null;
}
NormalizeSelectedCellForCurrentView();
}
private static string NormalizeMode(string? mode) =>
mode switch
{
TablesReferenceMode.NeedsCuration => TablesReferenceMode.NeedsCuration,
TablesReferenceMode.Curated => TablesReferenceMode.Curated,
_ => TablesReferenceMode.Reference
};
private static string NormalizeDensityMode(string? mode) =>
string.Equals(mode, TablesDensityMode.Dense, StringComparison.Ordinal) ? TablesDensityMode.Dense : TablesDensityMode.Comfortable;
private static string NormalizeRollInput(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{ {
return string.Empty; return string.Empty;
} }
if (!string.IsNullOrWhiteSpace(storedTableSlug) && var digitsOnly = new string(value.Where(char.IsDigit).ToArray());
referenceData.CriticalTables.Any(item => string.Equals(item.Key, storedTableSlug, StringComparison.OrdinalIgnoreCase))) return digitsOnly.Length == 0 ? string.Empty : digitsOnly;
}
private void NormalizeSelectedCellForCurrentView()
{ {
return storedTableSlug; if (selectedCell is null || tableDetail is null)
}
return referenceData.CriticalTables.First().Key;
}
private Task PersistSelectedTableAsync(string tableSlug) =>
JSRuntime.InvokeVoidAsync("localStorage.setItem", SelectedTableStorageKey, tableSlug).AsTask();
private string GetTableOptionCssClass(CriticalTableReference table)
{ {
var classes = new List<string>(); return;
}
if (string.Equals(table.Key, selectedTableSlug, StringComparison.OrdinalIgnoreCase)) var cell = tableDetail.Cells.FirstOrDefault(item => item.ResultId == selectedCell.ResultId);
if (cell is null || !MatchesCurrentView(cell))
{ {
classes.Add("is-selected"); selectedCell = null;
}
} }
classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation"); private bool MatchesCurrentView(CriticalTableCellDetail cell)
return string.Join(' ', classes);
}
private RenderFragment RenderCriticalTableCell(CriticalTableCellDetail cell) => @<div class="@GetCellCssClass(cell)">
<div class="critical-table-cell-shell">
<div class="critical-table-cell-actions">
@if (cell.IsCurated)
{ {
<span class="critical-cell-status-chip is-curated">Curated</span> if (!string.IsNullOrWhiteSpace(selectedGroupKey) && !string.Equals(cell.GroupKey, selectedGroupKey, StringComparison.OrdinalIgnoreCase))
}
else
{ {
<button return false;
type="button" }
class="critical-cell-action-button is-curation"
title="Open the curation preview for this cell." if (!string.IsNullOrWhiteSpace(selectedColumnKey) && !string.Equals(cell.ColumnKey, selectedColumnKey, StringComparison.OrdinalIgnoreCase))
@onclick="() => OpenCellCurationAsync(cell.ResultId)"> {
Needs Curation return false;
</button> }
return referenceMode switch
{
TablesReferenceMode.NeedsCuration => !cell.IsCurated,
TablesReferenceMode.Curated => cell.IsCurated,
_ => true
};
} }
<button
type="button"
class="critical-cell-action-button is-edit"
title="Open the full editor for this cell."
@onclick="() => OpenCellEditorAsync(cell.ResultId)">
Edit
</button>
</div>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())" />
</div>
</div>;
private static RenderFragment RenderEmptyCriticalTableCell() => @<div class="critical-table-cell critical-table-cell-empty">
<span class="empty-cell">—</span>
</div>;
} }

View File

@@ -0,0 +1,5 @@
@page "/tools/api"
<PageTitle>API Surface</PageTitle>
<ApiPageContent />

View File

@@ -0,0 +1,6 @@
@page "/tools/diagnostics"
@rendermode InteractiveServer
<PageTitle>Diagnostics</PageTitle>
<DiagnosticsPageContent />

View File

@@ -0,0 +1,40 @@
@page "/tools"
<PageTitle>Tools</PageTitle>
<ToolPageFrame
Eyebrow="Developer workflows"
Title="Tools"
Summary="Inspection, parser review, and API surface reference stay isolated here from play and table-reading flows.">
<ChildContent>
<div class="tools-hub-grid">
<ToolLinkCard
Title="Diagnostics"
Badge="Context-aware"
Summary="Inspect one stored critical-table cell with parser provenance, payload state, and engineering diagnostics."
Href="/tools/diagnostics"
ActionLabel="Open diagnostics">
<Details>
<div class="tool-link-card-meta">
<span>Table, roll band, variant, severity</span>
<span>Jump back to Tables or Curation</span>
</div>
</Details>
</ToolLinkCard>
<ToolLinkCard
Title="API Surface"
Badge="Reference"
Summary="Browse the core lookup and critical-cell editing endpoints without leaving the app shell."
Href="/tools/api"
ActionLabel="Open API docs">
<Details>
<div class="tool-link-card-meta">
<span>Reference data and lookup contracts</span>
<span>Load, re-parse, source-image, and save endpoints</span>
</div>
</Details>
</ToolLinkCard>
</div>
</ChildContent>
</ToolPageFrame>

View File

@@ -0,0 +1,37 @@
<button
type="@Type"
class="@BuildCssClass()"
title="@Title"
aria-label="@AriaLabel"
disabled="@Disabled"
@onclick="OnClick">
@ChildContent
</button>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public EventCallback<MouseEventArgs> 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}";
}

View File

@@ -0,0 +1,46 @@
<section class="@BuildCssClass()">
@if (!string.IsNullOrWhiteSpace(Title) || HeaderContent is not null)
{
<header class="inspector-section-header">
<div>
@if (!string.IsNullOrWhiteSpace(Title))
{
<h3 class="inspector-section-title">@Title</h3>
}
@if (!string.IsNullOrWhiteSpace(Description))
{
<p class="inspector-section-copy">@Description</p>
}
</div>
@HeaderContent
</header>
}
<div class="inspector-section-body">
@ChildContent
</div>
</section>
@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}";
}

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.App.Components.Primitives;
public sealed record SegmentedTabItem(
string Value,
string Label,
string? Badge = null,
bool IsDisabled = false);

View File

@@ -0,0 +1,45 @@
<div class="@BuildCssClass()" role="tablist" aria-label="@AriaLabel">
@foreach (var item in Items)
{
var isSelected = string.Equals(item.Value, SelectedValue, StringComparison.Ordinal);
<button
type="button"
class="segmented-tabs-button @(isSelected ? "is-selected" : null)"
role="tab"
aria-selected="@isSelected"
disabled="@item.IsDisabled"
@onclick="() => SelectAsync(item.Value)">
<span>@item.Label</span>
@if (!string.IsNullOrWhiteSpace(item.Badge))
{
<span class="segmented-tabs-badge">@item.Badge</span>
}
</button>
}
</div>
@code {
[Parameter]
public IReadOnlyList<SegmentedTabItem> Items { get; set; } = [];
[Parameter]
public string? SelectedValue { get; set; }
[Parameter]
public EventCallback<string> 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);
}

View File

@@ -0,0 +1,25 @@
<span class="@BuildCssClass()">
@ChildContent
</span>
@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<string> { "ui-status-chip", $"is-{Tone.Trim().ToLowerInvariant()}" };
if (!string.IsNullOrWhiteSpace(CssClass))
{
classes.Add(CssClass);
}
return string.Join(' ', classes);
}
}

View File

@@ -0,0 +1,29 @@
<span class="@BuildCssClass()">
<span class="ui-status-indicator-dot" aria-hidden="true"></span>
@if (ChildContent is not null)
{
<span class="ui-status-indicator-label">@ChildContent</span>
}
</span>
@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<string> { "ui-status-indicator", $"is-{Tone.Trim().ToLowerInvariant()}" };
if (!string.IsNullOrWhiteSpace(CssClass))
{
classes.Add(CssClass);
}
return string.Join(' ', classes);
}
}

View File

@@ -0,0 +1,77 @@
@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

@@ -0,0 +1,30 @@
@inject NavigationManager NavigationManager
<section class="panel tooling-surface">
<h1 class="panel-title">Redirecting…</h1>
<p class="panel-copy">This route moved to <code>@TargetPath</code>. If the redirect does not complete automatically, use the link below.</p>
<div class="action-row">
<a class="btn-link" href="@BuildTargetUri()">Continue</a>
</div>
</section>
@code {
[Parameter, EditorRequired]
public string TargetPath { get; set; } = string.Empty;
protected override void OnAfterRender(bool firstRender)
{
if (!firstRender)
{
return;
}
NavigationManager.NavigateTo(BuildTargetUri(), replace: true);
}
private string BuildTargetUri()
{
var currentUri = NavigationManager.ToAbsoluteUri(NavigationManager.Uri);
return string.Concat(TargetPath, currentUri.Query, currentUri.Fragment);
}
}

View File

@@ -1,7 +1,5 @@
@using System.Collections.Generic
@using System.Linq
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.JSInterop @using Microsoft.JSInterop
@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Features @using RolemasterDb.App.Features
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
@@ -32,120 +30,30 @@
<button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button> <button type="button" class="btn btn-link critical-editor-close" @onclick="OnClose">Close</button>
</header> </header>
@if (IsLoading)
{
<div class="critical-editor-body">
<p class="muted">Loading curation preview...</p>
</div>
}
else if (Model is null)
{
<div class="critical-editor-body">
@if (!string.IsNullOrWhiteSpace(GetVisibleErrorMessage()))
{
<p class="error-text critical-editor-error">@GetVisibleErrorMessage()</p>
}
else
{
<p class="muted">No curation preview is available.</p>
}
</div>
}
else
{
<div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)"> <div class="critical-editor-body critical-curation-body @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (!string.IsNullOrWhiteSpace(ErrorMessage)) <CurationWorkspace
{
<p class="error-text critical-editor-error">@ErrorMessage</p>
}
<div class="critical-curation-grid">
<div class="critical-editor-card critical-curation-preview-card @(IsQuickParseMode ? "is-quick-parse" : null)">
@if (IsQuickParseMode)
{
<CriticalCellQuickParseEditor
@ref="quickParseEditor"
Model="Model" Model="Model"
IsDisabled="@(IsSaving || IsReparsing)" IsLoading="IsLoading"
TextAreaCssClass="input-shell critical-editor-textarea critical-curation-quick-parse-textarea" 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"/> OnReparse="OnReparse"/>
}
else
{
<button
type="button"
class="critical-curation-preview-button"
@onclick="OnEnterQuickParse"
disabled="@(IsSaving || IsReparsing)">
<CompactCriticalCell
Description="@Model.DescriptionText"
Effects="@CriticalCellPresentation.BuildPreviewEffects(Model)"
Branches="@CriticalCellPresentation.BuildPreviewBranches(Model)" />
</button>
}
</div> </div>
<div class="critical-editor-card critical-curation-source-card">
@if (!string.IsNullOrWhiteSpace(Model.SourceImageUrl))
{
<img
class="critical-curation-source-image"
src="@Model.SourceImageUrl"
alt="@CriticalCellPresentation.BuildSourceImageAltText(Model)" />
}
else
{
<div class="critical-curation-source-empty">
<p class="muted">No source image is available for this cell yet.</p>
</div>
}
</div>
</div>
@if (!IsQuickParseMode)
{
@if (GetUsedLegendEntries(Model, LegendEntries) is { Count: > 0 } usedLegendEntries)
{
<div class="critical-curation-legend">
@foreach (var entry in usedLegendEntries)
{
<div class="critical-curation-legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<strong>@entry.Label</strong>
</div>
}
</div>
}
}
</div>
<footer class="critical-editor-footer">
@if (IsQuickParseMode)
{
<button type="button" class="btn btn-link" @onclick="OnCancelQuickParse" disabled="@(IsSaving || IsReparsing)">Cancel</button>
<button
type="button"
class="btn-ritual"
@onclick="HandleReparseClickAsync"
@onclick:stopPropagation="true"
@onclick:preventDefault="true"
disabled="@(IsSaving || IsReparsing)">
@(IsReparsing ? "Parsing..." : "Parse")
</button>
}
else
{
<button type="button" class="btn btn-link" @onclick="OnEdit" disabled="@(IsSaving || IsReparsing)">Edit</button>
<button type="button" class="btn-ritual" @onclick="OnMarkCurated" disabled="@(IsSaving || IsReparsing)">
@(IsSaving ? "Saving..." : "Mark as Curated")
</button>
}
</footer>
}
</div> </div>
</div> </div>
@code { @code {
[Parameter, EditorRequired] [Parameter, EditorRequired]
public CriticalCellEditorModel? Model { get; set; } public CriticalCellEditorModel? Model { get; set; }
@@ -190,25 +98,15 @@
private IJSObjectReference? jsModule; private IJSObjectReference? jsModule;
private bool isBackdropPointerDown; private bool isBackdropPointerDown;
private CriticalCellQuickParseEditor? quickParseEditor;
private bool shouldFocusQuickParseEditor;
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender) if (firstRender)
{ {
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>( jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./components/shared/critical-cell-editor-dialog.js");
"import",
"./Components/Shared/CriticalCellEditorDialog.razor.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll"); await jsModule.InvokeVoidAsync("lockBackgroundScroll");
} }
if (shouldFocusQuickParseEditor && quickParseEditor is not null)
{
shouldFocusQuickParseEditor = false;
await quickParseEditor.FocusAsync();
}
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -265,51 +163,6 @@
} }
private static string BuildColumnDisplayText(CriticalCellEditorModel model) => private static string BuildColumnDisplayText(CriticalCellEditorModel model) =>
string.IsNullOrWhiteSpace(model.GroupLabel) string.IsNullOrWhiteSpace(model.GroupLabel) ? model.ColumnLabel : $"{model.GroupLabel} / {model.ColumnLabel}";
? model.ColumnLabel
: $"{model.GroupLabel} / {model.ColumnLabel}";
private static IReadOnlyList<CriticalTableLegendEntry> GetUsedLegendEntries(
CriticalCellEditorModel model,
IReadOnlyList<CriticalTableLegendEntry>? legendEntries)
{
if (legendEntries is null || legendEntries.Count == 0)
{
return [];
}
var usedEffectCodes = model.Effects
.Select(effect => effect.EffectCode)
.Concat(model.Branches.SelectMany(branch => branch.Effects.Select(effect => effect.EffectCode)))
.Where(effectCode => !string.IsNullOrWhiteSpace(effectCode))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return legendEntries
.Where(entry => usedEffectCodes.Contains(entry.EffectCode))
.ToList();
}
private async Task HandleReparseClickAsync(MouseEventArgs _)
{
if (quickParseEditor is not null)
{
await quickParseEditor.ReparseAsync();
return;
}
await OnReparse.InvokeAsync();
}
private string? GetVisibleErrorMessage() =>
IsQuickParseMode && !string.IsNullOrWhiteSpace(QuickParseErrorMessage)
? QuickParseErrorMessage
: ErrorMessage;
protected override void OnParametersSet()
{
if (IsQuickParseMode && quickParseEditor is null)
{
shouldFocusQuickParseEditor = true;
}
}
} }

View File

@@ -382,7 +382,7 @@
jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>( jsModule = await JSRuntime.InvokeAsync<IJSObjectReference>(
"import", "import",
"./Components/Shared/CriticalCellEditorDialog.razor.js"); "./components/shared/critical-cell-editor-dialog.js");
await jsModule.InvokeVoidAsync("lockBackgroundScroll"); await jsModule.InvokeVoidAsync("lockBackgroundScroll");
} }

View File

@@ -0,0 +1,147 @@
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="app-shell">
<a class="skip-link" href="#app-main">Skip to main content</a>
<header class="app-shell-header">
<div class="app-shell-bar">
<div class="app-shell-brand-block">
@if (BrandContent is null)
{
<a class="app-shell-brand" href="/">
<span class="app-shell-brand-mark">RM</span>
<span class="app-shell-brand-copy">
<strong>Rolemaster DB</strong>
<span>Reference and lookup workspace</span>
</span>
</a>
}
else
{
@BrandContent
}
</div>
<nav class="app-shell-header-nav" aria-label="Primary">
@if (PrimaryNavContent is null)
{
<ShellPrimaryNav />
}
else
{
@PrimaryNavContent
}
</nav>
<div class="app-shell-header-omnibox">
@if (OmniboxContent is not null)
{
@OmniboxContent
}
</div>
<div class="app-shell-header-actions">
<button
type="button"
class="app-shell-menu-toggle"
aria-expanded="@isNavMenuOpen"
aria-controls="app-shell-drawer"
@onclick="ToggleNavMenu">
<span class="app-shell-menu-toggle-bar"></span>
<span class="app-shell-menu-toggle-bar"></span>
<span class="app-shell-menu-toggle-bar"></span>
<span class="visually-hidden">Toggle navigation</span>
</button>
@UtilityContent
</div>
</div>
@if (ShortcutContent is not null)
{
<nav class="app-shell-shortcuts" aria-label="Pinned and recent shortcuts">
@ShortcutContent
</nav>
}
</header>
<ShellOmniboxPalette />
<main id="app-main" class="app-shell-main">
<div class="content-shell">
@ChildContent
</div>
</main>
@if (isNavMenuOpen)
{
<button
type="button"
class="app-shell-drawer-backdrop"
aria-label="Close navigation"
@onclick="CloseNavMenu"></button>
<aside id="app-shell-drawer" class="app-shell-drawer" aria-label="Primary navigation">
<div class="app-shell-drawer-header">
<strong>Navigate</strong>
<button type="button" class="app-shell-drawer-close" @onclick="CloseNavMenu">
Close
</button>
</div>
<ShellPrimaryNav />
</aside>
}
<nav class="app-shell-mobile-nav" aria-label="Primary">
<ShellPrimaryNav IsBottomNav="true" />
</nav>
</div>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public RenderFragment? BrandContent { get; set; }
[Parameter]
public RenderFragment? PrimaryNavContent { get; set; }
[Parameter]
public RenderFragment? OmniboxContent { get; set; }
[Parameter]
public RenderFragment? ShortcutContent { get; set; }
[Parameter]
public RenderFragment? UtilityContent { get; set; }
private bool isNavMenuOpen;
protected override void OnInitialized()
{
NavigationManager.LocationChanged += HandleLocationChanged;
}
private void ToggleNavMenu()
{
isNavMenuOpen = !isNavMenuOpen;
}
private void CloseNavMenu()
{
isNavMenuOpen = false;
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
isNavMenuOpen = false;
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
NavigationManager.LocationChanged -= HandleLocationChanged;
}
}

View File

@@ -0,0 +1,262 @@
.app-shell {
--shell-header-height: 5.75rem;
--shell-mobile-nav-height: 0rem;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.skip-link {
position: absolute;
left: 1rem;
top: -3rem;
z-index: 80;
padding: 0.75rem 1rem;
border-radius: 999px;
background: var(--accent-5);
color: var(--text-on-accent);
text-decoration: none;
box-shadow: var(--shadow-1);
transition: top 140ms ease;
}
.skip-link:focus {
top: 1rem;
}
.app-shell-header {
position: sticky;
top: 0;
z-index: 40;
padding: 0.9rem 1rem 0;
}
.app-shell-bar {
display: grid;
grid-template-columns: auto minmax(0, 1fr) minmax(14rem, 20rem) auto;
align-items: center;
gap: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--border-default);
border-radius: 24px;
background: color-mix(in srgb, var(--bg-elevated) 78%, transparent);
box-shadow: var(--shadow-1);
backdrop-filter: blur(18px);
}
.app-shell-brand {
display: inline-flex;
align-items: center;
gap: 0.85rem;
color: inherit;
text-decoration: none;
}
.app-shell-brand:hover {
color: inherit;
}
.app-shell-brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
border-radius: 18px;
background: linear-gradient(145deg, var(--accent-3), var(--accent-5));
color: var(--text-on-accent);
font-family: var(--font-ui);
font-size: 0.85rem;
letter-spacing: 0.12em;
text-transform: uppercase;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
.app-shell-brand-copy {
display: grid;
gap: 0.1rem;
}
.app-shell-brand-copy strong {
font-family: var(--font-display);
font-size: 1rem;
font-weight: 600;
line-height: 1.1;
}
.app-shell-brand-copy span {
color: var(--text-secondary);
font-size: 0.82rem;
}
.app-shell-header-nav {
min-width: 0;
}
.app-shell-header-omnibox {
min-width: 0;
}
.app-shell-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.75rem;
min-height: 2.75rem;
}
.app-shell-menu-toggle {
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.24rem;
width: 2.75rem;
height: 2.75rem;
border: 1px solid var(--border-default);
border-radius: 16px;
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
color: var(--text-primary);
padding: 0;
}
.app-shell-menu-toggle-bar {
width: 1rem;
height: 2px;
border-radius: 999px;
background: currentColor;
}
.app-shell-shortcuts {
margin-top: 0.75rem;
}
.app-shell-main {
flex: 1 1 auto;
min-width: 0;
padding: 1rem 0 5.75rem;
}
.content-shell {
width: min(1600px, 100%);
margin: 0 auto;
padding: 0 1rem;
box-sizing: border-box;
}
.app-shell-mobile-nav {
position: sticky;
bottom: 0;
z-index: 35;
padding: 0 0.75rem 0.75rem;
margin-top: auto;
}
.app-shell-drawer-backdrop {
position: fixed;
inset: 0;
z-index: 45;
border: none;
background: var(--bg-overlay);
padding: 0;
}
.app-shell-drawer {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 50;
width: min(24rem, calc(100vw - 2rem));
padding: 1rem;
border: 1px solid var(--border-default);
border-radius: 24px;
background: color-mix(in srgb, var(--bg-elevated) 94%, transparent);
box-shadow: var(--shadow-2);
backdrop-filter: blur(18px);
}
.app-shell-drawer .shell-primary-nav {
flex-direction: column;
align-items: stretch;
}
.app-shell-drawer .shell-primary-nav-link {
justify-content: flex-start;
}
.app-shell-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.app-shell-drawer-close {
border: 1px solid var(--border-default);
border-radius: 999px;
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
color: var(--text-primary);
min-height: 2.5rem;
padding: 0.45rem 0.85rem;
}
@media (max-width: 767.98px) {
.app-shell {
--shell-header-height: 5.1rem;
--shell-mobile-nav-height: 5.5rem;
}
.app-shell-header {
padding: 0.65rem 0.75rem 0;
}
.app-shell-bar {
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.75rem;
padding: 0.7rem 0.85rem;
}
.app-shell-brand-copy span {
display: none;
}
.app-shell-header-nav {
display: none;
}
.app-shell-header-actions {
gap: 0.5rem;
}
}
@media (min-width: 768px) {
.app-shell-main {
padding-bottom: 1.25rem;
}
.app-shell-mobile-nav {
display: none;
}
}
@media (max-width: 1023.98px) {
.app-shell-bar {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.app-shell-header-nav {
display: none;
}
.app-shell-menu-toggle {
display: inline-flex;
}
}
@media (min-width: 1024px) {
.app-shell-drawer,
.app-shell-drawer-backdrop {
display: none;
}
}

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.App.Components.Shell;
public sealed record ShellOmniboxCommand(
string Shortcut,
string Label,
string Description,
string Href);

View File

@@ -0,0 +1,313 @@
@implements IDisposable
@using System.Linq
@using RolemasterDb.App.Frontend.AppState
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.PinnedTablesState PinnedTablesState
@inject RolemasterDb.App.Frontend.AppState.RecentTablesState RecentTablesState
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
@inject RolemasterDb.App.Frontend.AppState.ShellOmniboxState ShellOmniboxState
@if (ShellOmniboxState.IsOpen)
{
<button
type="button"
class="shell-omnibox-backdrop"
aria-label="Close search panel"
@onclick="CloseAsync"></button>
<section class="shell-omnibox-palette" role="dialog" aria-modal="true" aria-label="Search tables and commands">
<header class="shell-omnibox-header">
<label class="shell-omnibox-search">
<span class="visually-hidden">Search tables or commands</span>
<input
@ref="queryInput"
class="input-shell shell-omnibox-input"
placeholder="Search tables or type /"
@bind="query"
@bind:event="oninput"
@onkeydown="HandleInputKeyDownAsync" />
</label>
<button type="button" class="shell-omnibox-close" @onclick="CloseAsync">
Close
</button>
</header>
<div class="shell-omnibox-body">
@if (isLoading)
{
<InspectorSection Title="Searching" Description="Loading the critical table index and shortcuts.">
<StatusIndicator Tone="info">Loading searchable tables…</StatusIndicator>
</InspectorSection>
}
else
{
@if (MatchingCommands.Count > 0)
{
<InspectorSection Title="Commands" Description="Navigate directly to key workflows.">
<div class="shell-omnibox-results">
@foreach (var command in MatchingCommands)
{
<button type="button" class="shell-omnibox-result" @onclick="() => OpenCommandAsync(command.Href)">
<span class="shell-omnibox-result-main">
<strong>@command.Shortcut</strong>
<span>@command.Description</span>
</span>
<span class="shell-omnibox-result-meta">
<StatusChip Tone="neutral">@command.Label</StatusChip>
</span>
</button>
}
</div>
</InspectorSection>
}
@if (MatchingPinned.Count > 0)
{
<InspectorSection Title="Pinned tables" Description="Saved shortcuts available across destinations.">
<div class="shell-omnibox-results">
@foreach (var table in MatchingPinned)
{
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
<span class="shell-omnibox-result-main">
<strong>@table.Label</strong>
<span>@table.Family</span>
</span>
<span class="shell-omnibox-result-meta">
<StatusChip Tone="accent">Pinned</StatusChip>
</span>
</button>
}
</div>
</InspectorSection>
}
@if (MatchingRecent.Count > 0)
{
<InspectorSection Title="Recent tables" Description="Resume recently opened critical tables.">
<div class="shell-omnibox-results">
@foreach (var table in MatchingRecent)
{
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Slug)">
<span class="shell-omnibox-result-main">
<strong>@table.Label</strong>
<span>@table.Family</span>
</span>
<span class="shell-omnibox-result-meta">
<StatusChip Tone="info">Recent</StatusChip>
</span>
</button>
}
</div>
</InspectorSection>
}
@if (MatchingTables.Count > 0)
{
<InspectorSection Title="Tables" Description="Open a critical table in reference mode.">
<div class="shell-omnibox-results">
@foreach (var table in MatchingTables)
{
<button type="button" class="shell-omnibox-result" @onclick="() => OpenTableAsync(table.Key)">
<span class="shell-omnibox-result-main">
<strong>@table.Label</strong>
<span>@table.Family</span>
</span>
<span class="shell-omnibox-result-meta">
@if (PinnedTablesState.IsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<span class="chip">@($"{table.CurationPercentage}%")</span>
</span>
</button>
}
</div>
</InspectorSection>
}
@if (!HasResults)
{
<InspectorSection Title="No matches" Description="Try a different table name, family, or slash command.">
<StatusIndicator Tone="warning">Nothing matches “@query”.</StatusIndicator>
</InspectorSection>
}
}
</div>
</section>
}
@code {
private static readonly IReadOnlyList<ShellOmniboxCommand> Commands =
[
new("/tables", "Reference", "Open the reference tables surface.", "/tables"),
new("/curation", "Curation", "Open the queue-first curation workflow.", "/curation"),
new("/tools", "Tools", "Open the developer tools hub.", "/tools"),
new("/diag", "Diagnostics", "Open tooling diagnostics.", "/tools/diagnostics"),
new("/api", "API", "Open API surface documentation.", "/tools/api")
];
private ElementReference queryInput;
private LookupReferenceData? referenceData;
private bool isLoading;
private bool shouldFocusInput;
private string query = string.Empty;
private bool HasResults =>
MatchingCommands.Count > 0 ||
MatchingPinned.Count > 0 ||
MatchingRecent.Count > 0 ||
MatchingTables.Count > 0;
private IReadOnlyList<CriticalTableReference> MatchingTables =>
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();
private IReadOnlyList<RecentTableEntry> MatchingRecent =>
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();
protected override void OnInitialized()
{
ShellOmniboxState.Changed += HandleStateChanged;
NavigationManager.LocationChanged += HandleLocationChanged;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (shouldFocusInput && ShellOmniboxState.IsOpen)
{
shouldFocusInput = false;
await queryInput.FocusAsync();
}
}
public void Dispose()
{
ShellOmniboxState.Changed -= HandleStateChanged;
NavigationManager.LocationChanged -= HandleLocationChanged;
}
private void HandleStateChanged()
{
_ = InvokeAsync(HandleStateChangedAsync);
}
private async Task HandleStateChangedAsync()
{
if (ShellOmniboxState.IsOpen)
{
shouldFocusInput = true;
await EnsureLoadedAsync();
}
else
{
query = string.Empty;
shouldFocusInput = false;
}
await InvokeAsync(StateHasChanged);
}
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
if (!ShellOmniboxState.IsOpen)
{
return;
}
ShellOmniboxState.Close();
}
private async Task EnsureLoadedAsync()
{
if (referenceData is not null)
{
return;
}
isLoading = true;
try
{
await RecentTablesState.InitializeAsync();
await PinnedTablesState.InitializeAsync();
referenceData = await LookupService.GetReferenceDataAsync();
}
finally
{
isLoading = false;
}
}
private Task CloseAsync()
{
ShellOmniboxState.Close();
return Task.CompletedTask;
}
private async Task HandleInputKeyDownAsync(KeyboardEventArgs args)
{
if (string.Equals(args.Key, "Escape", StringComparison.Ordinal))
{
await CloseAsync();
}
}
private async Task OpenTableAsync(string tableSlug)
{
var snapshot = new TableContextSnapshot(
TableSlug: tableSlug,
Mode: TableContextMode.Reference);
await TableContextState.PersistAsync("tables", snapshot);
ShellOmniboxState.Close();
NavigationManager.NavigateTo(TableContextState.BuildUri("/tables", snapshot));
}
private Task OpenCommandAsync(string href)
{
ShellOmniboxState.Close();
NavigationManager.NavigateTo(href);
return Task.CompletedTask;
}
private bool MatchesTableQuery(CriticalTableReference table) =>
MatchesText(table.Label, table.Key, table.Family, table.SourceDocument);
private bool MatchesText(params string?[] values)
{
var normalizedQuery = query.Trim();
if (string.IsNullOrWhiteSpace(normalizedQuery))
{
return true;
}
return values.Any(value =>
!string.IsNullOrWhiteSpace(value) &&
value.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase));
}
private bool QueryStartsCommandMode() =>
query.TrimStart().StartsWith("/", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,19 @@
@inject RolemasterDb.App.Frontend.AppState.ShellOmniboxState ShellOmniboxState
<div class="shell-omnibox">
<AppBarActionButton
CssClass="@(ShellOmniboxState.IsOpen ? "shell-omnibox-trigger is-open" : "shell-omnibox-trigger")"
Title="Search tables or commands"
AriaLabel="Search tables or commands"
OnClick="ToggleOpenAsync">
<span class="shell-omnibox-trigger-label">Search tables or commands</span>
</AppBarActionButton>
</div>
@code {
private Task ToggleOpenAsync(MouseEventArgs _)
{
ShellOmniboxState.Toggle();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,19 @@
.shell-omnibox-trigger {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: flex-start;
min-height: 2.75rem;
padding: 0.65rem 0.9rem;
border: 1px solid var(--border-default);
border-radius: 999px;
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
color: var(--text-secondary);
box-sizing: border-box;
}
.shell-omnibox-trigger-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,22 @@
<div class="shell-primary-nav @(IsBottomNav ? "is-bottom-nav" : "is-top-nav")">
<NavLink class="shell-primary-nav-link" href="" Match="NavLinkMatch.All">
<span class="shell-primary-nav-label">Play</span>
</NavLink>
<NavLink class="shell-primary-nav-link" href="tables">
<span class="shell-primary-nav-label">Tables</span>
</NavLink>
<NavLink class="shell-primary-nav-link" href="curation">
<span class="shell-primary-nav-label">Curation</span>
</NavLink>
<NavLink class="shell-primary-nav-link is-tools-link" href="tools">
<span class="shell-primary-nav-label">Tools</span>
</NavLink>
</div>
@code {
[Parameter]
public bool IsBottomNav { get; set; }
}

View File

@@ -0,0 +1,79 @@
.shell-primary-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.shell-primary-nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
padding: 0.65rem 0.95rem;
border-radius: 999px;
border: 1px solid transparent;
color: var(--text-secondary);
text-decoration: none;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.shell-primary-nav-link:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--surface-2) 84%, transparent);
border-color: var(--border-subtle);
transform: translateY(-1px);
}
.shell-primary-nav-link.active {
color: var(--text-primary);
background: color-mix(in srgb, var(--accent-1) 84%, var(--surface-2));
border-color: color-mix(in srgb, var(--accent-3) 45%, var(--border-default));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.16);
}
.shell-primary-nav-link.is-tools-link {
color: color-mix(in srgb, var(--info-3) 42%, var(--text-secondary));
}
.shell-primary-nav-link.is-tools-link:hover {
background: color-mix(in srgb, var(--surface-tooling) 86%, transparent);
border-color: color-mix(in srgb, var(--info-2) 35%, var(--border-default));
}
.shell-primary-nav-link.is-tools-link.active {
background: color-mix(in srgb, var(--surface-tooling) 92%, var(--surface-2));
border-color: color-mix(in srgb, var(--info-2) 46%, var(--border-default));
}
.shell-primary-nav-label {
font-size: 0.92rem;
line-height: 1;
white-space: nowrap;
}
.shell-primary-nav.is-bottom-nav {
justify-content: space-between;
gap: 0.35rem;
padding: 0.35rem;
border: 1px solid var(--border-default);
border-radius: 24px;
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
box-shadow: var(--shadow-1);
backdrop-filter: blur(18px);
}
.shell-primary-nav.is-bottom-nav .shell-primary-nav-link {
flex: 1 1 0;
min-width: 0;
padding-inline: 0.45rem;
}
.shell-primary-nav.is-bottom-nav .shell-primary-nav-label {
font-size: 0.78rem;
}
@media (max-width: 767.98px) {
.shell-primary-nav.is-top-nav {
display: none;
}
}

View File

@@ -0,0 +1,48 @@
@implements IDisposable
@inject RolemasterDb.App.Frontend.AppState.ThemeState ThemeState
<label class="shell-theme-switcher">
<span class="shell-theme-switcher-label">Theme</span>
<select class="input-shell shell-theme-switcher-select" value="@CurrentValue" @onchange="HandleChanged">
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
@code {
private string CurrentValue =>
ThemeState.CurrentMode switch
{
RolemasterDb.App.Frontend.AppState.ThemeMode.Light => "light",
RolemasterDb.App.Frontend.AppState.ThemeMode.Dark => "dark",
_ => "system"
};
protected override void OnInitialized()
{
ThemeState.Changed += HandleThemeChanged;
}
private async Task HandleChanged(ChangeEventArgs args)
{
var nextMode = args.Value?.ToString() switch
{
"light" => RolemasterDb.App.Frontend.AppState.ThemeMode.Light,
"dark" => RolemasterDb.App.Frontend.AppState.ThemeMode.Dark,
_ => RolemasterDb.App.Frontend.AppState.ThemeMode.System
};
await ThemeState.SetModeAsync(nextMode);
}
private void HandleThemeChanged()
{
_ = InvokeAsync(StateHasChanged);
}
public void Dispose()
{
ThemeState.Changed -= HandleThemeChanged;
}
}

View File

@@ -0,0 +1,27 @@
.shell-theme-switcher {
display: inline-flex;
align-items: center;
gap: 0.6rem;
color: var(--text-secondary);
}
.shell-theme-switcher-label {
font-size: 0.82rem;
white-space: nowrap;
}
.shell-theme-switcher-select {
min-width: 7.5rem;
min-height: 2.75rem;
padding-block: 0.55rem;
}
@media (max-width: 767.98px) {
.shell-theme-switcher-label {
display: none;
}
.shell-theme-switcher-select {
min-width: 5.75rem;
}
}

View File

@@ -0,0 +1,352 @@
<div class="table-scroll">
<div class="critical-table-grid @BuildGridCssClass()" role="group" aria-label="@Detail.DisplayName" style="@gridTemplateStyle">
@if (Detail.Groups.Count > 0)
{
<div class="critical-table-grid-header-cell critical-table-grid-corner" aria-hidden="true"></div>
@foreach (var group in visibleGroups)
{
<div
class="@BuildGroupHeaderCssClass(group.Key)"
style="@BuildColumnSpanStyle(visibleColumns.Count)">
<span>@group.Label</span>
</div>
}
}
<div class="critical-table-grid-header-cell critical-table-grid-roll-band-header" aria-hidden="true"></div>
@foreach (var displayColumn in displayColumns)
{
<div class="@BuildColumnHeaderCssClass(displayColumn.GroupKey, displayColumn.ColumnKey)">
<span>@displayColumn.ColumnLabel</span>
</div>
}
@foreach (var rollBand in Detail.RollBands)
{
<div class="@BuildRollBandCssClass(rollBand.Label)">@rollBand.Label</div>
@foreach (var displayColumn in displayColumns)
{
if (TryGetCell(rollBand.Label, displayColumn.GroupKey, displayColumn.ColumnKey, out var resolvedCell) && resolvedCell is not null)
{
var cell = resolvedCell;
var isSelectedCell = IsSelectedCell(cell);
@if (MatchesModeFilter(cell))
{
<div
class="@GetCellCssClass(cell, displayColumn.GroupKey)"
role="button"
tabindex="0"
aria-pressed="@isSelectedCell"
@onclick="() => SelectCell(cell)"
@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))
{
<StatusIndicator Tone="@(cell.IsCurated ? "success" : "warning")" CssClass="tables-cell-status-indicator"/>
}
else if (cell.IsCurated)
{
<span class="critical-cell-status-chip is-curated">Curated</span>
}
else
{
<span class="critical-cell-status-chip needs-curation">Needs Curation</span>
}
@if (isSelectedCell)
{
<StatusChip Tone="accent">Selected</StatusChip>
}
</div>
<CompactCriticalCell
Description="@(cell.Description ?? string.Empty)"
Effects="@(cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>())"
Branches="@(cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>())"/>
</div>
</div>
}
else
{
<div class="critical-table-cell critical-table-cell-empty tables-filtered-cell">
<span class="empty-cell">Filtered</span>
</div>
}
}
else
{
<div class="critical-table-cell critical-table-cell-empty">
<span class="empty-cell">—</span>
</div>
}
}
}
</div>
</div>
@code {
private readonly Dictionary<(string RollBand, string? GroupKey, string ColumnKey), CriticalTableCellDetail> cellIndex = new();
private readonly List<(string? GroupKey, string ColumnKey, string ColumnLabel)> displayColumns = new();
private readonly List<CriticalGroupReference> visibleGroups = new();
private readonly List<CriticalColumnReference> visibleColumns = new();
private string gridTemplateStyle = string.Empty;
[Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!;
[Parameter]
public string CurrentMode { get; set; } = TablesReferenceMode.Reference;
[Parameter]
public string SelectedGroupKey { get; set; } = string.Empty;
[Parameter]
public string SelectedColumnKey { get; set; } = string.Empty;
[Parameter]
public string RollJumpValue { get; set; } = string.Empty;
[Parameter]
public string DensityMode { get; set; } = TablesDensityMode.Comfortable;
[Parameter]
public TablesCellSelection? SelectedCell { get; set; }
[Parameter]
public EventCallback<TablesCellSelection> OnSelectCell { get; set; }
protected override void OnParametersSet()
{
cellIndex.Clear();
displayColumns.Clear();
visibleGroups.Clear();
visibleColumns.Clear();
foreach (var cell in Detail.Cells)
{
cellIndex[(cell.RollBand, cell.GroupKey, cell.ColumnKey)] = cell;
}
var columnsToDisplay = ResolveVisibleColumns();
if (Detail.Groups.Count == 0)
{
foreach (var column in columnsToDisplay)
{
visibleColumns.Add(column);
displayColumns.Add((null, column.Key, column.Label));
}
}
else
{
var groupsToDisplay = ResolveVisibleGroups();
foreach (var group in groupsToDisplay)
{
visibleGroups.Add(group);
}
foreach (var column in columnsToDisplay)
{
visibleColumns.Add(column);
}
foreach (var group in visibleGroups)
{
foreach (var column in visibleColumns)
{
displayColumns.Add((group.Key, column.Key, column.Label));
}
}
}
var dataColumnCount = displayColumns.Count;
gridTemplateStyle = $"grid-template-columns: max-content repeat({dataColumnCount}, minmax(0, 1fr));";
}
private bool TryGetCell(string rollBand, string? groupKey, string columnKey, out CriticalTableCellDetail? cell) =>
cellIndex.TryGetValue((rollBand, groupKey, columnKey), out cell);
private bool MatchesGroupFilter(CriticalGroupReference group) =>
string.IsNullOrWhiteSpace(SelectedGroupKey) || string.Equals(group.Key, SelectedGroupKey, StringComparison.OrdinalIgnoreCase);
private bool MatchesColumnFilter(CriticalColumnReference column) =>
string.IsNullOrWhiteSpace(SelectedColumnKey) || string.Equals(column.Key, SelectedColumnKey, StringComparison.OrdinalIgnoreCase);
private IReadOnlyList<CriticalGroupReference> ResolveVisibleGroups()
{
var filteredGroups = Detail.Groups.Where(MatchesGroupFilter).ToList();
return filteredGroups.Count > 0 ? filteredGroups : Detail.Groups;
}
private IReadOnlyList<CriticalColumnReference> ResolveVisibleColumns()
{
var filteredColumns = Detail.Columns.Where(MatchesColumnFilter).ToList();
return filteredColumns.Count > 0 ? filteredColumns : Detail.Columns;
}
private bool MatchesModeFilter(CriticalTableCellDetail cell) =>
CurrentMode switch
{
TablesReferenceMode.NeedsCuration => !cell.IsCurated,
TablesReferenceMode.Curated => cell.IsCurated,
_ => 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>
{
string.Equals(DensityMode, TablesDensityMode.Dense, StringComparison.Ordinal) ? "is-dense" : "is-comfortable",
Detail.Groups.Count > 0 ? "has-groups" : "has-no-groups"
};
return string.Join(' ', classes);
}
private string BuildGroupHeaderCssClass(string groupKey)
{
var classes = new List<string>
{
"critical-table-grid-header-cell",
"critical-table-grid-group-header"
};
if (string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
return string.Join(' ', classes);
}
private string BuildColumnHeaderCssClass(string? groupKey, string columnKey)
{
var classes = new List<string>
{
"critical-table-grid-header-cell",
"critical-table-grid-column-header"
};
if (string.Equals(columnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-column");
}
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
return string.Join(' ', classes);
}
private string BuildRollBandCssClass(string rollBandLabel)
{
var classes = new List<string>
{
"critical-table-grid-header-cell",
"critical-table-grid-roll-band"
};
if (string.Equals(rollBandLabel, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-row");
}
if (string.Equals(rollBandLabel, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-roll-target");
}
return string.Join(' ', classes);
}
private string GetCellCssClass(CriticalTableCellDetail cell, string? groupKey)
{
var classes = new List<string>
{
"critical-table-cell",
cell.IsCurated ? "is-curated" : "needs-curation"
};
if (string.Equals(cell.RollBand, ActiveRollBand, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-row");
}
if (string.Equals(cell.ColumnKey, ActiveColumnKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-column");
}
if (!string.IsNullOrWhiteSpace(groupKey) && string.Equals(groupKey, ActiveGroupKey, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active-group");
}
if (SelectedCell is not null && cell.ResultId == SelectedCell.ResultId)
{
classes.Add("is-selected-cell");
}
if (string.Equals(cell.RollBand, ResolveRollJumpBandLabel(), StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-roll-target");
}
return string.Join(' ', classes);
}
private string? ResolveRollJumpBandLabel()
{
if (!int.TryParse(RollJumpValue, out var targetRoll))
{
return null;
}
foreach (var rollBand in Detail.RollBands)
{
if (targetRoll < rollBand.MinRoll)
{
continue;
}
if (rollBand.MaxRoll is null || targetRoll <= rollBand.MaxRoll.Value)
{
return rollBand.Label;
}
}
return null;
}
private Task SelectCell(CriticalTableCellDetail cell) =>
OnSelectCell.InvokeAsync(new TablesCellSelection(cell.ResultId, cell.RollBand, cell.ColumnKey, cell.GroupKey));
private bool IsSelectedCell(CriticalTableCellDetail cell) =>
SelectedCell is not null && cell.ResultId == SelectedCell.ResultId;
private Task HandleCellKeyDown(KeyboardEventArgs args, CriticalTableCellDetail cell)
{
if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) || string.Equals(args.Key, " ", StringComparison.Ordinal) || string.Equals(args.Key, "Spacebar", StringComparison.Ordinal))
{
return SelectCell(cell);
}
return Task.CompletedTask;
}
private static string BuildColumnSpanStyle(int span) => $"grid-column: span {span};";
}

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.App.Components.Tables;
public sealed record TablesCellSelection(
int ResultId,
string RollBand,
string ColumnKey,
string? GroupKey);

View File

@@ -0,0 +1,32 @@
<header class="table-browser-header tables-context-bar">
<div class="tables-context-primary">
<h2 class="panel-title">@Detail.DisplayName</h2>
<div class="action-row">
<button type="button" class="btn btn-link" @onclick="() => OnTogglePin.InvokeAsync()">
@(IsPinned ? "Unpin table" : "Pin table")
</button>
<button type="button" class="btn btn-link" @onclick="() => OnToggleLegend.InvokeAsync()">
@(IsLegendOpen ? "Hide help" : "Reading help")
</button>
</div>
</div>
</header>
@code {
[Parameter, EditorRequired]
public CriticalTableDetail Detail { get; set; } = default!;
[Parameter]
public bool IsPinned { get; set; }
[Parameter]
public EventCallback OnTogglePin { get; set; }
[Parameter]
public bool IsLegendOpen { get; set; }
[Parameter]
public EventCallback OnToggleLegend { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.App.Components.Tables;
public static class TablesDensityMode
{
public const string Comfortable = "comfortable";
public const string Dense = "dense";
}

View File

@@ -0,0 +1,347 @@
<section class="tables-index-rail" aria-labelledby="tables-index-heading">
<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">
<label class="tables-index-search-label" for="tables-index-search">Search tables</label>
<input
id="tables-index-search"
class="input-shell tables-index-search"
type="search"
placeholder="Search tables"
value="@searchText"
@oninput="HandleSearchInput"/>
@if (familyFilters.Count > 1)
{
<div class="tables-family-filters" aria-label="Filter table families">
@foreach (var family in familyFilters)
{
var isAllFilter = string.IsNullOrEmpty(family);
var label = isAllFilter ? "All families" : family;
<button
type="button"
class="@GetFamilyFilterCssClass(family)"
aria-pressed="@IsFamilyFilterSelected(family)"
@onclick="() => SelectFamilyFilter(family)">
@label
</button>
}
</div>
}
</div>
@if (pinnedTables.Count > 0)
{
<div class="tables-index-section">
<div class="tables-index-section-header">
<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">
@foreach (var table in pinnedTables)
{
@RenderTableOption(table)
}
</div>
</div>
}
@if (recentTables.Count > 0)
{
<div class="tables-index-section">
<div class="tables-index-section-header">
<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">
@foreach (var table in recentTables)
{
@RenderTableOption(table)
}
</div>
</div>
}
<div class="tables-index-section">
<div class="tables-index-section-header">
<h3>All tables</h3>
<span class="tables-index-section-count">@filteredTables.Count</span>
</div>
@if (filteredTables.Count == 0)
{
<div class="tables-index-empty">No tables match the current search.</div>
}
else
{
<div class="tables-index-list" role="listbox" aria-label="Critical tables">
@foreach (var table in filteredTables)
{
@RenderTableOption(table)
}
</div>
}
</div>
</section>
@code {
private readonly List<string> familyFilters = new();
private readonly List<CriticalTableReference> filteredTables = new();
private readonly List<CriticalTableReference> pinnedTables = new();
private readonly List<CriticalTableReference> recentTables = new();
private readonly List<CriticalTableReference> keyboardOptions = new();
private string searchText = string.Empty;
private string selectedFamily = string.Empty;
private string? activeOptionSlug;
[Parameter]
public IReadOnlyList<CriticalTableReference> Tables { get; set; } = Array.Empty<CriticalTableReference>();
[Parameter]
public string SelectedTableSlug { get; set; } = string.Empty;
[Parameter]
public IReadOnlyList<string> PinnedTableSlugs { get; set; } = Array.Empty<string>();
[Parameter]
public IReadOnlyList<string> RecentTableSlugs { get; set; } = Array.Empty<string>();
[Parameter]
public Func<string, bool>? IsPinned { get; set; }
[Parameter]
public EventCallback<string> OnSelectTable { get; set; }
protected override void OnParametersSet()
{
BuildFamilyFilters();
BuildSections();
EnsureActiveOption();
}
private bool GetIsPinned(string tableSlug) => IsPinned?.Invoke(tableSlug) ?? false;
private void BuildFamilyFilters()
{
familyFilters.Clear();
familyFilters.Add(string.Empty);
foreach (var family in Tables.Select(table => table.Family).Where(family => !string.IsNullOrWhiteSpace(family)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(family => family, StringComparer.OrdinalIgnoreCase))
{
familyFilters.Add(family);
}
}
private void BuildSections()
{
filteredTables.Clear();
pinnedTables.Clear();
recentTables.Clear();
keyboardOptions.Clear();
foreach (var table in Tables)
{
if (!MatchesFilters(table))
{
continue;
}
filteredTables.Add(table);
}
foreach (var slug in PinnedTableSlugs)
{
var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase));
if (table is not null)
{
pinnedTables.Add(table);
}
}
foreach (var slug in RecentTableSlugs)
{
var table = filteredTables.FirstOrDefault(item => string.Equals(item.Key, slug, StringComparison.OrdinalIgnoreCase));
if (table is not null && pinnedTables.All(item => !string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase)))
{
recentTables.Add(table);
}
}
foreach (var table in pinnedTables)
{
AddKeyboardOption(table);
}
foreach (var table in recentTables)
{
AddKeyboardOption(table);
}
foreach (var table in filteredTables)
{
AddKeyboardOption(table);
}
}
private void AddKeyboardOption(CriticalTableReference table)
{
if (keyboardOptions.Any(item => string.Equals(item.Key, table.Key, StringComparison.OrdinalIgnoreCase)))
{
return;
}
keyboardOptions.Add(table);
}
private bool MatchesFilters(CriticalTableReference table)
{
if (!string.IsNullOrEmpty(selectedFamily) && !string.Equals(table.Family, selectedFamily, StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (string.IsNullOrWhiteSpace(searchText))
{
return true;
}
return table.Label.Contains(searchText, StringComparison.OrdinalIgnoreCase) || table.Key.Contains(searchText, StringComparison.OrdinalIgnoreCase) || table.Family.Contains(searchText, StringComparison.OrdinalIgnoreCase);
}
private void HandleSearchInput(ChangeEventArgs args)
{
searchText = args.Value?.ToString()?.Trim() ?? string.Empty;
BuildSections();
EnsureActiveOption();
}
private void SelectFamilyFilter(string family)
{
selectedFamily = family;
BuildSections();
EnsureActiveOption();
}
private bool IsFamilyFilterSelected(string family) =>
string.Equals(selectedFamily, family, StringComparison.OrdinalIgnoreCase);
private string GetFamilyFilterCssClass(string family) =>
IsFamilyFilterSelected(family) ? "tables-family-filter is-selected" : "tables-family-filter";
private void HandleRailKeyDown(KeyboardEventArgs args)
{
if (keyboardOptions.Count == 0)
{
return;
}
if (string.Equals(args.Key, "ArrowDown", StringComparison.Ordinal))
{
MoveActiveOption(1);
return;
}
if (string.Equals(args.Key, "ArrowUp", StringComparison.Ordinal))
{
MoveActiveOption(-1);
return;
}
if (string.Equals(args.Key, "Enter", StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(activeOptionSlug))
{
_ = OnSelectTable.InvokeAsync(activeOptionSlug);
}
}
private void MoveActiveOption(int offset)
{
if (keyboardOptions.Count == 0)
{
activeOptionSlug = null;
return;
}
var currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase));
if (currentIndex < 0)
{
currentIndex = keyboardOptions.FindIndex(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase));
}
var nextIndex = currentIndex < 0 ? 0 : Math.Clamp(currentIndex + offset, 0, keyboardOptions.Count - 1);
activeOptionSlug = keyboardOptions[nextIndex].Key;
}
private void EnsureActiveOption()
{
if (keyboardOptions.Count == 0)
{
activeOptionSlug = null;
return;
}
if (!string.IsNullOrWhiteSpace(activeOptionSlug) && keyboardOptions.Any(item => string.Equals(item.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase)))
{
return;
}
if (!string.IsNullOrWhiteSpace(SelectedTableSlug))
{
var selectedOption = keyboardOptions.FirstOrDefault(item => string.Equals(item.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase));
if (selectedOption is not null)
{
activeOptionSlug = selectedOption.Key;
return;
}
}
activeOptionSlug = keyboardOptions[0].Key;
}
private string GetCurationTone(CriticalTableReference table) =>
table.CurationPercentage >= 100 ? "success" : "warning";
private string GetTableOptionCssClass(CriticalTableReference table)
{
var classes = new List<string>();
if (string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-selected");
}
if (string.Equals(table.Key, activeOptionSlug, StringComparison.OrdinalIgnoreCase))
{
classes.Add("is-active");
}
classes.Add(table.CurationPercentage >= 100 ? "is-curated" : "needs-curation");
return string.Join(' ', classes);
}
private void SetActiveOption(string tableSlug) => activeOptionSlug = tableSlug;
private RenderFragment RenderTableOption(CriticalTableReference table) => @<button
type="button"
role="option"
aria-selected="@string.Equals(table.Key, SelectedTableSlug, StringComparison.OrdinalIgnoreCase)"
class="table-index-option @GetTableOptionCssClass(table)"
@onfocus="() => SetActiveOption(table.Key)"
@onclick="() => OnSelectTable.InvokeAsync(table.Key)">
<span class="table-index-option-copy">
<strong class="table-index-option-title">@table.Label</strong>
<span class="table-index-option-meta">@table.Family</span>
</span>
<span class="table-index-option-chips">
@if (GetIsPinned(table.Key))
{
<StatusChip Tone="accent">Pinned</StatusChip>
}
<StatusChip Tone="@GetCurationTone(table)">@($"{table.CurationPercentage}%")</StatusChip>
</span>
</button>;
}

View File

@@ -0,0 +1,14 @@
<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

@@ -0,0 +1,62 @@
@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

@@ -0,0 +1,35 @@
@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

@@ -0,0 +1,27 @@
@if (LegendEntries.Count > 0)
{
<div class="critical-legend">
<div class="critical-legend-header">
<h4>Reading help</h4>
</div>
<div class="legend-grid">
@foreach (var entry in LegendEntries)
{
<div class="legend-item" title="@entry.Tooltip">
<span class="legend-symbol">@entry.Symbol</span>
<div>
<strong>@entry.Label</strong>
<span class="muted">@entry.Description</span>
</div>
</div>
}
</div>
</div>
}
@code {
[Parameter]
public IReadOnlyList<CriticalTableLegendEntry> LegendEntries { get; set; } = Array.Empty<CriticalTableLegendEntry>();
}

View File

@@ -0,0 +1,5 @@
<header class="tables-page-header">
<div class="tables-page-header-copy">
<h1 class="panel-title">Critical Tables</h1>
</div>
</header>

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Components.Tables;
public static class TablesReferenceMode
{
public const string Reference = "reference";
public const string NeedsCuration = "needs-curation";
public const string Curated = "curated";
}

View File

@@ -0,0 +1,24 @@
@if (SelectedCellDetail is not null)
{
<div class="tables-selection-menu" aria-label="Selected result actions">
@if (!SelectedCellDetail.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>
}
@code {
[Parameter]
public CriticalTableCellDetail? SelectedCellDetail { get; set; }
[Parameter]
public EventCallback OnEdit { get; set; }
[Parameter]
public EventCallback OnCurate { get; set; }
}

View File

@@ -0,0 +1,189 @@
<ToolPageFrame
Eyebrow="API reference"
Title="API Surface"
Summary="The stable lookup and critical-cell editing contracts are grouped here by workflow so diagnostics and curation work can move quickly without leaving the app shell.">
<Actions>
<a class="btn btn-link" href="/tools">All tools</a>
<a class="btn btn-secondary" href="/tools/diagnostics">Open diagnostics</a>
</Actions>
<ChildContent>
<div class="api-reference-groups">
<section class="api-reference-group">
<div class="api-reference-group-header">
<span class="tool-page-eyebrow">Read models</span>
<h2>Reference and lookup payloads</h2>
</div>
<div class="api-grid">
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Reference data</h3>
<code>GET /api/reference-data</code>
</div>
<div class="tool-link-card-summary">Bootstraps attack tables, critical tables, labels, and high-level metadata for the main client flows.</div>
<pre class="code-block">{
"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."
}
]
}</pre>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Attack lookup</h3>
<code>POST /api/lookup/attack</code>
</div>
<div class="tool-link-card-summary">Resolves attack-table outcomes for the player-facing `Play` surface.</div>
<pre class="code-block">{
"attackTable": "broadsword",
"armorType": "AT10",
"roll": 111,
"criticalRoll": 72
}</pre>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Critical lookup</h3>
<code>POST /api/lookup/critical</code>
</div>
<div class="tool-link-card-summary">Returns resolved critical outcomes plus the imported table metadata needed to inspect the underlying source row.</div>
<pre class="code-block">{
"criticalType": "mana",
"column": "E",
"roll": 100,
"group": null
}</pre>
</section>
</div>
</section>
<section class="api-reference-group">
<div class="api-reference-group-header">
<span class="tool-page-eyebrow">Curation contracts</span>
<h2>Critical-cell editing workflow</h2>
</div>
<div class="api-grid">
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell editor load</h3>
<code>GET /api/tables/critical/{slug}/cells/{resultId}</code>
</div>
<div class="tool-link-card-summary">Loads the full editable graph for one stored critical-table cell, including review notes and nested branches.</div>
<pre class="code-block">{
"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": []
}</pre>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell source image</h3>
<code>GET /api/tables/critical/{slug}/cells/{resultId}/source-image</code>
</div>
<div class="tool-link-card-summary">Streams the importer-generated PNG crop for the current critical cell and returns `404` when no stored artifact is available.</div>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell re-parse</h3>
<code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code>
</div>
<div class="tool-link-card-summary">Re-runs the single-cell parser and merges generated output with the current override state without persisting changes.</div>
<pre class="code-block">{
"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": []
}
}</pre>
</section>
<section class="panel tooling-surface api-endpoint-card">
<div class="api-endpoint-card-header">
<h3 class="panel-title">Cell editor save</h3>
<code>PUT /api/tables/critical/{slug}/cells/{resultId}</code>
</div>
<div class="tool-link-card-summary">Replaces the stored base result, branch rows, and effect rows for the targeted cell with the submitted curated payload.</div>
<pre class="code-block">{
"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": []
}</pre>
</section>
</div>
</section>
</div>
</ChildContent>
</ToolPageFrame>

View File

@@ -0,0 +1,505 @@
@using System
@using System.Collections.Generic
@using System.Linq
@inject NavigationManager NavigationManager
@inject LookupService LookupService
@inject RolemasterDb.App.Frontend.AppState.TableContextState TableContextState
<ToolPageFrame
Eyebrow="Inspection workspace"
Title="Critical Cell Diagnostics"
Summary="Parser provenance, payload state, and engineering-only review stay here instead of leaking into browse or curation workflows.">
<Actions>
<a class="btn btn-link" href="/tools">All tools</a>
@if (BuildTablesUri() is { } tablesUri)
{
<a class="btn btn-secondary" href="@tablesUri">Open tables</a>
}
@if (BuildCurationUri() is { } curationUri)
{
<a class="btn btn-secondary" href="@curationUri">Open curation</a>
}
</Actions>
<ChildContent>
<div class="diagnostics-page">
@if (referenceData is null)
{
<p class="muted">Loading table list...</p>
}
else if (!referenceData.CriticalTables.Any())
{
<p class="muted">No critical tables are available yet.</p>
}
else if (!hasInitializedContext)
{
<p class="muted">Restoring diagnostic context...</p>
}
else
{
<div class="diagnostics-selector-grid">
<div class="field-shell">
<label for="diagnostics-table-select">Table</label>
<select
id="diagnostics-table-select"
class="input-shell"
value="@selectedTableSlug"
@onchange="HandleTableChanged"
disabled="@isBusy">
@foreach (var table in referenceData.CriticalTables)
{
<option value="@table.Key">@table.Label</option>
}
</select>
</div>
<div class="field-shell">
<label for="diagnostics-roll-band-select">Roll Band</label>
<select
id="diagnostics-roll-band-select"
class="input-shell"
value="@selectedRollBand"
@onchange="HandleRollBandChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.RollBands.Any())">
@if (tableDetail is not null)
{
@foreach (var rollBand in tableDetail.RollBands)
{
<option value="@rollBand.Label">@rollBand.Label</option>
}
}
</select>
</div>
@if (tableDetail is { Groups.Count: > 0 })
{
<div class="field-shell">
<label for="diagnostics-group-select">Variant</label>
<select
id="diagnostics-group-select"
class="input-shell"
value="@selectedGroupKey"
@onchange="HandleGroupChanged"
disabled="@isBusy">
@foreach (var group in tableDetail.Groups)
{
<option value="@group.Key">@group.Label</option>
}
</select>
</div>
}
<div class="field-shell">
<label for="diagnostics-column-select">Severity</label>
<select
id="diagnostics-column-select"
class="input-shell"
value="@selectedColumnKey"
@onchange="HandleColumnChanged"
disabled="@(isBusy || tableDetail is null || !tableDetail.Columns.Any())">
@if (tableDetail is not null)
{
@foreach (var column in tableDetail.Columns)
{
<option value="@column.Key">@column.Label</option>
}
}
</select>
</div>
</div>
@if (!string.IsNullOrWhiteSpace(detailError))
{
<p class="error-text">@detailError</p>
}
else if (tableDetail is null)
{
<p class="muted">The selected table could not be loaded.</p>
}
else if (!tableDetail.Cells.Any())
{
<p class="muted">The selected table has no filled cells to inspect.</p>
}
else if (selectedCell is null)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>No Filled Cell At This Position</strong>
<div class="muted critical-editor-inline-copy">Pick another roll band, variant, or severity to inspect a stored result.</div>
</div>
</div>
</div>
}
else
{
<div class="diagnostics-selection-summary">
<strong>Inspecting</strong>
<span>@tableDetail.DisplayName</span>
<span>· Roll band <strong>@selectedCell.RollBand</strong></span>
<span>· Severity <strong>@selectedCell.ColumnLabel</strong></span>
@if (!string.IsNullOrWhiteSpace(selectedCell.GroupLabel))
{
<span>· Variant <strong>@selectedCell.GroupLabel</strong></span>
}
<span>· Result ID <strong>@selectedCell.ResultId</strong></span>
</div>
@if (isDiagnosticsLoading)
{
<p class="muted">Loading diagnostics...</p>
}
else if (!string.IsNullOrWhiteSpace(diagnosticsError))
{
<p class="error-text">@diagnosticsError</p>
}
else if (diagnosticsModel is not null)
{
<CriticalCellEngineeringDiagnostics Model="diagnosticsModel" />
}
}
}
</div>
</ChildContent>
</ToolPageFrame>
@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 hasInitializedContext;
private bool isBusy => isDetailLoading || isDiagnosticsLoading;
private string ContextDestination => "tools.diagnostics";
protected override async Task OnInitializedAsync()
{
referenceData = await LookupService.GetReferenceDataAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender || hasInitializedContext || referenceData?.CriticalTables.Count is not > 0)
{
return;
}
var initialContext = await TableContextState.RestoreAsync(
NavigationManager.Uri,
ContextDestination,
referenceData.CriticalTables,
RolemasterDb.App.Frontend.AppState.TableContextMode.Diagnostics);
selectedTableSlug = initialContext.TableSlug ?? string.Empty;
hasInitializedContext = true;
await LoadTableDetailAsync(initialContext);
await InvokeAsync(StateHasChanged);
}
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 PersistAndSyncRouteContextAsync();
await LoadSelectedCellDiagnosticsAsync();
}
private async Task HandleGroupChanged(ChangeEventArgs args)
{
selectedGroupKey = NormalizeOptionalText(args.Value?.ToString());
ResolveSelectedCell();
await PersistAndSyncRouteContextAsync();
await LoadSelectedCellDiagnosticsAsync();
}
private async Task HandleColumnChanged(ChangeEventArgs args)
{
selectedColumnKey = args.Value?.ToString() ?? string.Empty;
ResolveSelectedCell();
await PersistAndSyncRouteContextAsync();
await LoadSelectedCellDiagnosticsAsync();
}
private async Task LoadTableDetailAsync(RolemasterDb.App.Frontend.AppState.TableContextSnapshot? routeContext = null)
{
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;
}
if (!TryApplySelectionFromContext(tableDetail, routeContext))
{
SetDefaultSelection(tableDetail);
}
ResolveSelectedCell();
if (selectedCell is null && routeContext is not null)
{
SetDefaultSelection(tableDetail);
ResolveSelectedCell();
}
await PersistAndSyncRouteContextAsync();
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 bool TryApplySelectionFromContext(
CriticalTableDetail detail,
RolemasterDb.App.Frontend.AppState.TableContextSnapshot? routeContext)
{
if (routeContext is null)
{
return false;
}
CriticalTableCellDetail? matchedCell = null;
if (routeContext.ResultId is { } resultId)
{
matchedCell = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId);
}
matchedCell ??= detail.Cells.FirstOrDefault(cell =>
string.Equals(cell.RollBand, routeContext.RollBand, StringComparison.Ordinal) &&
string.Equals(cell.ColumnKey, routeContext.ColumnKey, StringComparison.Ordinal) &&
string.Equals(cell.GroupKey ?? string.Empty, routeContext.GroupKey ?? string.Empty, StringComparison.Ordinal));
if (matchedCell is not null)
{
selectedRollBand = matchedCell.RollBand;
selectedColumnKey = matchedCell.ColumnKey;
selectedGroupKey = matchedCell.GroupKey;
return true;
}
if (string.IsNullOrWhiteSpace(routeContext.RollBand) &&
string.IsNullOrWhiteSpace(routeContext.ColumnKey) &&
string.IsNullOrWhiteSpace(routeContext.GroupKey))
{
return false;
}
selectedRollBand = ResolveRollBand(detail, routeContext.RollBand);
selectedColumnKey = ResolveColumnKey(detail, routeContext.ColumnKey);
selectedGroupKey = ResolveGroupKey(detail, routeContext.GroupKey);
return true;
}
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);
await PersistAndSyncRouteContextAsync();
}
catch (Exception exception)
{
diagnosticsError = exception.Message;
}
finally
{
isDiagnosticsLoading = false;
}
}
private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private async Task PersistAndSyncRouteContextAsync()
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return;
}
var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot(
TableSlug: selectedTableSlug,
GroupKey: selectedGroupKey,
ColumnKey: selectedColumnKey,
RollBand: selectedRollBand,
ResultId: selectedCell?.ResultId,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Diagnostics);
await TableContextState.PersistAsync(ContextDestination, snapshot);
var targetUri = TableContextState.BuildUri("/tools/diagnostics", snapshot);
if (string.Equals(NavigationManager.ToBaseRelativePath(NavigationManager.Uri), targetUri.TrimStart('/'), StringComparison.Ordinal))
{
return;
}
NavigationManager.NavigateTo(targetUri, replace: true);
}
private static string ResolveRollBand(CriticalTableDetail detail, string? rollBand) =>
detail.RollBands.FirstOrDefault(item => string.Equals(item.Label, rollBand, StringComparison.Ordinal))?.Label
?? detail.RollBands.FirstOrDefault()?.Label
?? string.Empty;
private string? BuildTablesUri()
{
if (string.IsNullOrWhiteSpace(selectedTableSlug))
{
return null;
}
var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot(
TableSlug: selectedTableSlug,
GroupKey: selectedGroupKey,
ColumnKey: selectedColumnKey,
RollBand: selectedRollBand,
ResultId: selectedCell?.ResultId,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Reference);
return TableContextState.BuildUri("/tables", snapshot);
}
private string? BuildCurationUri()
{
if (selectedCell is null || string.IsNullOrWhiteSpace(selectedTableSlug))
{
return null;
}
var snapshot = new RolemasterDb.App.Frontend.AppState.TableContextSnapshot(
TableSlug: selectedTableSlug,
GroupKey: selectedGroupKey,
ColumnKey: selectedColumnKey,
RollBand: selectedRollBand,
ResultId: selectedCell.ResultId,
Mode: RolemasterDb.App.Frontend.AppState.TableContextMode.Curation,
QueueScope: RolemasterDb.App.Frontend.Curation.CurationQueueScopes.SelectedTable);
return TableContextState.BuildUri("/curation", snapshot);
}
private static string ResolveColumnKey(CriticalTableDetail detail, string? columnKey) =>
detail.Columns.FirstOrDefault(item => string.Equals(item.Key, columnKey, StringComparison.Ordinal))?.Key
?? detail.Columns.FirstOrDefault()?.Key
?? string.Empty;
private static string? ResolveGroupKey(CriticalTableDetail detail, string? groupKey)
{
if (detail.Groups.Count == 0)
{
return null;
}
return detail.Groups.FirstOrDefault(item => string.Equals(item.Key, groupKey, StringComparison.Ordinal))?.Key
?? detail.Groups.First().Key;
}
}

View File

@@ -0,0 +1,49 @@
<article class="tool-link-card">
<div class="tool-link-card-body">
<div class="tool-link-card-header">
<div class="tool-link-card-title-row">
<h2 class="tool-link-card-title">@Title</h2>
@if (!string.IsNullOrWhiteSpace(Badge))
{
<span class="chip">@Badge</span>
}
</div>
@if (!string.IsNullOrWhiteSpace(Summary))
{
<div class="tool-link-card-summary">@Summary</div>
}
</div>
@if (Details is not null)
{
<div class="tool-link-card-details">
@Details
</div>
}
</div>
<div class="tool-link-card-actions">
<NavLink class="btn btn-secondary" href="@Href">@ActionLabel</NavLink>
</div>
</article>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string? Summary { get; set; }
[Parameter]
public string? Badge { get; set; }
[Parameter, EditorRequired]
public string Href { get; set; } = string.Empty;
[Parameter]
public string ActionLabel { get; set; } = "Open";
[Parameter]
public RenderFragment? Details { get; set; }
}

View File

@@ -0,0 +1,45 @@
<section class="panel tooling-surface tool-page-frame">
<header class="tool-page-header">
<div class="tool-page-heading">
@if (!string.IsNullOrWhiteSpace(Eyebrow))
{
<span class="tool-page-eyebrow">@Eyebrow</span>
}
<h1 class="panel-title">@Title</h1>
@if (!string.IsNullOrWhiteSpace(Summary))
{
<div class="tool-page-summary">@Summary</div>
}
</div>
@if (Actions is not null)
{
<div class="tool-page-actions">
@Actions
</div>
}
</header>
<div class="tool-page-body">
@ChildContent
</div>
</section>
@code {
[Parameter]
public string? Eyebrow { get; set; }
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string? Summary { get; set; }
[Parameter]
public RenderFragment? Actions { get; set; }
[Parameter, EditorRequired]
public RenderFragment ChildContent { get; set; } = default!;
}

View File

@@ -10,5 +10,11 @@
@using RolemasterDb.App.Features @using RolemasterDb.App.Features
@using RolemasterDb.App @using RolemasterDb.App
@using RolemasterDb.App.Components @using RolemasterDb.App.Components
@using RolemasterDb.App.Components.Curation
@using RolemasterDb.App.Components.Layout @using RolemasterDb.App.Components.Layout
@using RolemasterDb.App.Components.Primitives
@using RolemasterDb.App.Components.Shell
@using RolemasterDb.App.Components.Shared @using RolemasterDb.App.Components.Shared
@using RolemasterDb.App.Components.Tables
@using RolemasterDb.App.Components.Tools
@using RolemasterDb.App.Frontend.Curation

View File

@@ -0,0 +1,11 @@
namespace RolemasterDb.App.Frontend.AppState;
public static class BrowserStorageKeys
{
public const string ThemeMode = "rolemaster.theme.mode";
public const string PinnedTables = "rolemaster.tables.pinned";
public const string RecentTables = "rolemaster.tables.recent";
public static string TableContext(string destination) =>
$"rolemaster.tables.context.{destination}";
}

View File

@@ -0,0 +1,12 @@
using Microsoft.JSInterop;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class BrowserStorageService(IJSRuntime jsRuntime)
{
public ValueTask<string?> GetItemAsync(string key) =>
jsRuntime.InvokeAsync<string?>("localStorage.getItem", key);
public ValueTask SetItemAsync(string key, string value) =>
jsRuntime.InvokeVoidAsync("localStorage.setItem", key, value);
}

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed record PinnedTableEntry(
string Slug,
string Label,
string Family,
int CurationPercentage,
DateTimeOffset PinnedAtUtc);

View File

@@ -0,0 +1,126 @@
using System.Text.Json;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class PinnedTablesState(BrowserStorageService browserStorage)
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private bool isInitialized;
public IReadOnlyList<PinnedTableEntry> Items { get; private set; } = [];
public event Action? Changed;
public async Task InitializeAsync()
{
if (isInitialized)
{
return;
}
try
{
var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.PinnedTables);
Items = DeserializeItems(storedValue);
isInitialized = true;
Changed?.Invoke();
}
catch (JsonException)
{
Items = [];
isInitialized = true;
Changed?.Invoke();
}
catch (InvalidOperationException)
{
// JS interop is unavailable during prerender. Retry on the next interactive render.
}
}
public bool IsPinned(string slug) =>
Items.Any(item => string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase));
public async Task ToggleAsync(string slug, string label, string family, int curationPercentage)
{
await InitializeAsync();
if (IsPinned(slug))
{
await UnpinAsync(slug);
return;
}
await PinAsync(slug, label, family, curationPercentage);
}
private async Task PinAsync(string slug, string label, string family, int curationPercentage)
{
if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(label))
{
return;
}
var updatedItems = new List<PinnedTableEntry>
{
new(
slug.Trim(),
label.Trim(),
family.Trim(),
curationPercentage,
DateTimeOffset.UtcNow)
};
foreach (var item in Items)
{
if (string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
continue;
}
updatedItems.Add(item);
}
Items = updatedItems;
await PersistAsync();
Changed?.Invoke();
}
private async Task UnpinAsync(string slug)
{
Items = Items
.Where(item => !string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase))
.ToList();
await PersistAsync();
Changed?.Invoke();
}
private async Task PersistAsync()
{
var serialized = JsonSerializer.Serialize(Items, SerializerOptions);
await browserStorage.SetItemAsync(BrowserStorageKeys.PinnedTables, serialized);
}
private static IReadOnlyList<PinnedTableEntry> DeserializeItems(string? storedValue)
{
if (string.IsNullOrWhiteSpace(storedValue))
{
return [];
}
var items = JsonSerializer.Deserialize<List<PinnedTableEntry>>(storedValue, SerializerOptions);
if (items is null || items.Count == 0)
{
return [];
}
return items
.Where(item => !string.IsNullOrWhiteSpace(item.Slug) && !string.IsNullOrWhiteSpace(item.Label))
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(item => item.PinnedAtUtc)
.First())
.OrderByDescending(item => item.PinnedAtUtc)
.ToList();
}
}

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed record RecentTableEntry(
string Slug,
string Label,
string Family,
int CurationPercentage,
DateTimeOffset ViewedAtUtc);

View File

@@ -0,0 +1,108 @@
using System.Text.Json;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class RecentTablesState(BrowserStorageService browserStorage)
{
private const int MaxItems = 8;
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private bool isInitialized;
public IReadOnlyList<RecentTableEntry> Items { get; private set; } = [];
public event Action? Changed;
public async Task InitializeAsync()
{
if (isInitialized)
{
return;
}
try
{
var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.RecentTables);
Items = DeserializeItems(storedValue);
isInitialized = true;
Changed?.Invoke();
}
catch (JsonException)
{
Items = [];
isInitialized = true;
Changed?.Invoke();
}
catch (InvalidOperationException)
{
// JS interop is unavailable during prerender. Retry on the next interactive render.
}
}
public async Task RecordVisitAsync(string slug, string label, string family, int curationPercentage)
{
if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(label))
{
return;
}
await InitializeAsync();
var updatedItems = new List<RecentTableEntry>
{
new(
slug.Trim(),
label.Trim(),
family.Trim(),
curationPercentage,
DateTimeOffset.UtcNow)
};
foreach (var item in Items)
{
if (string.Equals(item.Slug, slug, StringComparison.OrdinalIgnoreCase))
{
continue;
}
updatedItems.Add(item);
if (updatedItems.Count == MaxItems)
{
break;
}
}
Items = updatedItems;
await PersistAsync();
Changed?.Invoke();
}
private async Task PersistAsync()
{
var serialized = JsonSerializer.Serialize(Items, SerializerOptions);
await browserStorage.SetItemAsync(BrowserStorageKeys.RecentTables, serialized);
}
private static IReadOnlyList<RecentTableEntry> DeserializeItems(string? storedValue)
{
if (string.IsNullOrWhiteSpace(storedValue))
{
return [];
}
var items = JsonSerializer.Deserialize<List<RecentTableEntry>>(storedValue, SerializerOptions);
if (items is null || items.Count == 0)
{
return [];
}
return items
.Where(item => !string.IsNullOrWhiteSpace(item.Slug) && !string.IsNullOrWhiteSpace(item.Label))
.GroupBy(item => item.Slug, StringComparer.OrdinalIgnoreCase)
.Select(group => group
.OrderByDescending(item => item.ViewedAtUtc)
.First())
.OrderByDescending(item => item.ViewedAtUtc)
.Take(MaxItems)
.ToList();
}
}

View File

@@ -0,0 +1,41 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed class ShellOmniboxState
{
public bool IsOpen { get; private set; }
public event Action? Changed;
public void Open()
{
if (IsOpen)
{
return;
}
IsOpen = true;
Changed?.Invoke();
}
public void Close()
{
if (!IsOpen)
{
return;
}
IsOpen = false;
Changed?.Invoke();
}
public void Toggle()
{
if (IsOpen)
{
Close();
return;
}
Open();
}
}

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public enum TableContextMode
{
Reference,
Curation,
Diagnostics
}

View File

@@ -0,0 +1,3 @@
namespace RolemasterDb.App.Frontend.AppState;
public sealed record TableContextSnapshot(string? TableSlug = null, string? GroupKey = null, string? ColumnKey = null, string? RollBand = null, int? RollJump = null, int? ResultId = null, TableContextMode? Mode = null, string? QueueScope = null);

View File

@@ -0,0 +1,82 @@
using System.Text.Json;
using RolemasterDb.App.Features;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class TableContextState(
BrowserStorageService browserStorage,
TableContextUrlSerializer serializer)
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
public async Task<TableContextSnapshot> RestoreAsync(
string currentUri,
string destination,
IReadOnlyList<CriticalTableReference> availableTables,
TableContextMode defaultMode)
{
var routeContext = serializer.Parse(currentUri);
if (!string.IsNullOrWhiteSpace(routeContext.TableSlug))
{
return Normalize(routeContext, availableTables, defaultMode);
}
try
{
var storedValue = await browserStorage.GetItemAsync(BrowserStorageKeys.TableContext(destination));
if (!string.IsNullOrWhiteSpace(storedValue))
{
var storedContext = JsonSerializer.Deserialize<TableContextSnapshot>(storedValue, SerializerOptions);
if (storedContext is not null)
{
return Normalize(storedContext, availableTables, defaultMode);
}
}
}
catch (InvalidOperationException)
{
// JS interop is unavailable during prerender. Fall back to route/default context.
}
catch (JsonException)
{
// Ignore malformed storage payloads and fall back to route/default context.
}
return Normalize(routeContext, availableTables, defaultMode);
}
public Task PersistAsync(string destination, TableContextSnapshot context)
{
var serialized = JsonSerializer.Serialize(context, SerializerOptions);
return browserStorage.SetItemAsync(BrowserStorageKeys.TableContext(destination), serialized).AsTask();
}
public string BuildUri(string basePath, TableContextSnapshot context) =>
serializer.BuildRelativeUri(basePath, context);
public string ResolveTableSlug(IReadOnlyList<CriticalTableReference> availableTables, string? preferredSlug)
{
if (availableTables.Count == 0)
{
return string.Empty;
}
if (!string.IsNullOrWhiteSpace(preferredSlug) &&
availableTables.Any(item => string.Equals(item.Key, preferredSlug, StringComparison.OrdinalIgnoreCase)))
{
return preferredSlug;
}
return availableTables[0].Key;
}
private TableContextSnapshot Normalize(
TableContextSnapshot context,
IReadOnlyList<CriticalTableReference> availableTables,
TableContextMode defaultMode) =>
context with
{
TableSlug = ResolveTableSlug(availableTables, context.TableSlug),
Mode = context.Mode ?? defaultMode
};
}

View File

@@ -0,0 +1,73 @@
using Microsoft.AspNetCore.WebUtilities;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class TableContextUrlSerializer
{
private const string TableKey = "table";
private const string GroupKey = "group";
private const string ColumnKey = "column";
private const string RollBandKey = "rollBand";
private const string RollJumpKey = "roll";
private const string ResultIdKey = "result";
private const string ModeKey = "mode";
private const string QueueScopeKey = "scope";
public TableContextSnapshot Parse(string uri)
{
var currentUri = new Uri(uri, UriKind.Absolute);
var query = QueryHelpers.ParseQuery(currentUri.Query);
return new TableContextSnapshot(ReadValue(query, TableKey), ReadValue(query, GroupKey), ReadValue(query, ColumnKey), ReadValue(query, RollBandKey), ReadInt(query, RollJumpKey), ReadInt(query, ResultIdKey), ReadMode(ReadValue(query, ModeKey)), ReadValue(query, QueueScopeKey));
}
public string BuildRelativeUri(string basePath, TableContextSnapshot context)
{
var parameters = new Dictionary<string, string?>();
AddIfPresent(parameters, TableKey, context.TableSlug);
AddIfPresent(parameters, GroupKey, context.GroupKey);
AddIfPresent(parameters, ColumnKey, context.ColumnKey);
AddIfPresent(parameters, RollBandKey, context.RollBand);
AddIfPresent(parameters, RollJumpKey, context.RollJump?.ToString());
AddIfPresent(parameters, ResultIdKey, context.ResultId?.ToString());
AddIfPresent(parameters, ModeKey, WriteMode(context.Mode));
AddIfPresent(parameters, QueueScopeKey, context.QueueScope);
return parameters.Count == 0 ? basePath : QueryHelpers.AddQueryString(basePath, parameters);
}
private static void AddIfPresent(IDictionary<string, string?> parameters, string key, string? value)
{
if (!string.IsNullOrWhiteSpace(value))
{
parameters[key] = value.Trim();
}
}
private static string? ReadValue(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key) =>
query.TryGetValue(key, out var value) ? value.ToString() : null;
private static int? ReadInt(IReadOnlyDictionary<string, Microsoft.Extensions.Primitives.StringValues> query, string key)
{
var value = ReadValue(query, key);
return int.TryParse(value, out var parsed) ? parsed : null;
}
private static TableContextMode? ReadMode(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"reference" => TableContextMode.Reference,
"curation" => TableContextMode.Curation,
"diagnostics" => TableContextMode.Diagnostics,
_ => null
};
private static string? WriteMode(TableContextMode? mode) =>
mode switch
{
TableContextMode.Reference => "reference",
TableContextMode.Curation => "curation",
TableContextMode.Diagnostics => "diagnostics",
_ => null
};
}

View File

@@ -0,0 +1,8 @@
namespace RolemasterDb.App.Frontend.AppState;
public enum ThemeMode
{
System,
Light,
Dark
}

View File

@@ -0,0 +1,66 @@
using Microsoft.JSInterop;
namespace RolemasterDb.App.Frontend.AppState;
public sealed class ThemeState(BrowserStorageService browserStorage, IJSRuntime jsRuntime)
{
private bool isInitialized;
public ThemeMode CurrentMode { get; private set; } = ThemeMode.System;
public event Action? Changed;
public async Task InitializeAsync()
{
if (isInitialized)
{
return;
}
try
{
var storedMode = await browserStorage.GetItemAsync(BrowserStorageKeys.ThemeMode);
CurrentMode = ParseMode(storedMode);
await ApplyThemeAsync(CurrentMode);
isInitialized = true;
Changed?.Invoke();
}
catch (InvalidOperationException)
{
// JS interop is unavailable during prerender. Retry on the next interactive render.
}
}
public async Task SetModeAsync(ThemeMode mode)
{
if (isInitialized && CurrentMode == mode)
{
return;
}
CurrentMode = mode;
await ApplyThemeAsync(mode);
await browserStorage.SetItemAsync(BrowserStorageKeys.ThemeMode, ToStorageValue(mode));
isInitialized = true;
Changed?.Invoke();
}
private Task ApplyThemeAsync(ThemeMode mode) =>
jsRuntime.InvokeVoidAsync("rolemasterTheme.apply", ToStorageValue(mode)).AsTask();
private static ThemeMode ParseMode(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
"light" => ThemeMode.Light,
"dark" => ThemeMode.Dark,
_ => ThemeMode.System
};
private static string ToStorageValue(ThemeMode mode) =>
mode switch
{
ThemeMode.Light => "light",
ThemeMode.Dark => "dark",
_ => "system"
};
}

View File

@@ -0,0 +1,3 @@
namespace RolemasterDb.App.Frontend.Curation;
public sealed record CurationQueueItem(string TableSlug, string TableName, int ResultId, string RollBand, string ColumnKey, string ColumnLabel, string? GroupKey, string? GroupLabel);

View File

@@ -0,0 +1,95 @@
using RolemasterDb.App.Features;
using RolemasterDb.App.Frontend.AppState;
namespace RolemasterDb.App.Frontend.Curation;
public static class CurationQueueResolver
{
public static IReadOnlyList<CriticalTableCellDetail> GetOrderedCells(CriticalTableDetail detail)
{
ArgumentNullException.ThrowIfNull(detail);
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);
return detail.Cells.OrderBy(cell => rollOrder.GetValueOrDefault(cell.RollBand, int.MaxValue)).ThenBy(cell =>
{
if (string.IsNullOrWhiteSpace(cell.GroupKey))
{
return -1;
}
return groupOrder.GetValueOrDefault(cell.GroupKey, int.MaxValue);
}).ThenBy(cell => columnOrder.GetValueOrDefault(cell.ColumnKey, int.MaxValue)).ToList();
}
public static CriticalTableCellDetail? FindCell(CriticalTableDetail detail, TableContextSnapshot? context)
{
ArgumentNullException.ThrowIfNull(detail);
if (context is null)
{
return null;
}
if (context.ResultId is { } resultId)
{
var matchedByResultId = detail.Cells.FirstOrDefault(cell => cell.ResultId == resultId);
if (matchedByResultId is not null)
{
return matchedByResultId;
}
}
if (string.IsNullOrWhiteSpace(context.RollBand) && string.IsNullOrWhiteSpace(context.ColumnKey) && string.IsNullOrWhiteSpace(context.GroupKey))
{
return null;
}
return detail.Cells.FirstOrDefault(cell => string.Equals(cell.RollBand, context.RollBand, StringComparison.Ordinal) && string.Equals(cell.ColumnKey, context.ColumnKey, StringComparison.Ordinal) && string.Equals(cell.GroupKey ?? string.Empty, context.GroupKey ?? string.Empty, StringComparison.Ordinal));
}
public static CriticalTableCellDetail? FindFirstUncurated(CriticalTableDetail detail) =>
GetOrderedCells(detail).FirstOrDefault(cell => !cell.IsCurated);
public static CriticalTableCellDetail? FindNextUncurated(CriticalTableDetail detail, int currentResultId)
{
var orderedCells = GetOrderedCells(detail);
var currentIndex = orderedCells.Select((cell, index) => new
{
cell.ResultId,
index
}).FirstOrDefault(item => item.ResultId == currentResultId)?.index ?? -1;
for (var index = currentIndex + 1; index < orderedCells.Count; index++)
{
if (!orderedCells[index].IsCurated)
{
return orderedCells[index];
}
}
return null;
}
public static CurationQueueItem CreateQueueItem(CriticalTableDetail detail, CriticalTableCellDetail cell)
{
ArgumentNullException.ThrowIfNull(detail);
ArgumentNullException.ThrowIfNull(cell);
return new CurationQueueItem(detail.Slug, detail.DisplayName, cell.ResultId, cell.RollBand, cell.ColumnKey, cell.ColumnLabel, cell.GroupKey, cell.GroupLabel);
}
}

View File

@@ -0,0 +1,16 @@
namespace RolemasterDb.App.Frontend.Curation;
public static class CurationQueueScopes
{
public const string AllTables = "all";
public const string SelectedTable = "table";
public const string PinnedSet = "pinned";
public static string Normalize(string? value) =>
value?.Trim().ToLowerInvariant() switch
{
SelectedTable => SelectedTable,
PinnedSet => PinnedSet,
_ => AllTables
};
}

View File

@@ -1,16 +1,24 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Components; using RolemasterDb.App.Components;
using RolemasterDb.App.Data; using RolemasterDb.App.Data;
using RolemasterDb.App.Frontend.AppState;
using RolemasterDb.App.Features; using RolemasterDb.App.Features;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseStaticWebAssets();
var connectionString = builder.Configuration.GetConnectionString("RolemasterDb") ?? "Data Source=rolemaster.db"; var connectionString = builder.Configuration.GetConnectionString("RolemasterDb") ?? "Data Source=rolemaster.db";
builder.Services.AddRazorComponents() builder.Services.AddRazorComponents().AddInteractiveServerComponents();
.AddInteractiveServerComponents();
builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString)); builder.Services.AddDbContextFactory<RolemasterDbContext>(options => options.UseSqlite(connectionString));
builder.Services.AddSingleton<CriticalImportArtifactLocator>(); builder.Services.AddSingleton<CriticalImportArtifactLocator>();
builder.Services.AddScoped<LookupService>(); builder.Services.AddScoped<LookupService>();
builder.Services.AddScoped<BrowserStorageService>();
builder.Services.AddScoped<PinnedTablesState>();
builder.Services.AddScoped<RecentTablesState>();
builder.Services.AddScoped<ShellOmniboxState>();
builder.Services.AddScoped<TableContextState>();
builder.Services.AddSingleton<TableContextUrlSerializer>();
builder.Services.AddScoped<ThemeState>();
var app = builder.Build(); var app = builder.Build();
await RolemasterDbInitializer.InitializeAsync(app.Services); await RolemasterDbInitializer.InitializeAsync(app.Services);
@@ -19,13 +27,13 @@ if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseExceptionHandler("/Error", createScopeForErrors: true);
} }
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true); app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseAntiforgery(); app.UseAntiforgery();
app.MapStaticAssets(); app.MapStaticAssets();
var api = app.MapGroup("/api"); var api = app.MapGroup("/api");
api.MapGet("/reference-data", async (LookupService lookupService, CancellationToken cancellationToken) => api.MapGet("/reference-data", async (LookupService lookupService, CancellationToken cancellationToken) => Results.Ok(await lookupService.GetReferenceDataAsync(cancellationToken)));
Results.Ok(await lookupService.GetReferenceDataAsync(cancellationToken)));
api.MapPost("/lookup/attack", async (AttackLookupRequest request, LookupService lookupService, CancellationToken cancellationToken) => api.MapPost("/lookup/attack", async (AttackLookupRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
{ {
var result = await lookupService.LookupAttackAsync(request, cancellationToken); var result = await lookupService.LookupAttackAsync(request, cancellationToken);
@@ -56,7 +64,6 @@ api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken); var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result); return result is null ? Results.NotFound() : Results.Ok(result);
}); });
app.MapRazorComponents<App>() app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
.AddInteractiveServerRenderMode();
app.Run(); app.Run();

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View File

@@ -0,0 +1,26 @@
const scrollLockClassName = "critical-editor-scroll-locked";
let scrollLockCount = 0;
export function lockBackgroundScroll() {
scrollLockCount++;
if (scrollLockCount !== 1) {
return;
}
document.documentElement.classList.add(scrollLockClassName);
document.body.classList.add(scrollLockClassName);
}
export function unlockBackgroundScroll() {
if (scrollLockCount === 0) {
return;
}
scrollLockCount--;
if (scrollLockCount !== 0) {
return;
}
document.documentElement.classList.remove(scrollLockClassName);
document.body.classList.remove(scrollLockClassName);
}

View File

@@ -0,0 +1,23 @@
window.rolemasterTheme = {
storageKey: "rolemaster.theme.mode",
apply(mode) {
document.documentElement.dataset.theme = mode || "system";
},
init(storageKey) {
if (storageKey) {
this.storageKey = storageKey;
}
let mode = "system";
try {
mode = localStorage.getItem(this.storageKey) || "system";
} catch {
mode = "system";
}
this.apply(mode);
}
};

View File

@@ -0,0 +1,64 @@
using RolemasterDb.App.Features;
using RolemasterDb.App.Frontend.AppState;
using RolemasterDb.App.Frontend.Curation;
namespace RolemasterDb.ImportTool.Tests;
public sealed class CurationQueueResolverTests
{
[Fact]
public void Ordered_cells_follow_roll_group_and_column_sort_order()
{
var detail = CreateDetail(new CriticalTableCellDetail(4, "21-25", "C", "C", "severity", "B", "Beta", false, null, [], []), new CriticalTableCellDetail(1, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "21-25", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "21-25", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var orderedIds = CurationQueueResolver.GetOrderedCells(detail).Select(cell => cell.ResultId).ToArray();
Assert.Equal([1, 3, 2, 4], orderedIds);
}
[Fact]
public void Find_first_uncurated_returns_first_matching_cell_in_order()
{
var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], []));
var nextCell = CurationQueueResolver.FindFirstUncurated(detail);
Assert.NotNull(nextCell);
Assert.Equal(2, nextCell!.ResultId);
}
[Fact]
public void Find_next_uncurated_advances_without_wrapping()
{
var detail = CreateDetail(new CriticalTableCellDetail(1, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(2, "01-05", "B", "B", "severity", null, null, true, null, [], []), new CriticalTableCellDetail(3, "06-10", "A", "A", "severity", null, null, false, null, [], []));
Assert.Equal(3, CurationQueueResolver.FindNextUncurated(detail, 1)!.ResultId);
Assert.Null(CurationQueueResolver.FindNextUncurated(detail, 3));
}
[Fact]
public void Find_cell_prefers_result_id_and_falls_back_to_location_context()
{
var detail = CreateDetail(new CriticalTableCellDetail(11, "01-05", "A", "A", "severity", null, null, false, null, [], []), new CriticalTableCellDetail(12, "06-10", "B", "B", "severity", "A", "Alpha", false, null, [], []));
var byResult = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(ResultId: 12));
var byLocation = CurationQueueResolver.FindCell(detail, new TableContextSnapshot(GroupKey: "A", ColumnKey: "B", RollBand: "06-10"));
Assert.Equal(12, byResult!.ResultId);
Assert.Equal(12, byLocation!.ResultId);
}
private static CriticalTableDetail CreateDetail(params CriticalTableCellDetail[] cells) =>
new("slash", "Slash Critical Strike Table", "standard", "Slash.pdf", null, [
new CriticalColumnReference("A", "A", "severity", 1),
new CriticalColumnReference("B", "B", "severity", 2),
new CriticalColumnReference("C", "C", "severity", 3)
], [
new CriticalGroupReference("A", "Alpha", 1),
new CriticalGroupReference("B", "Beta", 2)
], [
new CriticalRollBandReference("01-05", 1, 5, 1),
new CriticalRollBandReference("06-10", 6, 10, 2),
new CriticalRollBandReference("21-25", 21, 25, 3)
], cells, []);
}