plotlink-ows 1.0.32 → 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 +10 -3
  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 +209 -28
  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 +1017 -144
  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-BFw-v-OZ.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,811 @@
1
+ import { isStaleTailedExport, isTextPanel, type Cut } from "./cuts";
2
+
3
+ const MAX_EXPORT_SIZE = 1024 * 1024;
4
+
5
+ export function checkExportSize(fileSizeBytes: number): string | null {
6
+ if (fileSizeBytes > MAX_EXPORT_SIZE) {
7
+ return `Export is ${(fileSizeBytes / 1024).toFixed(0)}KB, exceeds 1MB limit`;
8
+ }
9
+ return null;
10
+ }
11
+
12
+ export function checkCartoonReadiness(cuts: Cut[]): { ready: boolean; issues: string[] } {
13
+ const issues: string[] = [];
14
+
15
+ for (let i = 0; i < cuts.length; i++) {
16
+ const cut = cuts[i];
17
+ const label = `Cut ${i + 1}`;
18
+ // Text/interstitial panels (#350) have no clean image — they render text on
19
+ // a styled background — so they skip the clean-image/lettering gating, but
20
+ // (like every panel) still export + upload a final image before publish.
21
+ const textPanel = isTextPanel(cut);
22
+ const isNarrationOnly = !cut.cleanImagePath && (cut.narration || cut.dialogue.length > 0);
23
+
24
+ if (!textPanel && !isNarrationOnly && !cut.cleanImagePath) {
25
+ issues.push(`${label}: missing clean image`);
26
+ }
27
+ if (!textPanel && !isNarrationOnly && cut.cleanImagePath && cut.overlays.length === 0) {
28
+ issues.push(`${label}: no overlays (text not placed)`);
29
+ }
30
+ if (!textPanel && !isNarrationOnly && cut.cleanImagePath && !cut.finalImagePath) {
31
+ issues.push(`${label}: not exported`);
32
+ }
33
+ if (textPanel && !cut.finalImagePath) {
34
+ issues.push(`${label}: not exported`);
35
+ }
36
+ if (cut.finalImagePath && !cut.exportedAt) {
37
+ issues.push(`${label}: export metadata missing`);
38
+ }
39
+ if (isStaleTailedExport(cut)) {
40
+ issues.push(staleTailedExportIssue(label));
41
+ }
42
+ if (!cut.uploadedUrl) {
43
+ issues.push(`${label}: not uploaded`);
44
+ }
45
+ }
46
+
47
+ return { ready: issues.length === 0, issues };
48
+ }
49
+
50
+ function staleTailedExportIssue(label: string): string {
51
+ return `${label}: re-export required before publish — this final image uses an older speech-bubble tail style that can show a visible seam`;
52
+ }
53
+
54
+ /**
55
+ * Known pre-generation / instructional placeholder prose that an AI writer or a
56
+ * stale template can leave in plot-NN.md. None of this belongs in publish-facing
57
+ * cartoon markdown, which is image-only (plus `ows:cartoon-cut` marker comments).
58
+ * Published immutably, such prose renders as junk above the comic — exactly what
59
+ * happened in storyline #57 / plot 1 (#286): the line "Placeholder only. OWS
60
+ * should generate the publish markdown from `plot-01.cuts.json` ..." survived the
61
+ * readiness check because it sat OUTSIDE the cut marker blocks and carried no
62
+ * image reference. Matched case-insensitively anywhere in the markdown.
63
+ *
64
+ * Because the published content is immutable, this list errs toward catching
65
+ * leftovers: a false positive only asks the writer to delete a stray line before
66
+ * publishing, while a false negative bakes the junk on-chain forever.
67
+ */
68
+ export const PLACEHOLDER_PROSE_PATTERNS: RegExp[] = [
69
+ /placeholder only/i,
70
+ /\bOWS (?:should )?generates? the publish markdown/i,
71
+ /generate(?:s|d)? the publish markdown from/i,
72
+ /after clean images are approved/i,
73
+ /lettered final images are created/i,
74
+ /do not hand-?write/i,
75
+ /\b(?:TODO|FIXME)\b/,
76
+ ];
77
+
78
+ /**
79
+ * Return the first matched placeholder-prose snippet found in the markdown, or
80
+ * null if none. Shared by the publish readiness gate and the markdown generator
81
+ * (which strips these paragraphs so "Generate MD" output stays image-only).
82
+ */
83
+ export function findPlaceholderProse(markdown: string): string | null {
84
+ for (const re of PLACEHOLDER_PROSE_PATTERNS) {
85
+ const m = markdown.match(re);
86
+ if (m) return m[0];
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function extractCutBlock(markdown: string, id: string): string | null {
92
+ const start = `<!-- ows:cartoon-cut ${id} start -->`;
93
+ const end = `<!-- ows:cartoon-cut ${id} end -->`;
94
+ const startIdx = markdown.indexOf(start);
95
+ const endIdx = markdown.indexOf(end);
96
+ if (startIdx === -1 || endIdx === -1 || endIdx < startIdx) return null;
97
+ return markdown.slice(startIdx + start.length, endIdx);
98
+ }
99
+
100
+ /**
101
+ * Planning stage = a valid cut plan exists but the publish-facing markdown is
102
+ * still missing the `ows:cartoon-cut` marker block for one or more cuts. In this
103
+ * state the next action is simply "Generate MD" to lay down the skeleton, so the
104
+ * UI should surface that action rather than alarming missing-block publish errors.
105
+ */
106
+ export function isCartoonPlanningStage(markdown: string, cuts: Cut[]): boolean {
107
+ if (cuts.length === 0) return false;
108
+ for (let i = 0; i < cuts.length; i++) {
109
+ const id = `cut-${String(i + 1).padStart(3, "0")}`;
110
+ if (extractCutBlock(markdown, id) === null) return true;
111
+ }
112
+ return false;
113
+ }
114
+
115
+ export function checkMarkdownReadiness(
116
+ markdown: string,
117
+ cuts: Cut[],
118
+ ): { ready: boolean; issues: string[] } {
119
+ const issues: string[] = [];
120
+
121
+ // Fail closed for an empty cut plan (#422). With zero cuts the per-cut loop
122
+ // below never runs and an instructional-but-unmatched placeholder plot-NN.md
123
+ // would otherwise report ready=true — letting a not-started episode publish a
124
+ // blank/placeholder page on-chain via the direct API gate. A 0-cut episode is
125
+ // never publishable.
126
+ if (cuts.length === 0) {
127
+ return { ready: false, issues: ["This episode has no cuts planned yet — plan and produce its cuts before publishing."] };
128
+ }
129
+
130
+ for (let i = 0; i < cuts.length; i++) {
131
+ const cut = cuts[i];
132
+ const label = `Cut ${i + 1}`;
133
+ const id = `cut-${String(i + 1).padStart(3, "0")}`;
134
+
135
+ // Every publishable cut must have a recorded uploaded URL.
136
+ if (!cut.uploadedUrl) {
137
+ issues.push(`${label}: not uploaded (no recorded uploaded URL)`);
138
+ }
139
+ if (isStaleTailedExport(cut)) {
140
+ issues.push(staleTailedExportIssue(label));
141
+ }
142
+
143
+ const block = extractCutBlock(markdown, id);
144
+ if (block === null) {
145
+ issues.push(`${label}: missing or incomplete markdown block`);
146
+ continue;
147
+ }
148
+
149
+ // Each completed cut block must contain exactly one image reference whose
150
+ // URL exactly matches the cut's recorded uploadedUrl.
151
+ const refs = [...block.matchAll(/!\[[^\]]*\]\(([^)]*)\)/g)].map((m) => m[1].trim());
152
+ if (refs.length === 0) {
153
+ issues.push(`${label}: block has no image reference`);
154
+ } else if (refs.length > 1) {
155
+ issues.push(`${label}: block must contain exactly one image reference`);
156
+ } else if (cut.uploadedUrl && refs[0] !== cut.uploadedUrl) {
157
+ issues.push(`${label}: image URL does not match the recorded uploaded URL`);
158
+ }
159
+ }
160
+
161
+ if (/awaiting upload|image pending|final image pending|pending upload/i.test(markdown)) {
162
+ issues.push("Markdown contains awaiting-upload placeholders");
163
+ }
164
+
165
+ // Reject pre-generation / instructional placeholder prose anywhere in the
166
+ // markdown — not just inside cut blocks. This is what leaked on-chain in #286.
167
+ const placeholderProse = findPlaceholderProse(markdown);
168
+ if (placeholderProse) {
169
+ issues.push(
170
+ `This episode still has placeholder/instructional text ("${placeholderProse.slice(0, 60)}") — remove it or re-run “Prepare episode for publish” so the published episode is images only`,
171
+ );
172
+ }
173
+
174
+ // Every image reference anywhere in the markdown must be (1) an http(s) URL
175
+ // and (2) a recorded cut uploadedUrl. The http(s) check is independent so a
176
+ // bad recorded uploadedUrl (e.g. a local "assets/..." path) cannot be matched
177
+ // by equally-bad local markdown. The Set check rejects stray/extra https refs
178
+ // (outside or in duplicate cut blocks) not tied to a real uploaded cut image.
179
+ const uploadedUrls = new Set(
180
+ cuts.map((c) => c.uploadedUrl).filter((u): u is string => !!u && /^https?:\/\//i.test(u)),
181
+ );
182
+ const allRefs = [...markdown.matchAll(/!\[[^\]]*\]\(([^)]*)\)/g)];
183
+ for (const ref of allRefs) {
184
+ const url = ref[1].trim();
185
+ if (!/^https?:\/\//i.test(url)) {
186
+ issues.push(`Invalid image reference (not an http(s) URL): ${url.slice(0, 60)}`);
187
+ } else if (!uploadedUrls.has(url)) {
188
+ issues.push(`Image reference is not a recorded uploaded cut URL: ${url.slice(0, 60)}`);
189
+ }
190
+ }
191
+
192
+ if (markdown.length > 10000) {
193
+ issues.push(`Markdown is ${markdown.length} chars (limit 10,000)`);
194
+ }
195
+
196
+ return { ready: issues.length === 0, issues };
197
+ }
198
+
199
+ export type CartoonReadinessStage =
200
+ // No cuts planned yet — a scaffold placeholder / future episode (#422). This is
201
+ // a calm "not started" state, NOT an error: an empty cut plan can't be
202
+ // publishable, but it also shouldn't surface alarming publish warnings.
203
+ | "not-started"
204
+ | "planning"
205
+ | "awaiting-upload"
206
+ | "error"
207
+ | "ready";
208
+
209
+ export interface CartoonReadinessReport {
210
+ stage: CartoonReadinessStage;
211
+ issues: string[];
212
+ awaitingCount: number;
213
+ totalCuts: number;
214
+ }
215
+
216
+ /**
217
+ * Classify cartoon publish readiness into a single stage so the UI can render
218
+ * the right affordance instead of dumping every gating reason as a red error.
219
+ *
220
+ * - "planning": one or more cut marker blocks are not generated yet → Generate MD.
221
+ * - "awaiting-upload": every cut block exists but images are not uploaded yet.
222
+ * This is the normal post-`Generate MD` intermediate state, NOT an error.
223
+ * - "error": genuinely malformed markdown / invalid references / size, etc.
224
+ * - "ready": fully publishable.
225
+ *
226
+ * This does NOT relax the publish gate: `checkMarkdownReadiness` (used by the
227
+ * server in routes/publish.ts) is unchanged, so awaiting-upload markdown still
228
+ * cannot be published. We only reclassify how it is presented.
229
+ */
230
+ export function classifyCartoonReadiness(
231
+ markdown: string,
232
+ cuts: Cut[],
233
+ ): CartoonReadinessReport {
234
+ const totalCuts = cuts.length;
235
+
236
+ // An empty cut plan is a not-started placeholder / future episode (#422), not
237
+ // an error. Classify it first so a placeholder plot-NN.md (instructional prose,
238
+ // no cuts) reads as "not started yet" instead of dumping placeholder-prose /
239
+ // missing-block publish errors. The publish gate is unaffected — a 0-cut plan
240
+ // is never `ready`.
241
+ if (totalCuts === 0) {
242
+ return { stage: "not-started", issues: [], awaitingCount: 0, totalCuts: 0 };
243
+ }
244
+
245
+ if (isCartoonPlanningStage(markdown, cuts)) {
246
+ return { stage: "planning", issues: [], awaitingCount: 0, totalCuts };
247
+ }
248
+
249
+ const { ready, issues } = checkMarkdownReadiness(markdown, cuts);
250
+ if (ready) {
251
+ return { stage: "ready", issues: [], awaitingCount: 0, totalCuts };
252
+ }
253
+
254
+ // A cut is "awaiting upload" when its marker block exists, contains no image
255
+ // reference, and the cut has no recorded uploaded URL — i.e. the intentional
256
+ // skeleton placeholder produced by Generate MD.
257
+ const awaitingLabels = new Set<string>();
258
+ for (let i = 0; i < cuts.length; i++) {
259
+ const label = `Cut ${i + 1}`;
260
+ const id = `cut-${String(i + 1).padStart(3, "0")}`;
261
+ const block = extractCutBlock(markdown, id);
262
+ if (block === null) continue;
263
+ const hasImage = /!\[[^\]]*\]\([^)]*\)/.test(block);
264
+ if (!hasImage && !cuts[i].uploadedUrl) {
265
+ awaitingLabels.add(label);
266
+ }
267
+ }
268
+
269
+ // Filter out the expected awaiting-upload "noise" so only genuine problems
270
+ // remain. Anything left means the markdown is actually malformed.
271
+ const awaitingNoise = new Set<string>(["Markdown contains awaiting-upload placeholders"]);
272
+ for (const label of awaitingLabels) {
273
+ awaitingNoise.add(`${label}: not uploaded (no recorded uploaded URL)`);
274
+ awaitingNoise.add(`${label}: block has no image reference`);
275
+ }
276
+
277
+ const realIssues = issues.filter((issue) => !awaitingNoise.has(issue));
278
+
279
+ if (realIssues.length > 0) {
280
+ return {
281
+ stage: "error",
282
+ issues: realIssues,
283
+ awaitingCount: awaitingLabels.size,
284
+ totalCuts,
285
+ };
286
+ }
287
+
288
+ return {
289
+ stage: "awaiting-upload",
290
+ issues: [],
291
+ awaitingCount: awaitingLabels.size,
292
+ totalCuts,
293
+ };
294
+ }
295
+
296
+ /** One step-categorized group of publish-readiness issues for display (#360). */
297
+ export interface CartoonIssueGroup {
298
+ /** Stable category key. */
299
+ key: string;
300
+ /** Writer-facing step heading. */
301
+ title: string;
302
+ /** Issue lines in this group, with repeated per-cut reasons collapsed. */
303
+ lines: string[];
304
+ }
305
+
306
+ // Maps a raw readiness issue string to a workflow step, so the publish panel can
307
+ // show grouped, plain-language headings instead of a flat wall of repeated
308
+ // per-cut technical errors (#360). Ordered by where the step sits in the flow.
309
+ const CARTOON_ISSUE_CATEGORIES: { key: string; title: string; test: RegExp }[] = [
310
+ { key: "assemble", title: "Prepare the episode for publish", test: /markdown block|missing or incomplete/i },
311
+ { key: "export", title: "Export final images", test: /re-export|older speech-bubble|visible seam/i },
312
+ { key: "upload", title: "Upload final images", test: /not uploaded|no recorded uploaded url/i },
313
+ { key: "images", title: "Fix image references", test: /image reference|not an http|does not match|exactly one image/i },
314
+ { key: "cleanup", title: "Remove leftover text", test: /placeholder|instructional|awaiting-upload|awaiting upload/i },
315
+ { key: "size", title: "Shorten the episode", test: /\blimit\b|\bchars\b/i },
316
+ ];
317
+
318
+ // Collapse repeated "Cut N: <reason>" lines that share a reason into one
319
+ // "Cuts 1, 3, 5: <reason>" line; non-cut lines pass through unchanged.
320
+ function collapseCutLines(items: string[]): string[] {
321
+ const byReason = new Map<string, number[]>();
322
+ const order: string[] = [];
323
+ const passthrough: string[] = [];
324
+ for (const it of items) {
325
+ const m = it.match(/^Cut (\d+): (.+)$/);
326
+ if (m) {
327
+ const reason = m[2];
328
+ if (!byReason.has(reason)) { byReason.set(reason, []); order.push(reason); }
329
+ byReason.get(reason)!.push(Number(m[1]));
330
+ } else {
331
+ passthrough.push(it);
332
+ }
333
+ }
334
+ const collapsed = order.map((reason) => {
335
+ const nums = byReason.get(reason)!.slice().sort((a, b) => a - b);
336
+ const label = nums.length === 1 ? `Cut ${nums[0]}` : `Cuts ${nums.join(", ")}`;
337
+ return `${label}: ${reason}`;
338
+ });
339
+ return [...collapsed, ...passthrough];
340
+ }
341
+
342
+ /**
343
+ * Group flat publish-readiness issues by workflow step for the cartoon publish
344
+ * panel (#360). A non-technical writer sees "Upload final images" / "Prepare the
345
+ * episode for publish" headings with collapsed per-cut lines, instead of a long
346
+ * repeated list of "Cut N: not uploaded" technical errors. Unmatched issues fall
347
+ * into an "Other issues" group so nothing is dropped. Order follows the workflow.
348
+ */
349
+ export function groupCartoonIssues(issues: string[]): CartoonIssueGroup[] {
350
+ const catKey = (issue: string) =>
351
+ CARTOON_ISSUE_CATEGORIES.find((c) => c.test.test(issue))?.key ?? "other";
352
+ const buckets = new Map<string, string[]>();
353
+ for (const issue of issues) {
354
+ const k = catKey(issue);
355
+ if (!buckets.has(k)) buckets.set(k, []);
356
+ buckets.get(k)!.push(issue);
357
+ }
358
+ const order = [...CARTOON_ISSUE_CATEGORIES.map((c) => c.key), "other"];
359
+ const titleOf = (k: string) =>
360
+ CARTOON_ISSUE_CATEGORIES.find((c) => c.key === k)?.title ?? "Other issues";
361
+ const groups: CartoonIssueGroup[] = [];
362
+ for (const k of order) {
363
+ const items = buckets.get(k);
364
+ if (!items || items.length === 0) continue;
365
+ groups.push({ key: k, title: titleOf(k), lines: collapseCutLines(items) });
366
+ }
367
+ return groups;
368
+ }
369
+
370
+ /**
371
+ * Cartoon Genesis is the reader-facing opening/prologue: on PlotLink, readers
372
+ * encounter `genesis.md` before plot-01, so it must read as the actual story
373
+ * opening — premise, lead, stakes, tone — and bridge into Episode 01, NOT a
374
+ * back-cover synopsis, genre pitch, outline, or generic intro page (#400,
375
+ * tightening #359/#380). For cartoon MVP quality these are hard publish blockers
376
+ * rather than soft nudges: a weak Genesis bakes metadata-shaped junk in front of
377
+ * readers on-chain, where it is immutable.
378
+ *
379
+ * Blockers (each disables publish):
380
+ * - no real `# Title` heading — the opening needs a title readers see first (and
381
+ * the on-chain title would otherwise fall back to a non-reader-facing label),
382
+ * - too short to onboard a reader,
383
+ * - synopsis/outline shape (metadata labels / mostly bullets, no opening prose),
384
+ * - a single dense block with no buildup (a cold-open fragment, not a prologue).
385
+ *
386
+ * `warnings` is retained for future non-blocking nudges; #400 produces none.
387
+ *
388
+ * Fiction genesis does not use this — callers gate on `isCartoonGenesis`, so
389
+ * fiction Genesis behavior is unchanged.
390
+ */
391
+ export interface CartoonGenesisReadiness {
392
+ /** Whether `genesis.md` has a real (non-empty) `# Title` H1 heading. */
393
+ hasTitle: boolean;
394
+ /** Hard problems — publish is disabled while any exist. */
395
+ blockers: string[];
396
+ /** Soft nudges shown before publish but not blocking (currently unused). */
397
+ warnings: string[];
398
+ }
399
+
400
+ /** Below this many prose chars (H1 stripped), a cartoon Genesis is too thin to onboard a reader. */
401
+ export const GENESIS_MIN_BODY_CHARS = 220;
402
+
403
+ /** Metadata-label line shapes a synopsis/outline leaves behind ("Logline:", "Characters -", …). */
404
+ const GENESIS_METADATA_LABEL =
405
+ /^(genre|logline|synopsis|premise|setting|tone|theme|themes|summary|hook|characters?|cast|arc|status|word\s*count|length|title)\b\s*[:\-–]/i;
406
+
407
+ export function cartoonGenesisReadiness(content: string): CartoonGenesisReadiness {
408
+ const blockers: string[] = [];
409
+ const warnings: string[] = [];
410
+ const text = content ?? "";
411
+
412
+ // 1. Real H1 title (hard block). Horizontal whitespace only after `#` so a
413
+ // blank "# " line doesn't absorb the next paragraph as a fake title.
414
+ const h1 = text.match(/^#[ \t]+(.+)$/m);
415
+ const hasTitle = !!(h1 && h1[1].trim());
416
+ if (!hasTitle) {
417
+ blockers.push(
418
+ 'Add a “# Title” heading — the Story opening needs a real title readers see first.',
419
+ );
420
+ }
421
+
422
+ // Body = everything but the H1 line, used for the length / shape heuristics.
423
+ const body = text.replace(/^#\s+.+$/m, "").trim();
424
+
425
+ // 2. Too short to onboard a reader (block). A real opening needs the premise,
426
+ // the lead, and the stakes — not a one-line setup.
427
+ if (body.length < GENESIS_MIN_BODY_CHARS) {
428
+ blockers.push(
429
+ "This Story opening is too short. Open the story for readers — the premise, the lead, and the stakes across a few short paragraphs that bridge into Episode 01, not a one-line setup.",
430
+ );
431
+ } else {
432
+ // 3. Synopsis/outline shape rather than a reader-facing opening scene (block).
433
+ // Skipped when already blocked for length so a tiny stub raises one reason.
434
+ const lines = body.split("\n").map((l) => l.trim()).filter(Boolean);
435
+ const listish = lines.filter(
436
+ (l) => /^([-*+]|\d+[.)])\s/.test(l) || GENESIS_METADATA_LABEL.test(l),
437
+ ).length;
438
+ const paragraphs = body.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
439
+ const hasProseParagraph = paragraphs.some(
440
+ (p) =>
441
+ p.length >= 120 &&
442
+ !/^([-*+]|\d+[.)])\s/.test(p) &&
443
+ !GENESIS_METADATA_LABEL.test(p),
444
+ );
445
+ if ((lines.length > 0 && listish / lines.length >= 0.5) || !hasProseParagraph) {
446
+ blockers.push(
447
+ "This reads like a synopsis or outline. Write the Genesis as a reader-facing opening scene that sets up the first beat and stakes, then bridges into Episode 01 — not a logline, genre pitch, or character list.",
448
+ );
449
+ } else {
450
+ // 4. Real prose, but a single dense block with no buildup (block, #380/#400):
451
+ // a single dense block reads as a cold open rather than a prologue. A real
452
+ // opening builds across a few short paragraphs (premise → what the lead
453
+ // wants → hook → bridge into Episode 01). Count substantial prose
454
+ // paragraphs (not lists/metadata).
455
+ const proseParas = paragraphs.filter(
456
+ (p) => p.length >= 40 && !/^([-*+]|\d+[.)])\s/.test(p) && !GENESIS_METADATA_LABEL.test(p),
457
+ );
458
+ if (proseParas.length < 2) {
459
+ blockers.push(
460
+ "Give the opening room to build: open across a few short paragraphs — the premise, what the lead wants, and the hook — that lead into Episode 01, instead of a single dense block that drops readers into a cold scene.",
461
+ );
462
+ }
463
+ }
464
+ }
465
+
466
+ return { hasTitle, blockers, warnings };
467
+ }
468
+
469
+ // Short, writer-facing reminder that clean images are art only. Shown in the
470
+ // workflow guide so a first-time creator doesn't bake dialogue/SFX into the
471
+ // generated art (the lettering step adds those) (#335).
472
+ export const CARTOON_CLEAN_IMAGE_HELP =
473
+ "Clean images are the artwork only — no dialogue, narration, sound effects, or speech bubbles. You add those in the lettering step.";
474
+
475
+ /**
476
+ * Per-cut production progress, derived straight from cuts.json + local asset
477
+ * paths + uploaded URLs (#335). Drives the granular workflow checklist so each
478
+ * production step shows real status, not just a coarse stage.
479
+ *
480
+ * MVP rule (#335, operator finding on PR #338): EVERY current-schema cut is
481
+ * treated as image-required, so `needClean === total`. A planned cut carries its
482
+ * dialogue/narration in cuts.json before any art exists, so it looks identical
483
+ * to a deliberately text-only cut — inferring "narration-only" from
484
+ * `!cleanImagePath && narration/dialogue` would wrongly mark a brand-new planned
485
+ * cut as needing no image and skip the writer straight past "Create clean
486
+ * images". Counting all cuts as image-required matches the agent guidance that
487
+ * every publishable cut gets a clean → final → uploaded image.
488
+ */
489
+ export interface CartoonCutProgress {
490
+ total: number;
491
+ /** Cuts that require a clean image — image cuts only; text panels excluded (#350). */
492
+ needClean: number;
493
+ /** Of `needClean`, how many have a clean image recorded. */
494
+ withClean: number;
495
+ /** Of the clean-image cuts, how many have text overlays placed. */
496
+ withText: number;
497
+ /** Cuts (any kind) with an exported final image. */
498
+ exported: number;
499
+ /** Cuts (any kind) with a recorded uploaded URL. */
500
+ uploaded: number;
501
+ }
502
+
503
+ /**
504
+ * A clean-image path is a publishable format only when it's WebP/JPEG (#441).
505
+ * Pure path-extension check (browser-safe, no fs) mirroring the publish-strict
506
+ * `CLEAN_IMAGE_VALID_EXT`; a `.png` is a convert-me intermediate, not finished.
507
+ */
508
+ export function isSupportedCleanImage(cleanImagePath: string): boolean {
509
+ return /\.(webp|jpe?g)$/i.test(cleanImagePath);
510
+ }
511
+
512
+ export function summarizeCutProgress(cuts: Cut[]): CartoonCutProgress {
513
+ let needClean = 0;
514
+ let withClean = 0;
515
+ let withText = 0;
516
+ let exported = 0;
517
+ let uploaded = 0;
518
+ for (const cut of cuts) {
519
+ // Image cuts need a clean image → lettering; text/interstitial panels (#350)
520
+ // do not (they're text on a styled background). Every panel still exports +
521
+ // uploads a final image, so those are counted for both kinds.
522
+ if (!isTextPanel(cut)) {
523
+ needClean++;
524
+ // A PNG clean image is a draft intermediate, not a finished clean asset
525
+ // (#441): it must be converted to WebP/JPEG first, so it does NOT count as
526
+ // "clean" — the cut sits at the convert step, not lettering. Matches the
527
+ // publish-strict WebP/JPEG requirement without a disk read (path ext only).
528
+ if (cut.cleanImagePath && isSupportedCleanImage(cut.cleanImagePath)) {
529
+ withClean++;
530
+ // Guard a malformed/legacy cut missing `overlays` — the checklist runs on
531
+ // every cut-list render now (#414), so a bad persisted cut must not crash it.
532
+ if ((cut.overlays?.length ?? 0) > 0) withText++;
533
+ }
534
+ }
535
+ if (cut.finalImagePath && cut.exportedAt) exported++;
536
+ if (cut.uploadedUrl) uploaded++;
537
+ }
538
+ return { total: cuts.length, needClean, withClean, withText, exported, uploaded };
539
+ }
540
+
541
+ export type CartoonStepKey = "plan" | "clean" | "letter" | "export" | "upload" | "publish";
542
+
543
+ export interface CartoonChecklistStep {
544
+ key: CartoonStepKey;
545
+ /** Writer-facing label — product language, no build/file jargon (#335). */
546
+ label: string;
547
+ status: "done" | "current" | "todo";
548
+ /** Short progress detail like "3 / 6 cuts", or null when not countable. */
549
+ detail: string | null;
550
+ }
551
+
552
+ export interface CartoonChecklist {
553
+ steps: CartoonChecklistStep[];
554
+ nextStep: string | null;
555
+ }
556
+
557
+ const CHECKLIST_LABELS: Record<CartoonStepKey, string> = {
558
+ plan: "Plan cuts",
559
+ clean: "Create clean images",
560
+ letter: "Add speech bubbles & captions",
561
+ export: "Export final images",
562
+ upload: "Upload final images",
563
+ publish: "Publish to PlotLink",
564
+ };
565
+
566
+ function fraction(done: number, total: number): string {
567
+ return `${done} / ${total} cut${total === 1 ? "" : "s"}`;
568
+ }
569
+
570
+ /**
571
+ * Granular, writer-facing production checklist for a cartoon episode (#335).
572
+ * Expands the old 4-milestone guide into the six steps a creator actually
573
+ * performs — plan cuts → create clean images → add bubbles → export → upload →
574
+ * publish — and derives each step's status from real per-cut progress (clean
575
+ * images, overlays, exports, uploads) plus the file's publish status. The first
576
+ * incomplete step is "current"; everything before it is "done", after it "todo"
577
+ * (a linear checklist), and `nextStep` spells out the next action in plain
578
+ * language. Returns no steps when there is no cut plan yet (non-cartoon or an
579
+ * empty/unparsed plan), so the guide simply doesn't render there.
580
+ */
581
+ export function cartoonChecklist(input: { cuts: Cut[]; published?: boolean }): CartoonChecklist {
582
+ const { cuts, published = false } = input;
583
+ const p = summarizeCutProgress(cuts);
584
+ if (p.total === 0) return { steps: [], nextStep: null };
585
+
586
+ // Clean + letter gate only IMAGE cuts (needClean); export + upload gate EVERY
587
+ // cut including text panels (total). For an all-image story needClean === total
588
+ // so this is unchanged from before (#350).
589
+ const planDone = p.total > 0;
590
+ const cleanDone = planDone && p.withClean === p.needClean;
591
+ const letterDone = cleanDone && p.withText === p.needClean;
592
+ const exportDone = letterDone && p.exported === p.total;
593
+ const uploadDone = exportDone && p.uploaded === p.total;
594
+ const publishDone = uploadDone && published;
595
+
596
+ const complete: Record<CartoonStepKey, boolean> = {
597
+ plan: planDone,
598
+ clean: cleanDone,
599
+ letter: letterDone,
600
+ export: exportDone,
601
+ upload: uploadDone,
602
+ publish: publishDone,
603
+ };
604
+ const order: CartoonStepKey[] = ["plan", "clean", "letter", "export", "upload", "publish"];
605
+ const currentIdx = order.findIndex((k) => !complete[k]);
606
+
607
+ // Clean/letter count image cuts (needClean); export/upload count every cut
608
+ // (total). An all-text-panel episode has needClean === 0 → "no image cuts".
609
+ const imageDetail = (done: number) => (p.needClean > 0 ? fraction(done, p.needClean) : "no image cuts");
610
+ const detail: Record<CartoonStepKey, string | null> = {
611
+ plan: fraction(p.total, p.total),
612
+ clean: imageDetail(p.withClean),
613
+ letter: imageDetail(p.withText),
614
+ export: fraction(p.exported, p.total),
615
+ upload: fraction(p.uploaded, p.total),
616
+ publish: null,
617
+ };
618
+
619
+ const steps: CartoonChecklistStep[] = order.map((key, i) => ({
620
+ key,
621
+ label: CHECKLIST_LABELS[key],
622
+ status: currentIdx === -1 ? "done" : i < currentIdx ? "done" : i === currentIdx ? "current" : "todo",
623
+ detail: detail[key],
624
+ }));
625
+
626
+ const NEXT: Record<CartoonStepKey, string> = {
627
+ plan: "Plan the episode's cuts to begin.",
628
+ clean: "Create a clean image for each cut — artwork only, no text or bubbles.",
629
+ letter: "Open each cut in the lettering editor and place its speech bubbles and captions.",
630
+ export: "Export the lettered final image for each cut.",
631
+ upload: "Upload the exported final images so they're ready to publish.",
632
+ publish: "Preview the episode, then publish to PlotLink.",
633
+ };
634
+ const nextStep = currentIdx === -1 ? "Published — this episode is live on PlotLink." : NEXT[order[currentIdx]];
635
+
636
+ return { steps, nextStep };
637
+ }
638
+
639
+ /**
640
+ * Context for the preview action-bar footer guidance (#422). The cartoon
641
+ * scaffold mixes an outline (structure.md), a Genesis-as-Episode-1 (genesis.md
642
+ * + genesis.cuts.json), and future-episode placeholders (plot-NN.md +
643
+ * empty-cuts plot-NN.cuts.json). The old footer showed a single "write the
644
+ * genesis next" line for structure.md regardless of state, which was wrong once
645
+ * Genesis existed. This derives a state-aware line per selected file.
646
+ */
647
+ export interface PreviewFooterContext {
648
+ /** Selected file basename, e.g. "structure.md" | "genesis.md" | "plot-01.md". */
649
+ fileName: string;
650
+ contentType: "fiction" | "cartoon";
651
+ /** Whether the story already has a genesis.md. */
652
+ hasGenesis: boolean;
653
+ /** Whether the selected file is already published on-chain. */
654
+ isPublished: boolean;
655
+ /**
656
+ * Cut count for the selected episode file (Genesis or a plot), from its
657
+ * cuts.json. null when the file isn't an episode or its cuts are unknown.
658
+ */
659
+ cutCount: number | null;
660
+ /**
661
+ * Full production progress for a GENESIS episode, so the footer's next-step
662
+ * line tracks the real stage — clean art → lettering → export → upload (#451)
663
+ * — instead of saying "generate clean images" whenever nothing is uploaded.
664
+ * Null for plots (their stage guidance is the CartoonStepGuide) or when unknown.
665
+ */
666
+ cutProgress?: CartoonCutProgress | null;
667
+ }
668
+
669
+ const FICTION_OUTLINE_GUIDANCE = "This is your story outline — not publishable. Ask AI to write the genesis next.";
670
+
671
+ /**
672
+ * State-aware guidance for the preview footer (#422). Returns the line to show,
673
+ * or null to let the existing per-stage UI speak instead. Fiction is unchanged:
674
+ * structure.md keeps its original outline line and no other file is annotated.
675
+ */
676
+ export function previewFooterGuidance(ctx: PreviewFooterContext): string | null {
677
+ const { fileName, contentType, hasGenesis, isPublished, cutCount, cutProgress } = ctx;
678
+ const isStructure = fileName === "structure.md";
679
+ const isGenesis = fileName === "genesis.md";
680
+ const isPlot = /^plot-\d+\.md$/.test(fileName);
681
+
682
+ if (isStructure) {
683
+ if (contentType !== "cartoon") return FICTION_OUTLINE_GUIDANCE;
684
+ return hasGenesis
685
+ ? "Your story outline is set. Genesis (Episode 1) already exists — review its opening and cuts; you don't need to write the Genesis again."
686
+ : "This is your story outline — not publishable. Write the Genesis opening (Episode 1) next.";
687
+ }
688
+
689
+ // Cartoon episode files, pre-publish only. Published/ready files are handled
690
+ // by the publish controls and per-stage callouts, so don't annotate them.
691
+ if (contentType === "cartoon" && !isPublished && (isGenesis || isPlot) && cutCount !== null) {
692
+ if (cutCount === 0) {
693
+ return isGenesis
694
+ ? "Genesis is your Episode 1 opening. Plan its cuts, then generate clean images for them."
695
+ : "This episode hasn't been started — expand its cut plan before preparing it for publish.";
696
+ }
697
+ // Genesis: track the real production stage so the line advances past
698
+ // "generate clean images" once the clean art exists (#451). Clean art →
699
+ // lettering → export → upload → publish, worded so each is distinct.
700
+ if (isGenesis && cutProgress) {
701
+ const p = cutProgress;
702
+ if (p.withClean < p.needClean) {
703
+ return "Genesis has a cut plan — generate the clean images for its cuts next.";
704
+ }
705
+ if (p.withText < p.needClean) {
706
+ return "Genesis clean art is ready — review the cuts and add speech bubbles & captions next.";
707
+ }
708
+ if (p.exported < p.total) {
709
+ return "Genesis lettering is underway — export the final images next.";
710
+ }
711
+ if (p.uploaded < p.total) {
712
+ return "Genesis final images are exported — upload them next, then prepare to publish.";
713
+ }
714
+ // Every cut uploaded → the publish controls speak.
715
+ }
716
+ }
717
+
718
+ return null;
719
+ }
720
+
721
+ /**
722
+ * Two-axis publish verdict for cartoon markdown (#421). The pilot showed a
723
+ * confusing mix of a green "Readiness: Ready to publish" line next to raw
724
+ * validator warnings. Split the concepts a writer actually needs:
725
+ *
726
+ * - `possible` — the HARD blocker axis: can this publish at all? Mirrors the
727
+ * publish gate exactly (only a fully-ready episode is publishable).
728
+ * - `recommended` — the SOFT axis: is publishing advisable right now, or does
729
+ * the content look like planning/placeholder text?
730
+ *
731
+ * Plus a concise, user-facing headline / detail / suggested action — the raw
732
+ * validator strings stay available separately as collapsible technical details.
733
+ */
734
+ export interface CartoonPublishVerdict {
735
+ possible: boolean;
736
+ recommended: boolean;
737
+ tone: "ok" | "info" | "warning" | "blocker";
738
+ /** Concise user-facing status, e.g. "Not recommended yet". */
739
+ headline: string;
740
+ /** One short line explaining the state in plain language. */
741
+ detail: string;
742
+ /** Suggested next action, or null when none applies (already publishable). */
743
+ action: string | null;
744
+ }
745
+
746
+ export function cartoonPublishVerdict(input: {
747
+ stage: CartoonReadinessStage | null;
748
+ imageCount: number;
749
+ hasNonImageProse: boolean;
750
+ }): CartoonPublishVerdict {
751
+ const { stage, imageCount, hasNonImageProse } = input;
752
+
753
+ if (stage === "ready") {
754
+ return {
755
+ possible: true, recommended: true, tone: "ok",
756
+ headline: "Ready to publish",
757
+ detail: "Every cut has an uploaded final image.",
758
+ action: null,
759
+ };
760
+ }
761
+
762
+ // Placeholder / planning text: no images and the page is prose. This is the
763
+ // pilot's plot-NN.md "Episode 2 placeholder" — never label it ready, and frame
764
+ // it as a recommendation rather than a wall of validator errors (#421).
765
+ if (imageCount === 0 && hasNonImageProse) {
766
+ return {
767
+ possible: false, recommended: false, tone: "warning",
768
+ headline: "Not recommended yet — this looks like planning/placeholder text",
769
+ detail: "There are no images and the page is prose, so it reads as planning notes, not a finished episode.",
770
+ action: "Prepare episode for publish after final images are uploaded.",
771
+ };
772
+ }
773
+
774
+ switch (stage) {
775
+ case "not-started":
776
+ return {
777
+ possible: false, recommended: false, tone: "info",
778
+ headline: "Not started",
779
+ detail: "This episode has no cuts planned yet.",
780
+ action: "Plan its cuts, then create and upload images.",
781
+ };
782
+ case "planning":
783
+ return {
784
+ possible: false, recommended: false, tone: "info",
785
+ headline: "Not ready yet — prepare for publish",
786
+ detail: "The cut plan is set, but the publish layout isn't built yet.",
787
+ action: "Prepare the episode for publish.",
788
+ };
789
+ case "awaiting-upload":
790
+ return {
791
+ possible: false, recommended: false, tone: "info",
792
+ headline: "Waiting on image uploads",
793
+ detail: "Some cuts still need a final uploaded image.",
794
+ action: "Upload the remaining final images, then publish.",
795
+ };
796
+ case "error":
797
+ return {
798
+ possible: false, recommended: false, tone: "blocker",
799
+ headline: "Not publishable — needs fixes",
800
+ detail: "Some cuts have problems that must be fixed before publishing.",
801
+ action: "Open the technical details below to see what to fix.",
802
+ };
803
+ default:
804
+ return {
805
+ possible: false, recommended: false, tone: "info",
806
+ headline: "Checking readiness…",
807
+ detail: "",
808
+ action: null,
809
+ };
810
+ }
811
+ }