plotlink-ows 1.0.33 → 1.2.95

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 (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
@@ -0,0 +1,446 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
3
+ import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
4
+ import { cartoonWorkflowActiveKey, CartoonNextActionView } from "./CartoonNextAction";
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
+ /** Open the Story Info workflow page when metadata/cover is the next gate. */
12
+ onOpenStoryInfo?: () => void;
13
+ /** Bumped by the parent to force a refresh (e.g. after a publish). */
14
+ refreshKey?: number;
15
+ }
16
+
17
+ /**
18
+ * Story-level "View Progress" overview (#418, redesigned #438).
19
+ *
20
+ * For CARTOON stories this is the writer's main production dashboard: a vertical
21
+ * workflow map of numbered sections (Define Story Info → Story Whitepaper →
22
+ * Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
23
+ * status. The single next-action CTA stays persistent above the map, while the
24
+ * current section still marks where that action belongs.
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, onOpenStoryInfo, 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} onOpenStoryInfo={onOpenStoryInfo} />
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, plus a
132
+ * nested checklist. The header navigates to `openFile` when one is provided.
133
+ */
134
+ function Section({
135
+ index, title, status, items, fileName, openFile,
136
+ }: {
137
+ index: number;
138
+ title: string;
139
+ status: SectionStatus;
140
+ items: ChecklistItem[];
141
+ /** Power-user secondary text (real file name), shown small. */
142
+ fileName?: string | null;
143
+ /** Called to open the section's underlying file, or undefined for no navigation. */
144
+ openFile?: () => void;
145
+ }) {
146
+ const heading = (
147
+ <div className="flex items-center gap-2 min-w-0">
148
+ <span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
149
+ <span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
150
+ {fileName && <span className="text-[10px] text-muted truncate">{fileName}</span>}
151
+ <span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
152
+ </div>
153
+ );
154
+ return (
155
+ <div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
156
+ {openFile
157
+ ? <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>
158
+ : heading}
159
+ <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
160
+ {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ /** Map a cartoon episode's coarse state + whether it's the active step → a section status. */
167
+ function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
168
+ if (ep.published) return "published";
169
+ if (isActive) return "current";
170
+ if (ep.state === "placeholder") return "not-started";
171
+ if (ep.state === "blocked") return "needs-action";
172
+ return "needs-action";
173
+ }
174
+
175
+ /** Build the rendered checklist for a cartoon episode from its production checklist. */
176
+ function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
177
+ const steps = ep.checklist ?? [];
178
+ const items: ChecklistItem[] = [];
179
+ // Genesis is the reader-facing Episode 1 opening, so surface its opening text
180
+ // as the first checklist line (done once genesis.md is written; to-do in the
181
+ // not-yet-written stub); a plain plot episode starts at its cut plan.
182
+ if (ep.kind === "genesis") items.push({ label: "Opening text", status: openingDone ? "done" : "todo" });
183
+ if (steps.length === 0) {
184
+ // Not started yet — no cut plan. Show the first couple of steps as to-do so
185
+ // the writer sees what starting the episode involves.
186
+ items.push({ label: "Cut plan", status: "todo" });
187
+ items.push({ label: "Clean artwork", status: "todo" });
188
+ return items;
189
+ }
190
+ for (const s of steps) items.push(checklistStepItem(s));
191
+ return items;
192
+ }
193
+
194
+ function checklistStepItem(s: CartoonChecklistStep): ChecklistItem {
195
+ return { label: s.label, status: s.status, detail: s.detail };
196
+ }
197
+
198
+ /** The not-yet-written Genesis (Episode 1) stub, so the section — and its CTA —
199
+ * always render even before genesis.md exists. */
200
+ const GENESIS_STUB: EpisodeProgress = {
201
+ file: "genesis.md", label: "Episode 1 / Genesis", kind: "genesis", title: null,
202
+ state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
203
+ };
204
+
205
+ function CartoonWorkflowMap({
206
+ progress, storyName, onOpenFile, onOpenStoryInfo,
207
+ }: {
208
+ progress: StoryProgress;
209
+ storyName: string;
210
+ onOpenFile: (storyName: string, file: string) => void;
211
+ onOpenStoryInfo?: () => void;
212
+ }) {
213
+ const m = progress.metadata;
214
+ const hasStructure = progress.setup.hasStructure;
215
+ const coverDone = progress.cover === "present";
216
+ const metadataIncomplete = !m.title || !m.language || !m.genre;
217
+ const storyInfoIncomplete = metadataIncomplete || !coverDone;
218
+ const activeKey = cartoonWorkflowActiveKey(progress);
219
+
220
+ const topNextAction = (
221
+ <CartoonNextActionView
222
+ progress={progress}
223
+ onOpenStoryInfo={onOpenStoryInfo}
224
+ onCoachAction={(action, episodeFile) => {
225
+ if (action === "view-progress") return; // already here
226
+ if (episodeFile) onOpenFile(storyName, episodeFile);
227
+ }}
228
+ />
229
+ );
230
+
231
+ const infoItems: ChecklistItem[] = [
232
+ { label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
233
+ { label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
234
+ { label: "Genre", status: m.genre ? "done" : "todo", detail: m.genre ?? null },
235
+ { label: "Cover image", status: coverDone ? "done" : "todo", detail: progress.cover === "invalid" ? "Invalid — re-import" : coverDone ? null : "Missing" },
236
+ ];
237
+ const infoStatus: SectionStatus = activeKey === "story-info" ? "current" : storyInfoIncomplete ? "needs-action" : "done";
238
+ const whitepaperStatus: SectionStatus = hasStructure ? "done" : activeKey === "whitepaper" ? "current" : "not-started";
239
+
240
+ const genesisEp = progress.episodes.find((e) => e.kind === "genesis") ?? null;
241
+ const plotEps = progress.episodes.filter((e) => e.kind === "plot");
242
+
243
+ let idx = 0;
244
+
245
+ return (
246
+ <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
247
+ <ProgressHeader progress={progress} />
248
+ <div className="border-b border-border" data-testid="persistent-next-action">
249
+ {topNextAction}
250
+ </div>
251
+ <p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
252
+
253
+ <Section
254
+ index={++idx}
255
+ title="Define Story Info"
256
+ status={infoStatus}
257
+ items={infoItems}
258
+ />
259
+
260
+ <Section
261
+ index={++idx}
262
+ title="Story Whitepaper"
263
+ status={whitepaperStatus}
264
+ fileName="structure.md"
265
+ openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
266
+ items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
267
+ />
268
+
269
+ {/* Genesis / Episode 1 — always shown (a not-started stub before it's
270
+ written), so the "Write the Genesis" CTA always has a home. */}
271
+ {genesisEp ? (
272
+ <EpisodeSection
273
+ index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
274
+ storyName={storyName} onOpenFile={onOpenFile}
275
+ />
276
+ ) : (
277
+ <EpisodeSection
278
+ index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
279
+ storyName={storyName} onOpenFile={onOpenFile}
280
+ />
281
+ )}
282
+
283
+ {plotEps.map((ep) => (
284
+ <EpisodeSection
285
+ key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
286
+ storyName={storyName} onOpenFile={onOpenFile}
287
+ />
288
+ ))}
289
+
290
+ <div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
291
+ <span>{progress.summary.published} published</span>
292
+ <span>{progress.summary.readyToPublish} ready</span>
293
+ {progress.summary.placeholders > 0 && <span>{progress.summary.placeholders} not started</span>}
294
+ {progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
295
+ </div>
296
+ </div>
297
+ );
298
+ }
299
+
300
+ /** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
301
+ function EpisodeSection({
302
+ index, ep, isActive, storyName, onOpenFile, openingDone = true, canOpen = true,
303
+ }: {
304
+ index: number;
305
+ ep: EpisodeProgress;
306
+ isActive: boolean;
307
+ storyName: string;
308
+ onOpenFile: (storyName: string, file: string) => void;
309
+ /** Whether the genesis opening text is already written (false for the stub). */
310
+ openingDone?: boolean;
311
+ /** Whether the header navigates to the file (false for the not-yet-written stub). */
312
+ canOpen?: boolean;
313
+ }) {
314
+ const status = episodeStatus(ep, isActive);
315
+ const items = episodeItems(ep, openingDone);
316
+ const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
317
+ const heading = (
318
+ <div className="flex items-center gap-2 min-w-0">
319
+ <span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
320
+ <span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
321
+ <span className="text-[10px] text-muted truncate">{ep.file}</span>
322
+ <span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
323
+ </div>
324
+ );
325
+ return (
326
+ <div className="px-4 py-2.5 border-b border-border" data-testid={`workflow-section-${index}`} data-status={status}>
327
+ {canOpen ? (
328
+ <button
329
+ onClick={() => onOpenFile(storyName, ep.file)}
330
+ data-testid={`progress-episode-${ep.file}`}
331
+ data-state={ep.state}
332
+ className="w-full text-left rounded hover:bg-surface -mx-1 px-1 py-0.5"
333
+ >
334
+ {heading}
335
+ </button>
336
+ ) : (
337
+ <div data-state={ep.state}>{heading}</div>
338
+ )}
339
+ <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
340
+ {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
341
+ </div>
342
+ </div>
343
+ );
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Fiction progress view — the original, simpler layout (unchanged behavior)
348
+ // ---------------------------------------------------------------------------
349
+
350
+ const STATE_ICON: Record<EpisodeState, string> = {
351
+ published: "✓", ready: "●", "in-progress": "◐", planning: "○", placeholder: "○", blocked: "✕", draft: "○",
352
+ };
353
+ const STATE_TONE: Record<EpisodeState, string> = {
354
+ published: "text-green-700", ready: "text-green-700", "in-progress": "text-accent", planning: "text-accent", placeholder: "text-muted", blocked: "text-error", draft: "text-muted",
355
+ };
356
+ const STATE_LABEL: Record<EpisodeState, string> = {
357
+ published: "Published", ready: "Ready", "in-progress": "In progress", planning: "Planning", placeholder: "Not started", blocked: "Needs fixes", draft: "Draft",
358
+ };
359
+
360
+ function FictionProgressView({
361
+ progress, storyName, onOpenFile,
362
+ }: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
363
+ const [copied, setCopied] = useState(false);
364
+ return (
365
+ <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
366
+ <ProgressHeader progress={progress} />
367
+
368
+ {progress.nextAction && (
369
+ <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">
370
+ <div>
371
+ <span className="font-medium text-foreground">Next: </span>
372
+ <span className="text-muted">{progress.nextAction}</span>
373
+ </div>
374
+ {progress.nextPrompt && (
375
+ <div className="flex items-start gap-1.5" data-testid="progress-next-prompt">
376
+ <code className="flex-1 rounded border border-border bg-surface px-1.5 py-1 text-[10px] text-foreground break-words">{progress.nextPrompt}</code>
377
+ <button
378
+ onClick={() => { if (progress.nextPrompt) navigator.clipboard?.writeText(progress.nextPrompt).then(() => { setCopied(true); }).catch(() => {}); }}
379
+ data-testid="copy-next-prompt"
380
+ className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors flex-shrink-0"
381
+ >
382
+ {copied ? "Copied!" : "Copy"}
383
+ </button>
384
+ </div>
385
+ )}
386
+ </div>
387
+ )}
388
+
389
+ {/* Setup steps. */}
390
+ <div className="px-4 py-2 border-b border-border flex flex-col gap-1">
391
+ <StepRow done={progress.setup.hasStructure} label="Story bible (structure.md)"
392
+ onClick={progress.setup.hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined} />
393
+ <StepRow done={progress.setup.hasGenesis} label="Genesis written"
394
+ onClick={progress.setup.hasGenesis ? () => onOpenFile(storyName, "genesis.md") : undefined} />
395
+ </div>
396
+
397
+ {/* Chapter list. */}
398
+ <div className="px-4 py-2">
399
+ <p className="text-[11px] font-medium text-muted uppercase tracking-wider mb-1.5">Chapters</p>
400
+ {progress.episodes.length === 0 ? (
401
+ <p className="text-xs text-muted italic" data-testid="progress-no-episodes">No chapters yet — write the Genesis to start.</p>
402
+ ) : (
403
+ <ol className="flex flex-col gap-1">
404
+ {progress.episodes.map((ep) => (
405
+ <li key={ep.file}>
406
+ <button
407
+ onClick={() => onOpenFile(storyName, ep.file)}
408
+ data-testid={`progress-episode-${ep.file}`}
409
+ data-state={ep.state}
410
+ className="w-full text-left flex items-start gap-2 rounded px-2 py-1.5 hover:bg-surface"
411
+ >
412
+ <span className={`mt-0.5 ${STATE_TONE[ep.state]}`} aria-hidden>{STATE_ICON[ep.state]}</span>
413
+ <span className="min-w-0 flex-1">
414
+ <span className="flex items-center gap-1.5">
415
+ <span className="text-xs font-medium text-foreground">{ep.label}</span>
416
+ {ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
417
+ <span className={`ml-auto text-[10px] font-medium ${STATE_TONE[ep.state]}`}>{STATE_LABEL[ep.state]}</span>
418
+ </span>
419
+ <span className="block text-[11px] text-muted">{ep.summary}</span>
420
+ </span>
421
+ </button>
422
+ </li>
423
+ ))}
424
+ </ol>
425
+ )}
426
+ </div>
427
+
428
+ <div className="px-4 py-2 border-t border-border text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
429
+ <span>{progress.summary.published} published</span>
430
+ {progress.summary.blocked > 0 && <span className="text-error">{progress.summary.blocked} need fixes</span>}
431
+ </div>
432
+ </div>
433
+ );
434
+ }
435
+
436
+ function StepRow({ done, label, onClick }: { done: boolean; label: string; onClick?: () => void }) {
437
+ const inner = (
438
+ <span className="flex items-center gap-2 text-xs">
439
+ <span className={done ? "text-green-700" : "text-muted"} aria-hidden>{done ? "✓" : "○"}</span>
440
+ <span className={done ? "text-foreground" : "text-muted"}>{label}</span>
441
+ </span>
442
+ );
443
+ return onClick
444
+ ? <button onClick={onClick} className="text-left hover:underline">{inner}</button>
445
+ : <div>{inner}</div>;
446
+ }