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 obsolete create scene workflow is removed", StoryIntelligenceObsoleteCreateSceneWorkflowIsRemoved),
("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 modules are independent after character gate", StoryIntelligenceReviewModulesAreIndependentAfterCharacterGate),
("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.");
}
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()
{
var root = PlotLineRoot();

View File

@ -3,14 +3,14 @@
ViewData["Title"] = "Story Intelligence Review Centre";
}
<section class="onboarding-shell" aria-labelledby="story-review-centre-title">
<div class="onboarding-panel onboarding-review-panel story-review-page">
<partial name="_StoryIntelligencePipelineHeader" model="@(new StoryIntelligencePipelineHeaderViewModel { CurrentStage = "Review Centre" })" />
<div class="onboarding-copy story-review-heading">
<section class="story-review-centre-shell" aria-labelledby="story-review-centre-title">
<div class="story-review-centre-workspace">
<div class="story-review-centre-heading">
<p class="eyebrow">Story Intelligence</p>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -22,138 +22,169 @@
<div class="alert alert-success">@message</div>
}
<partial name="_StoryIntelligencePipelineSummary" model="Model.PipelineDashboard" />
<section class="story-review-centre-list" aria-label="Story Intelligence review categories">
<section class="story-review-centre-grid" aria-label="Story Intelligence review categories">
@ReviewModule(
"Characters",
Model.CharacterNeedsAttentionCount,
Model.CharacterGateSatisfied ? "Resolved" : "Needs attention",
"Resolve identity questions before reviewing dependent story intelligence.",
"Resolve Characters",
Model.CharacterGateSatisfied,
false,
"Resolve character identities before reviewing dependent Story Intelligence.",
Model.CharacterGateSatisfied ? "Review Characters" : "Resolve Characters",
"StoryIntelligenceCharacters",
false)
@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)
true)
@ReviewModule(
"Assets",
Model.AssetNeedsAttentionCount,
Model.PipelineDashboard.AssetStageComplete ? "Reviewed" : AttentionLabel(Model.AssetNeedsAttentionCount),
"Important objects and possessions detected from generic evidence such as recurrence, ownership, and scene prominence.",
"Review assets",
"StoryIntelligenceAssets",
!Model.CharacterGateSatisfied)
@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)
Model.PipelineDashboard.AssetStageComplete,
!Model.CharacterGateSatisfied,
"Important objects and possessions found in the manuscript.",
ModuleAction("Assets", Model.AssetNeedsAttentionCount, Model.PipelineDashboard.AssetStageComplete),
"StoryIntelligenceAssets")
@ReviewModule(
"Knowledge",
Model.KnowledgeNeedsAttentionCount,
Model.PipelineDashboard.KnowledgeStageComplete ? "Reviewed" : AttentionLabel(Model.KnowledgeNeedsAttentionCount),
"Facts, discoveries, beliefs, and suspicions that need a create, link, merge, or ignore decision.",
"Review knowledge",
"StoryIntelligenceKnowledge",
!Model.CharacterGateSatisfied)
Model.PipelineDashboard.KnowledgeStageComplete,
!Model.CharacterGateSatisfied,
"Facts, discoveries, beliefs, and suspicions found in the manuscript.",
ModuleAction("Knowledge", Model.KnowledgeNeedsAttentionCount, Model.PipelineDashboard.KnowledgeStageComplete),
"StoryIntelligenceKnowledge")
@ReviewModule(
"Locations",
Model.LocationNeedsAttentionCount,
Model.PipelineDashboard.LocationStageComplete,
!Model.CharacterGateSatisfied,
"Places found in the manuscript.",
ModuleAction("Locations", Model.LocationNeedsAttentionCount, Model.PipelineDashboard.LocationStageComplete),
"StoryIntelligenceLocations")
@ReviewModule(
"Plot Lines & Threads",
Model.NarrativeNeedsAttentionCount,
Model.PipelineDashboard.NarrativeStageComplete ? "Reviewed" : AttentionLabel(Model.NarrativeNeedsAttentionCount),
"Recurring arcs and threads organised by the narrative hierarchy discovered from scene evidence.",
"Review plot lines",
"StoryIntelligenceNarrative",
!Model.CharacterGateSatisfied)
Model.PipelineDashboard.NarrativeStageComplete,
!Model.CharacterGateSatisfied,
"Recurring arcs and threads discovered from scene evidence.",
ModuleAction("Plot Lines & Threads", Model.NarrativeNeedsAttentionCount, Model.PipelineDashboard.NarrativeStageComplete),
"StoryIntelligenceNarrative")
<article class="story-review-centre-row">
<div>
<h2>Scene Metrics</h2>
<p>Available from the imported scene analysis. Metrics are auto-applied where configured and do not require a mandatory review step.</p>
</div>
@if (Model.CharacterGateSatisfied)
{
<strong class="story-review-status story-review-status--ready">Available</strong>
<span class="story-review-centre-row__count">@Model.TotalAnalysedScenes.ToString("N0") scenes</span>
}
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>
@ReviewModule(
"Relationships",
Model.RelationshipNeedsAttentionCount,
Model.PipelineDashboard.RelationshipStageComplete,
!Model.CharacterGateSatisfied,
"Character relationships found in scene evidence.",
ModuleAction("Relationships", Model.RelationshipNeedsAttentionCount, Model.PipelineDashboard.RelationshipStageComplete),
"StoryIntelligenceRelationships")
@ReviewMetrics(!Model.CharacterGateSatisfied)
@if (Model.AnalysisIssueCount > 0)
{
<article class="story-review-centre-row">
<div>
<h2>Analysis Issues</h2>
<p>Some chapters or scenes reported analysis issues. Canonical chapters and scenes remain intact.</p>
</div>
<strong class="story-review-status story-review-status--warning">Needs attention</strong>
<a class="btn btn-outline-primary btn-sm" asp-controller="Books" asp-action="Details" asp-route-id="@Model.BookID">Return to book</a>
<span class="story-review-centre-row__count">@Model.AnalysisIssueCount.ToString("N0") need attention</span>
</article>
@ReviewModule(
"Analysis Issues",
Model.AnalysisIssueCount,
false,
false,
"Some chapters or scenes reported analysis issues.",
"Return to Book",
null)
}
</section>
<div class="onboarding-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-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open writer workspace</a>
<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-primary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div>
</div>
</section>
@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(
string title,
int count,
string status,
bool complete,
bool blocked,
string description,
string actionLabel,
string action,
bool blocked)
string? action,
bool primary = false)
{
var status = StatusText(title, count, complete, blocked);
var statusClass = blocked
? "story-review-status story-review-status--active"
: count > 0
? "story-review-status story-review-status--warning"
: "story-review-status story-review-status--ready";
var statusText = blocked ? "Resolve Characters first" : status;
var countText = blocked
? "Locked until Characters are resolved"
: count == 1
? "1 needs attention"
: $"{count:N0} need attention";
var disabled = blocked ? " disabled aria-disabled=\"true\"" : string.Empty;
var href = blocked
? "#"
? "story-review-centre-tile__status story-review-centre-tile__status--locked"
: count > 0 && !complete
? "story-review-centre-tile__status story-review-centre-tile__status--warning"
: "story-review-centre-tile__status story-review-centre-tile__status--ready";
var tileClass = "story-review-centre-tile"
+ (primary ? " story-review-centre-tile--primary" : string.Empty)
+ (blocked ? " story-review-centre-tile--locked" : string.Empty);
var href = action is null
? Url.Action("Details", "Books", new { id = Model.BookID }) ?? "#"
: 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($$"""
<article class="story-review-centre-row">
<article class="{{tileClass}}">
<div>
<h2>{{System.Net.WebUtility.HtmlEncode(title)}}</h2>
<p>{{System.Net.WebUtility.HtmlEncode(description)}}</p>
</div>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(statusText)}}</strong>
<a class="btn btn-outline-primary btn-sm{{(blocked ? " disabled" : string.Empty)}}" href="{{href}}"{{disabled}}>{{System.Net.WebUtility.HtmlEncode(actionLabel)}}</a>
<span class="story-review-centre-row__count">{{System.Net.WebUtility.HtmlEncode(countText)}}</span>
<strong class="{{statusClass}}">{{System.Net.WebUtility.HtmlEncode(status)}}</strong>
{{actionHtml}}
</article>
""");
}

View File

@ -1384,36 +1384,101 @@ summary.story-review-chapter-heading {
margin-top: .2rem;
}
.story-review-centre-list {
display: grid;
gap: .65rem;
.story-review-centre-shell {
max-width: 1100px;
margin: 0 auto;
padding: 2.25rem 1rem 3rem;
}
.story-review-centre-row {
.story-review-centre-workspace {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: .75rem 1rem;
align-items: center;
gap: 1rem;
}
.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-radius: 8px;
padding: .85rem 1rem;
padding: 1rem;
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;
font-size: 1.05rem;
}
.story-review-centre-row p {
.story-review-centre-tile p {
margin: .2rem 0 0;
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 {
grid-column: 1 / -1;
.story-review-centre-tile .btn {
align-self: flex-start;
}
.story-review-centre-tile .btn.disabled {
pointer-events: none;
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 {
@ -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) {
.story-pipeline-phase-list,
.story-database-build ul {
@ -2042,7 +2113,6 @@ summary.story-review-chapter-heading {
.story-review-summary,
.story-review-scene-panel,
.story-character-card__body dl,
.story-review-centre-row,
.story-future-stage-grid,
.story-scene-preview dl,
.story-live-current,
@ -2051,6 +2121,10 @@ summary.story-review-chapter-heading {
grid-template-columns: 1fr;
}
.story-review-centre-grid {
grid-template-columns: 1fr;
}
.story-live-progress-heading,
.story-review-chapter-heading {
flex-direction: column;