plotlink-ows 1.0.33 → 1.2.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,516 @@
1
+ import { useEffect, useState, type ReactNode } from "react";
2
+ import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
3
+ import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
4
+ import { WorkflowCoachView } from "./WorkflowCoach";
5
+
6
+ interface StoryProgressPanelProps {
7
+ storyName: string;
8
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
9
+ /** Open a file from the map (the workflow steps link to their file). */
10
+ onOpenFile: (storyName: string, file: string) => void;
11
+ /** Bumped by the parent to force a refresh (e.g. after a publish). */
12
+ refreshKey?: number;
13
+ }
14
+
15
+ /**
16
+ * Story-level "View Progress" overview (#418, redesigned #438).
17
+ *
18
+ * For CARTOON stories this is the writer's main production dashboard: a vertical
19
+ * workflow map of numbered sections (Define Story Info → Story Whitepaper →
20
+ * Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
21
+ * status. The single next-action CTA is rendered inside the section it belongs to
22
+ * (never a duplicated global bar), so a first-time creator understands the whole
23
+ * webtoon workflow and where they are in it without reading terminal output or
24
+ * raw file names.
25
+ *
26
+ * FICTION keeps the simpler original layout — metadata, setup steps, a chapter
27
+ * list — and is completely unaffected by the cartoon redesign.
28
+ */
29
+ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
30
+ const [progress, setProgress] = useState<StoryProgress | null>(null);
31
+ const [loading, setLoading] = useState(true);
32
+
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ const load = async () => {
36
+ setLoading(true);
37
+ try {
38
+ const res = await authFetch(`/api/stories/${storyName}/progress`);
39
+ const data = res.ok ? await res.json() : null;
40
+ if (!cancelled) { setProgress(data); setLoading(false); }
41
+ } catch {
42
+ if (!cancelled) { setProgress(null); setLoading(false); }
43
+ }
44
+ };
45
+ load();
46
+ return () => { cancelled = true; };
47
+ }, [storyName, authFetch, refreshKey]);
48
+
49
+ if (loading) {
50
+ return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="progress-loading">Loading progress…</div>;
51
+ }
52
+ // Guard against a missing/malformed response (not just null) so a partial
53
+ // payload can never crash the panel.
54
+ if (!progress || !progress.metadata || !Array.isArray(progress.episodes)) {
55
+ return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load story progress.</div>;
56
+ }
57
+
58
+ return progress.contentType === "cartoon"
59
+ ? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
60
+ : <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Shared header
65
+ // ---------------------------------------------------------------------------
66
+
67
+ function Chip({ label, value, tone = "muted" }: { label: string; value: string; tone?: "muted" | "ok" | "warn" }) {
68
+ const cls = tone === "ok" ? "text-green-700" : tone === "warn" ? "text-amber-700" : "text-muted";
69
+ return (
70
+ <span className="text-[11px]">
71
+ <span className="text-muted">{label}: </span>
72
+ <span className={`font-medium ${cls}`}>{value}</span>
73
+ </span>
74
+ );
75
+ }
76
+
77
+ function ProgressHeader({ progress }: { progress: StoryProgress }) {
78
+ const cartoon = progress.contentType === "cartoon";
79
+ const coverTone = progress.cover === "present" ? "ok" : progress.cover === "invalid" ? "warn" : "muted";
80
+ return (
81
+ <div className="px-4 py-3 border-b border-border">
82
+ <div className="flex items-center gap-2">
83
+ <h2 className="text-base font-serif text-foreground truncate">{progress.metadata.title || progress.name}</h2>
84
+ <span className={`rounded px-1.5 py-0.5 text-[10px] font-medium ${cartoon ? "bg-accent/10 text-accent" : "bg-surface text-muted"}`}>
85
+ {cartoon ? "Cartoon" : "Fiction"}
86
+ </span>
87
+ </div>
88
+ <div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-1">
89
+ <Chip label="Language" value={progress.metadata.language || "Needs metadata"} tone={progress.metadata.language ? "muted" : "warn"} />
90
+ <Chip label="Genre" value={progress.metadata.genre || "Needs metadata"} tone={progress.metadata.genre ? "muted" : "warn"} />
91
+ {progress.metadata.isNsfw != null && <Chip label="Adult" value={progress.metadata.isNsfw ? "Yes (18+)" : "No"} />}
92
+ {cartoon && <Chip label="Cover" value={progress.cover === "present" ? "Ready" : progress.cover === "invalid" ? "Invalid" : "Missing"} tone={coverTone} />}
93
+ </div>
94
+ </div>
95
+ );
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // Cartoon vertical workflow map (#438)
100
+ // ---------------------------------------------------------------------------
101
+
102
+ type SectionStatus = "published" | "done" | "current" | "needs-action" | "not-started";
103
+
104
+ const SECTION_ICON: Record<SectionStatus, string> = {
105
+ published: "✓", done: "●", current: "◉", "needs-action": "●", "not-started": "○",
106
+ };
107
+ const SECTION_TONE: Record<SectionStatus, string> = {
108
+ published: "text-green-700", done: "text-green-700", current: "text-accent", "needs-action": "text-amber-700", "not-started": "text-muted",
109
+ };
110
+ const SECTION_LABEL: Record<SectionStatus, string> = {
111
+ published: "Published", done: "Complete", current: "Current", "needs-action": "Needs action", "not-started": "Not started",
112
+ };
113
+
114
+ type CheckStatus = "done" | "current" | "todo";
115
+ const CHECK_ICON: Record<CheckStatus, string> = { done: "✓", current: "◓", todo: "○" };
116
+ const CHECK_TONE: Record<CheckStatus, string> = { done: "text-green-700", current: "text-accent", todo: "text-muted" };
117
+
118
+ interface ChecklistItem { label: string; status: CheckStatus; detail?: string | null }
119
+
120
+ function ChecklistRow({ item }: { item: ChecklistItem }) {
121
+ return (
122
+ <div className="flex items-baseline gap-2 text-[11px]" data-testid="checklist-item" data-status={item.status}>
123
+ <span className={`${CHECK_TONE[item.status]} flex-shrink-0`} aria-hidden>{CHECK_ICON[item.status]}</span>
124
+ <span className={item.status === "todo" ? "text-muted" : "text-foreground"}>{item.label}</span>
125
+ {item.detail && <span className="text-muted">· {item.detail}</span>}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ /**
131
+ * One numbered workflow section: a status bullet + title + status badge, a nested
132
+ * checklist, and — only for the active section — the single next-action CTA. The
133
+ * header navigates to `openFile` when one is provided (sections whose dedicated
134
+ * page does not exist yet, e.g. Story Info, render a plain header).
135
+ */
136
+ function Section({
137
+ index, title, status, items, fileName, openFile, cta,
138
+ }: {
139
+ index: number;
140
+ title: string;
141
+ status: SectionStatus;
142
+ items: ChecklistItem[];
143
+ /** Power-user secondary text (real file name), shown small. */
144
+ fileName?: string | null;
145
+ /** Called to open the section's underlying file, or undefined for no navigation. */
146
+ openFile?: () => void;
147
+ /** The single CTA node, rendered under this section when it is the active one. */
148
+ cta?: ReactNode;
149
+ }) {
150
+ const heading = (
151
+ <div className="flex items-center gap-2 min-w-0">
152
+ <span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
153
+ <span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
154
+ {fileName && <span className="text-[10px] text-muted truncate">{fileName}</span>}
155
+ <span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
156
+ </div>
157
+ );
158
+ return (
159
+ <div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
160
+ {openFile
161
+ ? <button onClick={openFile} className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5" data-testid={`section-open-${index}`}>{heading}</button>
162
+ : heading}
163
+ <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
164
+ {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
165
+ </div>
166
+ {cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
167
+ </div>
168
+ );
169
+ }
170
+
171
+ /** Map a cartoon episode's coarse state + whether it's the active step → a section status. */
172
+ function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
173
+ if (ep.published) return "published";
174
+ if (isActive) return "current";
175
+ if (ep.state === "placeholder") return "not-started";
176
+ if (ep.state === "blocked") return "needs-action";
177
+ return "needs-action";
178
+ }
179
+
180
+ /** Build the rendered checklist for a cartoon episode from its production checklist. */
181
+ function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
182
+ const steps = ep.checklist ?? [];
183
+ const items: ChecklistItem[] = [];
184
+ // Genesis is the reader-facing Episode 1 opening, so surface its opening text
185
+ // as the first checklist line (done once genesis.md is written; to-do in the
186
+ // not-yet-written stub); a plain plot episode starts at its cut plan.
187
+ if (ep.kind === "genesis") items.push({ label: "Opening text", status: openingDone ? "done" : "todo" });
188
+ if (steps.length === 0) {
189
+ // Not started yet — no cut plan. Show the first couple of steps as to-do so
190
+ // the writer sees what starting the episode involves.
191
+ items.push({ label: "Cut plan", status: "todo" });
192
+ items.push({ label: "Clean artwork", status: "todo" });
193
+ return items;
194
+ }
195
+ for (const s of steps) items.push(checklistStepItem(s));
196
+ return items;
197
+ }
198
+
199
+ function checklistStepItem(s: CartoonChecklistStep): ChecklistItem {
200
+ return { label: s.label, status: s.status, detail: s.detail };
201
+ }
202
+
203
+ /** The not-yet-written Genesis (Episode 1) stub, so the section — and its CTA —
204
+ * always render even before genesis.md exists. */
205
+ const GENESIS_STUB: EpisodeProgress = {
206
+ file: "genesis.md", label: "Episode 1 / Genesis", kind: "genesis", title: null,
207
+ state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
208
+ };
209
+
210
+ /** The single Story-Info next step, when cover/metadata is the active gate. */
211
+ function storyInfoNextStep(progress: StoryProgress): string {
212
+ if (progress.cover !== "present") {
213
+ return progress.cover === "invalid"
214
+ ? "Replace the cover image — it must be a valid WebP or JPEG."
215
+ : "Add a cover image before publishing.";
216
+ }
217
+ const missing: string[] = [];
218
+ if (!progress.metadata.language) missing.push("language");
219
+ if (!progress.metadata.genre) missing.push("genre");
220
+ if (!progress.metadata.title) missing.push("title");
221
+ return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
222
+ }
223
+
224
+ function CartoonWorkflowMap({
225
+ progress, storyName, onOpenFile,
226
+ }: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
227
+ const coach = progress.coach ?? null;
228
+ const m = progress.metadata;
229
+ const hasStructure = progress.setup.hasStructure;
230
+ const hasGenesis = progress.setup.hasGenesis;
231
+ const coverDone = progress.cover === "present";
232
+ // Required publish metadata (title/language/genre) still hard-gates the active
233
+ // step. A missing COVER is a publish-readiness recommendation, NOT the primary
234
+ // step (#462) — it's kept out of the active-gate decision while an episode is
235
+ // mid-production, so the cut/lettering production CTA leads instead.
236
+ const metadataIncomplete = !m.title || !m.language || !m.genre;
237
+ const storyInfoIncomplete = metadataIncomplete || !coverDone;
238
+ // The active (first unpublished) episode and whether it still has production
239
+ // work to do (anything short of publish-ready).
240
+ const activeEp = progress.episodes.find((e) => !e.published) ?? null;
241
+ const productionPending = !!activeEp && activeEp.state !== "ready";
242
+
243
+ // The SINGLE active gate, chosen in the same order buildStoryProgress derives
244
+ // its next step (structure → genesis → story info/cover → active episode), so
245
+ // the one CTA always matches the story-level next action and lands in its own
246
+ // section. `deriveCartoonCoach` agrees on every gate EXCEPT story info (it
247
+ // skips cover/metadata), so we own that gate here; the coach drives the rest.
248
+ // Crucially, every gate maps to a section that is ALWAYS rendered (Whitepaper,
249
+ // the always-present Genesis section, an episode, or the trailing block), so
250
+ // the CTA can never fall through the cracks (#444 review: it vanished when the
251
+ // bible was written but Genesis wasn't).
252
+ let activeKey: string | null;
253
+ if (!hasStructure) activeKey = "whitepaper";
254
+ else if (!hasGenesis) activeKey = "genesis.md";
255
+ else if (metadataIncomplete) activeKey = "story-info";
256
+ // #462: a mid-production episode leads over a missing cover — the cut/lettering
257
+ // production CTA is the primary step. A missing cover only becomes the active
258
+ // step once the active episode's production is complete (no work pending),
259
+ // where it reads as the publish-readiness recommendation.
260
+ else if (productionPending && coach?.episodeFile) activeKey = coach.episodeFile;
261
+ else if (!coverDone) activeKey = "story-info";
262
+ else activeKey = coach?.episodeFile ?? null;
263
+
264
+ // The coach-driven CTA (setup prompts + episode actions), reused from the
265
+ // tested coach view so routing stays byte-identical to the old overview.
266
+ const coachCta = coach ? (
267
+ <WorkflowCoachView
268
+ coach={coach}
269
+ onAction={(action, episodeFile) => {
270
+ if (action === "view-progress") return; // already here
271
+ if (episodeFile) onOpenFile(storyName, episodeFile);
272
+ }}
273
+ />
274
+ ) : null;
275
+
276
+ // Story Info owns the CTA when metadata/cover is the gate. There is no
277
+ // dedicated Story Info page yet (#439/§4) and the coach carries no cover
278
+ // action, so this is an informational next-step line (not a route) — still the
279
+ // one and only CTA, placed in the relevant section.
280
+ const storyInfoCta = (
281
+ <div className="flex items-center gap-2 px-3 py-2 bg-accent/5 border border-accent/30 rounded text-xs" data-testid="story-info-cta">
282
+ <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0">Story info</span>
283
+ <span className="min-w-0 flex-1 text-foreground"><span className="text-muted">Next: </span><span className="font-medium">{storyInfoNextStep(progress)}</span></span>
284
+ </div>
285
+ );
286
+
287
+ // Return the one CTA for a section, or null — guarantees a single visible CTA.
288
+ const ctaFor = (key: string): ReactNode => {
289
+ if (key !== activeKey) return null;
290
+ return activeKey === "story-info" ? storyInfoCta : coachCta;
291
+ };
292
+
293
+ const infoItems: ChecklistItem[] = [
294
+ { label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
295
+ { label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
296
+ { label: "Genre", status: m.genre ? "done" : "todo", detail: m.genre ?? null },
297
+ { label: "Cover image", status: coverDone ? "done" : "todo", detail: progress.cover === "invalid" ? "Invalid — re-import" : coverDone ? null : "Missing" },
298
+ ];
299
+ const infoStatus: SectionStatus = activeKey === "story-info" ? "current" : storyInfoIncomplete ? "needs-action" : "done";
300
+ const whitepaperStatus: SectionStatus = hasStructure ? "done" : activeKey === "whitepaper" ? "current" : "not-started";
301
+
302
+ const genesisEp = progress.episodes.find((e) => e.kind === "genesis") ?? null;
303
+ const plotEps = progress.episodes.filter((e) => e.kind === "plot");
304
+
305
+ let idx = 0;
306
+
307
+ return (
308
+ <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
309
+ <ProgressHeader progress={progress} />
310
+ <p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
311
+
312
+ <Section
313
+ index={++idx}
314
+ title="Define Story Info"
315
+ status={infoStatus}
316
+ items={infoItems}
317
+ cta={ctaFor("story-info") ?? undefined}
318
+ />
319
+
320
+ <Section
321
+ index={++idx}
322
+ title="Story Whitepaper"
323
+ status={whitepaperStatus}
324
+ fileName="structure.md"
325
+ openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
326
+ items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
327
+ cta={ctaFor("whitepaper") ?? undefined}
328
+ />
329
+
330
+ {/* Genesis / Episode 1 — always shown (a not-started stub before it's
331
+ written), so the "Write the Genesis" CTA always has a home. */}
332
+ {genesisEp ? (
333
+ <EpisodeSection
334
+ index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
335
+ storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(genesisEp.file) ?? undefined}
336
+ />
337
+ ) : (
338
+ <EpisodeSection
339
+ index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
340
+ storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor("genesis.md") ?? undefined}
341
+ />
342
+ )}
343
+
344
+ {plotEps.map((ep) => (
345
+ <EpisodeSection
346
+ key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
347
+ storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(ep.file) ?? undefined}
348
+ />
349
+ ))}
350
+
351
+ {/* All episodes published with nothing queued → a trailing "start next" CTA. */}
352
+ {activeKey === null && coachCta && (
353
+ <div className="px-4 py-2.5 border-b border-border" data-testid="workflow-next-episode">
354
+ <div className="ml-1" data-testid="section-cta">{coachCta}</div>
355
+ </div>
356
+ )}
357
+
358
+ <div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
359
+ <span>{progress.summary.published} published</span>
360
+ <span>{progress.summary.readyToPublish} ready</span>
361
+ {progress.summary.placeholders > 0 && <span>{progress.summary.placeholders} not started</span>}
362
+ {progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
363
+ </div>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ /** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
369
+ function EpisodeSection({
370
+ index, ep, isActive, storyName, onOpenFile, cta, openingDone = true, canOpen = true,
371
+ }: {
372
+ index: number;
373
+ ep: EpisodeProgress;
374
+ isActive: boolean;
375
+ storyName: string;
376
+ onOpenFile: (storyName: string, file: string) => void;
377
+ cta?: ReactNode;
378
+ /** Whether the genesis opening text is already written (false for the stub). */
379
+ openingDone?: boolean;
380
+ /** Whether the header navigates to the file (false for the not-yet-written stub). */
381
+ canOpen?: boolean;
382
+ }) {
383
+ const status = episodeStatus(ep, isActive);
384
+ const items = episodeItems(ep, openingDone);
385
+ const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
386
+ const heading = (
387
+ <div className="flex items-center gap-2 min-w-0">
388
+ <span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
389
+ <span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
390
+ <span className="text-[10px] text-muted truncate">{ep.file}</span>
391
+ <span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
392
+ </div>
393
+ );
394
+ return (
395
+ <div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
396
+ {canOpen ? (
397
+ <button
398
+ onClick={() => onOpenFile(storyName, ep.file)}
399
+ data-testid={`progress-episode-${ep.file}`}
400
+ data-state={ep.state}
401
+ className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5"
402
+ >
403
+ {heading}
404
+ </button>
405
+ ) : (
406
+ <div data-state={ep.state}>{heading}</div>
407
+ )}
408
+ <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
409
+ {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
410
+ </div>
411
+ {cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
412
+ </div>
413
+ );
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Fiction progress view — the original, simpler layout (unchanged behavior)
418
+ // ---------------------------------------------------------------------------
419
+
420
+ const STATE_ICON: Record<EpisodeState, string> = {
421
+ published: "✓", ready: "●", "in-progress": "◐", planning: "○", placeholder: "○", blocked: "✕", draft: "○",
422
+ };
423
+ const STATE_TONE: Record<EpisodeState, string> = {
424
+ published: "text-green-700", ready: "text-green-700", "in-progress": "text-accent", planning: "text-accent", placeholder: "text-muted", blocked: "text-error", draft: "text-muted",
425
+ };
426
+ const STATE_LABEL: Record<EpisodeState, string> = {
427
+ published: "Published", ready: "Ready", "in-progress": "In progress", planning: "Planning", placeholder: "Not started", blocked: "Needs fixes", draft: "Draft",
428
+ };
429
+
430
+ function FictionProgressView({
431
+ progress, storyName, onOpenFile,
432
+ }: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
433
+ const [copied, setCopied] = useState(false);
434
+ return (
435
+ <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
436
+ <ProgressHeader progress={progress} />
437
+
438
+ {progress.nextAction && (
439
+ <div className="px-4 py-2 border-b border-accent/30 bg-accent/5 text-xs space-y-1.5" data-testid="progress-next-action">
440
+ <div>
441
+ <span className="font-medium text-foreground">Next: </span>
442
+ <span className="text-muted">{progress.nextAction}</span>
443
+ </div>
444
+ {progress.nextPrompt && (
445
+ <div className="flex items-start gap-1.5" data-testid="progress-next-prompt">
446
+ <code className="flex-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] text-foreground break-words">{progress.nextPrompt}</code>
447
+ <button
448
+ onClick={() => { if (progress.nextPrompt) navigator.clipboard?.writeText(progress.nextPrompt).then(() => { setCopied(true); }).catch(() => {}); }}
449
+ data-testid="copy-next-prompt"
450
+ className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors flex-shrink-0"
451
+ >
452
+ {copied ? "Copied!" : "Copy"}
453
+ </button>
454
+ </div>
455
+ )}
456
+ </div>
457
+ )}
458
+
459
+ {/* Setup steps. */}
460
+ <div className="px-4 py-2 border-b border-border flex flex-col gap-1">
461
+ <StepRow done={progress.setup.hasStructure} label="Story bible (structure.md)"
462
+ onClick={progress.setup.hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined} />
463
+ <StepRow done={progress.setup.hasGenesis} label="Genesis written"
464
+ onClick={progress.setup.hasGenesis ? () => onOpenFile(storyName, "genesis.md") : undefined} />
465
+ </div>
466
+
467
+ {/* Chapter list. */}
468
+ <div className="px-4 py-2">
469
+ <p className="text-[11px] font-medium text-muted uppercase tracking-wider mb-1.5">Chapters</p>
470
+ {progress.episodes.length === 0 ? (
471
+ <p className="text-xs text-muted italic" data-testid="progress-no-episodes">No chapters yet — write the Genesis to start.</p>
472
+ ) : (
473
+ <ol className="flex flex-col gap-1">
474
+ {progress.episodes.map((ep) => (
475
+ <li key={ep.file}>
476
+ <button
477
+ onClick={() => onOpenFile(storyName, ep.file)}
478
+ data-testid={`progress-episode-${ep.file}`}
479
+ data-state={ep.state}
480
+ className="w-full text-left flex items-start gap-2 rounded px-2 py-1.5 hover:bg-surface"
481
+ >
482
+ <span className={`mt-0.5 ${STATE_TONE[ep.state]}`} aria-hidden>{STATE_ICON[ep.state]}</span>
483
+ <span className="min-w-0 flex-1">
484
+ <span className="flex items-center gap-1.5">
485
+ <span className="text-xs font-medium text-foreground">{ep.label}</span>
486
+ {ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
487
+ <span className={`ml-auto text-[10px] font-medium ${STATE_TONE[ep.state]}`}>{STATE_LABEL[ep.state]}</span>
488
+ </span>
489
+ <span className="block text-[11px] text-muted">{ep.summary}</span>
490
+ </span>
491
+ </button>
492
+ </li>
493
+ ))}
494
+ </ol>
495
+ )}
496
+ </div>
497
+
498
+ <div className="px-4 py-2 border-t border-border text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
499
+ <span>{progress.summary.published} published</span>
500
+ {progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
501
+ </div>
502
+ </div>
503
+ );
504
+ }
505
+
506
+ function StepRow({ done, label, onClick }: { done: boolean; label: string; onClick?: () => void }) {
507
+ const inner = (
508
+ <span className="flex items-center gap-2 text-xs">
509
+ <span className={done ? "text-green-700" : "text-muted"} aria-hidden>{done ? "✓" : "○"}</span>
510
+ <span className={done ? "text-foreground" : "text-muted"}>{label}</span>
511
+ </span>
512
+ );
513
+ return onClick
514
+ ? <button onClick={onClick} className="text-left hover:underline">{inner}</button>
515
+ : <div>{inner}</div>;
516
+ }