refactor: finish route-first shell
This commit is contained in:
45
README.md
45
README.md
@@ -6,7 +6,7 @@ RpgRoller is an ASP.NET Core and Blazor Server app for lightweight tabletop camp
|
|||||||
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration paths
|
- `RpgRoller.Tests/`: xUnit coverage for API behavior, services, hosting, payload budgets, and persistence and migration paths
|
||||||
- `RpgRoller.sln`: solution used by local development and repo scripts
|
- `RpgRoller.sln`: solution used by local development and repo scripts
|
||||||
- `POSTMORTEM.md`: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspace
|
- `POSTMORTEM.md`: architecture analysis of the May 2026 Firefox and RoboForm failure in the authenticated workspace
|
||||||
- `TASKS.md`: the current execution plan for the approved frontend routing rewrite
|
- `TASKS.md`: the completed execution log for the route-first authenticated shell rewrite
|
||||||
|
|
||||||
Test layout:
|
Test layout:
|
||||||
|
|
||||||
@@ -36,14 +36,17 @@ Backend:
|
|||||||
|
|
||||||
Frontend:
|
Frontend:
|
||||||
|
|
||||||
- `RpgRoller/Components/App.razor`: current HTML shell and the request-time branch that decides whether `/` serves the static auth page or the interactive app
|
- `RpgRoller/Components/App.razor`: HTML shell that serves the static `/login` auth document or the interactive route set based on request path
|
||||||
- `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup
|
- `RpgRoller/Components/Routes.razor`: Blazor router and layout hookup
|
||||||
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
|
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
|
||||||
- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document
|
- `RpgRoller/Components/Pages/LoginPage.razor`: route marker for the static `/login` auth document
|
||||||
- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace
|
- `RpgRoller/Components/Pages/PlayPage.razor`, `CampaignsPage.razor`, and `AdminPage.razor`: authenticated route entry points for the interactive workspace
|
||||||
- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages
|
- `RpgRoller/Components/Pages/AuthenticatedPageBase.cs`: shared logout-to-`/login` redirect helper for authenticated route pages
|
||||||
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated workspace UI with play, campaign management, admin, toasts, and modals
|
- `RpgRoller/Components/Pages/Workspace.razor`: authenticated shell with shared header, health banner, toast stack, and route-owned body slot
|
||||||
- `RpgRoller/Components/Pages/Workspace.razor.cs`: workspace composition root, lifecycle, coordinator wiring, JS-invokable entry points, and menu item construction
|
- `RpgRoller/Components/Pages/Workspace.razor.cs`: shell composition root, coordinator wiring, route initialization entry point, JS-invokable state-event hooks, and menu item construction
|
||||||
|
- `RpgRoller/Components/Pages/WorkspaceRouteView.razor`: route-local first-render bootstrapper that initializes the interactive workspace after the page mounts
|
||||||
|
- `RpgRoller/Components/Pages/PlayWorkspaceContent.razor`, `CampaignsWorkspaceContent.razor`, and `AdminWorkspaceContent.razor`: route-owned authenticated page subtrees
|
||||||
|
- `RpgRoller/Components/Pages/CharacterManagementModals.razor`: shared create and edit character modals used by play and campaign-management routes
|
||||||
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
|
- `RpgRoller/Components/Pages/WorkspaceState.cs`: workspace UI state plus pure computed and formatting projections used directly by the Razor view
|
||||||
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace`
|
- `RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs`, `WorkspaceCampaignCoordinator.cs`, `WorkspaceCampaignScopeCoordinator.cs`, `WorkspacePlayCoordinator.cs`, `WorkspaceAdminCoordinator.cs`, `WorkspaceLiveStateController.cs`, `WorkspaceFeedbackService.cs`, and `WorkspaceToast.cs`: session bootstrap, campaign scope, play and log, admin, live update, and toast concerns used by `Workspace`
|
||||||
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
|
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
|
||||||
@@ -55,8 +58,8 @@ Frontend:
|
|||||||
|
|
||||||
Current repo note:
|
Current repo note:
|
||||||
|
|
||||||
- `POSTMORTEM.md` documents why the current authenticated workspace architecture is fragile and why the next major frontend change is a route-first rewrite of the authenticated shell.
|
- `POSTMORTEM.md` documents why the previous authenticated workspace architecture was fragile under Firefox plus RoboForm.
|
||||||
- `TASKS.md` is the authoritative execution plan for that rewrite and must be kept current while the work proceeds.
|
- `TASKS.md` records the route-first rewrite that addressed that architecture.
|
||||||
|
|
||||||
## Runtime and Persistence
|
## Runtime and Persistence
|
||||||
|
|
||||||
@@ -95,26 +98,29 @@ Rolemaster support:
|
|||||||
|
|
||||||
## Current Frontend Architecture
|
## Current Frontend Architecture
|
||||||
|
|
||||||
The current frontend is in an intermediate state that was created while mitigating the Firefox and RoboForm failure documented in `POSTMORTEM.md`.
|
The frontend now uses a route-first authenticated shell that keeps the anonymous auth document outside the interactive Blazor subtree.
|
||||||
|
|
||||||
Today, `/` is an auth-aware entry redirect:
|
`/` is an auth-aware entry redirect:
|
||||||
|
|
||||||
- anonymous `GET /` redirects to `/login`
|
- anonymous `GET /` redirects to `/login`
|
||||||
- authenticated `GET /` redirects to `/play`
|
- authenticated `GET /` redirects to `/play`
|
||||||
- `RpgRoller/Components/App.razor` still decides between the static `/login` document and the interactive route set based on the request path, not auth state
|
- `RpgRoller/Components/App.razor` serves the static `/login` document or the interactive route set based on the request path, not auth state
|
||||||
|
|
||||||
Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are now real Blazor routes, and the hamburger menu navigates between those URLs. The interactive shell is still structurally transitional, because `Workspace.razor` continues to own all three major authenticated subtrees behind one component.
|
Inside the authenticated app, `/play`, `/campaigns`, and `/admin` are real Blazor routes, and the hamburger menu navigates between those URLs. `Workspace.razor` is now a shared shell only. Each authenticated route owns its own main content subtree through a route-specific component.
|
||||||
|
|
||||||
This architecture works functionally but remains structurally fragile because:
|
Interactive bootstrap is now route-local:
|
||||||
|
|
||||||
- the HTML shell still branches on request path to keep `/login` static
|
- `WorkspaceRouteView.razor` performs the first-render JS-dependent session initialization for the authenticated route that mounted
|
||||||
- the authenticated workspace still performs staged startup in `OnAfterRenderAsync`
|
- `Workspace.razor.cs` no longer uses `OnAfterRenderAsync` as the shell bootstrap orchestrator
|
||||||
- the app coordinates state across Blazor component state, browser `sessionStorage`, `fetch`, and SSE during early startup
|
- play-specific post-render behavior is limited to page-local controls such as log auto-scroll and modal autofocus inside child components
|
||||||
- the shared `Workspace` component still conditionally renders play, campaign management, and admin DOM instead of letting each route own its own subtree
|
|
||||||
|
|
||||||
## Approved Rewrite Direction
|
Remaining architectural constraints are deliberate:
|
||||||
|
|
||||||
The approved remediation direction is a route-first authenticated shell:
|
- `/login` stays plain HTML plus JavaScript so the anonymous auth path avoids Blazor form ownership entirely
|
||||||
|
- authenticated reads and writes still depend on JS interop-backed `fetch`, so first interactive initialization must still happen after mount
|
||||||
|
- live updates still use SSE and route-aware synchronization, with `/play` as the only route that keeps the play log and selected character sheet live
|
||||||
|
|
||||||
|
## Route-First Authenticated Shell
|
||||||
|
|
||||||
- `/` becomes an auth-aware entry point that redirects to `/login` or `/play`
|
- `/` becomes an auth-aware entry point that redirects to `/login` or `/play`
|
||||||
- `/login` hosts the anonymous auth experience
|
- `/login` hosts the anonymous auth experience
|
||||||
@@ -123,7 +129,7 @@ The approved remediation direction is a route-first authenticated shell:
|
|||||||
- SSE and heavy play bootstrap stay scoped to `/play`
|
- SSE and heavy play bootstrap stay scoped to `/play`
|
||||||
- the large `Workspace` component is split so each route owns a smaller, more stable subtree
|
- the large `Workspace` component is split so each route owns a smaller, more stable subtree
|
||||||
|
|
||||||
This rewrite is not complete yet. Follow `TASKS.md` for the execution plan.
|
This rewrite is complete. See `TASKS.md` for the execution history and milestone notes.
|
||||||
|
|
||||||
## Local Development
|
## Local Development
|
||||||
|
|
||||||
@@ -184,9 +190,10 @@ SQLite migration rule:
|
|||||||
|
|
||||||
## Frontend Runtime
|
## Frontend Runtime
|
||||||
|
|
||||||
- The UI currently runs as Blazor Server with interactive components for the authenticated workspace and a plain HTML plus JavaScript auth page for anonymous users at `/`.
|
- The UI runs as Blazor Server for authenticated routes and as plain HTML plus JavaScript for the anonymous `/login` document.
|
||||||
- Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs.
|
- Static assets are linked through Blazor's `@Assets[...]` pipeline for fingerprinted cache-busting URLs.
|
||||||
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
|
- Workspace reads are resolved through API requests in `WorkspaceQueryService`; browser interop stays focused on auth forms, session storage, SSE wiring, and small DOM helpers.
|
||||||
|
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
|
||||||
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
|
- Live workspace refresh compares separate roster, per-character sheet, and log versions so unrelated changes do not trigger full reloads.
|
||||||
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
|
- Campaign log data is loaded in bounded slices: campaign summaries, one selected roster, one selected character sheet, and a 25-row incremental log window from `/api/campaigns/{campaignId}/log/page`.
|
||||||
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
|
- Log rows return compact summary data first and lazy-load full detail from `/api/rolls/{rollId}` when expanded.
|
||||||
|
|||||||
BIN
RpgRoller/App_Data/rpgroller.development.db
Normal file
BIN
RpgRoller/App_Data/rpgroller.development.db
Normal file
Binary file not shown.
@@ -2,6 +2,10 @@
|
|||||||
@inherits AuthenticatedPageBase
|
@inherits AuthenticatedPageBase
|
||||||
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync">
|
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync">
|
||||||
<ChildContent Context="workspace">
|
<ChildContent Context="workspace">
|
||||||
<AdminWorkspaceContent Workspace="workspace"/>
|
<WorkspaceRouteView Workspace="workspace">
|
||||||
|
<ChildContent Context="readyWorkspace">
|
||||||
|
<AdminWorkspaceContent Workspace="readyWorkspace"/>
|
||||||
|
</ChildContent>
|
||||||
|
</WorkspaceRouteView>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
@inherits AuthenticatedPageBase
|
@inherits AuthenticatedPageBase
|
||||||
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync">
|
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync">
|
||||||
<ChildContent Context="workspace">
|
<ChildContent Context="workspace">
|
||||||
<CampaignsWorkspaceContent Workspace="workspace"/>
|
<WorkspaceRouteView Workspace="workspace">
|
||||||
|
<ChildContent Context="readyWorkspace">
|
||||||
|
<CampaignsWorkspaceContent Workspace="readyWorkspace"/>
|
||||||
|
</ChildContent>
|
||||||
|
</WorkspaceRouteView>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -82,8 +82,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
<section class="custom-roll-panel" aria-label="Custom roll panel">
|
||||||
@if (EnableCustomRollComposer)
|
|
||||||
{
|
|
||||||
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
||||||
<div class="custom-roll-composer-head">
|
<div class="custom-roll-composer-head">
|
||||||
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
||||||
@@ -109,16 +107,5 @@
|
|||||||
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
||||||
}
|
}
|
||||||
</form>
|
</form>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="custom-roll-composer">
|
|
||||||
<div class="custom-roll-composer-head">
|
|
||||||
<span class="custom-roll-label">Custom roll</span>
|
|
||||||
<span class="muted">@CustomRollStatusText</span>
|
|
||||||
</div>
|
|
||||||
<p class="field-help">Loading roll composer...</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -171,8 +171,6 @@ public partial class CampaignLogPanel
|
|||||||
|
|
||||||
[Parameter] public string RollVisibility { get; set; } = "public";
|
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||||
|
|
||||||
[Parameter] public bool EnableCustomRollComposer { get; set; }
|
|
||||||
|
|
||||||
[Parameter] public bool IsMutating { get; set; }
|
[Parameter] public bool IsMutating { get; set; }
|
||||||
|
|
||||||
[Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
|
[Parameter] public EventCallback<RollResult> CustomRollCreated { get; set; }
|
||||||
|
|||||||
@@ -49,8 +49,6 @@
|
|||||||
</span>
|
</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class="skill-filter-wrap">
|
<div class="skill-filter-wrap">
|
||||||
@if (EnableInteractiveControls)
|
|
||||||
{
|
|
||||||
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
<label class="sr-only" for="skill-filter-input">Filter skills</label>
|
||||||
<input id="skill-filter-input"
|
<input id="skill-filter-input"
|
||||||
class="skill-filter-input"
|
class="skill-filter-input"
|
||||||
@@ -58,25 +56,13 @@
|
|||||||
placeholder="Filter skills"
|
placeholder="Filter skills"
|
||||||
@bind="SkillFilterText"
|
@bind="SkillFilterText"
|
||||||
@bind:event="oninput"/>
|
@bind:event="oninput"/>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="muted">Loading skill controls...</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="chip-toolbar">
|
<div class="chip-toolbar">
|
||||||
@if (EnableInteractiveControls)
|
|
||||||
{
|
|
||||||
<label class="visibility-control" for="roll-visibility">Visibility</label>
|
<label class="visibility-control" for="roll-visibility">Visibility</label>
|
||||||
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
|
<select id="roll-visibility" value="@(RollVisibility == "private" ? "private" : "public")" @onchange="OnRollVisibilityChangedAsync">
|
||||||
<option value="public">Public</option>
|
<option value="public">Public</option>
|
||||||
<option value="private">Private</option>
|
<option value="private">Private</option>
|
||||||
</select>
|
</select>
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<p class="muted">Visibility: @(RollVisibility == "private" ? "Private" : "Public")</p>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@{
|
@{
|
||||||
|
|||||||
@@ -370,8 +370,6 @@ public partial class CharacterPanel
|
|||||||
|
|
||||||
[Parameter] public string RollVisibility { get; set; } = "public";
|
[Parameter] public string RollVisibility { get; set; } = "public";
|
||||||
|
|
||||||
[Parameter] public bool EnableInteractiveControls { get; set; }
|
|
||||||
|
|
||||||
[Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
|
[Parameter] public EventCallback<string> RollVisibilityChanged { get; set; }
|
||||||
|
|
||||||
[Parameter] public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
[Parameter] public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
@inherits AuthenticatedPageBase
|
@inherits AuthenticatedPageBase
|
||||||
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync">
|
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync">
|
||||||
<ChildContent Context="workspace">
|
<ChildContent Context="workspace">
|
||||||
<PlayWorkspaceContent Workspace="workspace"/>
|
<WorkspaceRouteView Workspace="workspace">
|
||||||
|
<ChildContent Context="readyWorkspace">
|
||||||
|
<PlayWorkspaceContent Workspace="readyWorkspace"/>
|
||||||
|
</ChildContent>
|
||||||
|
</WorkspaceRouteView>
|
||||||
</ChildContent>
|
</ChildContent>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
|
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
|
||||||
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
RollVisibility="Workspace.State.RollVisibility"
|
RollVisibility="Workspace.State.RollVisibility"
|
||||||
EnableInteractiveControls="Workspace.EnableCharacterControls"
|
|
||||||
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
|
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
|
||||||
OwnerLabel="Workspace.State.OwnerLabel"
|
OwnerLabel="Workspace.State.OwnerLabel"
|
||||||
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
|
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
|
||||||
@@ -38,7 +37,6 @@
|
|||||||
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
|
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
|
||||||
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
RollVisibility="Workspace.State.RollVisibility"
|
RollVisibility="Workspace.State.RollVisibility"
|
||||||
EnableCustomRollComposer="Workspace.EnableCustomRollComposer"
|
|
||||||
IsMutating="Workspace.State.IsMutating"
|
IsMutating="Workspace.State.IsMutating"
|
||||||
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
|
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
|
||||||
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
|
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
|
||||||
|
|||||||
@@ -14,34 +14,6 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
State.IsScreenMenuOpen = false;
|
State.IsScreenMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
State.HasInteractiveRenderStarted = true;
|
|
||||||
if (firstRender)
|
|
||||||
{
|
|
||||||
await Session.InitializeAsync();
|
|
||||||
HasSessionInitialized = true;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!HasSessionInitialized)
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (!EnableCharacterControls)
|
|
||||||
{
|
|
||||||
EnableCharacterControls = true;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (EnableCustomRollComposer)
|
|
||||||
return;
|
|
||||||
|
|
||||||
EnableCustomRollComposer = true;
|
|
||||||
await InvokeAsync(StateHasChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
[JSInvokable]
|
[JSInvokable]
|
||||||
public Task OnStateEventReceived(CampaignStateSnapshot state)
|
public Task OnStateEventReceived(CampaignStateSnapshot state)
|
||||||
{
|
{
|
||||||
@@ -126,6 +98,22 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return InvokeAsync(StateHasChanged);
|
return InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task InitializeRouteAsync()
|
||||||
|
{
|
||||||
|
return InitializationTask ??= InitializeRouteCoreAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitializeRouteCoreAsync()
|
||||||
|
{
|
||||||
|
if (HasSessionInitialized)
|
||||||
|
return;
|
||||||
|
|
||||||
|
State.HasInteractiveRenderStarted = true;
|
||||||
|
await Session.InitializeAsync();
|
||||||
|
HasSessionInitialized = true;
|
||||||
|
await RequestRefreshAsync();
|
||||||
|
}
|
||||||
|
|
||||||
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
|
||||||
{
|
{
|
||||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -145,16 +133,14 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
private WorkspaceState State { get; } = new();
|
private WorkspaceState State { get; } = new();
|
||||||
private bool HasSessionInitialized { get; set; }
|
private bool HasSessionInitialized { get; set; }
|
||||||
private bool EnableCharacterControls { get; set; }
|
|
||||||
private bool EnableCustomRollComposer { get; set; }
|
|
||||||
private bool IsPlayRoute => Route == WorkspaceRoute.Play;
|
private bool IsPlayRoute => Route == WorkspaceRoute.Play;
|
||||||
private bool IsCampaignsRoute => Route == WorkspaceRoute.Campaigns;
|
private bool IsCampaignsRoute => Route == WorkspaceRoute.Campaigns;
|
||||||
private bool IsAdminRoute => Route == WorkspaceRoute.Admin;
|
private bool IsAdminRoute => Route == WorkspaceRoute.Admin;
|
||||||
private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app";
|
private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app";
|
||||||
|
|
||||||
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
|
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
|
||||||
RequestRefreshAsync, EnableCharacterControls, EnableCustomRollComposer, AdminDatabaseDownloadUrl,
|
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems,
|
||||||
HeaderMenuItems, IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
|
IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
|
||||||
|
|
||||||
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
|
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
|
||||||
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
|
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
|
||||||
@@ -231,4 +217,5 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
|
|
||||||
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
private WorkspaceCampaignScopeCoordinator? m_Scope;
|
||||||
private WorkspaceSessionCoordinator? m_Session;
|
private WorkspaceSessionCoordinator? m_Session;
|
||||||
|
private Task? InitializationTask { get; set; }
|
||||||
}
|
}
|
||||||
@@ -9,9 +9,9 @@ public sealed class WorkspacePageContext(
|
|||||||
WorkspaceAdminCoordinator admin,
|
WorkspaceAdminCoordinator admin,
|
||||||
WorkspaceCampaignScopeCoordinator scope,
|
WorkspaceCampaignScopeCoordinator scope,
|
||||||
WorkspaceSessionCoordinator session,
|
WorkspaceSessionCoordinator session,
|
||||||
|
Func<Task> initializeRouteAsync,
|
||||||
|
bool hasSessionInitialized,
|
||||||
Func<Task> requestRefreshAsync,
|
Func<Task> requestRefreshAsync,
|
||||||
bool enableCharacterControls,
|
|
||||||
bool enableCustomRollComposer,
|
|
||||||
string adminDatabaseDownloadUrl,
|
string adminDatabaseDownloadUrl,
|
||||||
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
|
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
|
||||||
bool isPlayRoute,
|
bool isPlayRoute,
|
||||||
@@ -24,9 +24,9 @@ public sealed class WorkspacePageContext(
|
|||||||
public WorkspaceAdminCoordinator Admin { get; } = admin;
|
public WorkspaceAdminCoordinator Admin { get; } = admin;
|
||||||
public WorkspaceCampaignScopeCoordinator Scope { get; } = scope;
|
public WorkspaceCampaignScopeCoordinator Scope { get; } = scope;
|
||||||
public WorkspaceSessionCoordinator Session { get; } = session;
|
public WorkspaceSessionCoordinator Session { get; } = session;
|
||||||
|
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
|
||||||
|
public bool HasSessionInitialized { get; } = hasSessionInitialized;
|
||||||
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
|
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
|
||||||
public bool EnableCharacterControls { get; } = enableCharacterControls;
|
|
||||||
public bool EnableCustomRollComposer { get; } = enableCustomRollComposer;
|
|
||||||
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
|
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
|
||||||
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
|
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
|
||||||
public bool IsPlayRoute { get; } = isPlayRoute;
|
public bool IsPlayRoute { get; } = isPlayRoute;
|
||||||
|
|||||||
21
RpgRoller/Components/Pages/WorkspaceRouteView.razor
Normal file
21
RpgRoller/Components/Pages/WorkspaceRouteView.razor
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
@using Microsoft.AspNetCore.Components
|
||||||
|
|
||||||
|
@if (Workspace.HasSessionInitialized)
|
||||||
|
{
|
||||||
|
@ChildContent(Workspace)
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await Workspace.InitializeRouteAsync();
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public WorkspacePageContext Workspace { get; set; } = null!;
|
||||||
|
|
||||||
|
[Parameter, EditorRequired] public RenderFragment<WorkspacePageContext> ChildContent { get; set; } = null!;
|
||||||
|
}
|
||||||
7
TASKS.md
7
TASKS.md
@@ -21,7 +21,7 @@ The change is complete when a human can run the app, open `/`, observe the corre
|
|||||||
- [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) 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) 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: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.
|
||||||
- [ ] Reduce `OnAfterRenderAsync` to the smallest practical scope and keep staged startup out of the authenticated shell root.
|
- [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) Updated host tests, Selenium smoke tests, and docs so the real-route model is the documented and verified Milestone 2 behavior.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
@@ -41,6 +41,9 @@ The change is complete when a human can run the app, open `/`, observe the corre
|
|||||||
- Observation: the repository-wide backend suite currently contains a missing-fixture failure unrelated to the route-first rewrite.
|
- 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.
|
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 locally installed Snap Firefox build on this machine is viable for Selenium through `geckodriver`, but not for Playwright protocol control.
|
- 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.
|
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.
|
||||||
|
|
||||||
@@ -82,6 +85,8 @@ After Milestone 2, the authenticated shell now has first-class `/play`, `/campai
|
|||||||
|
|
||||||
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 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.
|
||||||
|
|
||||||
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.
|
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
|
## Context and Orientation
|
||||||
|
|||||||
Reference in New Issue
Block a user