# Rewrite The Web App Into A Route-First Authenticated Shell This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds. `PLANS.md` is checked into the repository root. This document must be maintained in accordance with `PLANS.md`. ## Purpose / Big Picture After this change, the browser URL will match the authenticated screen the user is actually using. Anonymous users who open `/` will be redirected to `/login`. Authenticated users who open `/` will be redirected to `/play`. The hamburger menu will navigate to real routes such as `/play`, `/campaigns`, and `/admin` instead of toggling large conditional branches inside one component at `/`. This matters because the current authenticated workspace is still one large, structurally dynamic Blazor Server surface. `POSTMORTEM.md` shows that this architecture is fragile when browser extensions mutate form-related DOM during startup. The route-first rewrite reduces the amount of UI that wakes up at once, removes the dual-purpose `/` shell, and makes the authenticated shell easier to reason about, test, and evolve. The change is complete when a human can run the app, open `/`, observe the correct redirect based on auth state, log in at `/login`, land on `/play`, navigate to `/campaigns` and `/admin` with real URLs, refresh any of those routes without being thrown back to `/`, and run the automated host and Selenium tests that prove the new behavior. ## Progress - [x] (2026-05-04 17:52Z) Reviewed `POSTMORTEM.md`, the current app shell, workspace routing behavior, and the existing host and frontend smoke tests to define the rewrite around real routes instead of `sessionStorage` screen switching. - [x] (2026-05-04 17:52Z) Updated `README.md` so it accurately describes the current architecture and the approved rewrite direction. - [x] (2026-05-04 18:29Z) Implemented a host-level `/` redirect to `/login` or `/play`, moved the static auth document to `/login`, switched login/logout targets to `/play` and `/login`, and updated the root-path host and smoke coverage to the new contract. - [x] (2026-05-04 19:26Z) Replaced the checked-in Playwright smoke coverage with a geckodriver+Selenium smoke runner, including a Firefox DOM-wrap addon for extension-like startup mutations, and updated repo scripts/docs to the new browser verification path. - [x] (2026-05-04) Introduced real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving the shared `Workspace` behavior behind those routes. - [x] (2026-05-04) Removed `screen` as a `sessionStorage` routing mechanism and replaced menu actions with URL navigation. - [x] (2026-05-04 21:42Z) Split the large `Workspace` render tree into a shared shell plus route-owned play, campaign-management, and admin content components, and kept the Selenium route and DOM-wrap coverage green after the split. - [x] (2026-05-04 21:58Z) Removed shell-level `OnAfterRenderAsync` bootstrapping, moved the JS-dependent authenticated startup into a route-owned `WorkspaceRouteView`, removed shell-owned staged control renders, restored the missing development database fixture, and updated README to describe the completed route-first architecture. - [x] (2026-05-04) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior. - [x] (2026-05-04) Added expanded workspace startup diagnostics across Blazor lifecycle logging, route-content render logging, and browser-side DOM mutation snapshots to narrow the remaining Firefox batch-2 crash. - [x] (2026-05-04) Extended the diagnostics to page-load time by wrapping the interactive host in a stable container and logging pre-Blazor body and host mutations before the first interactive batch applies. - [x] (2026-05-04) Reworked authenticated route startup into phased interactive batches so the first render mounts only a tiny shell, the second render mounts a simple header placeholder, the third render mounts route skeletons, and real control-heavy content appears only after route initialization completes. - [x] (2026-05-04 22:17Z) Removed global authenticated `Routes` interactivity, moved `InteractiveServerRenderMode(prerender: false)` onto the real authenticated pages, and switched to manual `Blazor.start({ ssr: { disableDomPreservation: true } })` startup based on the upstream Firefox guidance in `dotnet/aspnetcore#58824`. - [x] (2026-05-05) Confirmed the real fix in Firefox plus RoboForm, documented it in `README.md`, and removed the failed phased-render and diagnostics-only mitigation layers from the codebase. ## Surprises & Discoveries - Observation: the current browser API client is still implemented through JavaScript interop, so the authenticated UI cannot simply move all startup work into `OnInitializedAsync`. Evidence: `RpgRoller/Components/RpgRollerApiClient.cs` calls `js.InvokeAsync("rpgRollerApi.request", ...)`, which means authenticated data fetches currently depend on an interactive render before they can run. - Observation: the current smoke suite encodes the old dual-purpose `/` behavior and will fail as soon as `/` becomes a redirect entry point. Evidence: the checked-in smoke coverage originally expected anonymous `GET /` to render static auth markup and authenticated `GET /` to render the Blazor workspace shell, so it had to be rewritten when `/` became a redirect entry point. - Observation: the current host test also encodes an outdated assumption about `/`. Evidence: `RpgRoller.Tests/Api/FrontendHostTests.cs` currently asserts that `GET /` returns HTTP 200 and a Blazor shell containing `_framework/blazor.web.js`. - Observation: `MapRazorComponents()` does not serve the static `/login` document unless a matching component route exists, even though `App.razor` itself renders the static auth markup outside the interactive router. Evidence: the first Milestone 1 host test run returned HTTP 404 for `GET /login` until a minimal `RpgRoller/Components/Pages/LoginPage.razor` with `@page "/login"` was added. - Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite. Evidence: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` failed in `HostingCoverageTests.InitializeRpgRollerState_MigratesCopiedDevelopmentDatabaseAndPreservesD6Rolling` because `RpgRoller/App_Data/rpgroller.development.db` is not present in the worktree. - Observation: once the route-owned components controlled their own modal and page subtree rendering, the extra shell-owned play-control staging was no longer necessary for the DOM-wrap smoke coverage. Evidence: after moving authenticated startup into a route-owned wrapper and rendering play controls directly, `node ./scripts/run-selenium.js` still passed the extension-like DOM-wrap coverage against `/play`. - Observation: the first Milestone 4 attempt was still incomplete because authenticated startup remained route-agnostic behind `Session.InitializeAsync()`. Evidence: `/admin` and `/play` could still hit the Firefox `insertBefore` circuit crash until admin and campaign-management routes stopped preloading play-only campaign scope, selected sheets, logs, and SSE startup during their first interactive batch. - Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it. Evidence: after route-scoping startup, Firefox still reported `There was an error applying batch 2` with `TypeError: can't access property "insertBefore", n.parentNode is null`, which motivated adding route render lifecycle logs plus browser-side workspace mutation snapshots. - Observation: the RoboForm-triggered crash happens before any component `OnAfterRenderAsync` callback in the authenticated route tree. Evidence: in the failing `/play` and `/admin` reproductions, the last server-side logs were only `OnInitialized` and `OnParametersSet` entries for `Workspace` and its immediate child components; there were no `WorkspaceRouteView.OnAfterRenderAsync` or `Workspace.InitializeRouteCoreAsync` entries before the circuit terminated. - Observation: the current app still matched the upstream "global interactivity" failure shape even after the route-first rewrite, because `App.razor` continued to apply `@rendermode` to the root `Routes` component. Evidence: `RpgRoller/Components/App.razor` still rendered `` until the final follow-up pass, while `dotnet/aspnetcore#58824` explicitly reports Firefox crashes for Global mode and says PerPage mode does not reproduce. - Observation: once the authenticated pages moved to per-page interactivity, header route navigation needed full document reloads instead of in-circuit `NavigationManager.NavigateTo` transitions. Evidence: the first Selenium run after the per-page render-mode change reached `/play` in the URL but never mounted `#skill-filter-input` after `/campaigns -> /play` until `Workspace.NavigateToRouteAsync` switched to `forceLoad: true`. - Observation: the phased first-render shells and browser/server diagnostics were not part of the final fix. Evidence: after the app switched to per-page interactive render modes plus manual `Blazor.start({ ssr: { disableDomPreservation: true } })`, the Firefox plus RoboForm repro stopped even after those extra mitigations were removed. - Observation: the locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control. Evidence: Playwright stalled during the `-juggler-pipe` handshake, while a `geckodriver` plus Selenium session against `/snap/firefox/current/usr/lib/firefox/firefox` completed the same Milestone 1 verification successfully. ## Decision Log - Decision: implement the approved route-first approach rather than continuing to add localized mitigations inside the current `/` workspace shell. Rationale: the user approved this direction after reviewing three refactor options, and `POSTMORTEM.md` concludes that the problem is architectural rather than a single bug. Date/Author: 2026-05-04 / Codex and user - Decision: keep the anonymous auth page as plain HTML and JavaScript, but move it to `/login` instead of restoring it as an interactive Blazor form. Rationale: the anonymous path was intentionally isolated from Blazor in commit `2d2ed56`, and the postmortem treats that isolation as a valid mitigation for the login surface. The rewrite should not reintroduce a form-heavy Blazor login page unless there is a compelling reason later. Date/Author: 2026-05-04 / Codex - Decision: make `/` a server-side redirect entry point instead of continuing to let `App.razor` choose between auth and workspace content based on request-time auth state. Rationale: `App.razor` is currently a hidden architecture boundary. Moving auth-based entry selection to an HTTP redirect makes the boundary explicit, testable, and smaller. Date/Author: 2026-05-04 / Codex - Decision: use the URL path as the source of truth for the current authenticated screen. Rationale: the current `screen` preference in `sessionStorage` causes the app state and the browser URL to disagree. Real routes remove that mismatch and make refresh, deep-linking, and testing simpler. Date/Author: 2026-05-04 / Codex - Decision: stop using global authenticated interactivity and move the authenticated pages to per-page `InteractiveServerRenderMode(prerender: false)` with manual startup that disables SSR DOM preservation. Rationale: upstream issue `dotnet/aspnetcore#58824` identifies Firefox failures tied to Global interactivity and explicitly notes that PerPage mode does not share the problem. The Blazor startup guidance also documents manual `Blazor.start` configuration for SSR options such as `disableDomPreservation`. Date/Author: 2026-05-04 / Codex - Decision: remove the phased render system and the crash-diagnostics scaffolding after the real fix was confirmed. Rationale: those changes were useful for isolating the failure, but they increased code complexity without contributing to the final Firefox plus RoboForm solution. Date/Author: 2026-05-05 / Codex - Decision: stage the rewrite in two layers: first introduce real routes while preserving existing feature behavior, then split the large workspace tree into route-owned subtrees. Rationale: the current workspace is dense and risk-prone. A staged rewrite keeps the app working while the route model changes, and it gives the test suite meaningful checkpoints. Date/Author: 2026-05-04 / Codex - Decision: standardize frontend smoke verification on geckodriver plus Selenium instead of Playwright in this repository. Rationale: the user updated the repo instructions to make Selenium the required browser automation path, and the locally installed Firefox stack works reliably through geckodriver while Playwright cannot control the Snap Firefox build on this machine. Date/Author: 2026-05-04 / Codex and user ## Outcomes & Retrospective At plan creation time, the repository has an updated README and a concrete implementation plan, but no code for the route-first rewrite has been started yet. The immediate risk is not uncertainty about direction; it is carrying old assumptions about `/`, `Home.razor`, and `sessionStorage`-based screen switching into the first code changes. The milestones below are written to make those assumptions explicit and retire them in an observable order. After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests to `/` are now redirected before Blazor renders, the static auth document lives at `/login`, and successful login lands on `/play`. The main residual risk is that the authenticated shell is still monolithic behind the new `/play` route, so later milestones still need to replace in-memory screen switching with real route ownership. After the Selenium migration iteration, the repository’s browser smoke coverage once again matches the documented verification path. The smoke suite now runs against Firefox through geckodriver, and the DOM-wrap regression coverage remains intact through a temporary test addon. The next risk is purely architectural again: the authenticated shell still uses in-memory screen switching, so Milestone 2 remains the next code change on the critical path. After Milestone 2, the authenticated shell now has first-class `/play`, `/campaigns`, and `/admin` routes, and the menu navigates with URLs instead of `sessionStorage` screen names. The remaining risk is now narrower and more structural: `Workspace.razor` still owns mutually exclusive authenticated branches, and the root `OnAfterRenderAsync` path still stages page-specific startup work that should move into route-owned components in Milestones 3 and 4. After Milestone 3, `Workspace.razor` is now a shell that owns shared chrome, health state, and toast feedback, while the play, campaign-management, and admin DOM each live in route-owned components supplied by `/play`, `/campaigns`, and `/admin`. The route split preserved the host tests and full Selenium smoke coverage, including the DOM-wrap regression case, but the final startup path is still staged through `Workspace.razor.cs` and remains the next target for Milestone 4. After Milestone 4, authenticated startup is now triggered by a route-owned wrapper instead of `Workspace.razor.cs`, the shared shell no longer uses `OnAfterRenderAsync`, and the play route renders its controls directly without shell-driven follow-up batches. The route-first rewrite is now functionally complete: host tests pass, the Selenium smoke suite passes, and the restored development-database fixture lets the backend coverage suite validate the full repo behavior again. Follow-up: the first pass at Milestone 4 removed shell-level `OnAfterRenderAsync`, but did not yet split `Session.InitializeAsync()` by route. The final follow-up fix made startup genuinely route-scoped by keeping `/admin` off play-only campaign scope and SSE startup, gating the full shell behind authenticated initialization, and adding direct `/admin` smoke coverage so this regression path stays visible. Follow-up 2: gating the entire authenticated shell behind `HasSessionInitialized` produced another large first-batch subtree swap and broke the `/campaigns` to `/play` refresh path. The final stabilization renders a consistent route skeleton from batch 1, derives loading UI from `HasSessionInitialized` instead of mutating shared loading state in `WorkspaceRouteView`, and refreshes route-specific scope explicitly when the same `Workspace` instance changes from one authenticated route to another. This section must be updated after each major milestone. When the implementation is complete, summarize which parts of the old workspace architecture were fully removed, which compatibility constraints remain, and whether the final startup path still depends on any multi-batch structural rendering. ## Context and Orientation The current app serves both anonymous and authenticated experiences from the same HTML shell. In `RpgRoller/Components/App.razor`, the shell checks the current request path through `HttpContext`. If the request is for `/login`, it renders `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor` as plain HTML and loads `RpgRoller/wwwroot/js/rpgroller-api.js`. That JavaScript file binds the login and registration forms and sends `fetch` requests to `/api/auth/register` and `/api/auth/login`. If a valid session cookie exists, `App.razor` instead renders the interactive Blazor router. The authenticated shell is now entered through `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, which map `/play`, `/campaigns`, and `/admin` and forward into the shared `Workspace` component. The authenticated workspace lives in `RpgRoller/Components/Pages/Workspace.razor` and `Workspace.razor.cs`. The Razor file still contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` still drives session initialization and staged control enablement. The current route now comes from the page component parameter rather than `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` no longer persists a screen name in browser `sessionStorage`. In plain language, a “route-first authenticated shell” means that the browser path decides which authenticated page is being rendered. `/play` means the play page. `/campaigns` means the campaign management page. `/admin` means the admin page. The URL is not a decorative detail; it is the primary way the app chooses the screen. Menu clicks change the URL. Reloading the page preserves the same screen because the URL already says what the screen is. In this repository, “server-side redirect” means an HTTP redirect response such as `302 Found` returned before any Blazor UI is rendered. For example, `GET /` should answer with a redirect to `/login` or `/play` based on whether the session cookie maps to a real user through `IGameService.GetUserBySession`. The API surface is already session-cookie-based. `RpgRoller/Api/AuthEndpoints.cs` sets the session cookie on login, `RpgRoller/Api/MeEndpoints.cs` returns the authenticated user model, and the rest of the authenticated `/api` routes are behind `RequireSessionTokenFilter`. This means the routing rewrite does not need a new auth system. It needs a clearer frontend entry structure and smaller authenticated page ownership boundaries. One constraint must be kept in mind from the start: `RpgRoller/Components/RpgRollerApiClient.cs` performs requests through JavaScript interop. That means the authenticated UI still needs an interactive render before it can make its first data request. The rewrite must therefore reduce the amount of structure that changes after interactivity begins, not pretend that interactivity can be avoided entirely with the current client stack. ## Plan of Work Begin by separating the entry route from the anonymous auth page. Add a small host-level endpoint module, for example `RpgRoller/Api/FrontendEntryEndpoints.cs`, or an equivalent hosting extension, and map `GET /` before the Razor component host is mapped in `RpgRoller/Program.cs`. This endpoint must read the session cookie using the same cookie name defined in `RpgRoller/Api/SessionCookie.cs`, ask `IGameService` whether the cookie belongs to a real user, and return a redirect to `/play` for authenticated users or `/login` for anonymous users. This removes the dual-purpose `/` behavior. Next, simplify `RpgRoller/Components/App.razor` so it no longer chooses between anonymous and authenticated content based on auth state. It may still choose between a static `/login` document and the interactive authenticated router based on the request path, because the anonymous page is intentionally plain HTML. The important change is that auth-state branching moves out of the component tree. `App.razor` should become a stable host for either the static login document at `/login` or the interactive authenticated route set everywhere else. After that, introduce real component routes for the authenticated pages. Create `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`, each with an explicit `@page` directive. In the first implementation pass, it is acceptable to keep much of the existing state and coordinator logic by adapting `Workspace` so each route uses only the subtree it needs. The key result of this milestone is that the URL changes from `/play` to `/campaigns` to `/admin` and each route refreshes correctly. Once the routes exist, remove `screen` as a persistence and navigation concept. Delete the `CurrentScreen` routing responsibility from `WorkspaceState.cs` and remove the `screen` `sessionStorage` reads and writes from `WorkspaceSessionCoordinator.cs`. Replace menu items in `Workspace.razor.cs` and `AppHeader.razor` wiring so they navigate through `NavigationManager.NavigateTo(...)` or plain links to `/play`, `/campaigns`, and `/admin`. Keep `sessionStorage` only for true view preferences such as mobile panel state, selected campaign when appropriate, and roll visibility if those still earn their complexity. The next pass is the structural split. Extract the common authenticated chrome into a dedicated component such as `RpgRoller/Components/Pages/AuthenticatedShell.razor`. This shared shell should own the header, logout action, health banner, and toast stack. Then move the play-only DOM, campaign management DOM, and admin DOM out of the monolithic conditional branches in `Workspace.razor` and into page-specific route components. `PlayPage` should own SSE startup and the play-specific panels. `CampaignsPage` should own character create and edit workflows. `AdminPage` should own admin-only data loading and buttons. The goal is that each route owns a smaller and more stable subtree, rather than all authenticated screens living under one branching root. Finally, revisit startup sequencing. Because API reads still depend on JS interop, some post-render initialization may remain necessary, but it should be limited to the page that actually needs it. Remove the pattern where the authenticated shell root performs several structural follow-up renders merely to decide which screen to show. If staged initialization remains on `/play`, it should be contained to the play page and should reveal a stable page-local loading shell rather than reshaping the entire authenticated app. Record the exact remaining `OnAfterRenderAsync` responsibilities in the code and in `README.md`. Throughout the rewrite, keep the documentation and tests aligned. `README.md` must stop describing the rewrite as planned once the code lands, and the host and smoke tests must verify the new route-first behavior rather than preserve the old root-path assumptions. ## Milestones ### Milestone 1: Make `/` An Explicit Entry Redirect At the end of this milestone, a browser request to `/` no longer renders either the auth page or the workspace directly. Instead, the server returns a redirect to `/login` or `/play` based on the session cookie. The anonymous auth page is reachable at `/login`, and logging in transitions the user to `/play`. Implement this by adding the new entry endpoint mapping, updating `App.razor` to host `/login` without auth-state branching, and changing `rpgroller-api.js` so successful login goes to `/play` rather than `/`. Also update `Home.razor.cs` or its replacement logout helper so logout navigates to `/login` with the existing status message query behavior. Proof for this milestone is simple and observable. Anonymous `GET /` returns a redirect to `/login`. Authenticated `GET /` returns a redirect to `/play`. Opening `/login` renders the current static auth markup without loading `_framework/blazor.web.js`. Logging in from `/login` lands on the play workspace. ### Milestone 2: Add Real Authenticated Routes Without Breaking Features At the end of this milestone, `/play`, `/campaigns`, and `/admin` all exist as first-class routes, and the hamburger menu moves between them using URLs rather than `sessionStorage`. Feature behavior may still be backed by some shared workspace code, but the route model is now real. Implement this by creating the new page components and adapting the current workspace logic so the correct route renders the correct content. During this milestone it is acceptable to keep a shared backing component or service if that reduces churn, but the URL must be the authoritative screen selection mechanism. Direct navigation to `/campaigns` should show campaign management, not the play screen followed by an in-memory switch. Direct navigation to `/admin` should either show the admin page for admins or redirect non-admin users to `/play`. Acceptance for this milestone is that refreshing `/campaigns` leaves the user on `/campaigns`, refreshing `/play` leaves the user on `/play`, and opening `/admin` as a non-admin does not expose admin controls. ### Milestone 3: Split The Monolithic Workspace Tree At the end of this milestone, there is no longer a single authenticated component that conditionally renders all major screens under one branch-heavy root. Shared authenticated chrome is extracted, and each route owns its own main content subtree. Implement this by introducing a shared authenticated shell component and moving the play, campaign management, and admin markup and page-specific coordination into route-owned components. Keep shared models and helper methods where they still make sense, but stop letting the root workspace decide which major screen exists in the DOM. If common state still exists, narrow it to user identity, selected campaign context, and shared feedback only. Acceptance for this milestone is partly structural and partly behavioral. Structurally, `Workspace.razor` should no longer contain mutually exclusive branches for play, management, and admin screens. Behaviorally, the DOM-wrap smoke test or its replacement should still pass while each route loads only the controls it needs. ### Milestone 4: Reduce Startup Churn And Finalize Docs At the end of this milestone, the authenticated shell no longer uses `OnAfterRenderAsync` as the orchestration point for screen selection and broad structural staging. Any remaining post-render work is page-local, justified, and documented. Implement this by moving any remaining screen-routing or shell-bootstrap logic out of `Workspace.razor.cs`, narrowing `OnAfterRenderAsync` responsibilities, and updating `README.md` to describe the completed route-first architecture rather than a planned rewrite. Also update `POSTMORTEM.md` only if a concise follow-up note is warranted; do not rewrite its historical analysis. Acceptance for this milestone is a passing automated suite plus a manual browser run where `/`, `/login`, `/play`, `/campaigns`, and `/admin` all behave consistently with the final route model. ## Concrete Steps Run all commands from the repository root, which is `/home/frank/Code/RpgRoller`. Start by inspecting the current route and auth files before editing: sed -n '1,220p' RpgRoller/Components/App.razor sed -n '1,220p' RpgRoller/Components/Pages/Home.razor sed -n '1,260p' RpgRoller/Components/Pages/Workspace.razor.cs sed -n '1,260p' RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs sed -n '1,260p' RpgRoller/wwwroot/js/rpgroller-api.js sed -n '1,220p' RpgRoller.Tests/Api/FrontendHostTests.cs sed -n '1,260p' tests/e2e/smoke.js When implementing Milestone 1, update the host test first so the intended redirect behavior is explicit: dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --filter FrontendHostTests Expected direction after the edits: RootPath_RedirectsToLogin_WhenAnonymous RootPath_RedirectsToPlay_WhenAuthenticated LoginPath_ServesStaticAuthMarkup After wiring `/login` and the root redirect, run the app locally: dotnet run --project RpgRoller/RpgRoller.csproj Then verify in a browser: open http://localhost:5000/ observe: anonymous request lands on /login submit valid credentials observe: browser lands on /play When implementing route pages and navigation, prefer running the focused smoke suite against a temporary database: node ./scripts/run-selenium.js If the app is already running and a faster inner loop is needed, run the checked-in smoke file directly: npm run e2e:smoke After each milestone that touches C# files, run the relevant test suite and then the full backend suite before moving on: dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings After major frontend milestones, repeat browser verification in Firefox. If a Firefox profile with RoboForm is available, include that manual check and record the result in `Surprises & Discoveries` or `Outcomes & Retrospective`. ## Validation and Acceptance The final implementation is acceptable only if all of the following behaviors are true and visible. Anonymous navigation: `GET /` returns an HTTP redirect to `/login`. Opening `/login` shows the static auth document with the current register and login forms. The `/login` document must not load `_framework/blazor.web.js`, and it must still include the existing auth page hooks used by `rpgroller-api.js`. Authenticated navigation: After a successful login, the browser lands on `/play`. Opening `/` with an already valid session cookie redirects to `/play`. Refreshing `/play`, `/campaigns`, or `/admin` preserves the same route instead of rebuilding everything behind `/`. Menu behavior: The header menu items navigate to real routes. The active state matches the current route. Non-admin users cannot remain on `/admin`; they are redirected to `/play` or shown a deliberate authorization result defined by the implementation, but not an exposed admin UI. Workspace stability: The authenticated play route continues to support the existing play workflow, including campaign log rendering, character controls, and custom roll actions. The DOM-wrap smoke coverage for extension-like mutations must still pass, either through the existing test or an updated equivalent that targets `/play`. Automated coverage: `dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings` passes. `node ./scripts/run-selenium.js` passes against a temporary SQLite database. If any previous tests are deleted or renamed because they encoded the old `/` behavior, replace them with tests that prove the new route model instead of simply removing coverage. ## Idempotence and Recovery This rewrite should be implemented as a sequence of additive, testable steps. Each milestone must leave the app runnable and the tests meaningful. Avoid a large flag day where `/` is changed, the route pages half-exist, and the smoke suite is left broken for an extended period. The safest recovery strategy is to keep the current workspace internals temporarily while introducing the new route model. That means it is acceptable to reuse `Workspace` behind the new page routes during Milestone 2, as long as the route behavior is correct and clearly transitional. After that, extract route-specific subtrees in Milestone 3. When changing redirects or login targets, update the host and Selenium assertions in the same commit as the code change so the repository never has code and tests describing different route contracts. Use a temporary SQLite database for Selenium verification, as required by the repo instructions, so browser tests do not mutate the canonical development database. ## Artifacts and Notes Current evidence that must be retired by this rewrite: RpgRoller.Tests/Api/FrontendHostTests.cs Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Contains("_framework/blazor.web.js", html); tests/e2e/smoke.js browser checks for anonymous `/`, static `/login`, authenticated `/`, and the authenticated workspace flows Current evidence that explains the bootstrap constraint: RpgRoller/Components/RpgRollerApiClient.cs var response = await js.InvokeAsync("rpgRollerApi.request", method, path, payload); Current evidence that Milestone 2 is intentionally transitional rather than final: RpgRoller/Components/Pages/Workspace.razor @if (IsPlayRoute) { ... } else if (IsCampaignsRoute) { ... } else if (IsAdminRoute) { ... } ## Interfaces and Dependencies The implementation must continue to use the existing ASP.NET Core hosting model in `RpgRoller/Program.cs`, the minimal API auth surface in `RpgRoller/Api`, and the existing `IGameService` session lookup methods. Do not introduce a second auth mechanism. At the end of Milestone 1, the codebase must contain a host-level entry point with behavior equivalent to: GET / if session cookie maps to a valid user: redirect to /play otherwise: redirect to /login At the end of Milestone 2, the codebase must contain route components equivalent to: /play /campaigns /admin Each of those routes must be directly navigable and refreshable. At the end of Milestone 3, the codebase must contain a shared authenticated shell component or layout that owns common header and feedback concerns, while route pages own their feature-specific DOM. Stable names are recommended: RpgRoller/Components/Pages/AuthenticatedShell.razor RpgRoller/Components/Pages/PlayPage.razor RpgRoller/Components/Pages/CampaignsPage.razor RpgRoller/Components/Pages/AdminPage.razor These exact filenames are recommended because they make the route split obvious to a new contributor, but equivalent names are acceptable if the same ownership boundaries are preserved and the README is updated accordingly. ## Revision Note 2026-05-04 17:52Z: Initial ExecPlan created after the route-first rewrite direction was approved and the README was overhauled. The main reason for the plan is to replace the dual-purpose `/` shell with explicit routes while keeping the repository testable at every step.