Add paired writing intent follow screen

This commit is contained in:
Nick Beckley 2026-08-17 14:59:41 +00:00
parent fa2ffe4f17
commit 884a96d166
18 changed files with 865 additions and 170 deletions

View File

@ -76,7 +76,11 @@ var tests = new (string Name, Action Test)[]
("StoryPosition derives canonical scene context identity", StoryPositionDerivesCanonicalSceneContextIdentity),
("Writing Context route uses canonical story context service", WritingContextRouteUsesCanonicalStoryContextService),
("Writing Context client follows canonical story position events", WritingContextClientFollowsCanonicalStoryPositionEvents),
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops)
("Writing Context preserves selected tab and avoids event loops", WritingContextPreservesSelectedTabAndAvoidsEventLoops),
("Writing Intent route uses canonical story context and planning data", WritingIntentRouteUsesCanonicalStoryContextAndPlanningData),
("Writing Intent client uses shared writing follow helper", WritingIntentClientUsesSharedWritingFollowHelper),
("Writing mode pages are isolated from global Word navigation", WritingModePagesAreIsolatedFromGlobalWordNavigation),
("Writing pair shares canonical follow infrastructure", WritingPairSharesCanonicalFollowInfrastructure)
};
foreach (var test in tests)
@ -157,29 +161,109 @@ static void WritingContextRouteUsesCanonicalStoryContextService()
static void WritingContextClientFollowsCanonicalStoryPositionEvents()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
Assert(view.Contains("data-writing-context", StringComparison.Ordinal), "Writing Context shell should expose a stable script root.");
Assert(view.Contains("data-writing-mode=\"context\"", StringComparison.Ordinal), "Writing Context should identify itself as a writing-mode page.");
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Context should load the shared writing follow helper.");
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should initialize the shared writing follow helper.");
Assert(script.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Context must subscribe to canonical browser story-position events.");
Assert(script.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing Context must bridge SignalR story-position events.");
Assert(script.Contains("fetch(url", StringComparison.Ordinal), "Writing Context should refresh context content without full page navigation.");
Assert(script.Contains("data-writing-context-scene-select", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
Assert(initializer.Contains("[data-writing-context-scene-select]", StringComparison.Ordinal), "Writing Context manual fallback selector should be wired.");
Assert(script.Contains("publishChanged", StringComparison.Ordinal), "Manual scene selection should publish canonical story-position changes.");
}
static void WritingContextPreservesSelectedTabAndAvoidsEventLoops()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
Assert(script.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
Assert(initializer.Contains("plotdirector.writingContext.activeTab", StringComparison.Ordinal), "Writing Context should remember the selected tab.");
Assert(script.Contains("activateTab(tab)", StringComparison.Ordinal), "Writing Context should restore the selected tab after content refresh.");
Assert(script.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate current-scene events.");
Assert(script.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Context should ignore duplicate in-flight scene events.");
Assert(!script.Contains("window.location.assign", StringComparison.Ordinal) && !script.Contains("window.location.href", StringComparison.Ordinal), "Writing Context must not navigate away when Word changes scene.");
}
static void WritingIntentRouteUsesCanonicalStoryContextAndPlanningData()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
var viewModel = File.ReadAllText(Path.Combine(root, "ViewModels/CoreViewModels.cs"));
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
Assert(controller.Contains("[HttpGet(\"Intent\")", StringComparison.Ordinal), "Writing Intent shell route is missing.");
Assert(controller.Contains("[HttpGet(\"Intent/Scene\")", StringComparison.Ordinal), "Writing Intent partial route is missing.");
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing Intent must share canonical story context service injection.");
Assert(controller.Contains("storyContext.GetForSceneAsync", StringComparison.Ordinal), "Writing Intent scene requests must use canonical story context.");
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should reuse existing writer planning data.");
Assert(controller.Contains("writerWorkspace.GetChapterWorkflowAsync", StringComparison.Ordinal), "Writing Intent should load chapter workflow planning data.");
Assert(controller.Contains("projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched project scope.");
Assert(controller.Contains("bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value", StringComparison.Ordinal), "Writing Intent should reject mismatched book scope.");
Assert(viewModel.Contains("public sealed class WritingIntentDisplayViewModel", StringComparison.Ordinal), "Writing Intent display model is missing.");
Assert(viewModel.Contains("StoryContextViewModel? Context", StringComparison.Ordinal), "Writing Intent display should carry canonical story context.");
Assert(viewModel.Contains("ChapterWorkflowViewModel? ChapterWorkflow", StringComparison.Ordinal), "Writing Intent display should carry chapter planning workflow.");
Assert(contentView.Contains("No current scene. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should support chapter-only fallback.");
Assert(contentView.Contains("No scene-specific plan. Showing chapter intent.", StringComparison.Ordinal), "Writing Intent should prefer chapter intent when scene planning is sparse.");
}
static void WritingIntentClientUsesSharedWritingFollowHelper()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var initializer = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
var contentView = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
Assert(view.Contains("data-writing-intent", StringComparison.Ordinal), "Writing Intent shell should expose a stable script root.");
Assert(view.Contains("data-writing-mode=\"intent\"", StringComparison.Ordinal), "Writing Intent should identify itself as a writing-mode page.");
Assert(view.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Writing Intent should load the shared writing follow helper.");
Assert(initializer.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should initialize the shared helper.");
Assert(initializer.Contains("plotdirector.writingIntent.activeTab", StringComparison.Ordinal), "Writing Intent should remember the selected tab.");
Assert(initializer.Contains("defaultEndpoint: \"/Writing/Intent/Scene\"", StringComparison.Ordinal), "Writing Intent should refresh from its partial route.");
Assert(initializer.Contains("allowChapterOnly: true", StringComparison.Ordinal), "Writing Intent should accept canonical chapter-only positions.");
Assert(helper.Contains("window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Writing Intent should receive canonical story-position events through the shared helper.");
Assert(helper.Contains("fetch(url", StringComparison.Ordinal), "Writing Intent should update in place.");
Assert(helper.Contains("position.sceneId === currentSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate current-scene events.");
Assert(helper.Contains("position.sceneId === loadingSceneId", StringComparison.Ordinal), "Writing Intent should ignore duplicate in-flight events.");
Assert(helper.Contains("position.bookId && position.bookId !== scopedBookId", StringComparison.Ordinal), "Writing Intent should ignore unrelated scoped book events.");
Assert(helper.Contains("publishChanged", StringComparison.Ordinal), "Writing Intent manual fallback should publish canonical story-position changes.");
Assert(contentView.Contains("data-writing-intent-scene-select", StringComparison.Ordinal), "Writing Intent manual scene selector should be wired.");
Assert(contentView.Contains("data-writing-intent-chapter-select", StringComparison.Ordinal), "Writing Intent manual chapter selector should be wired.");
Assert(!helper.Contains("window.location.assign", StringComparison.Ordinal) && !helper.Contains("window.location.href", StringComparison.Ordinal), "Writing Intent must not navigate away when Word changes scene.");
}
static void WritingModePagesAreIsolatedFromGlobalWordNavigation()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var site = File.ReadAllText(Path.Combine(root, "wwwroot/js/site.js"));
Assert(site.Contains("document.querySelector(\"[data-writing-mode]\")", StringComparison.Ordinal), "Global Word follow should recognize writing-mode markers.");
Assert(site.Contains("path === \"/writing/context\"", StringComparison.Ordinal), "Writing Context route should be exempt from global scene navigation.");
Assert(site.Contains("path === \"/writing/intent\"", StringComparison.Ordinal), "Writing Intent route should be exempt from global scene navigation.");
Assert(site.Contains("window.location.assign(url)", StringComparison.Ordinal), "Normal pages should retain existing Word follow navigation.");
Assert(site.Contains("safeRelativeUrl(payload?.sceneUrl || payload?.SceneUrl || \"\", \"/Scenes/Edit/\")", StringComparison.Ordinal), "Normal scene follow should still target canonical Scene Edit URLs.");
}
static void WritingPairSharesCanonicalFollowInfrastructure()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var context = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-context.js"));
var intent = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-intent.js"));
var helper = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(context.Contains("source: \"WritingContext\"", StringComparison.Ordinal), "Writing Context should identify its canonical event source.");
Assert(intent.Contains("source: \"WritingIntent\"", StringComparison.Ordinal), "Writing Intent should identify its canonical event source.");
Assert(context.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Context should use shared follow initialization.");
Assert(intent.Contains("window.PlotDirectorWritingFollow?.initialize", StringComparison.Ordinal), "Writing Intent should use shared follow initialization.");
Assert(helper.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "The shared helper should bridge Word story-position events for both pages.");
Assert(helper.Contains("document.addEventListener(window.PlotDirectorStoryPosition?.eventName", StringComparison.Ordinal), "Both pages should react to the same canonical browser event.");
}
static void StripsMarkdownFence()
{
var result = StoryIntelligenceJsonRepair.Repair("""

View File

@ -12,7 +12,8 @@ namespace PlotLine.Controllers;
public sealed class WritingController(
IStoryContextService storyContext,
IRelationshipMapService relationshipMap,
IFloorPlanService floorPlans) : Controller
IFloorPlanService floorPlans,
IWriterWorkspaceService writerWorkspace) : Controller
{
[HttpGet("Context")]
public async Task<IActionResult> Context(int? projectId, int? bookId, int? chapterId, int? sceneId)
@ -31,6 +32,23 @@ public sealed class WritingController(
return View(model);
}
[HttpGet("Intent")]
public async Task<IActionResult> Intent(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
var model = new WritingIntentPageViewModel
{
ProjectID = projectId,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
Display = sceneId.HasValue || chapterId.HasValue
? await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Manual intent")
: new WritingIntentDisplayViewModel { FollowStatus = "Connected, waiting for scene" }
};
return View(model);
}
[HttpGet("Context/Scene")]
public async Task<IActionResult> ContextScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
@ -48,6 +66,23 @@ public sealed class WritingController(
: NotFound();
}
[HttpGet("Intent/Scene")]
public async Task<IActionResult> IntentScene(int? projectId, int? bookId, int? chapterId, int? sceneId)
{
if (!sceneId.HasValue && !chapterId.HasValue)
{
return PartialView("~/Views/Writing/_WritingIntentContent.cshtml", new WritingIntentDisplayViewModel
{
FollowStatus = "Connected, waiting for scene"
});
}
var model = await BuildIntentDisplayAsync(projectId, bookId, chapterId, sceneId, "Following Word");
return model is not null && (model.HasScene || model.HasChapter)
? PartialView("~/Views/Writing/_WritingIntentContent.cshtml", model)
: NotFound();
}
private async Task<WritingContextDisplayViewModel?> BuildDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
if (!sceneId.HasValue)
@ -77,6 +112,52 @@ public sealed class WritingController(
};
}
private async Task<WritingIntentDisplayViewModel?> BuildIntentDisplayAsync(int? projectId, int? bookId, int? chapterId, int? sceneId, string followStatus)
{
StoryContextViewModel? context = null;
if (sceneId.HasValue)
{
context = await storyContext.GetForSceneAsync(sceneId.Value, projectId, bookId);
if (context?.Scene is null)
{
return null;
}
}
var resolvedChapterId = context?.Position.ChapterId ?? chapterId;
var chapterWorkflow = resolvedChapterId.HasValue
? await writerWorkspace.GetChapterWorkflowAsync(resolvedChapterId.Value)
: null;
if (chapterWorkflow is not null)
{
if (projectId.HasValue && chapterWorkflow.Project.ProjectID != projectId.Value)
{
return null;
}
if (bookId.HasValue && chapterWorkflow.Book.BookID != bookId.Value)
{
return null;
}
}
if (context is null && chapterWorkflow is null)
{
return null;
}
return new WritingIntentDisplayViewModel
{
Context = context,
ChapterWorkflow = chapterWorkflow,
FollowStatus = followStatus,
SceneOptions = context?.Scene is not null
? BuildSceneOptions(context.Scene)
: BuildSceneOptions(chapterWorkflow)
};
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(SceneEditViewModel scene)
{
var options = scene.SceneOptions
@ -96,4 +177,15 @@ public sealed class WritingController(
.ThenBy(x => x.Text)
.ToList();
}
private static IReadOnlyList<SelectListItem> BuildSceneOptions(ChapterWorkflowViewModel? chapterWorkflow)
{
return chapterWorkflow?.Scenes
.Select(x => new SelectListItem(
$"Scene {x.SceneNumber:g}: {x.SceneTitle}",
x.SceneID.ToString()))
.OrderBy(x => x.Text)
.ToList()
?? [];
}
}

View File

@ -448,6 +448,26 @@ public sealed class WritingContextDisplayViewModel
public bool HasScene => Context?.Scene is not null;
}
public sealed class WritingIntentPageViewModel
{
public int? ProjectID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public WritingIntentDisplayViewModel? Display { get; set; }
}
public sealed class WritingIntentDisplayViewModel
{
public StoryContextViewModel? Context { get; set; }
public ChapterWorkflowViewModel? ChapterWorkflow { get; set; }
public string FollowStatus { get; set; } = "Connected, waiting for scene";
public IReadOnlyList<SelectListItem> SceneOptions { get; set; } = [];
public bool HasScene => Context?.Scene is not null;
public bool HasChapter => ChapterWorkflow?.Chapter is not null || Context?.Chapter is not null;
public bool IsChapterOnly => !HasScene && HasChapter;
}
public sealed class SceneDetailsSaveViewModel
{
public int SceneID { get; set; }

View File

@ -5,6 +5,7 @@
}
<main class="writing-context-page"
data-writing-mode="context"
data-writing-context
data-context-url="@Url.Action("ContextScene", "Writing")"
data-scope-project-id="@Model.ProjectID"
@ -26,5 +27,6 @@
</main>
@section Scripts {
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-context.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,32 @@
@model WritingIntentPageViewModel
@{
ViewData["Title"] = "Writing Intent";
ViewData["ProjectSection"] = "Writer";
}
<main class="writing-context-page"
data-writing-mode="intent"
data-writing-intent
data-context-url="@Url.Action("IntentScene", "Writing")"
data-scope-project-id="@Model.ProjectID"
data-scope-book-id="@Model.BookID">
<header class="writing-context-shell-header">
<div>
<p class="eyebrow">Writing Intent</p>
<h1>Writing Intent</h1>
</div>
<div class="writing-context-status" data-writing-intent-status>
<span class="writing-context-status-dot" aria-hidden="true"></span>
<span data-writing-intent-status-text>@(Model.Display?.FollowStatus ?? "Connected, waiting for scene")</span>
</div>
</header>
<section data-writing-intent-content>
<partial name="_WritingIntentContent" model="Model.Display ?? new WritingIntentDisplayViewModel()" />
</section>
</main>
@section Scripts {
<script src="~/js/writing-follow.js" asp-append-version="true"></script>
<script src="~/js/writing-intent.js" asp-append-version="true"></script>
}

View File

@ -0,0 +1,53 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var workflow = scene?.Workflow;
var checklist = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
var completed = checklist.Count(x => x.IsCompleted);
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Workflow</p>
@if (workflow is null || scene is null)
{
<p>No current scene workflow.</p>
}
else
{
<dl class="writing-context-facts">
<dt>Status</dt><dd>@workflow.DraftStatus</dd>
<dt>Priority</dt><dd>@(workflow.Priority?.ToString() ?? "None")</dd>
<dt>Estimate</dt><dd>@(workflow.EstimatedWordCount?.ToString("N0") ?? "Not set")</dd>
<dt>Actual</dt><dd>@(workflow.ActualWordCount?.ToString("N0") ?? "Not set")</dd>
<dt>Blocked</dt><dd>@(workflow.IsBlocked ? (workflow.BlockedReason ?? "Blocked") : "No")</dd>
</dl>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Checklist</p>
@if (!checklist.Any())
{
<p>No checklist items.</p>
}
else
{
<p>@completed / @checklist.Count completed</p>
<ul class="writing-context-list writing-intent-checklist">
@foreach (var item in checklist)
{
<li class="@(item.IsCompleted ? "is-complete" : "")">
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
<span>@item.ItemText</span>
</li>
}
</ul>
}
</article>
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Future Fulfilment</p>
<p>Stored checklist and workflow states are shown as recorded. Automatic fulfilment detection is not enabled here.</p>
</article>
</div>

View File

@ -0,0 +1,111 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var position = Model.Context?.Position;
var workflow = Model.ChapterWorkflow;
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
var book = workflow?.Book ?? Model.Context?.Book;
var project = workflow?.Project ?? Model.Context?.Project;
var projectId = position?.ProjectId ?? project?.ProjectID;
var bookId = position?.BookId ?? book?.BookID;
var chapterId = position?.ChapterId ?? chapter?.ChapterID;
var sceneId = position?.SceneId;
}
<div class="writing-context-content"
data-writing-intent-fragment
data-project-id="@projectId"
data-book-id="@bookId"
data-chapter-id="@chapterId"
data-scene-id="@sceneId"
data-follow-status="@Model.FollowStatus">
@if (!Model.HasChapter && scene is null)
{
<section class="writing-context-empty">
<p class="eyebrow">Waiting</p>
<h2>Waiting for Word...</h2>
<p>Open a linked manuscript or choose a planned chapter or scene once intent is available.</p>
</section>
}
else
{
<header class="writing-context-scene-header">
<div>
<p class="eyebrow">@project?.ProjectName</p>
<h2>@(scene?.SceneTitle ?? $"Chapter {chapter?.ChapterNumber:g}: {chapter?.ChapterTitle}")</h2>
<p>
@book?.BookDisplayTitle
@if (chapter is not null)
{
<span aria-hidden="true">/</span>
<span>Chapter @chapter.ChapterNumber:g</span>
}
@if (scene is not null)
{
<span aria-hidden="true">/</span>
<span>Scene @scene.SceneNumber:g</span>
}
</p>
@if (Model.IsChapterOnly)
{
<p>No current scene. Showing chapter intent.</p>
}
else if (scene is not null && string.IsNullOrWhiteSpace(scene.Summary) && string.IsNullOrWhiteSpace(scene.ScenePurposeNotes) && !scene.ChecklistItems.Any())
{
<p>No scene-specific plan. Showing chapter intent.</p>
}
</div>
<div class="writing-context-scene-actions">
@if (chapter is not null)
{
<label for="writing-intent-chapter-select">Jump to chapter</label>
<select id="writing-intent-chapter-select" class="form-select form-select-sm" data-writing-intent-chapter-select>
<option value="@chapter.ChapterID" selected>Chapter @chapter.ChapterNumber:g: @chapter.ChapterTitle</option>
</select>
}
<label for="writing-intent-scene-select">Jump to scene</label>
<select id="writing-intent-scene-select" class="form-select form-select-sm" data-writing-intent-scene-select>
<option value="">No current scene</option>
@foreach (var option in Model.SceneOptions)
{
<option value="@option.Value" selected="@option.Selected">@option.Text</option>
}
</select>
@if (scene is not null)
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Scenes" asp-action="Edit" asp-route-id="@scene.SceneID">Edit Scene</a>
}
else if (chapter is not null)
{
<a class="btn btn-outline-secondary btn-sm" asp-controller="Writer" asp-action="Chapter" asp-route-id="@chapter.ChapterID">Edit Chapter Plan</a>
}
</div>
</header>
<nav class="writing-context-tabs" aria-label="Writing intent tabs">
<button type="button" class="active" data-writing-intent-tab="plan">Plan</button>
<button type="button" data-writing-intent-tab="must-include">Must Include</button>
<button type="button" data-writing-intent-tab="notes">Notes</button>
<button type="button" data-writing-intent-tab="threads">Threads</button>
<button type="button" data-writing-intent-tab="checklist">Checklist</button>
</nav>
<div class="writing-context-tab-panels">
<section class="writing-context-tab-panel active" data-writing-intent-panel="plan">
<partial name="_WritingIntentPlan" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="must-include" hidden>
<partial name="_WritingIntentMustInclude" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="notes" hidden>
<partial name="_WritingIntentNotes" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="threads" hidden>
<partial name="_WritingIntentThreads" model="Model" />
</section>
<section class="writing-context-tab-panel" data-writing-intent-panel="checklist" hidden>
<partial name="_WritingIntentChecklist" model="Model" />
</section>
</div>
}
</div>

View File

@ -0,0 +1,42 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var goal = Model.ChapterWorkflow?.Goal;
var plannedItems = scene?.ChecklistItems.OrderBy(x => x.IsCompleted).ThenBy(x => x.SortOrder).ThenBy(x => x.ItemText).ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Chapter Must Include</p>
<p>@TextOr(goal?.MustInclude, "No chapter-level must-include notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Checklist</p>
@if (!plannedItems.Any())
{
<p>No scene checklist items.</p>
}
else
{
<ul class="writing-context-list writing-intent-checklist">
@foreach (var item in plannedItems)
{
<li class="@(item.IsCompleted ? "is-complete" : "")">
<span>@(item.IsCompleted ? "Done" : "Planned")</span>
<span>@item.ItemText</span>
</li>
}
</ul>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Relevant Goals</p>
<ul class="writing-context-list">
<li><strong>Goal</strong> @TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</li>
<li><strong>Reader</strong> @TextOr(goal?.ReaderTakeaway, "No reader takeaway yet.")</li>
</ul>
</article>
</div>

View File

@ -0,0 +1,36 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var goal = Model.ChapterWorkflow?.Goal;
var notes = scene?.Notes.OrderByDescending(x => x.IsPinned).ThenBy(x => x.IsResolved).ThenByDescending(x => x.UpdatedDate).ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card">
<p class="eyebrow">Chapter Risks</p>
<p>@TextOr(goal?.RisksOrConcerns, "No chapter risks or concerns recorded.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Notes</p>
@if (!notes.Any())
{
<p>No writing notes for this scene.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var note in notes)
{
<li>
<strong>@(string.IsNullOrWhiteSpace(note.NoteTitle) ? note.TypeName : note.NoteTitle)</strong>
@if (note.IsPinned) { <span class="writing-intent-status-label">Pinned</span> }
@if (note.IsResolved) { <span class="writing-intent-status-label">Resolved</span> }
<p>@note.NoteText</p>
</li>
}
</ul>
}
</article>
</div>

View File

@ -0,0 +1,53 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var workflow = Model.ChapterWorkflow;
var chapter = workflow?.Chapter ?? Model.Context?.Chapter;
var goal = workflow?.Goal;
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid writing-context-grid--overview">
<article class="writing-context-card writing-context-card--hero">
<p class="eyebrow">Chapter Plan</p>
<h3>Chapter @chapter?.ChapterNumber:g: @chapter?.ChapterTitle</h3>
<p>@TextOr(chapter?.Summary, "No chapter summary has been written yet.")</p>
<dl class="writing-context-facts">
<dt>Purpose</dt><dd>@TextOr(chapter?.ChapterPurposeName, "No chapter purpose selected.")</dd>
<dt>Goal</dt><dd>@TextOr(goal?.GoalSummary, "No chapter goal summary yet.")</dd>
</dl>
</article>
<article class="writing-context-card">
<p class="eyebrow">Current Scene</p>
@if (scene is null)
{
<p>No current scene. Chapter intent is shown until Word reports a scene.</p>
}
else
{
<h3>Scene @scene.SceneNumber:g: @scene.SceneTitle</h3>
<p>@TextOr(scene.Summary, "No scene summary has been written yet.")</p>
}
</article>
<article class="writing-context-card">
<p class="eyebrow">Scene Purpose</p>
<p>@TextOr(scene?.ScenePurposeNotes, "No scene-specific purpose notes yet.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Expected Outcome</p>
<p>@TextOr(scene?.SceneOutcomeNotes, "No expected outcome has been captured for this scene.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Emotional Goal</p>
<p>@TextOr(goal?.EmotionalGoal, "No emotional goal recorded for this chapter.")</p>
</article>
<article class="writing-context-card">
<p class="eyebrow">Reader Takeaway</p>
<p>@TextOr(goal?.ReaderTakeaway, "No reader takeaway recorded for this chapter.")</p>
</article>
</div>

View File

@ -0,0 +1,29 @@
@model WritingIntentDisplayViewModel
@{
var scene = Model.Context?.Scene;
var threadEvents = scene?.ThreadEvents.ToList() ?? [];
static string TextOr(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
}
<div class="writing-context-grid">
<article class="writing-context-card writing-context-card--wide">
<p class="eyebrow">Story Threads</p>
@if (!threadEvents.Any())
{
<p>No story thread events recorded for this scene.</p>
}
else
{
<ul class="writing-context-list">
@foreach (var item in threadEvents)
{
<li>
<strong>@item.PlotLineName / @item.ThreadTitle</strong>
<span class="writing-intent-status-label">@TextOr(item.EventTypeName, "Thread event")</span>
<p>@TextOr(item.EventTitle, item.EventDescription ?? "Thread intention recorded here.")</p>
</li>
}
</ul>
}
</article>
</div>

View File

@ -189,6 +189,38 @@ html {
color: var(--plotline-muted);
}
.writing-intent-status-label {
display: inline-flex;
align-items: center;
margin-left: .35rem;
padding: .1rem .45rem;
border: 1px solid var(--plotline-line);
border-radius: 999px;
background: var(--plotline-paper);
color: var(--plotline-muted);
font-size: .78rem;
font-weight: 700;
}
.writing-intent-checklist li {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr);
gap: .5rem;
align-items: start;
}
.writing-intent-checklist li > span:first-child {
color: var(--plotline-muted);
font-size: .78rem;
font-weight: 800;
text-transform: uppercase;
}
.writing-intent-checklist li.is-complete > span:last-child {
color: var(--plotline-muted);
text-decoration: line-through;
}
.writing-context-people-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));

File diff suppressed because one or more lines are too long

View File

@ -164,6 +164,14 @@
return false;
}
if (document.querySelector("[data-writing-mode]")
|| path === "/writing/context"
|| path.startsWith("/writing/context/")
|| path === "/writing/intent"
|| path.startsWith("/writing/intent/")) {
return false;
}
return !unsafePathPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
};

File diff suppressed because one or more lines are too long

View File

@ -1,165 +1,17 @@
(() => {
const root = document.querySelector("[data-writing-context]");
if (!root) {
return;
}
const contentHost = root.querySelector("[data-writing-context-content]");
const statusText = root.querySelector("[data-writing-context-status-text]");
const endpoint = root.dataset.contextUrl || "/Writing/Context/Scene";
const tabStorageKey = "plotdirector.writingContext.activeTab";
const scopedProjectId = Number.parseInt(root.dataset.scopeProjectId || "", 10);
const scopedBookId = Number.parseInt(root.dataset.scopeBookId || "", 10);
let currentSceneId = Number.parseInt(contentHost?.querySelector("[data-writing-context-fragment]")?.dataset.sceneId || "", 10);
let loadingSceneId = null;
const setStatus = (value) => {
if (statusText && value) {
statusText.textContent = value;
}
};
const activeTab = () => {
const active = contentHost?.querySelector("[data-writing-context-tab].active")?.dataset.writingContextTab;
if (active) {
return active;
}
try {
return localStorage.getItem(tabStorageKey) || "overview";
} catch {
return "overview";
}
};
const activateTab = (tabName) => {
const nextTab = tabName || "overview";
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
const active = button.dataset.writingContextTab === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
window.PlotDirectorWritingFollow?.initialize({
rootSelector: "[data-writing-context]",
contentSelector: "[data-writing-context-content]",
fragmentSelector: "[data-writing-context-fragment]",
statusTextSelector: "[data-writing-context-status-text]",
tabSelector: "[data-writing-context-tab]",
panelSelector: "[data-writing-context-panel]",
selectSelector: "[data-writing-context-scene-select]",
tabDataKey: "writingContextTab",
panelDataKey: "writingContextPanel",
storageKey: "plotdirector.writingContext.activeTab",
defaultTab: "overview",
defaultEndpoint: "/Writing/Context/Scene",
source: "WritingContext"
});
contentHost?.querySelectorAll("[data-writing-context-panel]").forEach((panel) => {
const active = panel.dataset.writingContextPanel === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
try {
localStorage.setItem(tabStorageKey, nextTab);
} catch {
// Remembering the tab is a convenience only.
}
};
const wireFragment = () => {
activateTab(activeTab());
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
const fragmentStatus = fragment?.dataset.followStatus;
if (fragmentStatus) {
setStatus(fragmentStatus);
}
const fragmentSceneId = Number.parseInt(fragment?.dataset.sceneId || "", 10);
if (Number.isInteger(fragmentSceneId) && fragmentSceneId > 0) {
currentSceneId = fragmentSceneId;
}
contentHost?.querySelectorAll("[data-writing-context-tab]").forEach((button) => {
button.addEventListener("click", () => activateTab(button.dataset.writingContextTab));
});
contentHost?.querySelector("[data-writing-context-scene-select]")?.addEventListener("change", (event) => {
const sceneId = Number.parseInt(event.target.value || "", 10);
if (!Number.isInteger(sceneId) || sceneId <= 0) {
return;
}
const fragment = contentHost?.querySelector("[data-writing-context-fragment]");
window.PlotDirectorStoryPosition?.publishChanged({
source: "WritingContext",
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId: fragment?.dataset.chapterId,
sceneId
}, "WritingContext");
});
};
const inScope = (position) => {
if (Number.isInteger(scopedProjectId) && scopedProjectId > 0 && position.projectId !== scopedProjectId) {
return false;
}
if (Number.isInteger(scopedBookId) && scopedBookId > 0 && position.bookId && position.bookId !== scopedBookId) {
return false;
}
return true;
};
const loadPosition = async (position) => {
if (!position || !Number.isInteger(position.sceneId) || position.sceneId <= 0 || !inScope(position)) {
return;
}
if (position.sceneId === currentSceneId || position.sceneId === loadingSceneId) {
return;
}
const tab = activeTab();
loadingSceneId = position.sceneId;
setStatus("Updating");
const url = new URL(endpoint, window.location.origin);
if (position.projectId) url.searchParams.set("projectId", position.projectId);
if (position.bookId) url.searchParams.set("bookId", position.bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
url.searchParams.set("sceneId", position.sceneId);
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error(`Context request failed: ${response.status}`);
}
contentHost.innerHTML = await response.text();
currentSceneId = position.sceneId;
wireFragment();
activateTab(tab);
} catch (error) {
console.debug("Writing Context could not update.", error);
setStatus("Connected, waiting for scene");
} finally {
loadingSceneId = null;
}
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
void loadPosition(event.detail);
});
if (window.signalR) {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("storyPositionChanged", (payload) => {
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
});
connection.start()
.then(() => {
if (!currentSceneId) {
setStatus("Connected, waiting for scene");
}
})
.catch((error) => {
console.debug("Writing Context follow connection unavailable.", error);
setStatus("Word not connected");
});
} else {
setStatus("Word not connected");
}
wireFragment();
})();

View File

@ -0,0 +1,231 @@
(() => {
const defaults = {
rootSelector: "",
contentSelector: "",
fragmentSelector: "",
statusTextSelector: "",
tabSelector: "",
panelSelector: "",
selectSelector: "",
tabDataKey: "",
panelDataKey: "",
storageKey: "",
defaultTab: "",
defaultEndpoint: "",
source: "WritingMode",
allowChapterOnly: false,
waitingStatus: "Connected, waiting for scene",
updatingStatus: "Updating",
disconnectedStatus: "Word not connected"
};
const parsePositiveInt = (value) => {
const parsed = Number.parseInt(value || "", 10);
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
};
const initialize = (options) => {
const config = { ...defaults, ...options };
const root = document.querySelector(config.rootSelector);
if (!root) {
return null;
}
const contentHost = root.querySelector(config.contentSelector);
const statusText = root.querySelector(config.statusTextSelector);
const endpoint = root.dataset.contextUrl || config.defaultEndpoint;
const scopedProjectId = parsePositiveInt(root.dataset.scopeProjectId);
const scopedBookId = parsePositiveInt(root.dataset.scopeBookId);
let currentFragment = contentHost?.querySelector(config.fragmentSelector);
let currentSceneId = parsePositiveInt(currentFragment?.dataset.sceneId);
let currentChapterId = parsePositiveInt(currentFragment?.dataset.chapterId);
let loadingSceneId = null;
let loadingChapterId = null;
const setStatus = (value) => {
if (statusText && value) {
statusText.textContent = value;
}
};
const readStoredTab = () => {
try {
return localStorage.getItem(config.storageKey) || config.defaultTab;
} catch {
return config.defaultTab;
}
};
const activeTab = () => {
const active = contentHost?.querySelector(`${config.tabSelector}.active`)?.dataset[config.tabDataKey];
return active || readStoredTab();
};
const activateTab = (tabName) => {
const nextTab = tabName || config.defaultTab;
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
const active = button.dataset[config.tabDataKey] === nextTab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
});
contentHost?.querySelectorAll(config.panelSelector).forEach((panel) => {
const active = panel.dataset[config.panelDataKey] === nextTab;
panel.classList.toggle("active", active);
panel.hidden = !active;
});
try {
localStorage.setItem(config.storageKey, nextTab);
} catch {
// Remembering the selected tab is a convenience only.
}
};
const wireFragment = () => {
activateTab(activeTab());
const fragment = contentHost?.querySelector(config.fragmentSelector);
const fragmentStatus = fragment?.dataset.followStatus;
if (fragmentStatus) {
setStatus(fragmentStatus);
}
const fragmentSceneId = parsePositiveInt(fragment?.dataset.sceneId);
if (fragmentSceneId) {
currentSceneId = fragmentSceneId;
}
const fragmentChapterId = parsePositiveInt(fragment?.dataset.chapterId);
if (fragmentChapterId) {
currentChapterId = fragmentChapterId;
}
contentHost?.querySelectorAll(config.tabSelector).forEach((button) => {
button.addEventListener("click", () => activateTab(button.dataset[config.tabDataKey]));
});
contentHost?.querySelector(config.selectSelector)?.addEventListener("change", (event) => {
const sceneId = parsePositiveInt(event.target.value);
if (!sceneId) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
window.PlotDirectorStoryPosition?.publishChanged({
source: config.source,
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId: fragment?.dataset.chapterId,
sceneId
}, config.source);
});
contentHost?.querySelector("[data-writing-intent-chapter-select]")?.addEventListener("change", (event) => {
const chapterId = parsePositiveInt(event.target.value);
if (!chapterId) {
return;
}
const fragment = contentHost?.querySelector(config.fragmentSelector);
window.PlotDirectorStoryPosition?.publishChanged({
source: config.source,
projectId: fragment?.dataset.projectId,
bookId: fragment?.dataset.bookId,
chapterId
}, config.source);
});
};
const inScope = (position) => {
if (scopedProjectId && position.projectId !== scopedProjectId) {
return false;
}
if (scopedBookId && position.bookId && position.bookId !== scopedBookId) {
return false;
}
return true;
};
const loadPosition = async (position) => {
const hasScene = Number.isInteger(position?.sceneId) && position.sceneId > 0;
const hasChapter = Number.isInteger(position?.chapterId) && position.chapterId > 0;
if (!position || !inScope(position) || (!hasScene && (!config.allowChapterOnly || !hasChapter))) {
return;
}
if (hasScene && (position.sceneId === currentSceneId || position.sceneId === loadingSceneId)) {
return;
}
if (!hasScene && (position.chapterId === currentChapterId || position.chapterId === loadingChapterId)) {
return;
}
const tab = activeTab();
loadingSceneId = hasScene ? position.sceneId : null;
loadingChapterId = !hasScene ? position.chapterId : null;
setStatus(config.updatingStatus);
const url = new URL(endpoint, window.location.origin);
if (position.projectId) url.searchParams.set("projectId", position.projectId);
if (position.bookId) url.searchParams.set("bookId", position.bookId);
if (position.chapterId) url.searchParams.set("chapterId", position.chapterId);
if (hasScene) url.searchParams.set("sceneId", position.sceneId);
try {
const response = await fetch(url, { headers: { "X-Requested-With": "XMLHttpRequest" } });
if (!response.ok) {
throw new Error(`${config.source} request failed: ${response.status}`);
}
contentHost.innerHTML = await response.text();
if (hasScene) {
currentSceneId = position.sceneId;
}
if (hasChapter) {
currentChapterId = position.chapterId;
}
wireFragment();
activateTab(tab);
} catch (error) {
console.debug(`${config.source} could not update.`, error);
setStatus(config.waitingStatus);
} finally {
loadingSceneId = null;
loadingChapterId = null;
}
};
document.addEventListener(window.PlotDirectorStoryPosition?.eventName || "plotdirector:story-position-changed", (event) => {
void loadPosition(event.detail);
});
if (window.signalR) {
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("storyPositionChanged", (payload) => {
window.PlotDirectorStoryPosition?.publishChanged(payload, payload?.source || payload?.Source || "WordCompanion");
});
connection.start()
.then(() => {
if (!currentSceneId) {
setStatus(config.waitingStatus);
}
})
.catch((error) => {
console.debug(`${config.source} follow connection unavailable.`, error);
setStatus(config.disconnectedStatus);
});
} else {
setStatus(config.disconnectedStatus);
}
wireFragment();
return { loadPosition, activateTab };
};
window.PlotDirectorWritingFollow = { initialize };
})();

View File

@ -0,0 +1,18 @@
(() => {
window.PlotDirectorWritingFollow?.initialize({
rootSelector: "[data-writing-intent]",
contentSelector: "[data-writing-intent-content]",
fragmentSelector: "[data-writing-intent-fragment]",
statusTextSelector: "[data-writing-intent-status-text]",
tabSelector: "[data-writing-intent-tab]",
panelSelector: "[data-writing-intent-panel]",
selectSelector: "[data-writing-intent-scene-select]",
tabDataKey: "writingIntentTab",
panelDataKey: "writingIntentPanel",
storageKey: "plotdirector.writingIntent.activeTab",
defaultTab: "plan",
defaultEndpoint: "/Writing/Intent/Scene",
source: "WritingIntent",
allowChapterOnly: true
});
})();