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
@@ -3,7 +3,15 @@ import ReactMarkdown from "react-markdown";
3
3
  import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6
- import { GENRES, LANGUAGES } from "../../../lib/genres";
6
+ import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
7
+ import { CartoonPreview } from "./CartoonPreview";
8
+ import { CartoonPublishPreview } from "./CartoonPublishPreview";
9
+ import { CutListPanel } from "./CutListPanel";
10
+ import { WorkflowCoach } from "./WorkflowCoach";
11
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
12
+ import { classifyCartoonReadiness, cartoonGenesisReadiness, summarizeCutProgress, previewFooterGuidance, type CartoonReadinessStage as CartoonStage, type CartoonCutProgress } from "@app-lib/cartoon-readiness";
13
+ import { validateCoverImage, cartoonCoverReadiness, COVER_GUIDANCE, derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
14
+ import { importImageToCompliantBlob } from "../lib/import-image";
7
15
 
8
16
  /** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title */
9
17
  const sanitizeSchema = {
@@ -48,9 +56,32 @@ interface PreviewPanelProps {
48
56
  storyName: string | null;
49
57
  fileName: string | null;
50
58
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
51
- onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => void;
59
+ // Resolves to true only on a confirmed-successful publish (so a selected cover
60
+ // may be dropped); false/void when blocked before the stream (#375) or when the
61
+ // publish fails/aborts before completing (#376), so the cover is kept.
62
+ onPublish?: (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => void | Promise<boolean | void>;
52
63
  publishingFile?: string | null;
53
64
  walletAddress?: string | null;
65
+ contentType?: "fiction" | "cartoon";
66
+ // Publish metadata resolved from .story.json (#424) so the controls seed the
67
+ // story's real values instead of the first-in-list defaults (Romance/English).
68
+ // `language` is the server-resolved language; `genre` is the raw stored label
69
+ // (canonicalized here). Absent ⇒ not set → controls show "Needs metadata".
70
+ language?: string;
71
+ genre?: string;
72
+ isNsfw?: boolean;
73
+ // Whether the story has a genesis.md, so the outline (structure.md) footer can
74
+ // tell "write the Genesis next" from "Genesis already exists, review it" (#422).
75
+ hasGenesis?: boolean;
76
+ // Deselect the file to reveal the story-level progress overview (#418).
77
+ onViewProgress?: () => void;
78
+ // Open a sibling file in this story, so the workflow coach (#429) can route to
79
+ // the episode an action concerns (e.g. from structure.md to the active episode).
80
+ onOpenFile?: (file: string) => void;
81
+ // Navigate to the cartoon Publish tab (#461), where publish readiness/title/
82
+ // issue diagnostics + the publish action now live. Cartoon episode views show a
83
+ // single compact CTA that calls this instead of hosting publish controls.
84
+ onViewPublish?: () => void;
54
85
  }
55
86
 
56
87
  interface FileData {
@@ -67,18 +98,59 @@ interface FileData {
67
98
 
68
99
  type Tab = "preview" | "edit";
69
100
 
70
- export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress }: PreviewPanelProps) {
101
+ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress, contentType = "fiction", language, genre: genreMeta, isNsfw: nsfwMeta, hasGenesis = false, onViewProgress, onOpenFile, onViewPublish }: PreviewPanelProps) {
71
102
  const [fileData, setFileData] = useState<FileData | null>(null);
72
103
  const [loading, setLoading] = useState(false);
73
104
  const [activeTab, setActiveTab] = useState<Tab>("preview");
105
+ // Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
106
+ // "inspect" = cuts.json planning inspector. Kept distinct so planning prose
107
+ // does not masquerade as publish content (#289).
108
+ const [cartoonPreviewMode, setCartoonPreviewMode] = useState<"publish" | "inspect">("publish");
109
+ // Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
110
+ // cuts), so its Edit tab offers two sub-views: the opening-text editor and the
111
+ // cut workspace (#429). Plots use the cut workspace directly; fiction never sees
112
+ // this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
113
+ // coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
114
+ // real, actionable workspace instead of the markdown editor.
115
+ const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">("text");
116
+ // #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
117
+ // tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
118
+ // re-trigger the focus/expand effect in CutListPanel; it is cleared once
119
+ // CutListPanel has applied it so re-entering the Edit tab manually is unaffected.
120
+ const [cutFocus, setCutFocus] = useState<{ cutId: number; openEditor: boolean; seq: number } | null>(null);
121
+ const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
122
+ setActiveTab("edit");
123
+ setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
124
+ }, []);
74
125
  const [editContent, setEditContent] = useState("");
75
126
  const [saving, setSaving] = useState(false);
76
127
  const [dirty, setDirty] = useState(false);
77
128
  const [retrying, setRetrying] = useState(false);
78
129
  const [indexTimeLeft, setIndexTimeLeft] = useState<number | null>(null);
79
- const [selectedGenre, setSelectedGenre] = useState(GENRES[0]);
80
- const [selectedLanguage, setSelectedLanguage] = useState(LANGUAGES[0]);
130
+ // "" unset ⇒ "Needs metadata" (no misleading Romance/English default).
131
+ // Seeded from .story.json props / structure.md in the seeding effect (#424).
132
+ const [selectedGenre, setSelectedGenre] = useState("");
133
+ const [selectedLanguage, setSelectedLanguage] = useState("");
81
134
  const [isNsfw, setIsNsfw] = useState(false);
135
+ // Coarse readiness stage for the selected cartoon plot — still drives the
136
+ // state-aware footer cut-count (#422) and the workflow coach refresh. The
137
+ // publish-issue list + 6-step checklist moved to the Publish tab (#461), so
138
+ // they're no longer kept here.
139
+ const [cartoonStage, setCartoonStage] = useState<CartoonStage | null>(null);
140
+ const [cartoonAwaitingCount, setCartoonAwaitingCount] = useState(0);
141
+ const [cartoonTotalCuts, setCartoonTotalCuts] = useState(0);
142
+ // Per-cut production tallies (clean/lettered/exported/uploaded) for the compact
143
+ // cartoon status summary in the bottom panel (#420).
144
+ const [cartoonCutProgress, setCartoonCutProgress] = useState<CartoonCutProgress | null>(null);
145
+ // Inputs for resolving + showing the public publish title before publish (#358):
146
+ // the story structure.md content (genesis story title) and the cut plan's
147
+ // episode title (cartoon plot).
148
+ const [structureContent, setStructureContent] = useState<string | null>(null);
149
+ const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(null);
150
+ // Bumped whenever the embedded cut editor mutates the cut plan (export/upload/
151
+ // save), so the readiness effect re-fetches and the Episode-steps panel stays
152
+ // in sync with the cut cards after a lettering export (#343).
153
+ const [cutsRefreshKey, setCutsRefreshKey] = useState(0);
82
154
  const textareaRef = useRef<HTMLTextAreaElement>(null);
83
155
  const dirtyRef = useRef(false);
84
156
 
@@ -91,9 +163,26 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
91
163
  const [coverPreview, setCoverPreview] = useState<string | null>(null);
92
164
  const [editSaving, setEditSaving] = useState(false);
93
165
  const [editMetaLoaded, setEditMetaLoaded] = useState(false);
166
+ // Whether the published storyline already has a cover attached (#337), read
167
+ // from its metadata so the cover step can show an "attached" status.
168
+ const [editHasCover, setEditHasCover] = useState(false);
94
169
  const [editError, setEditError] = useState<string | null>(null);
95
170
  const [editSuccess, setEditSuccess] = useState(false);
96
171
  const coverInputRef = useRef<HTMLInputElement>(null);
172
+ const coverImportInputRef = useRef<HTMLInputElement>(null);
173
+ const [coverImporting, setCoverImporting] = useState(false);
174
+ // Auto-detected agent-created cover (assets/cover.webp|jpg) for genesis (#296).
175
+ // detectedCover = the path actually loaded into the cover selection (status
176
+ // label); detectedCoverWarning = an invalid/oversize detected asset we won't use.
177
+ const [detectedCover, setDetectedCover] = useState<string | null>(null);
178
+ const [detectedCoverWarning, setDetectedCoverWarning] = useState<string | null>(null);
179
+ // Outcome of the generated-cover detection for an unpublished genesis (#312),
180
+ // so the publish flow can state explicitly whether a generated assets/cover.webp
181
+ // will be uploaded as the PlotLink cover, is invalid, or is missing.
182
+ const [coverStatus, setCoverStatus] = useState<"unknown" | "detected" | "selected" | "invalid" | "none">("unknown");
183
+ // Once the writer manually picks or removes a cover, stop auto-applying the
184
+ // detected one (so removal/override sticks and detection doesn't loop).
185
+ const coverUserTouchedRef = useRef(false);
97
186
 
98
187
  // Inline illustration state
99
188
  const [showIllustrations, setShowIllustrations] = useState(false);
@@ -128,7 +217,6 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
128
217
 
129
218
  // Initial load
130
219
  useEffect(() => {
131
- // eslint-disable-next-line react-hooks/set-state-in-effect -- async fetch on mount
132
220
  setLoading(true);
133
221
  loadFile().finally(() => setLoading(false));
134
222
  }, [loadFile]);
@@ -141,31 +229,128 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
141
229
  return () => clearInterval(interval);
142
230
  }, [storyName, fileName, loadFile, activeTab, dirty]);
143
231
 
144
- // Auto-detect genre from structure.md when story changes
232
+ // Genesis-as-Episode-1 cut progress (#422): discover + summarize
233
+ // genesis.cuts.json so the Genesis view reflects its real cut/image state and
234
+ // the footer can guide "plan cuts" vs "generate clean images".
235
+ const [genesisCutProgress, setGenesisCutProgress] = useState<CartoonCutProgress | null>(null);
236
+ const cartoonGenesisForCuts = contentType === "cartoon" && fileName === "genesis.md";
145
237
  useEffect(() => {
146
- if (!storyName) return;
238
+ if (!cartoonGenesisForCuts || !storyName) { setGenesisCutProgress(null); return; }
147
239
  let cancelled = false;
148
- authFetch(`/api/stories/${storyName}/structure.md`)
149
- .then((res) => res.ok ? res.json() : null)
150
- .then((data) => {
151
- if (cancelled || !data?.content) return;
152
- const match = data.content.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
153
- if (match) {
154
- const detected = match[1].replace(/\*+/g, "").trim();
155
- const found = GENRES.find((g) => g.toLowerCase() === detected.toLowerCase());
156
- if (found) setSelectedGenre(found);
240
+ authFetch(`/api/stories/${storyName}/cuts/genesis`)
241
+ .then((res) => (res.ok ? res.json() : null))
242
+ .then((data) => { if (!cancelled) setGenesisCutProgress(data ? summarizeCutProgress(data.cuts || []) : null); })
243
+ .catch(() => { if (!cancelled) setGenesisCutProgress(null); });
244
+ return () => { cancelled = true; };
245
+ }, [cartoonGenesisForCuts, storyName, authFetch]);
246
+
247
+ // Compute cartoon publish readiness for cartoon plot files
248
+ const cartoonPlotForReadiness = contentType === "cartoon" && !!fileName && /^plot-\d+\.md$/.test(fileName);
249
+ useEffect(() => {
250
+ if (!cartoonPlotForReadiness || !storyName || !fileName) {
251
+ setCartoonStage(null);
252
+ setCartoonAwaitingCount(0);
253
+ setCartoonTotalCuts(0);
254
+ setCartoonCutProgress(null);
255
+ return;
256
+ }
257
+ let cancelled = false;
258
+ const plotFile = fileName.replace(/\.md$/, "");
259
+ // Clear the prior plot's cut tallies while a new plot loads so a stale
260
+ // summary can never sit beside another episode's state (#420 / @re1).
261
+ setCartoonCutProgress(null);
262
+ (async () => {
263
+ try {
264
+ const [fileRes, cutsRes] = await Promise.all([
265
+ authFetch(`/api/stories/${storyName}/${fileName}`),
266
+ authFetch(`/api/stories/${storyName}/cuts/${plotFile}`),
267
+ ]);
268
+ if (cancelled) return;
269
+ if (!cutsRes.ok) {
270
+ setCartoonStage("error");
271
+ setCartoonAwaitingCount(0);
272
+ setCartoonTotalCuts(0);
273
+ setCartoonCutProgress(null);
274
+ return;
157
275
  }
158
- const langMatch = data.content.match(/\*{0,2}language\*{0,2}[:\s]+(.+)/i);
159
- if (langMatch) {
160
- const detected = langMatch[1].replace(/\*+/g, "").trim();
161
- const found = LANGUAGES.find((l) => l.toLowerCase() === detected.toLowerCase());
162
- if (found) setSelectedLanguage(found);
276
+ const cutsData = await cutsRes.json();
277
+ const cuts = cutsData.cuts || [];
278
+ const content = fileRes.ok ? (await fileRes.json()).content ?? "" : "";
279
+ const result = classifyCartoonReadiness(content, cuts);
280
+ if (!cancelled) {
281
+ setCartoonStage(result.stage);
282
+ setCartoonAwaitingCount(result.awaitingCount);
283
+ setCartoonTotalCuts(result.totalCuts);
284
+ setCartoonCutProgress(summarizeCutProgress(cuts));
285
+ // Cut plan's episode title for the publish-title display (#358).
286
+ setCartoonEpisodeTitle(typeof cutsData.title === "string" ? cutsData.title : null);
163
287
  }
164
- })
288
+ } catch {
289
+ if (!cancelled) {
290
+ setCartoonStage("error");
291
+ setCartoonAwaitingCount(0);
292
+ setCartoonTotalCuts(0);
293
+ setCartoonCutProgress(null);
294
+ }
295
+ }
296
+ })();
297
+ return () => { cancelled = true; };
298
+ }, [cartoonPlotForReadiness, storyName, fileName, authFetch, fileData?.content, fileData?.status, cutsRefreshKey]);
299
+
300
+ // Load structure.md once per story — used to resolve the public title before
301
+ // publish (#358) and as a metadata fallback when .story.json lacks genre/
302
+ // language (#424).
303
+ useEffect(() => {
304
+ if (!storyName) { setStructureContent(null); return; }
305
+ let cancelled = false;
306
+ authFetch(`/api/stories/${storyName}/structure.md`)
307
+ .then((res) => res.ok ? res.json() : null)
308
+ .then((data) => { if (!cancelled) setStructureContent(data?.content ?? null); })
165
309
  .catch(() => {});
166
310
  return () => { cancelled = true; };
167
311
  }, [storyName, authFetch]);
168
312
 
313
+ // Seed the publish metadata controls from the story's real values (#424).
314
+ // Priority: explicit .story.json metadata (props) → structure.md hints → an
315
+ // explicit "Needs metadata" genre (empty) instead of a misleading Romance/
316
+ // English default. Persisted edits round-trip through these same props, so
317
+ // re-seeding settles on the saved value and never clobbers a selection.
318
+ useEffect(() => {
319
+ if (!storyName) return;
320
+ // Genre: canonical .story.json label, else structure.md genre, else unset.
321
+ const metaGenre = canonicalizeGenre(genreMeta);
322
+ let genreVal = metaGenre ?? "";
323
+ if (!metaGenre && structureContent) {
324
+ const match = structureContent.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
325
+ // Canonicalize so a natural label like "Sci-Fi" preselects "Science
326
+ // Fiction" instead of being silently dropped (#412).
327
+ if (match) genreVal = canonicalizeGenre(match[1].replace(/\*+/g, "").trim()) ?? "";
328
+ }
329
+ setSelectedGenre(genreVal);
330
+ // Language: the server-resolved story language (explicit .story.json or
331
+ // script-detected), else structure.md, else unset ("Needs metadata" — no
332
+ // misleading English default). `language` is undefined when undetermined.
333
+ let langVal = (language && LANGUAGES.find((l) => l.toLowerCase() === language.toLowerCase())) || "";
334
+ if (!langVal && structureContent) {
335
+ const langMatch = structureContent.match(/\*{0,2}language\*{0,2}[:\s]+(.+)/i);
336
+ if (langMatch) langVal = LANGUAGES.find((l) => l.toLowerCase() === langMatch[1].replace(/\*+/g, "").trim().toLowerCase()) || "";
337
+ }
338
+ setSelectedLanguage(langVal);
339
+ setIsNsfw(nsfwMeta ?? false);
340
+ }, [storyName, genreMeta, language, nsfwMeta, structureContent]);
341
+
342
+ // Persist a publish-control edit back to .story.json so it sticks across
343
+ // refresh and the controls stay in sync with story metadata (#424). Best
344
+ // effort: a failure still leaves the selection applied to this publish.
345
+ const persistPublishMeta = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
346
+ if (!storyName) return;
347
+ authFetch(`/api/stories/${storyName}/publish-metadata`, {
348
+ method: "POST",
349
+ headers: { "Content-Type": "application/json" },
350
+ body: JSON.stringify(patch),
351
+ }).catch(() => { /* best-effort */ });
352
+ }, [storyName, authFetch]);
353
+
169
354
  const handleSave = useCallback(async () => {
170
355
  if (!storyName || !fileName) return;
171
356
  setSaving(true);
@@ -183,23 +368,134 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
183
368
  setSaving(false);
184
369
  }, [storyName, fileName, authFetch, editContent]);
185
370
 
371
+ // Generate the cartoon markdown skeleton from the cut plan, then refresh
372
+ // preview/readiness so the workflow coach's next action advances (#429). The
373
+ // "Prepare episode for publish" UI now lives on the Publish tab / cut workspace
374
+ // (#461); this stays as the coach's `generate-markdown` action handler.
375
+ const handleGenerateMarkdown = useCallback(async () => {
376
+ if (!storyName || !fileName) return;
377
+ const plotFile = fileName.replace(/\.md$/, "");
378
+ try {
379
+ const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
380
+ if (res.ok) {
381
+ await loadFile();
382
+ // Re-run readiness + reload the workflow coach so the next action moves
383
+ // off "Prepare the episode for publish" once the layout is built (#429).
384
+ setCutsRefreshKey((k) => k + 1);
385
+ }
386
+ } catch { /* ignore */ }
387
+ }, [storyName, fileName, authFetch, loadFile]);
388
+
389
+ // Route a workflow-coach UI action to the right control (#429). When the
390
+ // action concerns a different episode than the open file (e.g. the coach on
391
+ // structure.md points at the active episode), open that file first — the coach
392
+ // there offers the same action in place. Otherwise reveal the control: the cut
393
+ // workspace for letter/export/upload/refresh, the Preview tab for publish (the
394
+ // writer still confirms the irreversible publish), or run Prepare directly.
395
+ const handleCoachAction = useCallback((action: CoachUiAction, episodeFile: string | null) => {
396
+ if (action === "view-progress") { onViewProgress?.(); return; }
397
+ if (episodeFile && episodeFile !== fileName) { onOpenFile?.(episodeFile); return; }
398
+ switch (action) {
399
+ case "open-cuts":
400
+ case "open-lettering":
401
+ case "upload":
402
+ case "refresh-assets":
403
+ setActiveTab("edit");
404
+ // For Genesis the Edit tab defaults to the opening-text editor; switch to
405
+ // the cut workspace so the lettering/upload/refresh action is actionable.
406
+ // No-op for plots (the cut workspace is the only Edit view).
407
+ setGenesisEditMode("cuts");
408
+ break;
409
+ case "generate-markdown":
410
+ handleGenerateMarkdown();
411
+ break;
412
+ case "publish":
413
+ setActiveTab("preview");
414
+ break;
415
+ }
416
+ }, [fileName, onViewProgress, onOpenFile, handleGenerateMarkdown]);
417
+
186
418
  // Handle cover image selection
187
419
  const handleCoverSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
188
420
  const file = e.target.files?.[0];
189
421
  if (!file) return;
190
- if (file.size > 1024 * 1024) {
191
- setEditError("Image exceeds 1MB limit");
192
- return;
193
- }
194
- if (!file.type.startsWith("image/")) {
195
- setEditError("File must be an image");
422
+ // A manual pick overrides any auto-detected cover and stops re-detection.
423
+ coverUserTouchedRef.current = true;
424
+ setDetectedCover(null);
425
+ setDetectedCoverWarning(null);
426
+ // Reject oversized / non-WebP-JPEG covers at selection so the writer gets
427
+ // immediate feedback instead of a late error at save (the server enforces
428
+ // the same WebP/JPEG ≤1MB constraint).
429
+ const error = validateCoverImage(file);
430
+ if (error) {
431
+ // Discard any previously-queued valid cover and clear the input, so an
432
+ // invalid re-selection can't leave a stale cover that Save would still
433
+ // upload contrary to the user's latest choice (#281 follow-up).
434
+ setCoverFile(null);
435
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
436
+ if (coverInputRef.current) coverInputRef.current.value = "";
437
+ setEditError(error);
438
+ // Surface the rejected pick in the cartoon cover-status badge (#337), not
439
+ // just the inline error, so the cover step clearly reads "can't be used".
440
+ setCoverStatus("invalid");
196
441
  return;
197
442
  }
198
443
  setCoverFile(file);
199
- setCoverPreview(URL.createObjectURL(file));
444
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
200
445
  setEditError(null);
446
+ setCoverStatus("selected");
201
447
  }, []);
202
448
 
449
+ // Import a Codex-generated image (e.g. a large PNG) as the cover (#301). The
450
+ // browser converts/compresses it to a compliant WebP/JPEG <=1MB, then OWS
451
+ // saves it as the deterministic local asset (assets/cover.webp) via
452
+ // import-cover and loads it into the same coverFile the manual picker uses, so
453
+ // the existing publish flow attaches it with no special casing. A source that
454
+ // cannot be decoded/compressed surfaces a clear error and saves nothing.
455
+ const handleCoverImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
456
+ const file = e.target.files?.[0];
457
+ if (coverImportInputRef.current) coverImportInputRef.current.value = "";
458
+ if (!file || !storyName) return;
459
+ // A deliberate import overrides any auto-detected cover, like a manual pick.
460
+ coverUserTouchedRef.current = true;
461
+ setDetectedCover(null);
462
+ setCoverImporting(true);
463
+ setEditError(null);
464
+ try {
465
+ let blob: Blob;
466
+ try {
467
+ blob = await importImageToCompliantBlob(file);
468
+ } catch (err) {
469
+ setCoverFile(null);
470
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
471
+ setEditError(err instanceof Error ? err.message : "Could not import image");
472
+ return;
473
+ }
474
+ const ext = blob.type === "image/jpeg" ? "jpg" : "webp";
475
+ const imported = new File([blob], `cover.${ext}`, { type: blob.type });
476
+ const formData = new FormData();
477
+ formData.append("file", imported);
478
+ const res = await authFetch(`/api/stories/${storyName}/import-cover`, {
479
+ method: "POST",
480
+ body: formData,
481
+ });
482
+ if (!res.ok) {
483
+ const data = await res.json().catch(() => ({}));
484
+ setEditError(data.error || "Cover import failed");
485
+ return;
486
+ }
487
+ setCoverFile(imported);
488
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(imported); });
489
+ setDetectedCoverWarning(null);
490
+ setCoverStatus("selected");
491
+ setEditError(null);
492
+ } catch {
493
+ setEditError("Cover import failed");
494
+ } finally {
495
+ setCoverImporting(false);
496
+ }
497
+ }, [storyName, authFetch]);
498
+
203
499
  // Handle illustration image upload from File object
204
500
  const uploadIllustration = useCallback(async (file: File) => {
205
501
  if (file.size > 1024 * 1024) {
@@ -285,6 +581,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
285
581
 
286
582
  setEditSuccess(true);
287
583
  setCoverFile(null);
584
+ // A cover was just attached — reflect it in the cartoon cover status badge
585
+ // immediately, without closing/reopening Edit Story (#337, re1). Drop the
586
+ // local preview so the status reads "attached", not "selected".
587
+ if (coverCid !== undefined) {
588
+ setEditHasCover(true);
589
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
590
+ setCoverStatus("unknown");
591
+ if (coverInputRef.current) coverInputRef.current.value = "";
592
+ }
288
593
  setTimeout(() => setEditSuccess(false), 3000);
289
594
  } catch (err) {
290
595
  setEditError(err instanceof Error ? err.message : "Update failed");
@@ -304,8 +609,55 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
304
609
  setShowIllustrations(false);
305
610
  setUploadedImages([]);
306
611
  setIllustrationError(null);
612
+ setDetectedCover(null);
613
+ setDetectedCoverWarning(null);
614
+ setCoverStatus("unknown");
615
+ coverUserTouchedRef.current = false;
616
+ setGenesisEditMode("text");
307
617
  }, [storyName, fileName]);
308
618
 
619
+ // Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
620
+ // genesis and offer it as the default pre-publish cover (#296). Loads the file
621
+ // into the same coverFile/coverPreview the manual picker uses, so the existing
622
+ // publish flow attaches it (upload-cover → update-storyline) with no special
623
+ // casing. Invalid/oversize detected assets surface as a warning and are NOT used.
624
+ useEffect(() => {
625
+ if (fileName !== "genesis.md" || !storyName) return;
626
+ // Wait for the file to load AND confirm it is genuinely unpublished before
627
+ // touching the shared coverFile/coverPreview. On first render fileData is
628
+ // null, so without this an auto-detected cover could be set before the file
629
+ // load resolves and then leak into the published Edit Story panel (re1).
630
+ if (!fileData) return;
631
+ if (fileData.storylineId || fileData.status === "published" || fileData.status === "published-not-indexed") return;
632
+ if (coverUserTouchedRef.current) return; // a manual pick/removal wins
633
+ let cancelled = false;
634
+ (async () => {
635
+ try {
636
+ const res = await authFetch(`/api/stories/${storyName}/cover-asset`);
637
+ if (cancelled || !res.ok) return;
638
+ const data = await res.json();
639
+ if (cancelled) return;
640
+ if (!data?.found) { setCoverStatus("none"); return; }
641
+ if (!data.valid) {
642
+ setDetectedCoverWarning(data.error || "Detected cover asset is invalid and was not used");
643
+ setCoverStatus("invalid");
644
+ return;
645
+ }
646
+ const assetRes = await authFetch(`/api/stories/${storyName}/asset/${data.path.replace(/^assets\//, "")}`);
647
+ if (cancelled || !assetRes.ok) return;
648
+ const blob = await assetRes.blob();
649
+ const file = new File([blob], data.path.split("/").pop() || "cover.webp", { type: data.type });
650
+ // Reuse the exact client validation the manual picker uses.
651
+ if (validateCoverImage(file) || cancelled || coverUserTouchedRef.current) return;
652
+ setCoverFile(file);
653
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
654
+ setDetectedCover(data.path);
655
+ setCoverStatus("detected");
656
+ } catch { /* best-effort: no detected cover */ }
657
+ })();
658
+ return () => { cancelled = true; };
659
+ }, [storyName, fileName, fileData, fileData?.status, fileData?.storylineId, authFetch]);
660
+
309
661
  // Fetch current storyline metadata when edit panel opens
310
662
  useEffect(() => {
311
663
  if (!showEditPanel || !fileData?.storylineId) return;
@@ -321,7 +673,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
321
673
  return;
322
674
  }
323
675
  if (data.genre) {
324
- const found = GENRES.find((g) => g.toLowerCase() === data.genre.toLowerCase());
676
+ const found = canonicalizeGenre(data.genre);
325
677
  if (found) setEditGenre(found);
326
678
  }
327
679
  if (data.language) {
@@ -329,6 +681,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
329
681
  if (found) setEditLanguage(found);
330
682
  }
331
683
  if (data.isNsfw !== undefined) setEditNsfw(!!data.isNsfw);
684
+ // Track whether a cover is already attached so the cover step can show
685
+ // an "attached" status for a published cartoon story (#337).
686
+ setEditHasCover(!!(data.coverCid || data.coverUrl || data.cover));
332
687
  setEditMetaLoaded(true);
333
688
  })
334
689
  .catch(() => {
@@ -394,7 +749,157 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
394
749
  const charCount = content.length;
395
750
  const isGenesis = fileName === "genesis.md";
396
751
  const isPlot = fileName ? /^plot-\d+\.md$/.test(fileName) : false;
752
+ const isCartoonPlot = contentType === "cartoon" && isPlot;
753
+ const isCartoonGenesis = contentType === "cartoon" && isGenesis;
754
+ // #461: cartoon episode views (Genesis + plots) hide every publish-control /
755
+ // readiness block — those move to the Publish tab — and show only the opening
756
+ // content, production next-step guidance, and a compact "Review publish" CTA.
757
+ const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
397
758
  const isPublished = fileData?.status === "published" || fileData?.status === "published-not-indexed";
759
+
760
+ // State-aware preview footer guidance (#422). Cut count for the selected
761
+ // episode: Genesis from genesis.cuts.json, a plot from its readiness scan
762
+ // (null until loaded, so we don't flash "not started"). Drives outline/
763
+ // genesis/placeholder-plot guidance; null ⇒ let the per-stage UI speak.
764
+ const episodeCutCount = isCartoonGenesis
765
+ ? (genesisCutProgress ? genesisCutProgress.total : null)
766
+ : isCartoonPlot
767
+ ? (cartoonStage === null ? null : cartoonTotalCuts)
768
+ : null;
769
+ const footerGuidance = previewFooterGuidance({
770
+ fileName: fileName ?? "",
771
+ contentType,
772
+ hasGenesis,
773
+ isPublished,
774
+ cutCount: episodeCutCount,
775
+ // Pass the Genesis production progress so the footer advances by real stage
776
+ // (clean → letter → export → upload), not just "is anything uploaded" (#451).
777
+ cutProgress: isCartoonGenesis ? genesisCutProgress : null,
778
+ });
779
+
780
+ // Resolve + validate the public publish title shown before publish (#358).
781
+ // Scoped to cartoon (genesis story title + plot episode title); fiction is
782
+ // unchanged. `resolvedPublishTitle` mirrors what handlePublish/derivePublishTitle
783
+ // will send on-chain; `rawTitleBlocked` is true when it's still a raw filename
784
+ // label ("genesis"/"plot-NN"), which must block publish.
785
+ const showsPublishTitle = (isCartoonGenesis || isCartoonPlot) && !isPublished;
786
+ const resolvedPublishTitle = showsPublishTitle
787
+ ? derivePublishTitle({
788
+ fileName: fileName!,
789
+ fileContent: fileData?.content ?? "",
790
+ storySlug: storyName ?? "",
791
+ structureContent,
792
+ contentType: "cartoon",
793
+ episodeTitle: cartoonEpisodeTitle,
794
+ })
795
+ : null;
796
+ const rawTitleBlocked = !!resolvedPublishTitle && isRawFilenameTitle(resolvedPublishTitle, fileName!);
797
+ // #365: a cartoon plot must have an EXPLICIT reader-facing title — a real
798
+ // `# Title` H1 or a non-empty cut-plan title. The friendly "Episode NN"
799
+ // fallback (derivePublishTitle) is diagnostic only and must NOT be published,
800
+ // so a missing explicit title blocks publish (tightens #358's plot path).
801
+ const episodeTitleMissing = isCartoonPlot && !isPublished
802
+ && !hasExplicitEpisodeTitle({ fileContent: fileData?.content ?? "", episodeTitle: cartoonEpisodeTitle });
803
+
804
+ // Cartoon Genesis prologue readiness (#359, hardened in #400). Genesis is the
805
+ // reader-facing opening readers meet before plot-01, so block a missing H1
806
+ // title AND a too-short / synopsis-shaped / single-dense-block opening that
807
+ // doesn't read as a real story opening. Cartoon-only; fiction is unchanged.
808
+ const genesisReadiness = (isCartoonGenesis && !isPublished)
809
+ ? cartoonGenesisReadiness(fileData?.content ?? "")
810
+ : null;
811
+ const genesisBlocked = !!genesisReadiness && genesisReadiness.blockers.length > 0;
812
+ const cartoonStatusCardClass = "w-full max-w-[32rem] rounded-xl border px-3 py-3";
813
+
814
+ // Cartoon cover readiness badge + requirements (#337). Shown wherever a
815
+ // cartoon writer manages the cover (pre-publish picker and the published Edit
816
+ // Story panel) so the cover step is never silently skipped before publish.
817
+ const COVER_TONE: Record<"muted" | "accent" | "error" | "success", string> = {
818
+ muted: "text-muted",
819
+ accent: "text-accent",
820
+ error: "text-error",
821
+ success: "text-green-700",
822
+ };
823
+ const renderCoverStatus = (attached: boolean) => {
824
+ if (!isCartoonGenesis) return null;
825
+ const r = cartoonCoverReadiness({
826
+ hasSelectedCover: !!coverFile,
827
+ invalid: coverStatus === "invalid",
828
+ attached,
829
+ });
830
+ return (
831
+ <div className="flex flex-col gap-0.5" data-testid="cartoon-cover-status" data-state={r.state}>
832
+ <span className={`text-[11px] font-medium ${COVER_TONE[r.tone]}`}>{r.label}</span>
833
+ {/* Long cover spec/tips collapsed by default (#420) so the panel isn't a
834
+ wall of text; the concise status line above is always visible. */}
835
+ <details className="text-[10px] text-muted" data-testid="cover-details">
836
+ <summary className="cursor-pointer select-none">Cover tips</summary>
837
+ <span className="block mt-0.5" data-testid="cartoon-cover-guidance">{COVER_GUIDANCE}</span>
838
+ </details>
839
+ </div>
840
+ );
841
+ };
842
+
843
+ // The exact public title this file will publish with (#358), shown before the
844
+ // operator clicks publish. Reader-facing label (Story/Episode title), blocks
845
+ // when still a raw filename (#358) or — for cartoon plots — when there is no
846
+ // explicit title and only the diagnostic "Episode NN" fallback remains (#365).
847
+ const titleBlocked = rawTitleBlocked || episodeTitleMissing;
848
+ const renderPublishTitle = () => {
849
+ if (!showsPublishTitle || !resolvedPublishTitle) return null;
850
+ const label = isGenesis ? "Story title" : "Episode title";
851
+ return (
852
+ <div
853
+ className="flex flex-col gap-0.5"
854
+ data-testid="publish-title-preview"
855
+ data-raw={rawTitleBlocked ? "true" : "false"}
856
+ data-blocked={titleBlocked ? "true" : "false"}
857
+ >
858
+ <span className="text-[11px] text-foreground">
859
+ <span className="font-medium">{label}:</span>{" "}
860
+ <span className={titleBlocked ? "text-error font-medium" : "text-foreground"}>{resolvedPublishTitle}</span>
861
+ </span>
862
+ {rawTitleBlocked ? (
863
+ <span className="text-[10px] text-error" data-testid="publish-title-raw-error">
864
+ This would publish as a raw filename. {isGenesis
865
+ ? "Add a real “# Title” heading to genesis.md"
866
+ : "Set a title in the cut plan (or add a “# Title” to the episode)"} before publishing.
867
+ </span>
868
+ ) : episodeTitleMissing ? (
869
+ <span className="text-[10px] text-error" data-testid="publish-title-episode-required">
870
+ “{resolvedPublishTitle}” is a generic placeholder, not a reader-facing title, so it can’t be published. Set a real episode title in the cut plan (or add a “# Title” to the episode) — e.g. “Episode 01 — The Couple Coupon” — before publishing.
871
+ </span>
872
+ ) : null}
873
+ </div>
874
+ );
875
+ };
876
+
877
+ // Cartoon Genesis prologue readiness panel (#359, hardened in #400), shown
878
+ // before publish. Labels Genesis as the reader-facing Story opening / Prologue,
879
+ // blocks a missing title and a thin/synopsis-shaped/single-dense-block opening,
880
+ // and reminds the writer it should bridge into Episode 01. Fiction genesis never
881
+ // renders this (isCartoonGenesis).
882
+ const renderGenesisReadiness = () => {
883
+ if (!genesisReadiness) return null;
884
+ return (
885
+ <div
886
+ className="flex flex-col gap-1 rounded border border-border bg-surface/50 p-2"
887
+ data-testid="cartoon-genesis-readiness"
888
+ data-blocked={genesisBlocked ? "true" : "false"}
889
+ >
890
+ <span className="text-[11px] font-medium text-foreground">Story opening (Prologue)</span>
891
+ <span className="text-[10px] text-muted" data-testid="genesis-readiness-hint">
892
+ Genesis is the first thing readers see. Write it as the story opening/prologue, not a synopsis — set up the premise and stakes, then bridge into Episode 01.
893
+ </span>
894
+ {genesisReadiness.blockers.map((b, i) => (
895
+ <span key={`b-${i}`} className="text-[10px] text-error" data-testid="genesis-readiness-blocker">{b}</span>
896
+ ))}
897
+ {genesisReadiness.warnings.map((w, i) => (
898
+ <span key={`w-${i}`} className="text-[10px] text-amber-600" data-testid="genesis-readiness-warning">{w}</span>
899
+ ))}
900
+ </div>
901
+ );
902
+ };
398
903
  const charLimit = (isGenesis || isPlot) ? 10000 : null;
399
904
  // Don't show over-limit warning for already-published files
400
905
  const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
@@ -403,12 +908,52 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
403
908
  const publishContent = fileData?.content ?? "";
404
909
  const imageValidation = !isPublished ? validateImageRefs(publishContent) : { count: 0, warnings: [] };
405
910
 
911
+ // Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
912
+ const proseEditor = (
913
+ <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
914
+ <textarea
915
+ ref={textareaRef}
916
+ value={editContent}
917
+ onChange={(e) => { setEditContent(e.target.value); setDirty(true); dirtyRef.current = true; }}
918
+ className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
919
+ style={{
920
+ fontFamily: '"Geist Mono", ui-monospace, monospace',
921
+ background: "var(--paper-bg)",
922
+ color: "var(--text)",
923
+ }}
924
+ spellCheck={false}
925
+ />
926
+ <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
927
+ <span className="text-xs text-muted">
928
+ {dirty ? "Unsaved changes" : "No changes"}
929
+ </span>
930
+ <button
931
+ onClick={handleSave}
932
+ disabled={!dirty || saving}
933
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
934
+ >
935
+ {saving ? "Saving..." : "Save"}
936
+ </button>
937
+ </div>
938
+ </div>
939
+ );
940
+
406
941
  return (
407
942
  <div className="h-full flex flex-col">
408
943
  {/* Header with file path + tabs */}
409
944
  <div className="border-b border-border">
410
945
  <div className="px-3 py-1.5 flex items-center justify-between">
411
946
  <div className="flex items-center gap-2 text-xs font-mono text-muted">
947
+ {onViewProgress && (
948
+ <button
949
+ onClick={onViewProgress}
950
+ data-testid="view-progress-btn"
951
+ className="text-accent hover:underline font-sans"
952
+ title="Story progress overview"
953
+ >
954
+ ← Progress
955
+ </button>
956
+ )}
412
957
  <span>{storyName}/{fileName}</span>
413
958
  {fileData?.status === "published" && (
414
959
  <span className="text-green-700 font-medium">Published</span>
@@ -458,55 +1003,109 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
458
1003
  </div>
459
1004
  </div>
460
1005
 
1006
+ {/* Persistent cartoon workflow coach (#429): one clear next action across
1007
+ every cartoon file view, derived from real story/episode state. Sits
1008
+ above the content so it stays visible on both the Preview and Edit
1009
+ tabs. Fiction renders nothing (the coach is null), so fiction views are
1010
+ unchanged. */}
1011
+ {contentType === "cartoon" && storyName && fileName && (
1012
+ <WorkflowCoach
1013
+ storyName={storyName}
1014
+ fileName={fileName}
1015
+ authFetch={authFetch}
1016
+ refreshKey={cutsRefreshKey}
1017
+ onAction={handleCoachAction}
1018
+ />
1019
+ )}
1020
+
461
1021
  {/* Content area */}
462
1022
  {activeTab === "preview" ? (
463
- <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
464
- {fileData?.content ? (
465
- <div className="prose max-w-none">
466
- <ReactMarkdown
467
- remarkPlugins={[remarkBreaks, remarkGfm]}
468
- rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
1023
+ isCartoonPlot ? (
1024
+ <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
1025
+ {/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
1026
+ Cut Inspector (cuts.json planning metadata) — see #289. */}
1027
+ <div className="flex gap-1 px-3 py-1 border-b border-border">
1028
+ <button
1029
+ data-testid="cartoon-mode-publish"
1030
+ onClick={() => setCartoonPreviewMode("publish")}
1031
+ className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
469
1032
  >
470
- {fileData.content}
471
- </ReactMarkdown>
1033
+ Publish Preview
1034
+ </button>
1035
+ <button
1036
+ data-testid="cartoon-mode-inspect"
1037
+ onClick={() => setCartoonPreviewMode("inspect")}
1038
+ className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1039
+ >
1040
+ Cut Inspector
1041
+ </button>
472
1042
  </div>
473
- ) : (
474
- <p className="text-muted italic">No content</p>
475
- )}
1043
+ <div className="flex-1 min-h-0">
1044
+ {cartoonPreviewMode === "publish" ? (
1045
+ <CartoonPublishPreview content={fileData?.content ?? ""} stage={cartoonStage} />
1046
+ ) : (
1047
+ <CartoonPreview storyName={storyName!} fileName={fileName!} authFetch={authFetch} onEditCut={handleEditCut} />
1048
+ )}
1049
+ </div>
1050
+ </div>
1051
+ ) : (
1052
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
1053
+ {fileData?.content ? (
1054
+ <div className="prose max-w-none">
1055
+ <ReactMarkdown
1056
+ remarkPlugins={[remarkBreaks, remarkGfm]}
1057
+ rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
1058
+ >
1059
+ {fileData.content}
1060
+ </ReactMarkdown>
1061
+ </div>
1062
+ ) : (
1063
+ <p className="text-muted italic">No content</p>
1064
+ )}
1065
+ </div>
1066
+ )
1067
+ ) : isCartoonPlot ? (
1068
+ <div className="flex-1 min-h-[22rem] overflow-hidden" style={{ background: "var(--paper-bg)" }}>
1069
+ <CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
476
1070
  </div>
477
- ) : (
1071
+ ) : isCartoonGenesis ? (
1072
+ // Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
1073
+ // the coach's lettering/upload/refresh actions for Episode 1 are actionable
1074
+ // and Genesis cuts get the same workspace as plots — without losing the
1075
+ // hand-written opening prose editor.
478
1076
  <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
479
- <textarea
480
- ref={textareaRef}
481
- value={editContent}
482
- onChange={(e) => { setEditContent(e.target.value); setDirty(true); dirtyRef.current = true; }}
483
- className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
484
- style={{
485
- fontFamily: '"Geist Mono", ui-monospace, monospace',
486
- background: "var(--paper-bg)",
487
- color: "var(--text)",
488
- }}
489
- spellCheck={false}
490
- />
491
- <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
492
- <span className="text-xs text-muted">
493
- {dirty ? "Unsaved changes" : "No changes"}
494
- </span>
1077
+ <div className="flex gap-1 px-3 py-1 border-b border-border">
495
1078
  <button
496
- onClick={handleSave}
497
- disabled={!dirty || saving}
498
- className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
1079
+ data-testid="genesis-edit-mode-text"
1080
+ onClick={() => setGenesisEditMode("text")}
1081
+ className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
499
1082
  >
500
- {saving ? "Saving..." : "Save"}
1083
+ Opening text
1084
+ </button>
1085
+ <button
1086
+ data-testid="genesis-edit-mode-cuts"
1087
+ onClick={() => setGenesisEditMode("cuts")}
1088
+ className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1089
+ >
1090
+ Cuts
501
1091
  </button>
502
1092
  </div>
1093
+ <div className="flex-1 min-h-0">
1094
+ {genesisEditMode === "cuts" ? (
1095
+ <CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
1096
+ ) : (
1097
+ proseEditor
1098
+ )}
1099
+ </div>
503
1100
  </div>
1101
+ ) : (
1102
+ proseEditor
504
1103
  )}
505
1104
 
506
1105
  {/* Action bar */}
507
1106
  <div className="px-3 py-2 border-t border-border flex items-center justify-between">
508
1107
  {fileName === "structure.md" ? (
509
- <p className="text-muted text-xs italic">This is your story outline — not publishable. Ask AI to write the genesis next.</p>
1108
+ <p className="text-muted text-xs italic" data-testid="footer-guidance">{footerGuidance}</p>
510
1109
  ) : fileData?.status === "published-not-indexed" ? (
511
1110
  <div className="flex flex-col gap-1">
512
1111
  <div className="flex items-center gap-2 text-xs">
@@ -552,8 +1151,23 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
552
1151
  )}
553
1152
  {isPlot && (
554
1153
  <button
555
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
1154
+ onClick={() => {
1155
+ if (!storyName || !fileName) return;
1156
+ // #332: Retry Publish mints a NEW on-chain chainPlot. The
1157
+ // tx for this episode already exists (status is
1158
+ // published-not-indexed), so this is only for the rare case
1159
+ // where indexing never recovers — require an explicit
1160
+ // duplicate-risk confirm so it can't be clicked by reflex
1161
+ // instead of Retry Index, which would create a permanent
1162
+ // duplicate chapter on PlotLink.
1163
+ const ok = window.confirm(
1164
+ "This episode is already on-chain — try “Retry Index” first.\n\nRetry Publish creates a NEW on-chain transaction and a SECOND, permanent chapter on PlotLink (PlotLink content is immutable). Only do this if the chapter never appeared after indexing.\n\nCreate a new on-chain chapter anyway?",
1165
+ );
1166
+ if (!ok) return;
1167
+ onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1168
+ }}
556
1169
  disabled={!!publishingFile}
1170
+ data-testid="retry-publish-btn"
557
1171
  className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
558
1172
  >
559
1173
  {publishingFile === fileName ? "Publishing..." : "Retry Publish"}
@@ -629,6 +1243,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
629
1243
  {/* Cover image upload */}
630
1244
  <div className="flex flex-col gap-1.5">
631
1245
  <span className="text-xs font-medium text-foreground">Cover Image</span>
1246
+ {/* Attached/selected/invalid cover status for the published
1247
+ cartoon story (#337). */}
1248
+ {renderCoverStatus(editHasCover)}
632
1249
  <div className="flex items-start gap-3">
633
1250
  {coverPreview && (
634
1251
  <div className="relative">
@@ -638,7 +1255,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
638
1255
  className="w-16 h-24 object-cover rounded border border-border"
639
1256
  />
640
1257
  <button
641
- onClick={() => { setCoverFile(null); setCoverPreview(null); if (coverInputRef.current) coverInputRef.current.value = ""; }}
1258
+ onClick={() => { setCoverFile(null); setCoverPreview(null); setDetectedCoverWarning(null); setCoverStatus("unknown"); if (coverInputRef.current) coverInputRef.current.value = ""; }}
642
1259
  className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-error text-white rounded-full text-xs flex items-center justify-center"
643
1260
  >
644
1261
  x
@@ -649,9 +1266,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
649
1266
  <input
650
1267
  ref={coverInputRef}
651
1268
  type="file"
652
- accept="image/webp,image/jpeg,image/png"
1269
+ accept="image/webp,image/jpeg"
653
1270
  onChange={handleCoverSelect}
654
1271
  className="text-xs"
1272
+ data-testid="cover-input"
655
1273
  />
656
1274
  <span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
657
1275
  </div>
@@ -705,8 +1323,73 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
705
1323
  </div>
706
1324
  ) : (
707
1325
  <div className="flex flex-col gap-2">
1326
+ {/* Creator-facing 6-step production checklist so a first-time user
1327
+ can see which step is current/next without internal jargon
1328
+ (#320, expanded to per-cut granularity in #335). */}
1329
+ {/* Compact cartoon production status (#420): one scannable line of
1330
+ cut/clean/lettered/uploaded tallies, with a link to the full
1331
+ story progress overview (#418). The detailed 6-step guide stays
1332
+ below. */}
1333
+ {isCartoonPlot && cartoonCutProgress && cartoonCutProgress.total > 0 && (
1334
+ <div className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted" data-testid="cartoon-status-summary">
1335
+ <span>Cuts: <span className="text-foreground font-medium">{cartoonCutProgress.total}</span></span>
1336
+ <span>Clean: <span className="text-foreground font-medium">{cartoonCutProgress.withClean}/{cartoonCutProgress.needClean}</span></span>
1337
+ <span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.needClean}</span></span>
1338
+ <span>Uploaded: <span className="text-foreground font-medium">{cartoonCutProgress.uploaded}/{cartoonCutProgress.total}</span></span>
1339
+ {onViewProgress && (
1340
+ <button onClick={onViewProgress} className="ml-auto text-accent hover:underline" data-testid="status-view-progress">
1341
+ View progress →
1342
+ </button>
1343
+ )}
1344
+ </div>
1345
+ )}
1346
+ {/* #461: the 6-step production checklist is a publish/production
1347
+ checklist — it now lives on the Publish tab + the cut workspace's
1348
+ FinishEpisodePanel, so it no longer renders under the episode. */}
1349
+ {/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
1350
+ genesis.cuts.json so a writer sees its real cut/image state in the
1351
+ readiness UI instead of treating Genesis as text-only. */}
1352
+ {isCartoonGenesis && genesisCutProgress && (
1353
+ <div className="text-xs text-muted" data-testid="genesis-cuts-summary">
1354
+ {/* Distinguish clean art / lettering / final-export / upload so the
1355
+ state never collapses to just "uploaded" (#451). */}
1356
+ Episode 1 (Genesis) cuts: {genesisCutProgress.total} planned
1357
+ {genesisCutProgress.total > 0 && (
1358
+ <>
1359
+ {" "}· {genesisCutProgress.withClean} clean
1360
+ {" "}· {genesisCutProgress.withText} lettered
1361
+ {" "}· {genesisCutProgress.exported} exported
1362
+ {" "}· {genesisCutProgress.uploaded} uploaded
1363
+ </>
1364
+ )}
1365
+ </div>
1366
+ )}
1367
+ {/* State-aware guidance for a not-yet-produced Genesis or a future-
1368
+ episode placeholder (#422): plan cuts / generate clean images /
1369
+ expand the cut plan — never a misleading "ready to publish". */}
1370
+ {(isCartoonGenesis || isCartoonPlot) && footerGuidance && (
1371
+ <div
1372
+ className={`${cartoonStatusCardClass} flex flex-col gap-1 border-border bg-surface/50`}
1373
+ data-testid="cartoon-not-started"
1374
+ >
1375
+ <div className="flex items-center gap-2">
1376
+ <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted">
1377
+ {episodeCutCount === 0 ? "Not started" : "Next step"}
1378
+ </span>
1379
+ <span className="text-xs font-medium text-foreground">
1380
+ {isCartoonGenesis ? "Genesis (Episode 1)" : "Future episode"}
1381
+ </span>
1382
+ </div>
1383
+ <span className="text-xs text-muted">{footerGuidance}</span>
1384
+ </div>
1385
+ )}
1386
+ {/* #461: the planning-stage "Prepare episode for publish" callout and
1387
+ the awaiting-upload pending callout are publish-readiness states —
1388
+ they now live on the Publish tab. The cartoon episode keeps only
1389
+ production next-step guidance (cartoon-not-started / cut summaries)
1390
+ plus the compact "Review publish checklist" CTA below. */}
708
1391
  {/* Inline illustration upload for plot files (Preview tab only) */}
709
- {isPlot && activeTab === "preview" && (
1392
+ {isPlot && !isCartoonPlot && activeTab === "preview" && (
710
1393
  <div>
711
1394
  <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
712
1395
  <input
@@ -769,23 +1452,142 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
769
1452
  )}
770
1453
  </div>
771
1454
  )}
1455
+ {/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
1456
+ gets a cover before its first createStoryline. Reuses the same
1457
+ validation/stale-clear as the published Edit Story panel; the
1458
+ selected file is handed to the publish flow, which uploads it and
1459
+ sets it on the storyline once it exists.
1460
+ #450: hidden in the genesis CUT WORKSPACE (Cuts sub-mode) so the
1461
+ cut/lettering editor gets the height — the cover stays available
1462
+ in the Opening-text/Preview view, Story Info, and the Publish page,
1463
+ and the auto-detect effect still loads it for publish. */}
1464
+ {isGenesis && contentType !== "cartoon" && !(activeTab === "edit" && genesisEditMode === "cuts") && (
1465
+ <div className="flex flex-col gap-1.5" data-testid="prepublish-cover">
1466
+ <span className="text-xs font-medium text-foreground">Cover Image <span className="text-muted font-normal">(optional)</span></span>
1467
+ {/* Cartoon cover readiness + requirements (#337): keep the cover
1468
+ step visible before genesis publish so a pilot story isn't
1469
+ published coverless by accident. */}
1470
+ {renderCoverStatus(false)}
1471
+ <div className="flex items-start gap-3">
1472
+ {coverPreview && (
1473
+ <div className="relative">
1474
+ <img
1475
+ src={coverPreview}
1476
+ alt="Cover preview"
1477
+ className="w-16 h-24 object-cover rounded border border-border"
1478
+ />
1479
+ <button
1480
+ onClick={() => { coverUserTouchedRef.current = true; setDetectedCover(null); setDetectedCoverWarning(null); setCoverStatus("unknown"); setCoverFile(null); setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; }); if (coverInputRef.current) coverInputRef.current.value = ""; }}
1481
+ className="absolute -top-1.5 -right-1.5 w-4 h-4 bg-error text-white rounded-full text-xs flex items-center justify-center"
1482
+ >
1483
+ x
1484
+ </button>
1485
+ </div>
1486
+ )}
1487
+ <div className="flex flex-col gap-1">
1488
+ <input
1489
+ ref={coverInputRef}
1490
+ type="file"
1491
+ accept="image/webp,image/jpeg"
1492
+ onChange={handleCoverSelect}
1493
+ className="text-xs"
1494
+ data-testid="prepublish-cover-input"
1495
+ />
1496
+ <span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
1497
+ {/* Codex-image import (#301): convert a generated PNG (or any
1498
+ large image) to a compliant cover in-browser, save it as
1499
+ assets/cover.webp, and load it as the selected cover —
1500
+ no agent-side shell image tools. */}
1501
+ <input
1502
+ ref={coverImportInputRef}
1503
+ type="file"
1504
+ accept="image/png,image/webp,image/jpeg"
1505
+ onChange={handleCoverImport}
1506
+ className="hidden"
1507
+ data-testid="prepublish-cover-import-input"
1508
+ />
1509
+ <button
1510
+ type="button"
1511
+ onClick={() => coverImportInputRef.current?.click()}
1512
+ disabled={coverImporting}
1513
+ className="self-start px-2 py-1 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
1514
+ data-testid="prepublish-cover-import"
1515
+ >
1516
+ {coverImporting ? "Importing…" : "Import generated image (PNG ok)"}
1517
+ </button>
1518
+ {/* #312: make the generated-cover → PlotLink-cover connection
1519
+ explicit. Whenever a cover is selected (auto-detected,
1520
+ imported, or manually picked) it WILL be uploaded as the
1521
+ storyline cover at publish; an invalid or missing generated
1522
+ cover gets a clear action. */}
1523
+ {coverFile && (
1524
+ <span className="text-green-700 text-xs" data-testid="prepublish-cover-will-upload">
1525
+ This cover will be uploaded as the PlotLink storyline cover when you publish.
1526
+ </span>
1527
+ )}
1528
+ {detectedCover && (
1529
+ <span className="text-accent text-xs" data-testid="prepublish-cover-detected">
1530
+ Auto-detected generated cover {detectedCover} — pick a file to override.
1531
+ </span>
1532
+ )}
1533
+ {detectedCoverWarning && (
1534
+ <span className="text-amber-700 text-xs" data-testid="prepublish-cover-detected-warning">
1535
+ {detectedCoverWarning} Use &ldquo;Import generated image&rdquo; below to convert/compress it, or pick a file.
1536
+ </span>
1537
+ )}
1538
+ {contentType === "cartoon" && coverStatus === "none" && !coverFile && (
1539
+ <span className="text-muted text-xs" data-testid="prepublish-cover-none">
1540
+ No generated cover detected. Create <span className="font-mono">assets/cover.webp</span> or use &ldquo;Import generated image&rdquo; — it will be uploaded as the PlotLink storyline cover when you publish.
1541
+ </span>
1542
+ )}
1543
+ {editError && <span className="text-error text-xs" data-testid="prepublish-cover-error">{editError}</span>}
1544
+ </div>
1545
+ </div>
1546
+ </div>
1547
+ )}
1548
+ {/* Public title shown + validated before publish (#358). #461: moved
1549
+ to the Publish tab for cartoon — fiction keeps it inline. */}
1550
+ {!isCartoonEpisode && renderPublishTitle()}
1551
+ {/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
1552
+ the Publish tab; never rendered in the cartoon episode view. */}
1553
+ {!isCartoonEpisode && renderGenesisReadiness()}
1554
+ {/* #461: the genre/language selects + publish button + publish-disabled
1555
+ reasons are publish controls — for cartoon they live on the Publish
1556
+ tab. Fiction keeps the inline publish controls unchanged. */}
1557
+ {!isCartoonEpisode && (
772
1558
  <div className="flex items-center gap-2">
773
- {(isGenesis) && (
1559
+ {/* Genre/language are edited in Story Info for cartoon (#439/#450);
1560
+ the inline selects are fiction-only so the cartoon cut workspace
1561
+ isn't a metadata form. Cartoon publish still reads the persisted
1562
+ genre/language (seeded into these values from .story.json). */}
1563
+ {isGenesis && contentType !== "cartoon" && (
774
1564
  <>
775
1565
  <select
776
1566
  value={selectedGenre}
777
- onChange={(e) => setSelectedGenre(e.target.value)}
778
- className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1567
+ data-testid="publish-genre-select"
1568
+ onChange={(e) => {
1569
+ setSelectedGenre(e.target.value);
1570
+ if (e.target.value) persistPublishMeta({ genre: e.target.value });
1571
+ }}
1572
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedGenre ? "border-border" : "border-amber-500"}`}
779
1573
  >
1574
+ {/* Explicit unset state — no silent Romance default (#424). */}
1575
+ {!selectedGenre && <option value="" disabled>Needs metadata — select genre</option>}
780
1576
  {GENRES.map((g) => (
781
1577
  <option key={g} value={g}>{g}</option>
782
1578
  ))}
783
1579
  </select>
784
1580
  <select
785
1581
  value={selectedLanguage}
786
- onChange={(e) => setSelectedLanguage(e.target.value)}
787
- className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1582
+ data-testid="publish-language-select"
1583
+ onChange={(e) => {
1584
+ setSelectedLanguage(e.target.value);
1585
+ if (e.target.value) persistPublishMeta({ language: e.target.value });
1586
+ }}
1587
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
788
1588
  >
1589
+ {/* Explicit unset state — no silent English default (#424). */}
1590
+ {!selectedLanguage && <option value="" disabled>Needs metadata — select language</option>}
789
1591
  {LANGUAGES.map((l) => (
790
1592
  <option key={l} value={l}>{l}</option>
791
1593
  ))}
@@ -793,23 +1595,90 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
793
1595
  </>
794
1596
  )}
795
1597
  <button
796
- onClick={() => {
1598
+ onClick={async () => {
797
1599
  if (!storyName || !fileName) return;
798
1600
  if (imageValidation.count > 0) {
799
1601
  const msg = `This plot contains ${imageValidation.count} illustration(s). Content is immutable after publishing — image references cannot be changed or removed.\n\nPlease verify illustrations appear correctly in Preview before continuing.\n\nPublish now?`;
800
1602
  if (!window.confirm(msg)) return;
801
1603
  }
802
- onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1604
+ // Genesis carries the optional pre-publish cover (#284); plot
1605
+ // files never do. Only pass the 6th arg when a cover is
1606
+ // actually selected, so the no-cover call signature (and
1607
+ // existing fiction/plot publish behavior) is unchanged.
1608
+ // The cover may be a manual pick OR an auto-detected
1609
+ // assets/cover.webp loaded into coverFile (#296) — both flow
1610
+ // through the same attach path.
1611
+ const cover = isGenesis ? coverFile : null;
1612
+ if (cover) {
1613
+ // Drop the local cover selection ONLY on a confirmed-successful
1614
+ // publish (onPublish resolves truthy). A publish blocked before
1615
+ // the stream (#375) or one that opens then fails before `done`
1616
+ // (#376) resolves falsy, so the writer's selected/auto-detected
1617
+ // cover stays put for the retry.
1618
+ const published = await onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw, cover);
1619
+ if (published) {
1620
+ coverUserTouchedRef.current = true;
1621
+ setDetectedCover(null);
1622
+ setDetectedCoverWarning(null);
1623
+ setCoverStatus("unknown");
1624
+ setCoverFile(null);
1625
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
1626
+ if (coverInputRef.current) coverInputRef.current.value = "";
1627
+ }
1628
+ } else {
1629
+ onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1630
+ }
803
1631
  }}
804
- disabled={!!publishingFile || overLimit}
1632
+ disabled={!!publishingFile || overLimit || titleBlocked || genesisBlocked || (isGenesis && (!selectedGenre || !selectedLanguage)) || (isCartoonPlot && cartoonStage !== "ready")}
805
1633
  className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
806
1634
  >
807
1635
  {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
808
1636
  </button>
1637
+ {/* Cartoon edits these in Story Info, so point there instead of the
1638
+ hidden inline selects (#450); fiction keeps the inline guidance. */}
1639
+ {isGenesis && contentType === "cartoon" && (!selectedGenre || !selectedLanguage) && (
1640
+ <span className="text-amber-600 text-xs" data-testid="cartoon-metadata-needs-story-info">
1641
+ Set the genre and language in Story Info before publishing
1642
+ </span>
1643
+ )}
1644
+ {isGenesis && contentType !== "cartoon" && !selectedGenre && (
1645
+ <span className="text-amber-600 text-xs" data-testid="genre-needs-metadata">
1646
+ Needs metadata — choose a genre before publishing
1647
+ </span>
1648
+ )}
1649
+ {isGenesis && contentType !== "cartoon" && selectedGenre && !selectedLanguage && (
1650
+ <span className="text-amber-600 text-xs" data-testid="language-needs-metadata">
1651
+ Needs metadata — choose a language before publishing
1652
+ </span>
1653
+ )}
809
1654
  {overLimit && (
810
1655
  <span className="text-error text-xs">Reduce content to publish</span>
811
1656
  )}
1657
+ {isCartoonPlot && cartoonStage === "error" && (
1658
+ <span className="text-error text-xs" data-testid="publish-disabled-reason">Fix the issues below before publishing</span>
1659
+ )}
1660
+ {isCartoonPlot && cartoonStage === "planning" && (
1661
+ <span className="text-muted text-xs" data-testid="publish-disabled-reason">Prepare the episode for publish to continue</span>
1662
+ )}
1663
+ {isCartoonPlot && cartoonStage === "awaiting-upload" && (
1664
+ <span className="text-muted text-xs" data-testid="publish-disabled-reason">
1665
+ Upload all final images, then “Prepare episode for publish” — {cartoonAwaitingCount} of {cartoonTotalCuts} still need an uploaded image
1666
+ </span>
1667
+ )}
812
1668
  </div>
1669
+ )}
1670
+ {/* #461: the grouped publish-readiness issues card (#360/#421) is a
1671
+ publish checklist — it now renders on the Publish tab. The cartoon
1672
+ episode view links there via the compact CTA below. */}
1673
+ {isCartoonEpisode && (
1674
+ <button
1675
+ onClick={() => onViewPublish?.()}
1676
+ data-testid="cartoon-review-publish"
1677
+ className="self-start rounded border border-accent/40 px-3 py-1 text-xs text-accent hover:bg-accent/5 transition-colors"
1678
+ >
1679
+ Review publish checklist →
1680
+ </button>
1681
+ )}
813
1682
  {imageValidation.warnings.length > 0 && (
814
1683
  <div className="flex flex-col gap-0.5">
815
1684
  {imageValidation.warnings.map((w, i) => (
@@ -817,13 +1686,17 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
817
1686
  ))}
818
1687
  </div>
819
1688
  )}
820
- {(isGenesis) && (
1689
+ {/* Adult-content flag is edited in Story Info for cartoon (#450). */}
1690
+ {isGenesis && contentType !== "cartoon" && (
821
1691
  <div className="flex items-center gap-2">
822
1692
  <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
823
1693
  <input
824
1694
  type="checkbox"
825
1695
  checked={isNsfw}
826
- onChange={(e) => setIsNsfw(e.target.checked)}
1696
+ onChange={(e) => {
1697
+ setIsNsfw(e.target.checked);
1698
+ persistPublishMeta({ isNsfw: e.target.checked });
1699
+ }}
827
1700
  className="rounded border-border"
828
1701
  />
829
1702
  This story contains adult content (18+)