Streamline core import wizard steps

This commit is contained in:
Nick Beckley 2026-08-27 19:32:27 +00:00
parent ea41ec3aac
commit 4f82dc308a
5 changed files with 173 additions and 33 deletions

View File

@ -93,6 +93,8 @@ var tests = new (string Name, Action Test)[]
("Core import scene titles ride existing structural AI call", CoreImportSceneTitlesRideExistingStructuralAiCall), ("Core import scene titles ride existing structural AI call", CoreImportSceneTitlesRideExistingStructuralAiCall),
("Core import scene title preference is review scoped", CoreImportSceneTitlePreferenceIsReviewScoped), ("Core import scene title preference is review scoped", CoreImportSceneTitlePreferenceIsReviewScoped),
("Core import scene title preference moved to Detect Scenes", CoreImportSceneTitlePreferenceMovedToDetectScenes), ("Core import scene title preference moved to Detect Scenes", CoreImportSceneTitlePreferenceMovedToDetectScenes),
("Core import Review Chapters is compact and chapter only", CoreImportReviewChaptersIsCompactAndChapterOnly),
("Core import Detect Scenes is compact and owns title option", CoreImportDetectScenesIsCompactAndOwnsTitleOption),
("Book context is canonical import metadata", BookContextIsCanonicalImportMetadata), ("Book context is canonical import metadata", BookContextIsCanonicalImportMetadata),
("Names defaults from selected book context", NamesDefaultsFromSelectedBookContext), ("Names defaults from selected book context", NamesDefaultsFromSelectedBookContext),
("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete), ("Story Intelligence starts from core import complete", StoryIntelligenceStartsFromCoreImportComplete),
@ -2579,6 +2581,49 @@ static void CoreImportSceneTitlePreferenceMovedToDetectScenes()
Assert(hub.Contains("StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)", StringComparison.Ordinal), "Build hub should accept the title preference without a separate AI call."); Assert(hub.Contains("StartOnboardingProjectBuild(Guid previewId, bool generateSceneTitles = true)", StringComparison.Ordinal), "Build hub should accept the title preference without a separate AI call.");
} }
static void CoreImportReviewChaptersIsCompactAndChapterOnly()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var review = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml"));
Assert(!review.Contains("Generate descriptive scene titles", StringComparison.Ordinal), "Review Chapters should not render the scene-title option.");
Assert(review.Contains("chapters selected", StringComparison.Ordinal), "Review Chapters should render the selected chapter summary.");
Assert(review.Contains("onboarding-review-chapter-row", StringComparison.Ordinal), "Review Chapters should render compact chapter rows.");
Assert(!review.Contains("<details class=\"onboarding-review-chapter\"", StringComparison.Ordinal), "Review Chapters should not use large disclosure cards for chapter selection.");
Assert(review.Contains("data-bulk-target=\"chapter\" data-bulk-state=\"true\"", StringComparison.Ordinal), "Review Chapters should retain Include all chapters.");
Assert(review.Contains("data-bulk-target=\"chapter\" data-bulk-state=\"false\"", StringComparison.Ordinal), "Review Chapters should retain Exclude all chapters.");
Assert(review.Contains("type=\"reset\">Restore scan defaults", StringComparison.Ordinal), "Review Chapters should retain Restore scan defaults.");
Assert(review.Contains("Continue to Scene Detection", StringComparison.Ordinal), "Review Chapters should retain Continue to Scene Detection.");
Assert(review.Contains("name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal), "Compact rows should still post chapter inclusion choices.");
Assert(review.Contains("name=\"Chapters[@chapterIndex].Title\"", StringComparison.Ordinal), "Compact rows should still post reviewed chapter titles.");
var checkboxIndex = review.IndexOf("type=\"checkbox\" name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal);
var hiddenIndex = review.IndexOf("type=\"hidden\" name=\"Chapters[@chapterIndex].Include\"", StringComparison.Ordinal);
Assert(checkboxIndex >= 0 && hiddenIndex > checkboxIndex, "Chapter include checkbox should post before the hidden false value.");
}
static void CoreImportDetectScenesIsCompactAndOwnsTitleOption()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var index = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js"));
var analyzer = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs"));
var detectScenesBlock = Between(index, "@if (structureReadyToImport)", "else\n {");
Assert(CountOccurrences(index, "Generate descriptive scene titles") == 1, "Detect Scenes should render the scene-title option exactly once.");
Assert(detectScenesBlock.Contains("data-onboarding-generate-scene-titles checked", StringComparison.Ordinal), "Detect Scenes should keep the existing ON default.");
Assert(detectScenesBlock.Contains("data-word-companion-compact-status-label", StringComparison.Ordinal), "Detect Scenes should render compact Word connection status.");
Assert(detectScenesBlock.Contains("data-word-companion-document", StringComparison.Ordinal), "Detect Scenes should include the current Word document name.");
Assert(detectScenesBlock.Contains("Keep Word open while PlotDirector detects scenes and writes the scene markers.", StringComparison.Ordinal), "Detect Scenes should give the required concise Word-open instruction.");
Assert(detectScenesBlock.Contains("data-onboarding-build-panel hidden", StringComparison.Ordinal), "Detect Scenes should hide processing progress until detection starts.");
Assert(!detectScenesBlock.Contains("onboarding-companion-card", StringComparison.Ordinal), "Detect Scenes should not render the large Word Companion card.");
Assert(!detectScenesBlock.Contains("Word Companion Connected", StringComparison.Ordinal), "Detect Scenes should not render the old dashboard heading.");
Assert(!detectScenesBlock.Contains("data-onboarding-scan-counts", StringComparison.Ordinal), "Detect Scenes should not render old scan counters.");
Assert(!detectScenesBlock.Contains("Ready to detect scenes.", StringComparison.Ordinal), "Detect Scenes should not render the old ready panel.");
Assert(script.Contains("StartOnboardingProjectBuild\", previewId, generateSceneTitles", StringComparison.Ordinal), "Detect Scenes should start the existing canonical scene-detection operation.");
Assert(script.Contains("node.hidden = false;", StringComparison.Ordinal), "Processing state should reveal the build progress panel only when work starts.");
Assert(!analyzer.Contains("SceneTitle-Prompt", StringComparison.Ordinal), "Scene title generation should remain inside the existing chapter-structure call.");
}
static void BookContextIsCanonicalImportMetadata() static void BookContextIsCanonicalImportMetadata()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
@ -3503,6 +3548,32 @@ static void WordCompanionReplaysLiveCurrentSceneToWritingPanes()
static int CountCsvRows(string path) static int CountCsvRows(string path)
=> File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line)); => File.ReadLines(path).Skip(1).Count(line => !string.IsNullOrWhiteSpace(line));
static int CountOccurrences(string value, string search)
{
var count = 0;
var index = 0;
while ((index = value.IndexOf(search, index, StringComparison.Ordinal)) >= 0)
{
count++;
index += search.Length;
}
return count;
}
static string Between(string value, string start, string end)
{
var startIndex = value.IndexOf(start, StringComparison.Ordinal);
if (startIndex < 0)
{
return string.Empty;
}
startIndex += start.Length;
var endIndex = value.IndexOf(end, startIndex, StringComparison.Ordinal);
return endIndex < 0 ? value[startIndex..] : value[startIndex..endIndex];
}
static JsonSerializerOptions JsonOptions() static JsonSerializerOptions JsonOptions()
=> new() => new()
{ {

View File

@ -265,7 +265,7 @@
@if (Model.IsMicrosoftWordPath) @if (Model.IsMicrosoftWordPath)
{ {
<h1 id="onboarding-title">@(structureReadyToImport ? "Detect Scenes" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1> <h1 id="onboarding-title">@(structureReadyToImport ? "Detect Scenes" : currentJourneyOrder >= 6 ? "Scan Manuscript" : "Connect your Word Companion")</h1>
<p>@(structureReadyToImport ? "PlotDirector will now analyse the approved chapters to determine where each scene begins and ends. Keep Word open while scene markers are added and the document is saved." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p> <p>@(structureReadyToImport ? "PlotDirector will analyse the selected chapters to determine where each scene begins and ends." : "Open your manuscript in Word and start the Companion. When it is connected, use the wizard button below to scan and continue.")</p>
} }
else else
{ {
@ -282,22 +282,22 @@
data-word-companion-status="@Model.CompanionPresence.Status" data-word-companion-status="@Model.CompanionPresence.Status"
aria-label="Scene detection"> aria-label="Scene detection">
<p class="onboarding-compact-status"> <p class="onboarding-compact-status">
<span data-word-companion-waiting-status>@(Model.CompanionPresence.IsConnected ? "Word connected" : "Waiting for Word")</span> <span aria-hidden="true">@(Model.CompanionPresence.IsConnected ? "\u2713" : "!")</span>
&middot; @Model.ScanState.ChapterCount.ToString("N0") chapter@(Model.ScanState.ChapterCount == 1 ? string.Empty : "s") <span data-word-companion-compact-status-label>@(Model.CompanionPresence.IsConnected ? "Word Companion connected" : "Word Companion disconnected")</span>
&middot; @Model.ScanState.TotalWordCount.ToString("N0") words &middot; <span data-word-companion-document>@(Model.CompanionPresence.CurrentDocumentName ?? "No document reported yet")</span>
</p> </p>
<label class="onboarding-inline-choice"> <label class="onboarding-inline-choice">
<input type="checkbox" data-onboarding-generate-scene-titles checked /> <input type="checkbox" data-onboarding-generate-scene-titles checked />
<span> <span>
<strong>Generate descriptive scene titles</strong> <strong>Generate descriptive scene titles</strong>
<small>Short working titles used only within PlotDirector.</small> <small>Adds optional PlotDirector-only titles such as "Behind the Fire Exit". Titles can be edited later and do not change the manuscript.</small>
</span> </span>
</label> </label>
<p>Keep Word open until scene markers have been added. If Word cannot save automatically, PlotDirector will ask you to save and confirm.</p> <p>Keep Word open while PlotDirector detects scenes and writes the scene markers.</p>
<div class="onboarding-build-panel" data-onboarding-build-panel> <div class="onboarding-build-panel" data-onboarding-build-panel hidden>
<div> <div>
<p class="onboarding-scan-next">Ready to detect scenes.</p> <p class="onboarding-scan-next">Detecting scenes</p>
<p class="onboarding-build-message" data-onboarding-build-message>Scene detection has not started yet.</p> <p class="onboarding-build-message" data-onboarding-build-message>Analysing scene boundaries...</p>
</div> </div>
<strong data-onboarding-build-percent></strong> <strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true"> <div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -388,7 +388,8 @@
type="button" type="button"
data-onboarding-build-start data-onboarding-build-start
data-onboarding-build-action data-onboarding-build-action
data-preview-id="@Model.ScanState.PreviewID"> data-preview-id="@Model.ScanState.PreviewID"
disabled="@(!CanStartSceneDetection(Model.CompanionPresence))">
Detect Scenes Detect Scenes
</button> </button>
} }
@ -452,6 +453,9 @@
private static bool CanStartScan(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan) private static bool CanStartScan(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan)
=> presence.IsConnected && presence.DocumentOpen && !scan.IsRunning; => presence.IsConnected && presence.DocumentOpen && !scan.IsRunning;
private static bool CanStartSceneDetection(CompanionPresenceViewModel presence)
=> presence.IsConnected && presence.DocumentOpen;
private static string CompanionScanMessage(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan) private static string CompanionScanMessage(CompanionPresenceViewModel presence, ManuscriptScanStateViewModel scan)
{ {
if (!presence.IsConnected) if (!presence.IsConnected)

View File

@ -127,24 +127,18 @@
} }
@foreach (var chapter in Model.Chapters) @foreach (var chapter in Model.Chapters)
{ {
<details class="onboarding-review-chapter"> <label class="onboarding-review-chapter-row">
<summary>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.ReviewTitle</strong>
<small>@chapter.WordCount.ToString("N0") words</small>
</summary>
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" /> <input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" /> <input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
<label class="onboarding-review-toggle"> <input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" /> <input type="hidden" name="Chapters[@chapterIndex].Include" value="false" />
<input type="hidden" name="Chapters[@chapterIndex].Include" value="false" /> <input type="hidden" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
Include chapter <span class="onboarding-review-chapter-row__title">
</label> <strong>Chapter @chapter.ChapterNumber</strong>
<label class="onboarding-review-field"> <span>@chapter.ReviewTitle</span>
Chapter title </span>
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" /> <small>@chapter.WordCount.ToString("N0") words</small>
</label> </label>
</details>
chapterIndex++; chapterIndex++;
} }
</div> </div>

View File

@ -427,12 +427,8 @@
.onboarding-detect-scenes { .onboarding-detect-scenes {
display: grid; display: grid;
gap: .85rem; gap: .75rem;
margin: 1rem 0 1.25rem; margin: .75rem 0 1rem;
padding: 1rem;
border: 1px solid rgba(31, 42, 68, .12);
border-radius: 8px;
background: rgba(255, 255, 255, .72);
} }
.onboarding-detect-scenes > p:not(.onboarding-compact-status) { .onboarding-detect-scenes > p:not(.onboarding-compact-status) {
@ -632,7 +628,68 @@
.onboarding-review-chapters { .onboarding-review-chapters {
display: grid; display: grid;
gap: .85rem; gap: .35rem;
}
.onboarding-review-chapter-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: .65rem;
min-height: 2.55rem;
margin: 0;
border: 1px solid rgba(31, 42, 68, .1);
border-radius: 8px;
padding: .45rem .65rem;
background: rgba(255, 255, 255, .58);
cursor: pointer;
}
.onboarding-review-chapter-row:hover {
border-color: rgba(47, 111, 99, .28);
background: rgba(47, 111, 99, .055);
}
.onboarding-review-chapter-row:has(input[type="checkbox"]:checked) {
border-color: rgba(47, 111, 99, .34);
background: rgba(47, 111, 99, .08);
}
.onboarding-review-chapter-row input[type="checkbox"] {
width: 1.05rem;
height: 1.05rem;
margin: 0;
}
.onboarding-review-chapter-row__title {
display: flex;
align-items: baseline;
gap: .45rem;
min-width: 0;
}
.onboarding-review-chapter-row__title strong,
.onboarding-review-chapter-row__title span {
min-width: 0;
}
.onboarding-review-chapter-row__title strong {
flex: 0 0 auto;
font-size: .92rem;
}
.onboarding-review-chapter-row__title span {
overflow: hidden;
color: var(--bs-body-color);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.onboarding-review-chapter-row small {
color: var(--bs-secondary-color);
font-weight: 800;
white-space: nowrap;
} }
.onboarding-review-chapter { .onboarding-review-chapter {

View File

@ -46,6 +46,9 @@
document.querySelectorAll("[data-word-companion-status-label]").forEach((node) => { document.querySelectorAll("[data-word-companion-status-label]").forEach((node) => {
node.textContent = label; node.textContent = label;
}); });
document.querySelectorAll("[data-word-companion-compact-status-label]").forEach((node) => {
node.textContent = connected ? "Word Companion connected" : "Word Companion disconnected";
});
document.querySelectorAll("[data-word-companion-waiting-status]").forEach((node) => { document.querySelectorAll("[data-word-companion-waiting-status]").forEach((node) => {
node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection..."; node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection...";
}); });
@ -90,6 +93,13 @@
button.disabled = openingReview || !companionConnected || !companionDocumentOpen || currentScanStatus === "Running"; button.disabled = openingReview || !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
button.textContent = openingReview ? "Opening chapter review..." : currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript"; button.textContent = openingReview ? "Opening chapter review..." : currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
}); });
document.querySelectorAll("[data-onboarding-build-start]").forEach((button) => {
if (button.dataset.buildMode === "confirm-save") {
return;
}
button.disabled = !companionConnected || !companionDocumentOpen;
});
}; };
const applyScanState = (state) => { const applyScanState = (state) => {
@ -195,6 +205,7 @@
pendingBuildPreviewId = pendingManualSavePreviewId || pendingBuildPreviewId; pendingBuildPreviewId = pendingManualSavePreviewId || pendingBuildPreviewId;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.hidden = false;
node.classList.add("is-running", "is-manual-save"); node.classList.add("is-running", "is-manual-save");
}); });
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
@ -284,6 +295,7 @@
return; return;
} }
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.hidden = false;
node.classList.add("is-running"); node.classList.add("is-running");
node.classList.remove("is-manual-save"); node.classList.remove("is-manual-save");
}); });
@ -353,6 +365,7 @@
clearManualSaveRequired(); clearManualSaveRequired();
button.disabled = true; button.disabled = true;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.hidden = false;
node.classList.add("is-running"); node.classList.add("is-running");
}); });
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
@ -373,6 +386,7 @@
} catch (error) { } catch (error) {
button.disabled = false; button.disabled = false;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.hidden = false;
node.classList.remove("is-running"); node.classList.remove("is-running");
}); });
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {