Implement milestone 2 route navigation

This commit is contained in:
2026-05-04 21:23:45 +02:00
parent c13a2ce7c7
commit def2a3f680
21 changed files with 334 additions and 193 deletions

View File

@@ -39,13 +39,14 @@ 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/Routes.razor`: Blazor router and layout hookup
- `RpgRoller/Components/Layout/MainLayout.razor`: default layout
- `RpgRoller/Components/Pages/Home.razor`: current root route component for `/`; it only renders `Workspace`
- `RpgRoller/Components/Pages/Home.razor.cs`: logout navigation helper that force-loads `/` and carries auth status query text
- `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/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.cs`: workspace composition root, lifecycle, coordinator wiring, JS-invokable entry points, and menu item construction
- `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/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used when `/` is requested without a valid session
- `RpgRoller/Components/Pages/HomeControls/StaticAuthPage.razor`: plain HTML login and registration page used at `/login`
- `RpgRoller/Components/Pages/HomeControls/`: workspace child components, forms, header, panels, and modal controls
- `RpgRoller/Components/RpgRollerApiClient.cs`: browser API client for write actions
- `RpgRoller/Components/WorkspaceQueryService.cs`: browser-facing read client for workspace data
@@ -96,19 +97,20 @@ Rolemaster support:
The current frontend is in an intermediate state that was created while mitigating the Firefox and RoboForm failure documented in `POSTMORTEM.md`.
Today, `/` is dual-purpose:
Today, `/` is an auth-aware entry redirect:
- when the request has no valid session cookie, `RpgRoller/Components/App.razor` renders `StaticAuthPage.razor` as plain HTML and `RpgRoller/wwwroot/js/rpgroller-api.js` handles login and registration through `fetch`
- when the request has a valid session cookie, `App.razor` renders the interactive Blazor app and `Home.razor` loads the authenticated `Workspace`
- anonymous `GET /` redirects to `/login`
- 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
Inside the authenticated app, the hamburger menu does not navigate to different URLs. Instead, `WorkspaceSessionCoordinator.cs` stores a `screen` preference in `sessionStorage`, and `Workspace.razor` conditionally swaps between play, campaign management, and admin screens inside one large component tree.
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.
This architecture works functionally but remains structurally fragile because:
- the root shell still branches on request-time `HttpContext`
- the HTML shell still branches on request path to keep `/login` static
- the authenticated workspace still performs staged startup in `OnAfterRenderAsync`
- the app coordinates state across Blazor component state, browser `sessionStorage`, `fetch`, and SSE during early startup
- the route URL does not represent the authenticated screen the user is actually viewing
- 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

View File

@@ -40,4 +40,20 @@ public sealed class FrontendHostTests(WebApplicationFactory<Program> factory) :
Assert.Contains("data-auth-page", html);
Assert.DoesNotContain("_framework/blazor.web.js", html);
}
[Theory]
[InlineData("/play")]
[InlineData("/campaigns")]
[InlineData("/admin")]
public async Task AuthenticatedRoutes_ServeInteractiveShell(string path)
{
using var factory = CreateFactory(1);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var response = await client.GetAsync(path);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("_framework/blazor.web.js", html);
Assert.DoesNotContain("data-auth-page", html);
}
}

View File

@@ -28,7 +28,8 @@ public sealed class WorkspaceStateTests
public void SkillDefinitionLabel_FormatsD6RolemasterAndDefaultRulesets()
{
var skill = new CharacterSheetSkill(Guid.NewGuid(), null, "Awareness", "d100!+15", 1, true, 5, true);
var state = new WorkspaceState { SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
var state = new WorkspaceState
{ SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), []) };
Assert.Equal("d100!+15, wild 1, fumble on", state.SkillDefinitionLabel(skill));
@@ -49,7 +50,8 @@ public sealed class WorkspaceStateTests
var state = new WorkspaceState
{
User = new(userId, "user", "User", []),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"), [ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(Guid.NewGuid(), "GM"),
[ownedCharacter, secondOwnedCharacter, otherCharacter]),
SelectedCharacterId = secondOwnedCharacter.Id,
ActiveCharacterId = ownedCharacter.Id,
SelectedCharacterSkills = [new(Guid.NewGuid(), null, "Stealth", "2D+1", 1, true, null, false)],
@@ -69,33 +71,26 @@ public sealed class WorkspaceStateTests
}
[Fact]
public void ScreenAndConnectionFlags_ReflectCurrentState()
public void CampaignAndConnectionFlags_ReflectCurrentState()
{
var adminId = Guid.NewGuid();
var state = new WorkspaceState
{
User = new(adminId, "admin", "Admin", [UserRoles.Admin]),
SelectedCampaign = new(Guid.NewGuid(), "Alpha", "d6", new(adminId, "Admin"), []),
CurrentScreen = "admin",
ConnectionState = "reconnecting"
};
Assert.True(state.IsAdminScreen);
Assert.False(state.IsPlayScreen);
Assert.True(state.IsCurrentUserAdmin);
Assert.True(state.IsCurrentUserGm);
Assert.True(state.CanDeleteSelectedCampaign);
Assert.True(state.IsSelectedCampaignD6);
Assert.Equal("Reconnecting", state.ConnectionStateLabel);
Assert.Equal("warn", state.ConnectionStateCssClass);
Assert.Equal("rr-app", state.AppCssClass);
state.CurrentScreen = "play";
state.ConnectionState = "connected";
Assert.True(state.IsPlayScreen);
Assert.Equal("Connected", state.ConnectionStateLabel);
Assert.Equal("ok", state.ConnectionStateCssClass);
Assert.Equal("rr-app app-play", state.AppCssClass);
}
}

View File

@@ -0,0 +1,3 @@
@page "/admin"
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Admin" LoggedOut="OnLoggedOutAsync"/>

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class AdminPage
{
}

View File

@@ -0,0 +1,29 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public abstract class AuthenticatedPageBase : ComponentBase
{
protected Task OnLoggedOutAsync(string? message)
{
if (string.IsNullOrWhiteSpace(message))
{
Navigation.NavigateTo("/login", forceLoad: true);
return Task.CompletedTask;
}
var query = new Dictionary<string, string?>
{
["message"] = message,
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
};
Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true);
return Task.CompletedTask;
}
[Inject] protected NavigationManager Navigation { get; set; } = null!;
}

View File

@@ -0,0 +1,3 @@
@page "/campaigns"
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Campaigns" LoggedOut="OnLoggedOutAsync"/>

View File

@@ -0,0 +1,8 @@
using System.Diagnostics.CodeAnalysis;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class CampaignsPage
{
}

View File

@@ -1,2 +1,3 @@
@page "/play"
<Workspace LoggedOut="OnLoggedOutAsync"/>
@inherits AuthenticatedPageBase
<Workspace Route="WorkspaceRoute.Play" LoggedOut="OnLoggedOutAsync"/>

View File

@@ -1,29 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class PlayPage
{
private Task OnLoggedOutAsync(string? message)
{
if (string.IsNullOrWhiteSpace(message))
{
Navigation.NavigateTo("/login", forceLoad: true);
return Task.CompletedTask;
}
var query = new Dictionary<string, string?>
{
["message"] = message,
["kind"] = message.Contains("expired", StringComparison.OrdinalIgnoreCase) ? "error" : "success"
};
Navigation.NavigateTo(QueryHelpers.AddQueryString("/login", query), forceLoad: true);
return Task.CompletedTask;
}
[Inject] private NavigationManager Navigation { get; set; } = null!;
}

View File

@@ -1,5 +1,5 @@
@using RpgRoller.Components.Pages.HomeControls
<div class="@State.AppCssClass">
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
@if (State.HasHealthIssue)
@@ -28,7 +28,7 @@
ToggleMenuRequested="ToggleScreenMenu"
LogoutRequested="Session.LogoutAsync"/>
@if (State.IsPlayScreen)
@if (IsPlayRoute)
{
<main class="play-screen @(State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
@@ -87,7 +87,7 @@
</button>
</nav>
}
else if (State.IsManagementScreen)
else if (IsCampaignsRoute)
{
<CampaignManagementPanel
Campaigns="State.Campaigns"
@@ -106,7 +106,7 @@
EditCharacterRequested="Campaigns.OpenEditCharacterModal"
DeleteCharacterRequested="Campaigns.DeleteCharacterAsync"/>
}
else if (State.IsAdminScreen)
else if (IsAdminRoute)
{
<main class="management-screen">
@if (State.IsCurrentUserAdmin)

View File

@@ -9,6 +9,11 @@ namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public partial class Workspace : IAsyncDisposable
{
protected override void OnParametersSet()
{
State.IsScreenMenuOpen = false;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
State.HasInteractiveRenderStarted = true;
@@ -100,6 +105,22 @@ public partial class Workspace : IAsyncDisposable
State.IsScreenMenuOpen = !State.IsScreenMenuOpen;
}
private Task NavigateToRouteAsync(string route)
{
State.IsScreenMenuOpen = false;
Navigation.NavigateTo(route);
return InvokeAsync(StateHasChanged);
}
private Task RedirectToPlayAsync()
{
if (IsPlayRoute)
return Task.CompletedTask;
Navigation.NavigateTo("/play");
return Task.CompletedTask;
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
@@ -114,22 +135,30 @@ public partial class Workspace : IAsyncDisposable
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Parameter] public EventCallback<string?> LoggedOut { get; set; }
[Parameter] public WorkspaceRoute Route { get; set; } = WorkspaceRoute.Play;
private WorkspaceState State { get; } = new();
private bool HasSessionInitialized { get; set; }
private bool EnableCharacterControls { get; set; }
private bool EnableCustomRollComposer { get; set; }
private bool IsPlayRoute => Route == WorkspaceRoute.Play;
private bool IsCampaignsRoute => Route == WorkspaceRoute.Campaigns;
private bool IsAdminRoute => Route == WorkspaceRoute.Admin;
private string AppCssClass => IsPlayRoute ? "rr-app app-play" : "rr-app";
private WorkspaceCampaignScopeCoordinator Scope => m_Scope ??= new(State, Feedback, JS, WorkspaceQuery,
Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync, Play.RefreshCampaignLogAsync,
Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking, ClearAuthenticatedState,
() => IsPlayRoute, Play.EnsureSelectedCharacterActiveAsync, Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync, Play.ResetCampaignLogDetailState, Play.ResetCampaignStateTracking,
ClearAuthenticatedState,
StopStateEventsAsync, message => LoggedOut.InvokeAsync(message));
private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, StartStateEventsCoreAsync,
private WorkspaceLiveStateController Live => m_Live ??= new(State, Feedback, () => IsPlayRoute, () => IsAdminRoute,
StartStateEventsCoreAsync,
StopStateEventsCoreAsync, Scope.RefreshCampaignRosterAsync, Play.RefreshSelectedCharacterSheetAsync,
Play.RefreshCampaignLogAsync, () => InvokeAsync(StateHasChanged));
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, ApiClient, WorkspaceQuery,
private WorkspacePlayCoordinator Play => m_Play ??= new(State, Feedback, () => IsPlayRoute, ApiClient,
WorkspaceQuery,
CanEditCharacter, () => InvokeAsync(StateHasChanged));
private WorkspaceCampaignCoordinator Campaigns => m_Campaigns ??= new(State, Feedback, JS, ApiClient,
@@ -142,10 +171,10 @@ public partial class Workspace : IAsyncDisposable
private WorkspaceFeedbackService Feedback => m_Feedback ??= new(State, () => InvokeAsync(StateHasChanged));
private WorkspaceSessionCoordinator Session => m_Session ??= new(State, Feedback, JS, ApiClient, WorkspaceQuery,
() => IsAdminRoute, RedirectToPlayAsync,
Scope.ReloadCampaignsAsync, Scope.ReloadCharacterCampaignOptionsAsync, Scope.RefreshCampaignScopeAsync,
Live.SyncStateEventsAsync, Live.StopStateEventsAsync, EnsureAdminUsersLoadedAsync,
Play.ResetCampaignLogDetailState, () => InvokeAsync(StateHasChanged),
message => LoggedOut.InvokeAsync(message));
Play.ResetCampaignLogDetailState, message => LoggedOut.InvokeAsync(message));
private IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems
{
@@ -156,14 +185,14 @@ public partial class Workspace : IAsyncDisposable
new()
{
Label = "Play",
IsActive = State.IsPlayScreen,
OnSelected = () => Session.SwitchScreenAsync("play")
IsActive = IsPlayRoute,
OnSelected = () => NavigateToRouteAsync("/play")
},
new()
{
Label = "Campaign Management",
IsActive = State.IsManagementScreen,
OnSelected = () => Session.SwitchScreenAsync("management")
IsActive = IsCampaignsRoute,
OnSelected = () => NavigateToRouteAsync("/campaigns")
}
};
@@ -172,8 +201,8 @@ public partial class Workspace : IAsyncDisposable
items.Add(new()
{
Label = "Admin",
IsActive = State.IsAdminScreen,
OnSelected = () => Session.SwitchScreenAsync(ScreenAdmin)
IsActive = IsAdminRoute,
OnSelected = () => NavigateToRouteAsync("/admin")
});
}
@@ -184,7 +213,6 @@ public partial class Workspace : IAsyncDisposable
private string AdminDatabaseDownloadUrl => Navigation.ToAbsoluteUri("api/admin/database").ToString();
private DotNetObjectReference<Workspace>? DotNetRef { get; set; }
private const string ScreenAdmin = "admin";
private WorkspaceAdminCoordinator? m_Admin;
private WorkspaceCampaignCoordinator? m_Campaigns;
private WorkspaceFeedbackService? m_Feedback;

View File

@@ -4,7 +4,20 @@ using Microsoft.JSInterop;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, WorkspaceQueryService workspaceQuery, Func<Task> ensureSelectedCharacterActiveAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Action resetCampaignLogDetailState, Action resetCampaignStateTracking, Action clearAuthenticatedState, Func<Task> stopStateEventsAsync, Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceCampaignScopeCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
WorkspaceQueryService workspaceQuery,
Func<bool> isPlayRoute,
Func<Task> ensureSelectedCharacterActiveAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Action resetCampaignLogDetailState,
Action resetCampaignStateTracking,
Action clearAuthenticatedState,
Func<Task> stopStateEventsAsync,
Func<string?, Task> onLoggedOutAsync)
{
public async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
@@ -24,13 +37,15 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work
else if (!state.SelectedCampaignId.HasValue || !campaignIds.Contains(state.SelectedCampaignId.Value))
state.SelectedCampaignId = state.Campaigns[0].Id;
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, state.SelectedCampaignId?.ToString());
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey,
state.SelectedCampaignId?.ToString());
}
public async Task ReloadCharacterCampaignOptionsAsync()
{
var campaignOptions = await workspaceQuery.GetCharacterCampaignOptionsAsync();
state.CharacterCampaignOptions = campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.CharacterCampaignOptions =
campaignOptions.OrderBy(campaign => campaign.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public async Task RefreshCampaignRosterAsync()
@@ -45,7 +60,8 @@ public sealed class WorkspaceCampaignScopeCoordinator(WorkspaceState state, Work
state.SelectedCampaign = await workspaceQuery.GetCampaignAsync(state.SelectedCampaignId.Value);
SyncSelectedCharacter();
if (state.IsPlayScreen && state.PlaySelectedCharacterId.HasValue && state.SelectedCharacterId != state.PlaySelectedCharacterId)
if (isPlayRoute() && state.PlaySelectedCharacterId.HasValue &&
state.SelectedCharacterId != state.PlaySelectedCharacterId)
state.SelectedCharacterId = state.PlaySelectedCharacterId;
await ensureSelectedCharacterActiveAsync();

View File

@@ -4,7 +4,17 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspaceLiveStateController(WorkspaceState state, WorkspaceFeedbackService feedback, Func<Guid, Task> startStateEventsAsync, Func<Task> stopStateEventsCoreAsync, Func<Task> refreshCampaignRosterAsync, Func<Task> refreshSelectedCharacterSheetAsync, Func<Guid?, Task> refreshCampaignLogAsync, Func<Task> requestRefreshAsync)
public sealed class WorkspaceLiveStateController(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<bool> isPlayRoute,
Func<bool> isAdminRoute,
Func<Guid, Task> startStateEventsAsync,
Func<Task> stopStateEventsCoreAsync,
Func<Task> refreshCampaignRosterAsync,
Func<Task> refreshSelectedCharacterSheetAsync,
Func<Guid?, Task> refreshCampaignLogAsync,
Func<Task> requestRefreshAsync)
{
public async Task OnStateEventReceivedAsync(CampaignStateSnapshot state1)
{
@@ -27,15 +37,17 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
var previousSelectedCharacterId = state.SelectedCharacterId;
var previousSelectedCharacterVersion = GetCharacterVersion(previousState, previousSelectedCharacterId);
var rosterChanged = state1.RosterVersion != previousState.RosterVersion;
var logChanged = state.IsPlayScreen && state1.LogVersion != previousState.LogVersion;
var logChanged = isPlayRoute() && state1.LogVersion != previousState.LogVersion;
if (rosterChanged)
await refreshCampaignRosterAsync();
var selectedCharacterChanged = previousSelectedCharacterId != state.SelectedCharacterId;
var selectedCharacterVersionChanged = state.IsPlayScreen && !selectedCharacterChanged && GetCharacterVersion(state1, state.SelectedCharacterId) != previousSelectedCharacterVersion;
var selectedCharacterVersionChanged = isPlayRoute() && !selectedCharacterChanged &&
GetCharacterVersion(state1, state.SelectedCharacterId) !=
previousSelectedCharacterVersion;
if (state.IsPlayScreen && (selectedCharacterChanged || selectedCharacterVersionChanged))
if (isPlayRoute() && (selectedCharacterChanged || selectedCharacterVersionChanged))
await refreshSelectedCharacterSheetAsync();
if (logChanged)
@@ -54,9 +66,9 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
{
state.ConnectionState = state1 switch
{
"connected" => "connected",
"connected" => "connected",
"reconnecting" => "reconnecting",
_ => "offline"
_ => "offline"
};
if (state.ConnectionState == "reconnecting")
@@ -70,7 +82,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
public async Task SyncStateEventsAsync()
{
if (state.User is null || !state.SelectedCampaignId.HasValue || state.IsAdminScreen)
if (state.User is null || !state.SelectedCampaignId.HasValue || isAdminRoute())
{
await StopStateEventsAsync();
state.ConnectionState = "offline";
@@ -94,6 +106,7 @@ public sealed class WorkspaceLiveStateController(WorkspaceState state, Workspace
if (!characterId.HasValue)
return 0;
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)?.Version ?? 0;
return snapshot.CharacterVersions.FirstOrDefault(version => version.CharacterId == characterId.Value)
?.Version ?? 0;
}
}

View File

@@ -5,11 +5,18 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
[ExcludeFromCodeCoverage]
public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<CharacterSummary, bool> canEditCharacter, Func<Task> requestRefreshAsync)
public sealed class WorkspacePlayCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
Func<bool> isPlayRoute,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<CharacterSummary, bool> canEditCharacter,
Func<Task> requestRefreshAsync)
{
public async Task RefreshCampaignLogAsync(Guid? afterRollId = null)
{
if (!state.SelectedCampaignId.HasValue || !state.IsPlayScreen)
if (!state.SelectedCampaignId.HasValue || !isPlayRoute())
{
state.CampaignLog = [];
state.CampaignLogCursor = null;
@@ -18,7 +25,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var previousLogCount = state.CampaignLog.Count;
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId, CampaignLogWindowSize);
var page = await workspaceQuery.GetCampaignLogPageAsync(state.SelectedCampaignId.Value, afterRollId,
CampaignLogWindowSize);
Guid? newestRollId = null;
if (!afterRollId.HasValue || page.ResetRequired)
state.CampaignLog = page.Entries.ToList();
@@ -30,7 +38,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var shouldAutoExpandNewest = afterRollId.HasValue && page.Entries.Length > 0;
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null && previousLogCount == 0 && page.Entries.Length > 0)
if (!shouldAutoExpandNewest && !afterRollId.HasValue && state.CurrentCampaignState is not null &&
previousLogCount == 0 && page.Entries.Length > 0)
shouldAutoExpandNewest = true;
if (shouldAutoExpandNewest)
@@ -58,7 +67,7 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
public async Task RefreshSelectedCharacterSheetAsync()
{
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !state.IsPlayScreen)
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null || !isPlayRoute())
{
state.SelectedCharacterSkills = [];
state.SelectedCharacterSkillGroups = [];
@@ -66,8 +75,10 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
var sheet = await workspaceQuery.GetCharacterSheetAsync(state.SelectedCharacterId.Value);
state.SelectedCharacterSkillGroups = sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills = sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkillGroups =
sheet.SkillGroups.OrderBy(group => group.Name, StringComparer.OrdinalIgnoreCase).ToList();
state.SelectedCharacterSkills =
sheet.Skills.OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase).ToList();
}
public Task EnsureSelectedCharacterActiveAsync()
@@ -152,7 +163,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return Task.CompletedTask;
}
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
StringComparison.OrdinalIgnoreCase))
{
OpenRolemasterSkillRollModal(skill);
return Task.CompletedTask;
@@ -177,7 +189,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.IsSubmittingRolemasterSkillRoll = true;
try
{
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, keepModalOpenOnError: true);
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier,
keepModalOpenOnError: true);
}
finally
{
@@ -199,7 +212,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.IsMutating = true;
try
{
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier));
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll",
new RollSkillRequest(state.RollVisibility, situationalModifier));
CloseRolemasterSkillRollModal();
await HandleRecordedRollAsync(roll);
}
@@ -314,7 +328,8 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return;
var character = state.SelectedCampaign.Characters.FirstOrDefault(c => c.Id == state.SelectedCharacterId.Value);
if (character is null || !CanActivateCharacter(character, state.User) || state.ActiveCharacterId == character.Id)
if (character is null || !CanActivateCharacter(character, state.User) ||
state.ActiveCharacterId == character.Id)
return;
try
@@ -345,13 +360,16 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
{
var visibleRollIds = state.CampaignLog.Select(entry => entry.RollId).ToHashSet();
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetails.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetails.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetailsLoading.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailsLoading.Remove(rollId);
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId)).ToArray())
foreach (var rollId in state.CampaignLogDetailErrors.Keys.Where(rollId => !visibleRollIds.Contains(rollId))
.ToArray())
state.CampaignLogDetailErrors.Remove(rollId);
if (state.ExpandedCampaignLogRollId.HasValue && !visibleRollIds.Contains(state.ExpandedCampaignLogRollId.Value))

View File

@@ -0,0 +1,8 @@
namespace RpgRoller.Components.Pages;
public enum WorkspaceRoute
{
Play,
Campaigns,
Admin
}

View File

@@ -3,18 +3,31 @@ using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceFeedbackService feedback, IJSRuntime js, RpgRollerApiClient apiClient, WorkspaceQueryService workspaceQuery, Func<Guid?, Task> reloadCampaignsAsync, Func<Task> reloadCharacterCampaignOptionsAsync, Func<Task> refreshCampaignScopeAsync, Func<Task> syncStateEventsAsync, Func<Task> stopStateEventsAsync, Func<Task> ensureAdminUsersLoadedAsync, Action resetCampaignLogDetailState, Func<Task> requestRefreshAsync, Func<string?, Task> onLoggedOutAsync)
public sealed class WorkspaceSessionCoordinator(
WorkspaceState state,
WorkspaceFeedbackService feedback,
IJSRuntime js,
RpgRollerApiClient apiClient,
WorkspaceQueryService workspaceQuery,
Func<bool> isAdminRoute,
Func<Task> redirectToPlayAsync,
Func<Guid?, Task> reloadCampaignsAsync,
Func<Task> reloadCharacterCampaignOptionsAsync,
Func<Task> refreshCampaignScopeAsync,
Func<Task> syncStateEventsAsync,
Func<Task> stopStateEventsAsync,
Func<Task> ensureAdminUsersLoadedAsync,
Action resetCampaignLogDetailState,
Func<string?, Task> onLoggedOutAsync)
{
public async Task InitializeAsync()
{
var storedScreen = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
var storedPanel = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
state.MobilePanel = "log";
var storedRollVisibility = await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
var storedRollVisibility =
await js.InvokeAsync<string?>("rpgRollerApi.getSessionValue", RollVisibilitySessionKey);
state.RollVisibility = NormalizeRollVisibility(storedRollVisibility);
Guid? preferredCampaignId = null;
@@ -78,30 +91,6 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
await onLoggedOutAsync("Logged out.");
}
public async Task SwitchScreenAsync(string screen)
{
var targetScreen = NormalizeRequestedScreen(screen) ?? ScreenPlay;
if (string.Equals(targetScreen, ScreenAdmin, StringComparison.OrdinalIgnoreCase) && !state.IsCurrentUserAdmin)
targetScreen = ScreenPlay;
state.CurrentScreen = targetScreen;
state.IsScreenMenuOpen = false;
await PersistScreenPreferenceAsync(state.CurrentScreen);
await requestRefreshAsync();
if (state.User is not null)
{
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
}
if (state.IsAdminScreen)
{
await ensureAdminUsersLoadedAsync();
await requestRefreshAsync();
}
}
public async Task OnRollVisibilityChangedAsync(string visibility)
{
state.RollVisibility = NormalizeRollVisibility(visibility);
@@ -168,14 +157,15 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
state.User = me.User;
state.ActiveCharacterId = me.ActiveCharacterId;
await EnsureScreenAccessAsync();
if (!await EnsureRouteAccessAsync())
return true;
await reloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId);
await reloadCharacterCampaignOptionsAsync();
await refreshCampaignScopeAsync();
await syncStateEventsAsync();
if (state.IsAdminScreen)
if (isAdminRoute())
await ensureAdminUsersLoadedAsync();
return true;
@@ -193,33 +183,17 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
}
}
private async Task EnsureScreenAccessAsync()
private async Task<bool> EnsureRouteAccessAsync()
{
if (state.IsCurrentUserAdmin)
return;
if (state.IsCurrentUserAdmin || !isAdminRoute())
{
return true;
}
state.AdminUsers = [];
state.HasLoadedAdminUsers = false;
if (!state.IsAdminScreen)
return;
state.CurrentScreen = ScreenPlay;
await PersistScreenPreferenceAsync(state.CurrentScreen);
}
private async Task PersistScreenPreferenceAsync(string screen)
{
try
{
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, screen);
}
catch (JSDisconnectedException)
{
}
catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex))
{
}
await redirectToPlayAsync();
return false;
}
private static string NormalizeRollVisibility(string? visibility)
@@ -227,29 +201,6 @@ public sealed class WorkspaceSessionCoordinator(WorkspaceState state, WorkspaceF
return string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public";
}
private static string? NormalizeRequestedScreen(string? screen)
{
if (string.Equals(screen, ScreenAdmin, StringComparison.OrdinalIgnoreCase))
return ScreenAdmin;
if (string.Equals(screen, ScreenManagement, StringComparison.OrdinalIgnoreCase))
return ScreenManagement;
if (string.Equals(screen, ScreenPlay, StringComparison.OrdinalIgnoreCase))
return ScreenPlay;
return null;
}
private static bool IsStaticRenderInteropException(InvalidOperationException exception)
{
return exception.Message.Contains("JavaScript interop calls cannot be issued", StringComparison.OrdinalIgnoreCase);
}
private const string ScreenPlay = "play";
private const string ScreenManagement = "management";
private const string ScreenAdmin = "admin";
private const string ScreenSessionKey = "screen";
private const string CampaignSessionKey = "campaign";
private const string MobilePanelSessionKey = "play-panel";
private const string RollVisibilitySessionKey = "roll-visibility";

View File

@@ -17,7 +17,9 @@ public sealed class WorkspaceState
if (ownerUserId == SelectedCampaign.Gm.Id)
return $"{SelectedCampaign.Gm.DisplayName} (GM)";
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId).Select(character => character.OwnerDisplayName).FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
var ownerDisplayName = SelectedCampaign.Characters.Where(character => character.OwnerUserId == ownerUserId)
.Select(character => character.OwnerDisplayName)
.FirstOrDefault(displayName => !string.IsNullOrWhiteSpace(displayName));
return string.IsNullOrWhiteSpace(ownerDisplayName) ? "Unknown owner" : ownerDisplayName;
}
@@ -26,8 +28,10 @@ public sealed class WorkspaceState
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange, skill.RolemasterAutoRetry);
if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster,
StringComparison.OrdinalIgnoreCase))
return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange,
skill.RolemasterAutoRetry);
return skill.DiceRollDefinition;
}
@@ -59,7 +63,6 @@ public sealed class WorkspaceState
public bool HasHealthIssue { get; set; }
public string HealthIssueMessage { get; set; } = "Retry to restore the API connection.";
public List<WorkspaceToast> Toasts { get; } = [];
public string CurrentScreen { get; set; } = "play";
public string MobilePanel { get; set; } = "character";
public string ConnectionState { get; set; } = "offline";
public string LiveAnnouncement { get; set; } = string.Empty;
@@ -88,7 +91,9 @@ public sealed class WorkspaceState
public HashSet<Guid> CampaignLogDetailsLoading { get; } = [];
public Dictionary<Guid, string> CampaignLogDetailErrors { get; } = [];
public string? SelectedCampaignName => SelectedCampaign?.Name ?? Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)?.Name;
public string? SelectedCampaignName => SelectedCampaign?.Name ??
Campaigns.FirstOrDefault(campaign => campaign.Id == SelectedCampaignId)
?.Name;
public CharacterSummary? SelectedCharacter =>
SelectedCampaign?.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId);
@@ -101,11 +106,14 @@ public sealed class WorkspaceState
return null;
if (User is null)
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, []);
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
[]);
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id).ToArray();
var ownedCharacters = SelectedCampaign.Characters.Where(character => character.OwnerUserId == User.Id)
.ToArray();
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm, ownedCharacters);
return new(SelectedCampaign.Id, SelectedCampaign.Name, SelectedCampaign.RulesetId, SelectedCampaign.Gm,
ownedCharacters);
}
}
@@ -119,14 +127,18 @@ public sealed class WorkspaceState
if (SelectedCharacterId.HasValue)
{
var selectedCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == SelectedCharacterId.Value);
var selectedCharacter =
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == SelectedCharacterId.Value);
if (selectedCharacter is not null)
return selectedCharacter;
}
if (ActiveCharacterId.HasValue)
{
var activeCharacter = playSelectedCampaign.Characters.FirstOrDefault(character => character.Id == ActiveCharacterId.Value);
var activeCharacter =
playSelectedCampaign.Characters.FirstOrDefault(character =>
character.Id == ActiveCharacterId.Value);
if (activeCharacter is not null)
return activeCharacter;
}
@@ -157,23 +169,17 @@ public sealed class WorkspaceState
public bool IsSelectedCampaignD6 =>
string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase);
public bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase);
public bool IsManagementScreen => string.Equals(CurrentScreen, "management", StringComparison.OrdinalIgnoreCase);
public bool IsAdminScreen => string.Equals(CurrentScreen, "admin", StringComparison.OrdinalIgnoreCase);
public string ConnectionStateLabel => ConnectionState switch
{
"connected" => "Connected",
"connected" => "Connected",
"reconnecting" => "Reconnecting",
_ => "Offline fallback"
_ => "Offline fallback"
};
public string ConnectionStateCssClass => ConnectionState switch
{
"connected" => "ok",
"connected" => "ok",
"reconnecting" => "warn",
_ => "offline"
_ => "offline"
};
public string AppCssClass => IsPlayScreen ? "rr-app app-play" : "rr-app";
}

View File

@@ -18,11 +18,11 @@ The change is complete when a human can run the app, open `/`, observe the corre
- [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.
- [ ] Introduce real authenticated routes for `/play`, `/campaigns`, and `/admin` while preserving current behavior.
- [ ] Remove `screen` as a `sessionStorage` routing mechanism and replace menu actions with URL navigation.
- [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.
- [ ] Split the large `Workspace` render tree so play, campaign management, and admin each own a smaller subtree.
- [ ] Reduce `OnAfterRenderAsync` to the smallest practical scope and keep staged startup out of the authenticated shell root.
- [ ] Update host tests, Selenium smoke tests, and docs so the new route model is the only documented and verified 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
@@ -78,15 +78,17 @@ After Milestone 1, the dual-purpose `/` entry point is gone. Anonymous requests
After the Selenium migration iteration, the repositorys 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.
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 root request path. In `RpgRoller/Components/App.razor`, the HTML shell checks the current request path and session cookie through `HttpContext`. If the request is for `/` and no valid session exists, 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`.
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 only current component route for the authenticated shell is `RpgRoller/Components/Pages/Home.razor`, which maps `@page "/"` and immediately renders `Workspace`. `RpgRoller/Components/Pages/Home.razor.cs` is only a logout redirect helper; it is not a real page controller anymore.
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 contains the header, play screen, campaign management screen, admin screen, toasts, and modals. The code-behind wires several coordinator classes, and `OnAfterRenderAsync` drives session initialization and staged control enablement. The currently selected screen is stored in `WorkspaceState.CurrentScreen`, and `WorkspaceSessionCoordinator.cs` persists that screen name in browser `sessionStorage` under the key `rpgroller.screen`.
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.
@@ -249,12 +251,12 @@ Current evidence that explains the bootstrap constraint:
RpgRoller/Components/RpgRollerApiClient.cs
var response = await js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
Current evidence that explains why route navigation must replace screen persistence:
Current evidence that Milestone 2 is intentionally transitional rather than final:
RpgRoller/Components/Pages/WorkspaceSessionCoordinator.cs
private const string ScreenSessionKey = "screen";
state.CurrentScreen = NormalizeRequestedScreen(storedScreen) ?? ScreenPlay;
await js.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, state.CurrentScreen);
RpgRoller/Components/Pages/Workspace.razor
@if (IsPlayRoute) { ... }
else if (IsCampaignsRoute) { ... }
else if (IsAdminRoute) { ... }
## Interfaces and Dependencies

View File

@@ -1,7 +1,7 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { Builder, By, until } = require("selenium-webdriver");
const { Builder, By, Key, until } = require("selenium-webdriver");
const firefox = require("selenium-webdriver/firefox");
const baseUrl = process.env.SELENIUM_BASE_URL || "http://127.0.0.1:5000";

View File

@@ -87,6 +87,61 @@ const tests = [
assert.equal(response.headers.get("location"), "/play");
}
},
{
name: "authenticated route navigation and refresh use real URLs",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("routes");
const { sessionCookie } = await registerAndLoginApi(username, "Route Navigation");
const campaign = await postJson("/api/campaigns", {
name: "Route Navigation",
rulesetId: "d6"
}, { cookie: sessionCookie });
await postJson("/api/characters", {
name: "Navigator",
campaignId: campaign.id
}, { cookie: sessionCookie });
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/campaigns"));
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
assert.equal(await hasSelector(driver, "#skill-filter-input"), false);
await driver.navigate().refresh();
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
await clickSelector(driver, ".menu-toggle");
await clickText(driver, ".menu-item", "Play");
await waitForUrl(driver, "/play");
await waitForSelector(driver, "#skill-filter-input");
await driver.navigate().refresh();
await waitForUrl(driver, "/play");
await waitForSelector(driver, "#skill-filter-input");
await clickSelector(driver, ".menu-toggle");
await clickText(driver, ".menu-item", "Campaign Management");
await waitForUrl(driver, "/campaigns");
await waitForSelector(driver, "#campaign-select");
})
},
{
name: "non-admin users are redirected away from admin route",
run: async () => withDriver({}, async (driver) => {
const username = uniqueName("admin-redirect");
const { sessionCookie } = await registerAndLoginApi(username, "Admin Redirect");
await seedAuthenticatedBrowser(driver, sessionCookie);
await driver.get(absoluteUrl("/admin"));
await waitForUrl(driver, "/play");
await waitForText(driver, "Campaign Log");
assert.equal(await hasSelector(driver, ".management-list"), false);
})
},
{
name: "successful login transitions to play workspace",
run: async () => withDriver({}, async (driver) => {