Redesign Story Intelligence Review Centre as tile grid

This commit is contained in:
Nick Beckley 2026-08-29 09:56:32 +00:00
parent 730537daee
commit dd4b0badb6
3 changed files with 260 additions and 109 deletions

View File

@ -233,6 +233,7 @@ var tests = new (string Name, Action Test)[]
("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume), ("Story Intelligence Review Centre route replaces linear review resume", StoryIntelligenceReviewCentreRouteReplacesLinearReviewResume),
("Story Intelligence obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved), ("Story Intelligence obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved),
("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories), ("Story Intelligence Review Centre counts only needs-attention categories", StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories),
("Story Intelligence Review Centre uses independent tile grid", StoryIntelligenceReviewCentreUsesIndependentTileGrid),
("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate), ("Story Intelligence Review Centre opens with character gate", StoryIntelligenceReviewCentreOpensWithCharacterGate),
("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate), ("Story Intelligence review modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate),
("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis), ("Story Intelligence downstream review uses completed scene analysis", StoryIntelligenceDownstreamReviewUsesCompletedSceneAnalysis),
@ -665,6 +666,51 @@ static void StoryIntelligenceReviewCentreCountsOnlyNeedsAttentionCategories()
Assert(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload."); Assert(!summary.Contains("Locations identified", StringComparison.Ordinal), "Summary should not present total identified rows as review workload.");
} }
static void StoryIntelligenceReviewCentreUsesIndependentTileGrid()
{
var root = PlotLineRoot();
var centre = File.ReadAllText(Path.Combine(root, "Views/Onboarding/StoryIntelligenceReviewCentre.cshtml"));
var css = File.ReadAllText(Path.Combine(root, "wwwroot/css/onboarding.css"));
var tileOrder = new[]
{
"\"Characters\"",
"\"Assets\"",
"\"Knowledge\"",
"\"Locations\"",
"\"Plot Lines & Threads\"",
"\"Relationships\"",
"ReviewMetrics"
};
Assert(!centre.Contains("_StoryIntelligencePipelineHeader", StringComparison.Ordinal), "Review Centre should not render wizard progress header.");
Assert(!centre.Contains("_StoryIntelligencePipelineSummary", StringComparison.Ordinal), "Review Centre should not render separate summary statistics.");
Assert(!centre.Contains("story-review-centre-list", StringComparison.Ordinal), "Review Centre should no longer use the sequential row list.");
Assert(centre.Contains("story-review-centre-grid", StringComparison.Ordinal), "Review Centre should render a category tile grid.");
Assert(centre.Contains("story-review-centre-tile--primary", StringComparison.Ordinal), "Characters tile should have subtle first-action emphasis.");
Assert(centre.Contains("Resolve Characters first.", StringComparison.Ordinal), "Header should explain character-first gating compactly.");
Assert(centre.Contains("can be reviewed in any order", StringComparison.Ordinal), "Header should communicate independent review order.");
Assert(centre.Contains("Locked until Characters are resolved", StringComparison.Ordinal), "Locked dependent tiles should be visible and clearly locked.");
Assert(centre.Contains(">Locked</span>", StringComparison.Ordinal), "Locked tile action should render as non-clickable text.");
Assert(centre.Contains("Available", StringComparison.Ordinal) && centre.Contains("View Metrics", StringComparison.Ordinal), "Scene Metrics should be available, not a mandatory review workload.");
Assert(!centre.Contains("Next", StringComparison.Ordinal) && !centre.Contains("Previous", StringComparison.Ordinal), "Review Centre should not contain wizard navigation.");
Assert(!centre.Contains("Core Import", StringComparison.Ordinal) && !centre.Contains("Analyse Manuscript", StringComparison.Ordinal), "Review Centre should not show phase panels.");
var lastIndex = -1;
foreach (var marker in tileOrder)
{
var index = centre.IndexOf(marker, StringComparison.Ordinal);
Assert(index > lastIndex, $"{marker} should appear in the expected tile order.");
lastIndex = index;
}
Assert(css.Contains(".story-review-centre-grid", StringComparison.Ordinal), "Review Centre grid CSS should exist.");
Assert(css.Contains("grid-template-columns: repeat(3, minmax(0, 1fr))", StringComparison.Ordinal), "Desktop Review Centre should use three columns.");
Assert(css.Contains("@media (max-width: 980px)", StringComparison.Ordinal)
&& css.Contains("grid-template-columns: repeat(2, minmax(0, 1fr))", StringComparison.Ordinal), "Tablet Review Centre should use two columns.");
Assert(css.Contains("@media (max-width: 720px)", StringComparison.Ordinal)
&& css.Contains(".story-review-centre-grid {\n grid-template-columns: 1fr;", StringComparison.Ordinal), "Mobile Review Centre should use one column.");
}
static void StoryIntelligenceReviewCentreOpensWithCharacterGate() static void StoryIntelligenceReviewCentreOpensWithCharacterGate()
{ {
var root = PlotLineRoot(); var root = PlotLineRoot();

View File

@ -3,14 +3,14 @@
ViewData["Title"] = "Story Intelligence Review Centre"; ViewData["Title"] = "Story Intelligence Review Centre";
} }
<section class="onboarding-shell" aria-labelledby="story-review-centre-title"> <section class="story-review-centre-shell" aria-labelledby="story-review-centre-title">
<div class="onboarding-panel onboarding-review-panel story-review-page"> <div class="story-review-centre-workspace">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Centre" })" /> <div class="story-review-centre-heading">
<div class="onboarding-copy story-review-heading">
<p class="eyebrow">Story Intelligence</p> <p class="eyebrow">Story Intelligence</p>
<h1 id="story-review-centre-title">Review Centre</h1> <h1 id="story-review-centre-title">Review Centre</h1>
<p>Your manuscript analysis is complete. Review only the story intelligence that needs an author decision; everything here is saved and can be picked up later.</p> <p>@(Model.CharacterGateSatisfied
? "Your manuscript analysis is complete. Choose any category to inspect or review."
: "Resolve Characters first. Once character identities are confirmed, the remaining Story Intelligence categories can be reviewed in any order.")</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -22,138 +22,169 @@
<div class="alert alert-success">@message</div> <div class="alert alert-success">@message</div>
} }
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" /> <section class="story-review-centre-grid" aria-label="Story Intelligence review categories">
<section class="story-review-centre-list" aria-label="Story Intelligence review categories">
@ReviewModule( @ReviewModule(
"Characters", "Characters",
Model.CharacterNeedsAttentionCount, Model.CharacterNeedsAttentionCount,
Model.CharacterGateSatisfied ? "Resolved" : "Needs attention", Model.CharacterGateSatisfied,
"Resolve identity questions before reviewing dependent story intelligence.", false,
"Resolve Characters", "Resolve character identities before reviewing dependent Story Intelligence.",
Model.CharacterGateSatisfied ? "Review Characters" : "Resolve Characters",
"StoryIntelligenceCharacters", "StoryIntelligenceCharacters",
false) true)
@ReviewModule(
"Locations",
Model.LocationNeedsAttentionCount,
Model.PipelineDashboard.LocationStageComplete ? "Reviewed" : AttentionLabel(Model.LocationNeedsAttentionCount),
"Places found in the manuscript that may be worth creating, linking, or ignoring.",
"Review locations",
"StoryIntelligenceLocations",
!Model.CharacterGateSatisfied)
@ReviewModule( @ReviewModule(
"Assets", "Assets",
Model.AssetNeedsAttentionCount, Model.AssetNeedsAttentionCount,
Model.PipelineDashboard.AssetStageComplete ? "Reviewed" : AttentionLabel(Model.AssetNeedsAttentionCount), Model.PipelineDashboard.AssetStageComplete,
"Important objects and possessions detected from generic evidence such as recurrence, ownership, and scene prominence.", !Model.CharacterGateSatisfied,
"Review assets", "Important objects and possessions found in the manuscript.",
"StoryIntelligenceAssets", ModuleAction("Assets", Model.AssetNeedsAttentionCount, Model.PipelineDashboard.AssetStageComplete),
!Model.CharacterGateSatisfied) "StoryIntelligenceAssets")
@ReviewModule(
"Relationships",
Model.RelationshipNeedsAttentionCount,
Model.PipelineDashboard.RelationshipStageComplete ? "Reviewed" : AttentionLabel(Model.RelationshipNeedsAttentionCount),
"Relationship candidates grouped by the canonical character identities involved.",
"Review relationships",
"StoryIntelligenceRelationships",
!Model.CharacterGateSatisfied)
@ReviewModule( @ReviewModule(
"Knowledge", "Knowledge",
Model.KnowledgeNeedsAttentionCount, Model.KnowledgeNeedsAttentionCount,
Model.PipelineDashboard.KnowledgeStageComplete ? "Reviewed" : AttentionLabel(Model.KnowledgeNeedsAttentionCount), Model.PipelineDashboard.KnowledgeStageComplete,
"Facts, discoveries, beliefs, and suspicions that need a create, link, merge, or ignore decision.", !Model.CharacterGateSatisfied,
"Review knowledge", "Facts, discoveries, beliefs, and suspicions found in the manuscript.",
"StoryIntelligenceKnowledge", ModuleAction("Knowledge", Model.KnowledgeNeedsAttentionCount, Model.PipelineDashboard.KnowledgeStageComplete),
!Model.CharacterGateSatisfied) "StoryIntelligenceKnowledge")
@ReviewModule(
"Locations",
Model.LocationNeedsAttentionCount,
Model.PipelineDashboard.LocationStageComplete,
!Model.CharacterGateSatisfied,
"Places found in the manuscript.",
ModuleAction("Locations", Model.LocationNeedsAttentionCount, Model.PipelineDashboard.LocationStageComplete),
"StoryIntelligenceLocations")
@ReviewModule( @ReviewModule(
"Plot Lines & Threads", "Plot Lines & Threads",
Model.NarrativeNeedsAttentionCount, Model.NarrativeNeedsAttentionCount,
Model.PipelineDashboard.NarrativeStageComplete ? "Reviewed" : AttentionLabel(Model.NarrativeNeedsAttentionCount), Model.PipelineDashboard.NarrativeStageComplete,
"Recurring arcs and threads organised by the narrative hierarchy discovered from scene evidence.", !Model.CharacterGateSatisfied,
"Review plot lines", "Recurring arcs and threads discovered from scene evidence.",
"StoryIntelligenceNarrative", ModuleAction("Plot Lines & Threads", Model.NarrativeNeedsAttentionCount, Model.PipelineDashboard.NarrativeStageComplete),
!Model.CharacterGateSatisfied) "StoryIntelligenceNarrative")
<article class="story-review-centre-row"> @ReviewModule(
<div> "Relationships",
<h2>Scene Metrics</h2> Model.RelationshipNeedsAttentionCount,
<p>Available from the imported scene analysis. Metrics are auto-applied where configured and do not require a mandatory review step.</p> Model.PipelineDashboard.RelationshipStageComplete,
</div> !Model.CharacterGateSatisfied,
@if (Model.CharacterGateSatisfied) "Character relationships found in scene evidence.",
{ ModuleAction("Relationships", Model.RelationshipNeedsAttentionCount, Model.PipelineDashboard.RelationshipStageComplete),
<strong class="story-review-status story-review-status--ready">Available</strong> "StoryIntelligenceRelationships")
<span class="story-review-centre-row__count">@Model.TotalAnalysedScenes.ToString("N0") scenes</span>
} @ReviewMetrics(!Model.CharacterGateSatisfied)
else
{
<strong class="story-review-status story-review-status--active">Resolve Characters first</strong>
<span class="story-review-centre-row__count">Locked until Characters are resolved</span>
}
</article>
@if (Model.AnalysisIssueCount > 0) @if (Model.AnalysisIssueCount > 0)
{ {
<article class="story-review-centre-row"> @ReviewModule(
<div> "Analysis Issues",
<h2>Analysis Issues</h2> Model.AnalysisIssueCount,
<p>Some chapters or scenes reported analysis issues. Canonical chapters and scenes remain intact.</p> false,
</div> false,
<strong class="story-review-status story-review-status--warning">Needs attention</strong> "Some chapters or scenes reported analysis issues.",
<a class="btn btn-outline-primary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a> "Return to Book",
<span class="story-review-centre-row__count">@Model.AnalysisIssueCount.ToString("N0") need attention</span> null)
</article>
} }
</section> </section>
<div class="onboarding-actions"> <div class="onboarding-actions story-review-centre-actions">
<a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a> <a class="btn btn-outline-secondary" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to Book</a>
<a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open writer workspace</a> <a class="btn btn-outline-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div> </div>
</div> </div>
</section> </section>
@functions { @functions {
private string AttentionLabel(int count) => count == 0 ? "No outstanding decisions" : "Needs attention"; private string ModuleAction(string title, int count, bool complete)
=> count > 0 && !complete ? $"Review {title}" : $"View {title}";
private string StatusText(string title, int count, bool complete, bool blocked)
{
if (blocked)
{
return "Locked until Characters are resolved";
}
if (string.Equals(title, "Scene Metrics", StringComparison.Ordinal))
{
return "Available";
}
if (complete)
{
return "Complete";
}
return count switch
{
0 => "No review needed",
1 => "1 needs attention",
_ => $"{count:N0} need attention"
};
}
private Microsoft.AspNetCore.Html.IHtmlContent ReviewMetrics(bool blocked)
{
var status = StatusText("Scene Metrics", Model.TotalAnalysedScenes, false, blocked);
var statusClass = blocked
? "story-review-centre-tile__status story-review-centre-tile__status--locked"
: "story-review-centre-tile__status story-review-centre-tile__status--ready";
var actionHtml = blocked
? "<span class=\"btn btn-outline-secondary btn-sm disabled\" aria-disabled=\"true\">Locked</span>"
: "<span class=\"btn btn-outline-primary btn-sm disabled\" aria-disabled=\"true\">View Metrics</span>";
return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-tile{{(blocked ? " story-review-centre-tile--locked" : string.Empty)}}">
<div>
<h2>Scene Metrics</h2>
<p>Auto-applied metrics from analysed scenes.</p>
</div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
{{actionHtml}}
</article>
""");
}
private Microsoft.AspNetCore.Html.IHtmlContent ReviewModule( private Microsoft.AspNetCore.Html.IHtmlContent ReviewModule(
string title, string title,
int count, int count,
string status, bool complete,
bool blocked,
string description, string description,
string actionLabel, string actionLabel,
string action, string? action,
bool blocked) bool primary = false)
{ {
var status = StatusText(title, count, complete, blocked);
var statusClass = blocked var statusClass = blocked
? "story-review-status story-review-status--active" ? "story-review-centre-tile__status story-review-centre-tile__status--locked"
: count > 0 : count > 0 && !complete
? "story-review-status story-review-status--warning" ? "story-review-centre-tile__status story-review-centre-tile__status--warning"
: "story-review-status story-review-status--ready"; : "story-review-centre-tile__status story-review-centre-tile__status--ready";
var statusText = blocked ? "Resolve Characters first" : status; var tileClass = "story-review-centre-tile"
var countText = blocked + (primary ? " story-review-centre-tile--primary" : string.Empty)
? "Locked until Characters are resolved" + (blocked ? " story-review-centre-tile--locked" : string.Empty);
: count == 1 var href = action is null
? "1 needs attention" ? Url.Action("Details", "Books", new { id = Model.BookID }) ?? "#"
: $"{count:N0} need attention";
var disabled = blocked ? " disabled aria-disabled=\"true\"" : string.Empty;
var href = blocked
? "#"
: Url.Action(action, "Onboarding", new { batchId = Model.BatchID }) ?? "#"; : Url.Action(action, "Onboarding", new { batchId = Model.BatchID }) ?? "#";
var actionHtml = blocked
? "<span class=\"btn btn-outline-secondary btn-sm disabled\" aria-disabled=\"true\">Locked</span>"
: $"<a class=\"btn btn-outline-primary btn-sm\" href=\"{System.Net.WebUtility.HtmlEncode(href)}\">{System.Net.WebUtility.HtmlEncode(actionLabel)}</a>";
return new Microsoft.AspNetCore.Html.HtmlString($$""" return new Microsoft.AspNetCore.Html.HtmlString($$"""
<article class="story-review-centre-row"> <article class="{{tileClass}}">
<div> <div>
<h2>{{System.Net.WebUtility.HtmlEncode(title)}}</h2> <h2>{{System.Net.WebUtility.HtmlEncode(title)}}</h2>
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p> <p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div> </div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(statusText)}}</strong> <strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
<a class="btn btn-outline-primary btn-sm{{(blocked ? " disabled" : string.Empty)}}" href="{{href}}"{{disabled}}>{{System.Net.WebUtility.HtmlEncode(actionLabel)}}</a> {{actionHtml}}
<span class="story-review-centre-row__count">{{System.Net.WebUtility.HtmlEncode(countText)}}</span>
</article> </article>
"""); """);
} }

View File

@ -1384,36 +1384,101 @@ summary.story-review-chapter-heading {
margin-top: .2rem; margin-top: .2rem;
} }
.story-review-centre-list { .story-review-centre-shell {
display: grid; max-width: 1100px;
gap: .65rem; margin: 0 auto;
padding: 2.25rem 1rem 3rem;
} }
.story-review-centre-row { .story-review-centre-workspace {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; gap: 1rem;
gap: .75rem 1rem; }
align-items: center;
.story-review-centre-heading {
max-width: 760px;
}
.story-review-centre-heading h1 {
margin: .15rem 0 .35rem;
}
.story-review-centre-heading p:not(.eyebrow) {
margin: 0;
color: var(--bs-secondary-color);
font-size: 1.02rem;
}
.story-review-centre-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: .9rem;
align-items: stretch;
}
.story-review-centre-tile {
min-height: 12rem;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: .85rem;
border: 1px solid rgba(31, 42, 68, .1); border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px; border-radius: 8px;
padding: .85rem 1rem; padding: 1rem;
background: rgba(255, 255, 255, .64); background: rgba(255, 255, 255, .64);
} }
.story-review-centre-row h2 { .story-review-centre-tile--primary {
border-color: rgba(122, 74, 39, .32);
box-shadow: 0 10px 24px rgba(122, 74, 39, .08);
}
.story-review-centre-tile--locked {
background: rgba(255, 255, 255, .46);
}
.story-review-centre-tile h2 {
margin: 0; margin: 0;
font-size: 1.05rem; font-size: 1.05rem;
} }
.story-review-centre-row p { .story-review-centre-tile p {
margin: .2rem 0 0; margin: .2rem 0 0;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-size: .94rem;
}
.story-review-centre-tile__status {
display: block;
font-weight: 700;
color: var(--bs-body-color);
}
.story-review-centre-tile__status--ready {
color: #146c43;
}
.story-review-centre-tile__status--warning {
color: #7a4a27;
}
.story-review-centre-tile__status--locked {
color: var(--bs-secondary-color);
} }
.story-review-centre-row__count { .story-review-centre-tile .btn {
grid-column: 1 / -1; align-self: flex-start;
}
.story-review-centre-tile .btn.disabled {
pointer-events: none;
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-weight: 700; border-color: rgba(31, 42, 68, .16);
background: rgba(31, 42, 68, .04);
}
.story-review-centre-actions {
justify-content: flex-start;
} }
.story-scene-preview-list { .story-scene-preview-list {
@ -1972,6 +2037,12 @@ summary.story-review-chapter-heading {
} }
} }
@media (max-width: 980px) {
.story-review-centre-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) { @media (max-width: 720px) {
.story-pipeline-phase-list, .story-pipeline-phase-list,
.story-database-build ul { .story-database-build ul {
@ -2042,7 +2113,6 @@ summary.story-review-chapter-heading {
.story-review-summary, .story-review-summary,
.story-review-scene-panel, .story-review-scene-panel,
.story-character-card__body dl, .story-character-card__body dl,
.story-review-centre-row,
.story-future-stage-grid, .story-future-stage-grid,
.story-scene-preview dl, .story-scene-preview dl,
.story-live-current, .story-live-current,
@ -2051,6 +2121,10 @@ summary.story-review-chapter-heading {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.story-review-centre-grid {
grid-template-columns: 1fr;
}
.story-live-progress-heading, .story-live-progress-heading,
.story-review-chapter-heading { .story-review-chapter-heading {
flex-direction: column; flex-direction: column;