fix: phase authenticated startup
This commit is contained in:
@@ -196,6 +196,7 @@ SQLite migration rule:
|
|||||||
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
|
- Interactive authenticated startup begins in `WorkspaceRouteView.razor` after first render because `RpgRollerApiClient` still depends on JS interop-backed `fetch`.
|
||||||
- Workspace startup diagnostics now log route initialization, route-content render phases, and browser-side workspace mutation snapshots to help isolate the remaining Firefox startup crash documented in `POSTMORTEM.md`.
|
- Workspace startup diagnostics now log route initialization, route-content render phases, and browser-side workspace mutation snapshots to help isolate the remaining Firefox startup crash documented in `POSTMORTEM.md`.
|
||||||
- Pre-Blazor diagnostics also watch the static `#rr-interactive-host` container before `_framework/blazor.web.js` connects, so extension-driven DOM mutations can be compared against the first failing interactive batch.
|
- Pre-Blazor diagnostics also watch the static `#rr-interactive-host` container before `_framework/blazor.web.js` connects, so extension-driven DOM mutations can be compared against the first failing interactive batch.
|
||||||
|
- Authenticated routes now mount through phased interactive batches: minimal shell first, then a simple header placeholder, then route skeletons, and only then the real header and control-heavy route content after route initialization succeeds.
|
||||||
- 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.
|
||||||
|
|||||||
@@ -1,67 +1,85 @@
|
|||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
|
|
||||||
<main class="management-screen">
|
@if (!Workspace.ShowLiveContent)
|
||||||
@if (Workspace.State.IsCurrentUserAdmin)
|
{
|
||||||
{
|
<main class="management-screen">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
<h2>Database</h2>
|
<h2>Admin</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
|
<div class="skeleton-stack">
|
||||||
<div class="management-actions">
|
<div class="skeleton-line"></div>
|
||||||
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a>
|
<div class="skeleton-line short"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
}
|
</main>
|
||||||
<section class="card">
|
}
|
||||||
<div class="section-head">
|
else
|
||||||
<h2>User Management</h2>
|
{
|
||||||
</div>
|
<main class="management-screen">
|
||||||
@if (IsAdminDataLoading)
|
@if (Workspace.State.IsCurrentUserAdmin)
|
||||||
{
|
{
|
||||||
<p class="empty">Loading users...</p>
|
<section class="card">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Database</h2>
|
||||||
|
</div>
|
||||||
|
<p class="muted">Download the current SQLite file for backup or offline inspection.</p>
|
||||||
|
<div class="management-actions">
|
||||||
|
<a class="action-link" href="@Workspace.AdminDatabaseDownloadUrl" download>Download SQLite database</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
else if (!Workspace.State.IsCurrentUserAdmin)
|
<section class="card">
|
||||||
{
|
<div class="section-head">
|
||||||
<p class="empty">Admin role is required to manage users.</p>
|
<h2>User Management</h2>
|
||||||
}
|
</div>
|
||||||
else if (Workspace.State.AdminUsers.Count == 0)
|
@if (IsAdminDataLoading)
|
||||||
{
|
{
|
||||||
<p class="empty">No users found.</p>
|
<p class="empty">Loading users...</p>
|
||||||
}
|
}
|
||||||
else
|
else if (!Workspace.State.IsCurrentUserAdmin)
|
||||||
{
|
{
|
||||||
<ul class="management-list">
|
<p class="empty">Admin role is required to manage users.</p>
|
||||||
@foreach (var user in Workspace.State.AdminUsers)
|
}
|
||||||
{
|
else if (Workspace.State.AdminUsers.Count == 0)
|
||||||
<li>
|
{
|
||||||
<div>
|
<p class="empty">No users found.</p>
|
||||||
<strong>@user.Username</strong>
|
}
|
||||||
<p class="muted">@user.DisplayName</p>
|
else
|
||||||
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
{
|
||||||
</div>
|
<ul class="management-list">
|
||||||
<div class="skill-chip-actions">
|
@foreach (var user in Workspace.State.AdminUsers)
|
||||||
<button type="button"
|
{
|
||||||
class="chip-button"
|
<li>
|
||||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
<div>
|
||||||
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
|
<strong>@user.Username</strong>
|
||||||
<span aria-hidden="true" class="emoji">🛡️</span>
|
<p class="muted">@user.DisplayName</p>
|
||||||
<span class="sr-only">Toggle admin role for @user.Username</span>
|
<p class="muted">Roles: @(user.Roles.Count == 0 ? "none" : string.Join(", ", user.Roles))</p>
|
||||||
</button>
|
</div>
|
||||||
<button type="button"
|
<div class="skill-chip-actions">
|
||||||
class="chip-button"
|
<button type="button"
|
||||||
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
class="chip-button"
|
||||||
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
|
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||||
<span aria-hidden="true" class="emoji">🗑️</span>
|
@onclick="() => Workspace.Admin.ToggleAdminRoleAsync(user)">
|
||||||
<span class="sr-only">Delete user @user.Username</span>
|
<span aria-hidden="true" class="emoji">🛡️</span>
|
||||||
</button>
|
<span class="sr-only">Toggle admin role for @user.Username</span>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
<button type="button"
|
||||||
}
|
class="chip-button"
|
||||||
</ul>
|
disabled="@(Workspace.State.IsMutating || user.Id == Workspace.State.User?.Id)"
|
||||||
}
|
@onclick="() => Workspace.Admin.DeleteUserAsync(user)">
|
||||||
</section>
|
<span aria-hidden="true" class="emoji">🗑️</span>
|
||||||
</main>
|
<span class="sr-only">Delete user @user.Username</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
|
private bool IsAdminDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsAdminDataLoading;
|
||||||
|
|||||||
@@ -1,24 +1,42 @@
|
|||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
|
||||||
<CampaignManagementPanel
|
@if (!Workspace.ShowLiveContent)
|
||||||
Campaigns="Workspace.State.Campaigns"
|
{
|
||||||
SelectedCampaignId="Workspace.State.SelectedCampaignId"
|
<main class="management-screen">
|
||||||
SelectedCampaign="Workspace.State.SelectedCampaign"
|
<section class="card">
|
||||||
Rulesets="Workspace.State.Rulesets"
|
<div class="section-head">
|
||||||
IsMutating="Workspace.State.IsMutating"
|
<h2>Campaign Management</h2>
|
||||||
OwnerLabel="Workspace.State.OwnerLabel"
|
</div>
|
||||||
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
<div class="skeleton-stack">
|
||||||
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
|
<div class="skeleton-line"></div>
|
||||||
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
|
<div class="skeleton-line short"></div>
|
||||||
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
<div class="skeleton-line"></div>
|
||||||
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
|
</div>
|
||||||
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
|
</section>
|
||||||
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
|
</main>
|
||||||
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
}
|
||||||
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
|
else
|
||||||
|
{
|
||||||
|
<CampaignManagementPanel
|
||||||
|
Campaigns="Workspace.State.Campaigns"
|
||||||
|
SelectedCampaignId="Workspace.State.SelectedCampaignId"
|
||||||
|
SelectedCampaign="Workspace.State.SelectedCampaign"
|
||||||
|
Rulesets="Workspace.State.Rulesets"
|
||||||
|
IsMutating="Workspace.State.IsMutating"
|
||||||
|
OwnerLabel="Workspace.State.OwnerLabel"
|
||||||
|
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
||||||
|
CanDeleteCharacter="Workspace.Campaigns.CanDeleteCharacter"
|
||||||
|
CanDeleteCampaign="Workspace.State.CanDeleteSelectedCampaign"
|
||||||
|
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
|
||||||
|
CampaignCreated="Workspace.Campaigns.OnCampaignCreatedAsync"
|
||||||
|
DeleteCampaignRequested="Workspace.Campaigns.DeleteSelectedCampaignAsync"
|
||||||
|
CreateCharacterRequested="Workspace.Campaigns.OpenCreateCharacterModal"
|
||||||
|
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
||||||
|
DeleteCharacterRequested="Workspace.Campaigns.DeleteCharacterAsync"/>
|
||||||
|
|
||||||
<CharacterManagementModals Workspace="Workspace"/>
|
<CharacterManagementModals Workspace="Workspace"/>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args)
|
||||||
|
|||||||
@@ -1,74 +1,103 @@
|
|||||||
@using Microsoft.AspNetCore.Components
|
@using Microsoft.AspNetCore.Components
|
||||||
@using RpgRoller.Components.Pages.HomeControls
|
@using RpgRoller.Components.Pages.HomeControls
|
||||||
|
|
||||||
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
@if (!Workspace.ShowLiveContent)
|
||||||
<CharacterPanel
|
{
|
||||||
IsCampaignDataLoading="@IsCampaignDataLoading"
|
<main class="play-screen mobile-character">
|
||||||
SelectedCampaign="Workspace.State.PlaySelectedCampaign"
|
<section class="card character-panel">
|
||||||
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
|
<div class="skeleton-stack">
|
||||||
SelectedCharacter="Workspace.State.PlaySelectedCharacter"
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line short"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<aside class="card log-panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>Campaign Log</h2>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-stack">
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
<div class="skeleton-line short"></div>
|
||||||
|
<div class="skeleton-line"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
<div class="mobile-bottom-nav" aria-hidden="true">
|
||||||
|
<span class="switch active">Character</span>
|
||||||
|
<span class="switch">Log</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<main class="play-screen @(Workspace.State.MobilePanel == "log" ? "mobile-log" : "mobile-character")">
|
||||||
|
<CharacterPanel
|
||||||
|
IsCampaignDataLoading="@IsCampaignDataLoading"
|
||||||
|
SelectedCampaign="Workspace.State.PlaySelectedCampaign"
|
||||||
|
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
|
||||||
|
SelectedCharacter="Workspace.State.PlaySelectedCharacter"
|
||||||
|
IsMutating="Workspace.State.IsMutating"
|
||||||
|
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills"
|
||||||
|
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
|
||||||
|
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
|
RollVisibility="Workspace.State.RollVisibility"
|
||||||
|
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
|
||||||
|
OwnerLabel="Workspace.State.OwnerLabel"
|
||||||
|
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
|
||||||
|
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
||||||
|
CanEditSkill="Workspace.Play.CanEditSkill"
|
||||||
|
CharacterSelected="Workspace.Play.SelectCharacterAsync"
|
||||||
|
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
||||||
|
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
|
||||||
|
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
|
||||||
|
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
|
||||||
|
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
|
||||||
|
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
|
||||||
|
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
|
||||||
|
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
|
||||||
|
RollRequested="Workspace.Play.RollSkillAsync"/>
|
||||||
|
|
||||||
|
<CampaignLogPanel
|
||||||
|
IsCampaignDataLoading="@IsCampaignDataLoading"
|
||||||
|
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
|
||||||
|
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
|
||||||
|
FreshRollId="Workspace.State.FreshCampaignLogRollId"
|
||||||
|
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
|
||||||
|
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
|
||||||
|
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
||||||
|
RollVisibility="Workspace.State.RollVisibility"
|
||||||
|
IsMutating="Workspace.State.IsMutating"
|
||||||
|
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
|
||||||
|
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
|
||||||
|
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
|
||||||
|
GetRollDetailError="Workspace.Play.GetRollDetailError"
|
||||||
|
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
|
||||||
|
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
|
||||||
|
</main>
|
||||||
|
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||||
|
<button type="button" class="switch @(Workspace.State.MobilePanel == "character" ? "active" : string.Empty)"
|
||||||
|
@onclick='() => Workspace.Scope.SetMobilePanelAsync("character")'>
|
||||||
|
Character
|
||||||
|
</button>
|
||||||
|
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
|
||||||
|
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
|
||||||
|
Log
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<CharacterManagementModals Workspace="Workspace"/>
|
||||||
|
|
||||||
|
<RolemasterSkillRollModal
|
||||||
|
Visible="Workspace.State.ShowRolemasterSkillRollModal"
|
||||||
|
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
||||||
|
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
||||||
|
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
|
||||||
|
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
|
||||||
|
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
|
||||||
IsMutating="Workspace.State.IsMutating"
|
IsMutating="Workspace.State.IsMutating"
|
||||||
SelectedCharacterSkills="Workspace.State.PlaySelectedCharacterSkills"
|
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
|
||||||
SelectedCharacterSkillGroups="Workspace.State.PlaySelectedCharacterSkillGroups"
|
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
|
||||||
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
|
||||||
RollVisibility="Workspace.State.RollVisibility"
|
}
|
||||||
RollVisibilityChanged="Workspace.Session.OnRollVisibilityChangedAsync"
|
|
||||||
OwnerLabel="Workspace.State.OwnerLabel"
|
|
||||||
SkillDefinitionLabel="Workspace.State.SkillDefinitionLabel"
|
|
||||||
CanEditCharacter="Workspace.Campaigns.CanEditCharacter"
|
|
||||||
CanEditSkill="Workspace.Play.CanEditSkill"
|
|
||||||
CharacterSelected="Workspace.Play.SelectCharacterAsync"
|
|
||||||
EditCharacterRequested="Workspace.Campaigns.OpenEditCharacterModal"
|
|
||||||
SkillCreated="Workspace.Play.OnSkillCreatedAsync"
|
|
||||||
SkillUpdated="Workspace.Play.OnSkillUpdatedAsync"
|
|
||||||
SkillGroupCreated="Workspace.Play.OnSkillGroupCreatedAsync"
|
|
||||||
SkillGroupUpdated="Workspace.Play.OnSkillGroupUpdatedAsync"
|
|
||||||
SkillDeleted="Workspace.Play.OnSkillDeletedAsync"
|
|
||||||
SkillGroupDeleted="Workspace.Play.OnSkillGroupDeletedAsync"
|
|
||||||
ErrorOccurred="Workspace.Play.OnCharacterPanelErrorAsync"
|
|
||||||
RollRequested="Workspace.Play.RollSkillAsync"/>
|
|
||||||
|
|
||||||
<CampaignLogPanel
|
|
||||||
IsCampaignDataLoading="@IsCampaignDataLoading"
|
|
||||||
CampaignLog="Workspace.State.PlayVisibleCampaignLog"
|
|
||||||
ExpandedRollId="Workspace.State.ExpandedCampaignLogRollId"
|
|
||||||
FreshRollId="Workspace.State.FreshCampaignLogRollId"
|
|
||||||
SelectedCharacterId="Workspace.State.PlaySelectedCharacterId"
|
|
||||||
SelectedCharacterName="@(Workspace.State.PlaySelectedCharacter?.Name)"
|
|
||||||
SelectedCampaignRulesetId="@(Workspace.State.PlaySelectedCampaign?.RulesetId ?? string.Empty)"
|
|
||||||
RollVisibility="Workspace.State.RollVisibility"
|
|
||||||
IsMutating="Workspace.State.IsMutating"
|
|
||||||
ToggleRollDetailRequested="Workspace.Play.ToggleRollDetailAsync"
|
|
||||||
ResolveRollDetail="Workspace.Play.ResolveRollDetail"
|
|
||||||
IsRollDetailLoading="Workspace.Play.IsRollDetailLoading"
|
|
||||||
GetRollDetailError="Workspace.Play.GetRollDetailError"
|
|
||||||
CustomRollCreated="Workspace.Play.OnCustomRollCreatedAsync"
|
|
||||||
ErrorOccurred="Workspace.Play.OnCampaignLogPanelErrorAsync"/>
|
|
||||||
</main>
|
|
||||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
|
||||||
<button type="button" class="switch @(Workspace.State.MobilePanel == "character" ? "active" : string.Empty)"
|
|
||||||
@onclick='() => Workspace.Scope.SetMobilePanelAsync("character")'>
|
|
||||||
Character
|
|
||||||
</button>
|
|
||||||
<button type="button" class="switch @(Workspace.State.MobilePanel == "log" ? "active" : string.Empty)"
|
|
||||||
@onclick='() => Workspace.Scope.SetMobilePanelAsync("log")'>
|
|
||||||
Log
|
|
||||||
</button>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<CharacterManagementModals Workspace="Workspace"/>
|
|
||||||
|
|
||||||
<RolemasterSkillRollModal
|
|
||||||
Visible="Workspace.State.ShowRolemasterSkillRollModal"
|
|
||||||
SkillName="@(Workspace.State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
|
|
||||||
Expression="@(Workspace.State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
|
|
||||||
ModifierText="@Workspace.State.PendingRolemasterSituationalModifier"
|
|
||||||
ModifierTextChanged="@(text => Workspace.State.PendingRolemasterSituationalModifier = text)"
|
|
||||||
ErrorMessage="@Workspace.State.PendingRolemasterSkillRollError"
|
|
||||||
IsMutating="Workspace.State.IsMutating"
|
|
||||||
IsSubmitting="Workspace.State.IsSubmittingRolemasterSkillRoll"
|
|
||||||
ConfirmRequested="Workspace.Play.SubmitRolemasterSkillRollAsync"
|
|
||||||
CancelRequested="Workspace.Play.CancelRolemasterSkillRollAsync"/>
|
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading;
|
private bool IsCampaignDataLoading => !Workspace.HasSessionInitialized || Workspace.State.IsCampaignDataLoading;
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
<div class="@AppCssClass"
|
<div class="@AppCssClass"
|
||||||
data-workspace-root="true"
|
data-workspace-root="true"
|
||||||
data-workspace-route="@Route"
|
data-workspace-route="@Route"
|
||||||
|
data-workspace-phase="@PageContext.RenderPhase"
|
||||||
data-workspace-session-initialized="@HasSessionInitialized"
|
data-workspace-session-initialized="@HasSessionInitialized"
|
||||||
data-workspace-campaign-loading="@State.IsCampaignDataLoading"
|
data-workspace-campaign-loading="@State.IsCampaignDataLoading"
|
||||||
data-workspace-admin-loading="@State.IsAdminDataLoading"
|
data-workspace-admin-loading="@State.IsAdminDataLoading"
|
||||||
data-workspace-user="@(State.User?.Username ?? "loading")">
|
data-workspace-user="@(State.User?.Username ?? "loading")">
|
||||||
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
<p class="sr-only" aria-live="polite">@State.LiveAnnouncement</p>
|
||||||
|
|
||||||
@if (State.HasHealthIssue)
|
@if (PageContext.ShowLiveContent && State.HasHealthIssue)
|
||||||
{
|
{
|
||||||
<section class="health-banner" role="alert">
|
<section class="health-banner" role="alert">
|
||||||
<div>
|
<div>
|
||||||
@@ -20,27 +21,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="workspace-shell">
|
<div class="workspace-shell">
|
||||||
<AppHeader
|
@if (PageContext.ShowLiveContent)
|
||||||
User="State.User"
|
|
||||||
ShowCampaign="@ShowCampaignInHeader"
|
|
||||||
CampaignName="@State.SelectedCampaignName"
|
|
||||||
ShowConnectionState="@ShowConnectionStateInHeader"
|
|
||||||
ConnectionStateLabel="@State.ConnectionStateLabel"
|
|
||||||
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
|
||||||
IsMenuOpen="State.IsScreenMenuOpen"
|
|
||||||
MenuButtonId="workspace-screen-menu-button"
|
|
||||||
MenuId="workspace-screen-menu"
|
|
||||||
MenuItems="HeaderMenuItems"
|
|
||||||
ToggleMenuRequested="ToggleScreenMenu"
|
|
||||||
LogoutRequested="Session.LogoutAsync"/>
|
|
||||||
|
|
||||||
@if (ChildContent is not null)
|
|
||||||
{
|
{
|
||||||
@ChildContent(PageContext)
|
<AppHeader
|
||||||
|
User="State.User"
|
||||||
|
ShowCampaign="@ShowCampaignInHeader"
|
||||||
|
CampaignName="@State.SelectedCampaignName"
|
||||||
|
ShowConnectionState="@ShowConnectionStateInHeader"
|
||||||
|
ConnectionStateLabel="@State.ConnectionStateLabel"
|
||||||
|
ConnectionStateCssClass="@State.ConnectionStateCssClass"
|
||||||
|
IsMenuOpen="State.IsScreenMenuOpen"
|
||||||
|
MenuButtonId="workspace-screen-menu-button"
|
||||||
|
MenuId="workspace-screen-menu"
|
||||||
|
MenuItems="HeaderMenuItems"
|
||||||
|
ToggleMenuRequested="ToggleScreenMenu"
|
||||||
|
LogoutRequested="Session.LogoutAsync"/>
|
||||||
|
|
||||||
|
@if (ChildContent is not null)
|
||||||
|
{
|
||||||
|
@ChildContent(PageContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (PageContext.ShowHeaderPlaceholder)
|
||||||
|
{
|
||||||
|
<header class="workspace-header">
|
||||||
|
<div class="header-row">
|
||||||
|
<h1>RpgRoller</h1>
|
||||||
|
<p class="header-identity">
|
||||||
|
<strong>Loading workspace...</strong>
|
||||||
|
</p>
|
||||||
|
@if (ShowCampaignInHeader)
|
||||||
|
{
|
||||||
|
<p class="header-campaign">Campaign: <strong>Loading...</strong></p>
|
||||||
|
}
|
||||||
|
@if (ShowConnectionStateInHeader)
|
||||||
|
{
|
||||||
|
<div class="header-connection-cell">
|
||||||
|
<p class="connection offline">Offline fallback</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@if (PageContext.ShowRouteSkeleton && ChildContent is not null)
|
||||||
|
{
|
||||||
|
@ChildContent(PageContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<section class="card" aria-live="polite">
|
||||||
|
<p>Loading workspace...</p>
|
||||||
|
</section>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (State.Toasts.Count > 0)
|
@if (PageContext.ShowLiveContent && State.Toasts.Count > 0)
|
||||||
{
|
{
|
||||||
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
<div class="toast-stack" aria-live="polite" aria-atomic="false">
|
||||||
@foreach (var toast in State.Toasts)
|
@foreach (var toast in State.Toasts)
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
RenderCount += 1;
|
RenderCount += 1;
|
||||||
Logger.LogInformation(
|
Logger.LogInformation(
|
||||||
"Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
|
"Workspace.OnAfterRenderAsync route={Route} renderCount={RenderCount} firstRender={FirstRender} renderPhase={RenderPhase} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
|
||||||
Route, RenderCount, firstRender, HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(State));
|
Route, RenderCount, firstRender, RenderPhase, HasSessionInitialized,
|
||||||
|
WorkspaceDiagnosticSummary.DescribeState(State));
|
||||||
|
if (RenderPhase < WorkspaceRenderPhase.RouteSkeleton)
|
||||||
|
return AdvanceRenderPhaseAsync("Workspace.OnAfterRenderAsync");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +153,14 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return InitializationTask ??= InitializeRouteCoreAsync();
|
return InitializationTask ??= InitializeRouteCoreAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task EnsureLiveRenderPhaseAsync()
|
||||||
|
{
|
||||||
|
if (RenderPhase == WorkspaceRenderPhase.Live)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
return AdvanceRenderPhaseAsync("WorkspaceRouteView.EnsureLiveRenderPhaseAsync", WorkspaceRenderPhase.Live);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task InitializeRouteCoreAsync()
|
private async Task InitializeRouteCoreAsync()
|
||||||
{
|
{
|
||||||
if (HasSessionInitialized)
|
if (HasSessionInitialized)
|
||||||
@@ -199,6 +211,19 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private Task AdvanceRenderPhaseAsync(string source, WorkspaceRenderPhase? targetPhase = null)
|
||||||
|
{
|
||||||
|
var nextPhase = targetPhase ?? (WorkspaceRenderPhase)((int)RenderPhase + 1);
|
||||||
|
if (nextPhase <= RenderPhase)
|
||||||
|
return Task.CompletedTask;
|
||||||
|
|
||||||
|
Logger.LogInformation(
|
||||||
|
"Workspace.AdvanceRenderPhaseAsync source={Source} route={Route} from={FromPhase} to={ToPhase}",
|
||||||
|
source, Route, RenderPhase, nextPhase);
|
||||||
|
RenderPhase = nextPhase;
|
||||||
|
return RequestRefreshAsync(source);
|
||||||
|
}
|
||||||
|
|
||||||
[Inject] private IJSRuntime JS { get; set; } = null!;
|
[Inject] private IJSRuntime JS { get; set; } = null!;
|
||||||
|
|
||||||
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
[Inject] private RpgRollerApiClient ApiClient { get; set; } = null!;
|
||||||
@@ -223,8 +248,8 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private bool ShowConnectionStateInHeader => IsPlayRoute;
|
private bool ShowConnectionStateInHeader => IsPlayRoute;
|
||||||
|
|
||||||
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
|
private WorkspacePageContext PageContext => new(State, Play, Campaigns, Admin, Scope, Session,
|
||||||
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, AdminDatabaseDownloadUrl, HeaderMenuItems,
|
InitializeRouteAsync, HasSessionInitialized, RequestRefreshAsync, EnsureLiveRenderPhaseAsync,
|
||||||
IsPlayRoute, IsCampaignsRoute, IsAdminRoute);
|
AdminDatabaseDownloadUrl, HeaderMenuItems, RenderPhase, 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,
|
||||||
@@ -308,4 +333,5 @@ public partial class Workspace : IAsyncDisposable
|
|||||||
private Task? InitializationTask { get; set; }
|
private Task? InitializationTask { get; set; }
|
||||||
private WorkspaceRoute? PreviousRoute { get; set; }
|
private WorkspaceRoute? PreviousRoute { get; set; }
|
||||||
private int RenderCount { get; set; }
|
private int RenderCount { get; set; }
|
||||||
|
private WorkspaceRenderPhase RenderPhase { get; set; }
|
||||||
}
|
}
|
||||||
@@ -12,8 +12,10 @@ public sealed class WorkspacePageContext(
|
|||||||
Func<Task> initializeRouteAsync,
|
Func<Task> initializeRouteAsync,
|
||||||
bool hasSessionInitialized,
|
bool hasSessionInitialized,
|
||||||
Func<Task> requestRefreshAsync,
|
Func<Task> requestRefreshAsync,
|
||||||
|
Func<Task> ensureLiveRenderPhaseAsync,
|
||||||
string adminDatabaseDownloadUrl,
|
string adminDatabaseDownloadUrl,
|
||||||
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
|
IReadOnlyList<AppHeaderMenuItem> headerMenuItems,
|
||||||
|
WorkspaceRenderPhase renderPhase,
|
||||||
bool isPlayRoute,
|
bool isPlayRoute,
|
||||||
bool isCampaignsRoute,
|
bool isCampaignsRoute,
|
||||||
bool isAdminRoute)
|
bool isAdminRoute)
|
||||||
@@ -27,9 +29,14 @@ public sealed class WorkspacePageContext(
|
|||||||
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
|
public Func<Task> InitializeRouteAsync { get; } = initializeRouteAsync;
|
||||||
public bool HasSessionInitialized { get; } = hasSessionInitialized;
|
public bool HasSessionInitialized { get; } = hasSessionInitialized;
|
||||||
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
|
public Func<Task> RequestRefreshAsync { get; } = requestRefreshAsync;
|
||||||
|
public Func<Task> EnsureLiveRenderPhaseAsync { get; } = ensureLiveRenderPhaseAsync;
|
||||||
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
|
public string AdminDatabaseDownloadUrl { get; } = adminDatabaseDownloadUrl;
|
||||||
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
|
public IReadOnlyList<AppHeaderMenuItem> HeaderMenuItems { get; } = headerMenuItems;
|
||||||
|
public WorkspaceRenderPhase RenderPhase { get; } = renderPhase;
|
||||||
public bool IsPlayRoute { get; } = isPlayRoute;
|
public bool IsPlayRoute { get; } = isPlayRoute;
|
||||||
public bool IsCampaignsRoute { get; } = isCampaignsRoute;
|
public bool IsCampaignsRoute { get; } = isCampaignsRoute;
|
||||||
public bool IsAdminRoute { get; } = isAdminRoute;
|
public bool IsAdminRoute { get; } = isAdminRoute;
|
||||||
|
public bool ShowHeaderPlaceholder => RenderPhase >= WorkspaceRenderPhase.HeaderPlaceholder;
|
||||||
|
public bool ShowRouteSkeleton => RenderPhase >= WorkspaceRenderPhase.RouteSkeleton;
|
||||||
|
public bool ShowLiveContent => RenderPhase == WorkspaceRenderPhase.Live;
|
||||||
}
|
}
|
||||||
9
RpgRoller/Components/Pages/WorkspaceRenderPhase.cs
Normal file
9
RpgRoller/Components/Pages/WorkspaceRenderPhase.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace RpgRoller.Components.Pages;
|
||||||
|
|
||||||
|
public enum WorkspaceRenderPhase
|
||||||
|
{
|
||||||
|
Minimal = 0,
|
||||||
|
HeaderPlaceholder = 1,
|
||||||
|
RouteSkeleton = 2,
|
||||||
|
Live = 3
|
||||||
|
}
|
||||||
@@ -7,15 +7,16 @@
|
|||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
Logger.LogInformation(
|
Logger.LogInformation(
|
||||||
"WorkspaceRouteView.OnAfterRenderAsync routeFlags=play:{IsPlayRoute} campaigns:{IsCampaignsRoute} admin:{IsAdminRoute} firstRender={FirstRender} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
|
"WorkspaceRouteView.OnAfterRenderAsync routeFlags=play:{IsPlayRoute} campaigns:{IsCampaignsRoute} admin:{IsAdminRoute} firstRender={FirstRender} renderPhase={RenderPhase} hasSessionInitialized={HasSessionInitialized} state=[{State}]",
|
||||||
Workspace.IsPlayRoute, Workspace.IsCampaignsRoute, Workspace.IsAdminRoute, firstRender,
|
Workspace.IsPlayRoute, Workspace.IsCampaignsRoute, Workspace.IsAdminRoute, firstRender,
|
||||||
Workspace.HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
|
Workspace.RenderPhase, Workspace.HasSessionInitialized, WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
|
||||||
await TryMarkWorkspacePhaseAsync(firstRender ? "after-first-render" : "after-render");
|
await TryMarkWorkspacePhaseAsync(firstRender ? "after-first-render" : "after-render");
|
||||||
if (!firstRender)
|
if (!firstRender)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await TryInstallWorkspaceDiagnosticsAsync();
|
await TryInstallWorkspaceDiagnosticsAsync();
|
||||||
await Workspace.InitializeRouteAsync();
|
await Workspace.InitializeRouteAsync();
|
||||||
|
await Workspace.EnsureLiveRenderPhaseAsync();
|
||||||
Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]",
|
Logger.LogInformation("WorkspaceRouteView.OnAfterRenderAsync initialized state=[{State}]",
|
||||||
WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
|
WorkspaceDiagnosticSummary.DescribeState(Workspace.State));
|
||||||
await TryMarkWorkspacePhaseAsync("after-initialize-route");
|
await TryMarkWorkspacePhaseAsync("after-initialize-route");
|
||||||
|
|||||||
4
TASKS.md
4
TASKS.md
@@ -25,6 +25,7 @@ The change is complete when a human can run the app, open `/`, observe the corre
|
|||||||
- [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.
|
||||||
- [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) 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) 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.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
|
||||||
@@ -52,6 +53,9 @@ The change is complete when a human can run the app, open `/`, observe the corre
|
|||||||
- Observation: the remaining Firefox failure still happens during Blazor batch application, so server-side coordinator logs alone are not enough to localize it.
|
- 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.
|
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 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.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user