Phase 16R - Word Companion UX Simplification and Workflow Redesign

This commit is contained in:
Nick Beckley 2026-06-29 14:59:06 +01:00
parent 5ff2713afb
commit f77d9ccfc7
7 changed files with 202 additions and 43 deletions

View File

@ -7,14 +7,18 @@ namespace PlotLine.Controllers;
[AllowAnonymous]
[Route("word-companion")]
public sealed class WordCompanionHostController(ICurrentUserService currentUser) : Controller
public sealed class WordCompanionHostController(ICurrentUserService currentUser, IAuthorizationService authorization) : Controller
{
[HttpGet("")]
public IActionResult Index()
public async Task<IActionResult> Index()
{
var isAdmin = currentUser.IsAuthenticated
&& (await authorization.AuthorizeAsync(User, "AdminOnly")).Succeeded;
return View(new WordCompanionHostViewModel
{
IsAuthenticated = currentUser.IsAuthenticated,
IsAdmin = isAdmin,
DisplayName = currentUser.DisplayName,
LoginUrl = Url.Action("Login", "Account", new { returnUrl = "/word-companion" }) ?? "/Account/Login?returnUrl=%2Fword-companion"
});

View File

@ -3,6 +3,7 @@ namespace PlotLine.ViewModels;
public sealed class WordCompanionHostViewModel
{
public bool IsAuthenticated { get; init; }
public bool IsAdmin { get; init; }
public string? DisplayName { get; init; }
public string LoginUrl { get; init; } = string.Empty;
}

View File

@ -13,7 +13,7 @@
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
</head>
<body>
<main class="word-companion-shell" data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()">
<main class="word-companion-shell" data-authenticated="@Model.IsAuthenticated.ToString().ToLowerInvariant()" data-admin="@Model.IsAdmin.ToString().ToLowerInvariant()">
<header class="word-companion-header">
<div>
<h1>PlotDirector</h1>
@ -89,15 +89,26 @@
</div>
</section>
<section class="word-companion-section word-companion-linked-card" data-linked-manuscript-card hidden aria-label="Linked manuscript">
<div>
<span>Manuscript</span>
<strong><span data-linked-project-title>Project</span> &gt; <span data-linked-book-title>Book</span></strong>
</div>
<p>Linked to PlotDirector</p>
</section>
<nav class="word-companion-tabs" aria-label="Companion sections">
<button type="button" class="is-active" data-tab-button="scene" aria-selected="true">Scene</button>
<button type="button" data-tab-button="links" aria-selected="false">Links</button>
<button type="button" data-tab-button="structure" aria-selected="false">Structure</button>
<button type="button" data-tab-button="review" aria-selected="false">Review</button>
<button type="button" data-tab-button="maintenance" aria-selected="false">Maintenance</button>
@if (Model.IsAdmin)
{
<button type="button" data-tab-button="diagnostics" aria-selected="false">Diagnostics</button>
}
</nav>
<section class="word-companion-tab-panel is-active" data-tab-panel="scene" aria-label="Scene">
<section class="word-companion-section word-companion-connection" aria-label="Project and book selection">
<section class="word-companion-section word-companion-connection" data-runtime-book-selectors aria-label="Project and book selection">
<div>
<label for="word-companion-project">Project</label>
<select id="word-companion-project" data-project-select disabled>
@ -144,13 +155,9 @@
<strong data-current-word-count>-</strong>
</div>
<div>
<span>Actual</span>
<span>PlotDirector</span>
<strong data-plotdirector-actual-words>-</strong>
</div>
<div>
<span>Estimated</span>
<strong data-plotdirector-estimated-words>-</strong>
</div>
<div>
<span>Revision</span>
<strong data-plotdirector-revision-status>-</strong>
@ -159,6 +166,20 @@
<span>Last Sync</span>
<strong data-runtime-last-sync>-</strong>
</div>
</div>
<section class="word-companion-section word-companion-brief" data-writing-brief-block hidden aria-label="Writing Brief">
<span>Writing Brief</span>
<p data-writing-brief></p>
</section>
<details class="word-companion-collapsible word-companion-advanced">
<summary>Advanced</summary>
<div class="word-companion-metrics">
<div>
<span>Estimated</span>
<strong data-plotdirector-estimated-words>-</strong>
</div>
<div>
<span>Blocked</span>
<strong data-plotdirector-blocked-status>-</strong>
@ -168,9 +189,7 @@
<strong data-plotdirector-blocked-reason>-</strong>
</div>
</div>
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink this Word document</button>
</details>
<p class="word-companion-message" data-sync-note></p>
</section>
@ -212,46 +231,56 @@
<p class="word-companion-message" data-create-link-status></p>
</section>
<section class="word-companion-section word-companion-brief" data-writing-brief-block hidden aria-label="Writing Brief">
<span>Writing Brief</span>
<p data-writing-brief></p>
</section>
</section>
<section class="word-companion-tab-panel" data-tab-panel="links" aria-label="Links" hidden>
<section class="word-companion-tab-panel" data-tab-panel="review" aria-label="Review" hidden>
<section class="word-companion-section word-companion-link-groups" data-plotdirector-link-groups aria-label="Scene Links">
<div class="word-companion-section-header">
<span>Scene Links</span>
<span>Review Discoveries</span>
<button type="button" data-save-scene-links disabled>Save Scene Links</button>
</div>
<p class="word-companion-message" data-scene-links-status>Link a PlotDirector scene first.</p>
<p class="word-companion-link-context" data-links-editing-scene>Link a PlotDirector scene first.</p>
<div>
<span>Characters</span>
<p class="word-companion-message">Characters, assets and locations detected while writing will appear here.</p>
<details class="word-companion-review-group" open>
<summary data-character-review-summary>Characters (0)</summary>
<div class="word-companion-check-list" data-character-chips></div>
</div>
<div>
<span>Assets</span>
</details>
<details class="word-companion-review-group">
<summary data-asset-review-summary>Assets (0)</summary>
<div class="word-companion-check-list" data-asset-chips></div>
</div>
<div>
</details>
<details class="word-companion-review-group">
<summary data-location-review-summary>Locations (0)</summary>
<label for="word-companion-primary-location">Primary Location</label>
<select id="word-companion-primary-location" data-primary-location-select disabled>
<option value="">None</option>
</select>
</div>
</details>
</section>
</section>
<section class="word-companion-tab-panel" data-tab-panel="structure" aria-label="Structure" hidden>
<section class="word-companion-tab-panel" data-tab-panel="maintenance" aria-label="Maintenance" hidden>
<section class="word-companion-section word-companion-maintenance" aria-label="Manuscript maintenance">
<div class="word-companion-section-header">
<span>Manuscript Maintenance</span>
</div>
<p class="word-companion-message">Use these tools when manuscript headings, scene boundaries, or document links need attention.</p>
<div class="word-companion-actions word-companion-maintenance-actions">
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<button type="button" data-refresh-document>Refresh Local Cache</button>
<button type="button" class="word-companion-danger-action" data-unlink-word-document hidden>Unlink this Word document</button>
</div>
</section>
<section class="word-companion-section word-companion-structure-preview" aria-label="Structure Sync Preview">
<div class="word-companion-section-header">
<span>Structure Sync Preview</span>
<span>Structure Maintenance</span>
<div class="word-companion-header-actions">
<button type="button" data-analyse-manuscript-structure disabled>Analyse Manuscript Structure</button>
<button type="button" data-apply-structure-sync disabled>Apply Structure Sync</button>
</div>
</div>
<p class="word-companion-message">Use these tools if manuscript headings or scene boundaries have changed significantly.</p>
<p class="word-companion-message" data-structure-preview-status>Select a project and book to analyse manuscript structure.</p>
<div class="word-companion-preview-groups" data-structure-preview-results>
<p>No preview generated.</p>
@ -266,6 +295,8 @@
</details>
</section>
@if (Model.IsAdmin)
{
<section class="word-companion-tab-panel" data-tab-panel="diagnostics" aria-label="Diagnostics" hidden>
<section class="word-companion-section">
<div class="word-companion-section-header">
@ -392,6 +423,7 @@
</div>
</section>
</section>
}
<footer class="word-companion-footer">
<span data-office-status>Word Companion loading</span>
@ -401,7 +433,6 @@
<span data-summary-connection></span>
<span data-summary-project></span>
<span data-summary-book></span>
<button type="button" data-refresh-document></button>
<button type="button" data-refresh-plotdirector-scene></button>
<span data-sync-word-count></span>
<section data-plotdirector-scene-details>

View File

@ -259,7 +259,7 @@ body {
.word-companion-tabs {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 3px;
border: 1px solid var(--companion-line);
border-radius: 9px;
@ -338,6 +338,29 @@ body {
gap: 9px;
}
.word-companion-linked-card {
gap: 4px;
padding: 8px 9px;
}
.word-companion-linked-card div {
display: grid;
gap: 2px;
}
.word-companion-linked-card strong {
color: var(--companion-accent-dark);
font-size: 0.94rem;
line-height: 1.2;
}
.word-companion-linked-card p {
margin: 0;
color: #2f6b45;
font-size: 0.8rem;
font-weight: 800;
}
.word-companion-first-run {
gap: 10px;
}
@ -568,6 +591,16 @@ body {
background: #fef2f2;
}
.word-companion-maintenance .word-companion-danger-action {
border-color: #fecaca;
color: #b91c1c;
}
.word-companion-maintenance .word-companion-danger-action:hover:not(:disabled) {
border-color: #ef4444;
background: #fef2f2;
}
.word-companion-message:empty {
display: none;
}
@ -696,6 +729,30 @@ body {
gap: 3px;
}
.word-companion-review-group {
display: grid;
gap: 7px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 7px;
padding: 8px;
background: #fffdf8;
}
.word-companion-review-group summary {
cursor: pointer;
color: var(--companion-accent-dark);
font-size: 0.9rem;
font-weight: 800;
}
.word-companion-review-group:not([open]) > :not(summary) {
display: none !important;
}
.word-companion-review-group label {
margin-top: 6px;
}
.word-companion-resolve-diagnostics {
padding-top: 4px;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -759,18 +816,18 @@ body {
.word-companion-check-list {
display: grid;
gap: 4px;
gap: 0.75rem;
}
.word-companion-check-item {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 7px;
min-height: 29px;
gap: 0.75rem;
min-height: 42px;
border: 1px solid rgba(111, 75, 45, 0.18);
border-radius: 6px;
padding: 5px 7px;
padding: 0.75rem;
background: #fffdf8;
color: var(--companion-ink);
font-size: 0.9rem;
@ -800,6 +857,19 @@ body {
background: #fff8ec;
}
.word-companion-advanced {
border: 0;
border-top: 1px solid var(--companion-line);
border-radius: 0;
padding: 7px 0 0;
background: transparent;
box-shadow: none;
}
.word-companion-maintenance-actions {
grid-template-columns: 1fr;
}
.word-companion-structure-preview {
border-color: rgba(182, 138, 70, 0.35);
}

File diff suppressed because one or more lines are too long

View File

@ -10,12 +10,15 @@
projectId: "plotdirector.word.boundProjectId",
bindingVersion: "plotdirector.word.bindingVersion"
};
const validTabNames = new Set(["scene", "links", "structure", "diagnostics"]);
const validTabNames = new Set(["scene", "review", "maintenance", "diagnostics"]);
const shell = document.querySelector(".word-companion-shell");
const tabsNav = document.querySelector(".word-companion-tabs");
const tabButtons = [...document.querySelectorAll("[data-tab-button]")];
const tabPanels = [...document.querySelectorAll("[data-tab-panel]")];
const linkedManuscriptCard = document.querySelector("[data-linked-manuscript-card]");
const linkedProjectTitle = document.querySelector("[data-linked-project-title]");
const linkedBookTitle = document.querySelector("[data-linked-book-title]");
const firstRunWizard = document.querySelector("[data-first-run-wizard]");
const firstRunProjectSelect = document.querySelector("[data-first-run-project-select]");
const firstRunBookSelect = document.querySelector("[data-first-run-book-select]");
@ -48,6 +51,7 @@
const summaryBook = document.querySelector("[data-summary-book]");
const projectSelect = document.querySelector("[data-project-select]");
const bookSelect = document.querySelector("[data-book-select]");
const runtimeBookSelectors = document.querySelector("[data-runtime-book-selectors]");
const projectMessage = document.querySelector("[data-project-message]");
const bookMessage = document.querySelector("[data-book-message]");
const refreshCurrentSceneButton = document.querySelector("[data-refresh-current-scene]");
@ -88,6 +92,9 @@
const writingBriefBlock = document.querySelector("[data-writing-brief-block]");
const writingBrief = document.querySelector("[data-writing-brief]");
const plotDirectorLinkGroups = document.querySelector("[data-plotdirector-link-groups]");
const characterReviewSummary = document.querySelector("[data-character-review-summary]");
const assetReviewSummary = document.querySelector("[data-asset-review-summary]");
const locationReviewSummary = document.querySelector("[data-location-review-summary]");
const characterChips = document.querySelector("[data-character-chips]");
const assetChips = document.querySelector("[data-asset-chips]");
const locationChips = document.querySelector("[data-location-chips]");
@ -280,9 +287,20 @@
}
};
const normalizeTabName = (tabName) => {
if (tabName === "links") {
return "review";
}
if (tabName === "structure") {
return "maintenance";
}
return tabName;
};
const selectTab = (tabName, persist = true) => {
const targetTab = validTabNames.has(tabName) && tabButtons.some((button) => button.dataset.tabButton === tabName)
? tabName
const requestedTab = normalizeTabName(tabName);
const targetTab = validTabNames.has(requestedTab) && tabButtons.some((button) => button.dataset.tabButton === requestedTab)
? requestedTab
: "scene";
for (const button of tabButtons) {
@ -303,7 +321,7 @@
};
const restoreActiveTab = () => {
const storedTab = readStoredText(storageKeys.activeTab);
const storedTab = normalizeTabName(readStoredText(storageKeys.activeTab));
selectTab(validTabNames.has(storedTab) ? storedTab : "scene", false);
};
@ -365,6 +383,7 @@
setText(anchorSceneId, Number.isInteger(resolvedSceneId) && resolvedSceneId > 0 ? String(resolvedSceneId) : "-");
if (attachPlotDirectorIdsButton) {
setHidden(attachPlotDirectorIdsButton, attached && !storedAnchorInvalid);
attachPlotDirectorIdsButton.disabled = !resolvedSceneId || (attached && !storedAnchorInvalid);
attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId && !attached
? "Reattach PlotDirector IDs"
@ -593,6 +612,7 @@
const lastSyncUtc = runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc;
setText(runtimeLastSync, formatLastSynced(lastSyncUtc));
setText(syncLastSynced, formatLastSynced(lastSyncUtc));
updateLinkedManuscriptUi();
};
const clearRuntimeCaches = () => {
@ -708,6 +728,7 @@
renderSceneLinkList(characterChips, [], "character");
renderSceneLinkList(assetChips, [], "asset");
renderPrimaryLocationOptions([], null, false);
updateReviewSummaries();
setResolvedScene(null);
setText(syncPlotDirectorCount, "-");
setText(plotDirectorSceneTitle, "-");
@ -830,6 +851,7 @@
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId && !sceneContextStale);
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId && !sceneContextStale);
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId && !sceneContextStale);
updateReviewSummaries();
updateLinksEditingScene();
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
@ -1115,6 +1137,26 @@
return subtitle ? `${title}: ${subtitle}` : title;
};
const runtimeProjectTitle = () =>
runtimeBookContext?.projectTitle
|| selectedProject()?.title
|| "Project";
const runtimeBookTitle = () =>
runtimeBookContext?.bookTitle
|| bookDisplayTitle(selectedBook())
|| "Book";
const updateLinkedManuscriptUi = () => {
const linked = !!firstRunDocumentBinding;
setHidden(linkedManuscriptCard, !linked);
setHidden(runtimeBookSelectors, linked);
if (linked) {
setText(linkedProjectTitle, runtimeProjectTitle());
setText(linkedBookTitle, runtimeBookTitle());
}
};
const selectedProject = () => {
const projectId = Number.parseInt(projectSelect?.value || "", 10);
return projects.find((project) => project.projectId === projectId) || null;
@ -1161,6 +1203,7 @@
setHidden(firstRunWizard, !visible);
setHidden(tabsNav, visible);
updateUnlinkWordDocumentButton();
updateLinkedManuscriptUi();
for (const panel of tabPanels) {
setHidden(panel, visible ? true : panel.dataset.tabPanel !== "scene" && !panel.classList.contains("is-active"));
}
@ -1438,6 +1481,16 @@
syncNote.textContent = book ? "" : "Select a PlotDirector book before refreshing.";
}
setAnalyseButtonEnabled();
updateLinkedManuscriptUi();
};
const updateReviewSummaries = () => {
const characterCount = Array.isArray(currentSceneLinks.characters) ? currentSceneLinks.characters.length : 0;
const assetCount = Array.isArray(currentSceneLinks.assets) ? currentSceneLinks.assets.length : 0;
const locationCount = Array.isArray(currentSceneLinks.locations) ? currentSceneLinks.locations.length : 0;
setText(characterReviewSummary, `Characters (${characterCount})`);
setText(assetReviewSummary, `Assets (${assetCount})`);
setText(locationReviewSummary, `Locations (${locationCount})`);
};
const normalizedStyleName = (paragraph) => {

File diff suppressed because one or more lines are too long