Fix basic location onboarding wizard UI

This commit is contained in:
Nick Beckley 2026-08-31 16:48:13 +00:00
parent 66e28b7f0e
commit b6caf9df8e
8 changed files with 210 additions and 33 deletions

View File

@ -3930,8 +3930,8 @@ static void CoreImportWizardOrdersScenesBeforeCharacters()
Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review."); Assert(onboarding.Contains("ValidateScanReview(new ManuscriptScanReviewDecision", StringComparison.Ordinal), "Character validation should run when finalising character review.");
Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist."); Assert(onboarding.Contains("Import the approved chapters and scenes before reviewing characters.", StringComparison.Ordinal), "Character finalisation should refuse to run before structural scenes exist.");
Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter."); Assert(complete.Contains("Detected scene structure", StringComparison.Ordinal), "Scene detection result page should show detected counts by chapter.");
Assert(complete.Contains("Review characters", StringComparison.Ordinal), "After Word sync, the next onboarding step should be character review."); Assert(complete.Contains("Review locations", StringComparison.Ordinal), "After Word sync, the next onboarding step should be location review.");
Assert(complete.Contains("Keep this browser workflow open to review characters.", StringComparison.Ordinal), "Scene detection completion should continue to character review without marking optional Story Intelligence as required."); Assert(complete.Contains("Keep this browser workflow open to review locations.", StringComparison.Ordinal), "Scene detection completion should continue to location review without marking optional Story Intelligence as required.");
} }
static void CoreImportRemapsCharactersAfterReview() static void CoreImportRemapsCharactersAfterReview()
@ -4533,12 +4533,13 @@ static void OnboardingWizardKeepsCharacterPagesInsideOneMainStep()
Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page."); Assert(!header.Contains("BuildDynamicSteps", StringComparison.Ordinal), "Onboarding header should not create one top-level step per character page.");
Assert(header.Contains("new(\"Detect Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene detection as a distinct main step."); Assert(header.Contains("new(\"Detect Scenes\", 8)", StringComparison.Ordinal), "Onboarding header should include scene detection as a distinct main step.");
Assert(header.Contains("new(\"Review Characters\", 9)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step."); Assert(header.Contains("new(\"Review Locations\", 9)", StringComparison.Ordinal), "Onboarding header should include basic location review as a distinct main step.");
Assert(header.Contains("new(\"Review Characters\", 10)", StringComparison.Ordinal), "Onboarding header should keep character review as one main step.");
Assert(!header.Contains("Story Summary", StringComparison.Ordinal), "Core onboarding header should not present Story Summary as a mandatory import step."); Assert(!header.Contains("Story Summary", StringComparison.Ordinal), "Core onboarding header should not present Story Summary as a mandatory import step.");
Assert(!header.Contains("Story Context", StringComparison.Ordinal), "Core onboarding header should not present Story Context as a mandatory import step."); Assert(!header.Contains("Story Context", StringComparison.Ordinal), "Core onboarding header should not present Story Context as a mandatory import step.");
Assert(review.Contains("var currentStep = isCharacterStep ? 9 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step."); Assert(review.Contains("var currentStep = isCharacterStep ? 10 : 7;", StringComparison.Ordinal), "Scan review should keep every character page on the same main wizard step.");
Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress."); Assert(review.Contains("onboarding-subprogress", StringComparison.Ordinal), "Character pagination should be shown as section-level progress.");
Assert(storyService.Contains("const int totalWizardSteps = 10;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed core wizard step count."); Assert(storyService.Contains("const int totalWizardSteps = 11;", StringComparison.Ordinal), "Story Intelligence summary/context should use the fixed core wizard step count.");
} }
static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel() static void StoryIntelligenceExperienceBootDoesNotSerialiseLiveModel()

View File

@ -93,6 +93,7 @@ public sealed class OnboardingController(
} }
[HttpGet("scan-locations")] [HttpGet("scan-locations")]
[HttpGet("scan/locations")]
public async Task<IActionResult> ScanLocations(Guid previewId) public async Task<IActionResult> ScanLocations(Guid previewId)
{ {
var model = await onboarding.GetBasicLocationReviewAsync(previewId); var model = await onboarding.GetBasicLocationReviewAsync(previewId);
@ -112,6 +113,7 @@ public sealed class OnboardingController(
} }
[HttpPost("scan-locations")] [HttpPost("scan-locations")]
[HttpPost("scan/locations")]
[RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)] [RequestFormLimits(ValueCountLimit = 20000, KeyLengthLimit = 4096, ValueLengthLimit = 1024 * 1024)]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> SaveBasicLocations(CoreImportBasicLocationReviewForm form, string intent = "save") public async Task<IActionResult> SaveBasicLocations(CoreImportBasicLocationReviewForm form, string intent = "save")

View File

@ -88,7 +88,7 @@ public sealed class OnboardingStoryIntelligenceService(
var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText)); var missingTextCount = included.Count(chapter => string.IsNullOrWhiteSpace(chapter.Preview.ChapterText));
const int storySummaryStep = 10; const int storySummaryStep = 10;
const int storyContextStep = 10; const int storyContextStep = 10;
const int totalWizardSteps = 10; const int totalWizardSteps = 11;
return new StoryIntelligenceOverviewViewModel return new StoryIntelligenceOverviewViewModel
{ {
PreviewID = preview.PreviewID, PreviewID = preview.PreviewID,

View File

@ -6,7 +6,7 @@
<section class="onboarding-shell" aria-labelledby="build-complete-title"> <section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel"> <div class="onboarding-panel onboarding-review-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 10 : 8, TotalSteps = 10 })" /> <partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = coreComplete ? 11 : 8, TotalSteps = 11 })" />
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">@(coreComplete ? "Core Import Complete" : "Scene Detection Complete")</p> <p class="eyebrow">@(coreComplete ? "Core Import Complete" : "Scene Detection Complete")</p>
<div class="onboarding-success-heading"> <div class="onboarding-success-heading">
@ -20,11 +20,11 @@
} }
else if (Model.AlreadyBuilt) else if (Model.AlreadyBuilt)
{ {
@:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review characters to complete the core import. @:This manuscript structure has already been created in PlotDirector, so nothing was duplicated. Review locations before reviewing characters to complete the core import.
} }
else else
{ {
@:@Model.ScenesCreated.ToString("N0") scenes imported across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review characters. @:@Model.ScenesCreated.ToString("N0") scenes imported across @Model.ChaptersCreated.ToString("N0") chapters. Keep this browser workflow open to review locations.
} }
</p> </p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
@ -87,8 +87,8 @@
else else
{ {
<section class="onboarding-complete-next"> <section class="onboarding-complete-next">
<h2>Next: review characters</h2> <h2>Next: review locations</h2>
<p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p> <p>Basic location review is part of core import. Review detected places before reviewing characters and finishing the manuscript import.</p>
</section> </section>
<section class="onboarding-build-panel onboarding-marker-resync-panel" data-onboarding-build-panel> <section class="onboarding-build-panel onboarding-marker-resync-panel" data-onboarding-build-panel>

View File

@ -4,10 +4,13 @@
} }
<section class="onboarding-shell"> <section class="onboarding-shell">
<div class="onboarding-header"> <div class="onboarding-panel onboarding-review-panel onboarding-location-review-panel">
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = 9, TotalSteps = 11 })" />
<div class="onboarding-copy">
<p class="eyebrow">Core import</p> <p class="eyebrow">Core import</p>
<h1>Review locations</h1> <h1 id="scan-location-review-title">Review Locations</h1>
<p class="lede">@Model.DocumentTitle - @Model.SelectedProjectName - @Model.SelectedBookTitle</p> <p>@Model.DocumentTitle - @Model.SelectedProjectName - @Model.SelectedBookTitle</p>
</div> </div>
@if (TempData["OnboardingReviewMessage"] is string message) @if (TempData["OnboardingReviewMessage"] is string message)
@ -19,7 +22,6 @@
<div class="alert alert-danger">@error</div> <div class="alert alert-danger">@error</div>
} }
<div class="onboarding-panel">
<div class="review-summary-grid"> <div class="review-summary-grid">
<div> <div>
<span class="metric-label">Raw observations</span> <span class="metric-label">Raw observations</span>
@ -42,8 +44,11 @@
<article class="location-review-row"> <article class="location-review-row">
<input type="hidden" name="Locations[@i].StableKey" value="@candidate.StableKey" /> <input type="hidden" name="Locations[@i].StableKey" value="@candidate.StableKey" />
<div class="location-review-main"> <div class="location-review-main">
<div class="location-review-title-row">
<h2>@candidate.ProposedName</h2> <h2>@candidate.ProposedName</h2>
<p>@candidate.ObservationCount observation@(candidate.ObservationCount == 1 ? "" : "s") - first seen in Chapter @candidate.FirstChapterNumber, Scene @candidate.FirstSceneNumber</p> <span>@candidate.ObservationCount observation@(candidate.ObservationCount == 1 ? "" : "s")</span>
</div>
<p>First seen in Chapter @candidate.FirstChapterNumber, Scene @candidate.FirstSceneNumber</p>
@if (candidate.KnownNames.Count > 1) @if (candidate.KnownNames.Count > 1)
{ {
<p>Known names: @string.Join(", ", candidate.KnownNames)</p> <p>Known names: @string.Join(", ", candidate.KnownNames)</p>
@ -51,7 +56,7 @@
<div class="location-evidence"> <div class="location-evidence">
@foreach (var evidence in candidate.Evidence.Take(3)) @foreach (var evidence in candidate.Evidence.Take(3))
{ {
<blockquote><strong>@evidence.Label:</strong> @evidence.EvidenceText</blockquote> <blockquote><strong>@evidence.Label</strong><span>@evidence.EvidenceText</span></blockquote>
} }
</div> </div>
</div> </div>
@ -119,7 +124,7 @@
} }
</div> </div>
<div class="form-actions"> <div class="onboarding-actions location-review-actions">
<button class="btn btn-secondary" type="submit" name="intent" value="save">Save choices</button> <button class="btn btn-secondary" type="submit" name="intent" value="save">Save choices</button>
<button class="btn btn-outline-secondary" type="submit" name="intent" value="skip">Skip locations</button> <button class="btn btn-outline-secondary" type="submit" name="intent" value="skip">Skip locations</button>
<button class="btn btn-primary" type="submit" name="intent" value="continue">@Model.ContinueButtonText</button> <button class="btn btn-primary" type="submit" name="intent" value="continue">@Model.ContinueButtonText</button>

View File

@ -31,8 +31,8 @@
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1; var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount); var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList(); var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
var currentStep = isCharacterStep ? 9 : 7; var currentStep = isCharacterStep ? 10 : 7;
const int totalSteps = 10; const int totalSteps = 11;
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}"; var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
var duplicateCanonicalNames = primaryCharacters var duplicateCanonicalNames = primaryCharacters
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)) .Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))

View File

@ -37,8 +37,9 @@
new("Connect Word", 6), new("Connect Word", 6),
new("Review Chapters", 7), new("Review Chapters", 7),
new("Detect Scenes", 8), new("Detect Scenes", 8),
new("Review Characters", 9), new("Review Locations", 9),
new("Complete", 10) new("Review Characters", 10),
new("Complete", 11)
]; ];
private static string StepClass(int order, int currentStep) private static string StepClass(int order, int currentStep)

View File

@ -747,6 +747,153 @@
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
} }
.onboarding-location-review-panel {
width: min(1240px, 100%);
}
.onboarding-location-review-panel .review-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
margin-bottom: 1rem;
}
.onboarding-location-review-panel .review-summary-grid div {
display: grid;
gap: .15rem;
padding: .85rem 1rem;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
background: rgba(255, 255, 255, .58);
}
.onboarding-location-review-panel .metric-label {
color: var(--bs-secondary-color);
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.onboarding-location-review-panel .review-summary-grid strong {
font-size: 1.45rem;
line-height: 1.1;
}
.location-review-list {
display: grid;
gap: .85rem;
}
.location-review-row {
display: grid;
grid-template-columns: minmax(18rem, .95fr) minmax(28rem, 1.35fr);
gap: 1rem;
align-items: start;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
padding: 1rem;
background: rgba(255, 255, 255, .62);
}
.location-review-title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: .75rem;
}
.location-review-title-row h2 {
margin: 0;
font-size: 1.25rem;
line-height: 1.2;
}
.location-review-title-row span {
flex: 0 0 auto;
color: var(--bs-secondary-color);
font-size: .78rem;
font-weight: 800;
white-space: nowrap;
}
.location-review-main > p {
margin: .3rem 0 0;
color: var(--bs-secondary-color);
}
.location-evidence {
display: grid;
gap: .45rem;
margin-top: .75rem;
}
.location-evidence blockquote {
display: grid;
gap: .25rem;
margin: 0;
border-left: 3px solid rgba(47, 111, 99, .34);
border-radius: 6px;
padding: .55rem .65rem;
background: rgba(47, 111, 99, .06);
}
.location-evidence strong {
color: var(--bs-body-color);
font-size: .78rem;
}
.location-evidence span {
color: var(--bs-secondary-color);
font-size: .9rem;
line-height: 1.4;
}
.location-review-controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: .75rem;
}
.location-review-controls label {
display: grid;
gap: .3rem;
margin: 0;
color: var(--bs-secondary-color);
font-size: .82rem;
font-weight: 800;
}
.location-review-controls input,
.location-review-controls select {
width: 100%;
min-height: 2.35rem;
border: 1px solid rgba(31, 42, 68, .18);
border-radius: 6px;
padding: .45rem .6rem;
background-color: var(--bs-body-bg);
color: var(--bs-body-color);
font-size: .95rem;
font-weight: 500;
}
.location-review-controls input:focus,
.location-review-controls select:focus {
border-color: var(--bs-primary);
box-shadow: 0 0 0 .2rem rgba(47, 111, 99, .16);
outline: 0;
}
.location-review-actions {
position: sticky;
bottom: 0;
z-index: 2;
margin: 1rem calc(clamp(1.25rem, 3vw, 2.5rem) * -1) calc(clamp(1.25rem, 3vw, 2.5rem) * -1);
border-top: 1px solid rgba(31, 42, 68, .1);
padding: 1rem clamp(1.25rem, 3vw, 2.5rem);
background: rgba(255, 255, 255, .92);
backdrop-filter: blur(10px);
}
[data-story-intelligence-onboarding] .onboarding-review-counts { [data-story-intelligence-onboarding] .onboarding-review-counts {
position: static; position: static;
margin-bottom: 1rem; margin-bottom: 1rem;
@ -2132,6 +2279,27 @@ summary.story-review-chapter-heading {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.onboarding-location-review-panel .review-summary-grid,
.location-review-row,
.location-review-controls {
grid-template-columns: 1fr;
}
.location-review-title-row {
align-items: flex-start;
flex-direction: column;
gap: .35rem;
}
.location-review-actions {
position: static;
justify-content: stretch;
}
.location-review-actions .btn {
width: 100%;
}
.onboarding-companion-details { .onboarding-companion-details {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }