plotlink-ows 1.0.33 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
@@ -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,110 @@ 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
+ showEmptyState
1019
+ />
1020
+ )}
1021
+
461
1022
  {/* Content area */}
462
1023
  {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]]}
1024
+ isCartoonPlot ? (
1025
+ <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
1026
+ {/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
1027
+ Cut Inspector (cuts.json planning metadata) — see #289. */}
1028
+ <div className="flex gap-1 px-3 py-1 border-b border-border">
1029
+ <button
1030
+ data-testid="cartoon-mode-publish"
1031
+ onClick={() => setCartoonPreviewMode("publish")}
1032
+ className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
469
1033
  >
470
- {fileData.content}
471
- </ReactMarkdown>
1034
+ Publish Preview
1035
+ </button>
1036
+ <button
1037
+ data-testid="cartoon-mode-inspect"
1038
+ onClick={() => setCartoonPreviewMode("inspect")}
1039
+ className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1040
+ >
1041
+ Cut Inspector
1042
+ </button>
472
1043
  </div>
473
- ) : (
474
- <p className="text-muted italic">No content</p>
475
- )}
1044
+ <div className="flex-1 min-h-0">
1045
+ {cartoonPreviewMode === "publish" ? (
1046
+ <CartoonPublishPreview content={fileData?.content ?? ""} stage={cartoonStage} />
1047
+ ) : (
1048
+ <CartoonPreview storyName={storyName!} fileName={fileName!} authFetch={authFetch} onEditCut={handleEditCut} />
1049
+ )}
1050
+ </div>
1051
+ </div>
1052
+ ) : (
1053
+ <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
1054
+ {fileData?.content ? (
1055
+ <div className="prose max-w-none">
1056
+ <ReactMarkdown
1057
+ remarkPlugins={[remarkBreaks, remarkGfm]}
1058
+ rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
1059
+ >
1060
+ {fileData.content}
1061
+ </ReactMarkdown>
1062
+ </div>
1063
+ ) : (
1064
+ <p className="text-muted italic">No content</p>
1065
+ )}
1066
+ </div>
1067
+ )
1068
+ ) : isCartoonPlot ? (
1069
+ <div className="flex-1 min-h-[22rem] overflow-hidden" style={{ background: "var(--paper-bg)" }}>
1070
+ <CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
476
1071
  </div>
477
- ) : (
1072
+ ) : isCartoonGenesis ? (
1073
+ // Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
1074
+ // the coach's lettering/upload/refresh actions for Episode 1 are actionable
1075
+ // and Genesis cuts get the same workspace as plots — without losing the
1076
+ // hand-written opening prose editor.
478
1077
  <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>
1078
+ <div className="flex gap-1 px-3 py-1 border-b border-border">
495
1079
  <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"
1080
+ data-testid="genesis-edit-mode-text"
1081
+ onClick={() => setGenesisEditMode("text")}
1082
+ className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
499
1083
  >
500
- {saving ? "Saving..." : "Save"}
1084
+ Opening text
1085
+ </button>
1086
+ <button
1087
+ data-testid="genesis-edit-mode-cuts"
1088
+ onClick={() => setGenesisEditMode("cuts")}
1089
+ className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1090
+ >
1091
+ Cuts
501
1092
  </button>
502
1093
  </div>
1094
+ <div className="flex-1 min-h-0">
1095
+ {genesisEditMode === "cuts" ? (
1096
+ <CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
1097
+ ) : (
1098
+ proseEditor
1099
+ )}
1100
+ </div>
503
1101
  </div>
1102
+ ) : (
1103
+ proseEditor
504
1104
  )}
505
1105
 
506
1106
  {/* Action bar */}
507
1107
  <div className="px-3 py-2 border-t border-border flex items-center justify-between">
508
1108
  {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>
1109
+ <p className="text-muted text-xs italic" data-testid="footer-guidance">{footerGuidance}</p>
510
1110
  ) : fileData?.status === "published-not-indexed" ? (
511
1111
  <div className="flex flex-col gap-1">
512
1112
  <div className="flex items-center gap-2 text-xs">
@@ -552,8 +1152,23 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
552
1152
  )}
553
1153
  {isPlot && (
554
1154
  <button
555
- onClick={() => storyName && fileName && onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw)}
1155
+ onClick={() => {
1156
+ if (!storyName || !fileName) return;
1157
+ // #332: Retry Publish mints a NEW on-chain chainPlot. The
1158
+ // tx for this episode already exists (status is
1159
+ // published-not-indexed), so this is only for the rare case
1160
+ // where indexing never recovers — require an explicit
1161
+ // duplicate-risk confirm so it can't be clicked by reflex
1162
+ // instead of Retry Index, which would create a permanent
1163
+ // duplicate chapter on PlotLink.
1164
+ const ok = window.confirm(
1165
+ "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?",
1166
+ );
1167
+ if (!ok) return;
1168
+ onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1169
+ }}
556
1170
  disabled={!!publishingFile}
1171
+ data-testid="retry-publish-btn"
557
1172
  className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
558
1173
  >
559
1174
  {publishingFile === fileName ? "Publishing..." : "Retry Publish"}
@@ -629,6 +1244,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
629
1244
  {/* Cover image upload */}
630
1245
  <div className="flex flex-col gap-1.5">
631
1246
  <span className="text-xs font-medium text-foreground">Cover Image</span>
1247
+ {/* Attached/selected/invalid cover status for the published
1248
+ cartoon story (#337). */}
1249
+ {renderCoverStatus(editHasCover)}
632
1250
  <div className="flex items-start gap-3">
633
1251
  {coverPreview && (
634
1252
  <div className="relative">
@@ -638,7 +1256,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
638
1256
  className="w-16 h-24 object-cover rounded border border-border"
639
1257
  />
640
1258
  <button
641
- onClick={() => { setCoverFile(null); setCoverPreview(null); if (coverInputRef.current) coverInputRef.current.value = ""; }}
1259
+ onClick={() => { setCoverFile(null); setCoverPreview(null); setDetectedCoverWarning(null); setCoverStatus("unknown"); if (coverInputRef.current) coverInputRef.current.value = ""; }}
642
1260
  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
1261
  >
644
1262
  x
@@ -649,9 +1267,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
649
1267
  <input
650
1268
  ref={coverInputRef}
651
1269
  type="file"
652
- accept="image/webp,image/jpeg,image/png"
1270
+ accept="image/webp,image/jpeg"
653
1271
  onChange={handleCoverSelect}
654
1272
  className="text-xs"
1273
+ data-testid="cover-input"
655
1274
  />
656
1275
  <span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
657
1276
  </div>
@@ -705,8 +1324,73 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
705
1324
  </div>
706
1325
  ) : (
707
1326
  <div className="flex flex-col gap-2">
1327
+ {/* Creator-facing 6-step production checklist so a first-time user
1328
+ can see which step is current/next without internal jargon
1329
+ (#320, expanded to per-cut granularity in #335). */}
1330
+ {/* Compact cartoon production status (#420): one scannable line of
1331
+ cut/clean/lettered/uploaded tallies, with a link to the full
1332
+ story progress overview (#418). The detailed 6-step guide stays
1333
+ below. */}
1334
+ {isCartoonPlot && cartoonCutProgress && cartoonCutProgress.total > 0 && (
1335
+ <div className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted" data-testid="cartoon-status-summary">
1336
+ <span>Cuts: <span className="text-foreground font-medium">{cartoonCutProgress.total}</span></span>
1337
+ <span>Clean: <span className="text-foreground font-medium">{cartoonCutProgress.withClean}/{cartoonCutProgress.needClean}</span></span>
1338
+ <span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.total}</span></span>
1339
+ <span>Uploaded: <span className="text-foreground font-medium">{cartoonCutProgress.uploaded}/{cartoonCutProgress.total}</span></span>
1340
+ {onViewProgress && (
1341
+ <button onClick={onViewProgress} className="ml-auto text-accent hover:underline" data-testid="status-view-progress">
1342
+ View progress →
1343
+ </button>
1344
+ )}
1345
+ </div>
1346
+ )}
1347
+ {/* #461: the 6-step production checklist is a publish/production
1348
+ checklist — it now lives on the Publish tab + the cut workspace's
1349
+ FinishEpisodePanel, so it no longer renders under the episode. */}
1350
+ {/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
1351
+ genesis.cuts.json so a writer sees its real cut/image state in the
1352
+ readiness UI instead of treating Genesis as text-only. */}
1353
+ {isCartoonGenesis && genesisCutProgress && (
1354
+ <div className="text-xs text-muted" data-testid="genesis-cuts-summary">
1355
+ {/* Distinguish clean art / lettering / final-export / upload so the
1356
+ state never collapses to just "uploaded" (#451). */}
1357
+ Episode 1 (Genesis) cuts: {genesisCutProgress.total} planned
1358
+ {genesisCutProgress.total > 0 && (
1359
+ <>
1360
+ {" "}· {genesisCutProgress.withClean} clean
1361
+ {" "}· {genesisCutProgress.withText} lettered
1362
+ {" "}· {genesisCutProgress.exported} exported
1363
+ {" "}· {genesisCutProgress.uploaded} uploaded
1364
+ </>
1365
+ )}
1366
+ </div>
1367
+ )}
1368
+ {/* State-aware guidance for a not-yet-produced Genesis or a future-
1369
+ episode placeholder (#422): plan cuts / generate clean images /
1370
+ expand the cut plan — never a misleading "ready to publish". */}
1371
+ {(isCartoonGenesis || isCartoonPlot) && footerGuidance && (
1372
+ <div
1373
+ className={`${cartoonStatusCardClass} flex flex-col gap-1 border-border bg-surface/50`}
1374
+ data-testid="cartoon-not-started"
1375
+ >
1376
+ <div className="flex items-center gap-2">
1377
+ <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted">
1378
+ {episodeCutCount === 0 ? "Not started" : "Next step"}
1379
+ </span>
1380
+ <span className="text-xs font-medium text-foreground">
1381
+ {isCartoonGenesis ? "Genesis (Episode 1)" : "Future episode"}
1382
+ </span>
1383
+ </div>
1384
+ <span className="text-xs text-muted">{footerGuidance}</span>
1385
+ </div>
1386
+ )}
1387
+ {/* #461: the planning-stage "Prepare episode for publish" callout and
1388
+ the awaiting-upload pending callout are publish-readiness states —
1389
+ they now live on the Publish tab. The cartoon episode keeps only
1390
+ production next-step guidance (cartoon-not-started / cut summaries)
1391
+ plus the compact "Review publish checklist" CTA below. */}
708
1392
  {/* Inline illustration upload for plot files (Preview tab only) */}
709
- {isPlot && activeTab === "preview" && (
1393
+ {isPlot && !isCartoonPlot && activeTab === "preview" && (
710
1394
  <div>
711
1395
  <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
712
1396
  <input
@@ -769,23 +1453,142 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
769
1453
  )}
770
1454
  </div>
771
1455
  )}
1456
+ {/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
1457
+ gets a cover before its first createStoryline. Reuses the same
1458
+ validation/stale-clear as the published Edit Story panel; the
1459
+ selected file is handed to the publish flow, which uploads it and
1460
+ sets it on the storyline once it exists.
1461
+ #450: hidden in the genesis CUT WORKSPACE (Cuts sub-mode) so the
1462
+ cut/lettering editor gets the height — the cover stays available
1463
+ in the Opening-text/Preview view, Story Info, and the Publish page,
1464
+ and the auto-detect effect still loads it for publish. */}
1465
+ {isGenesis && contentType !== "cartoon" && !(activeTab === "edit" && genesisEditMode === "cuts") && (
1466
+ <div className="flex flex-col gap-1.5" data-testid="prepublish-cover">
1467
+ <span className="text-xs font-medium text-foreground">Cover Image <span className="text-muted font-normal">(optional)</span></span>
1468
+ {/* Cartoon cover readiness + requirements (#337): keep the cover
1469
+ step visible before genesis publish so a pilot story isn't
1470
+ published coverless by accident. */}
1471
+ {renderCoverStatus(false)}
1472
+ <div className="flex items-start gap-3">
1473
+ {coverPreview && (
1474
+ <div className="relative">
1475
+ <img
1476
+ src={coverPreview}
1477
+ alt="Cover preview"
1478
+ className="w-16 h-24 object-cover rounded border border-border"
1479
+ />
1480
+ <button
1481
+ 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 = ""; }}
1482
+ 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"
1483
+ >
1484
+ x
1485
+ </button>
1486
+ </div>
1487
+ )}
1488
+ <div className="flex flex-col gap-1">
1489
+ <input
1490
+ ref={coverInputRef}
1491
+ type="file"
1492
+ accept="image/webp,image/jpeg"
1493
+ onChange={handleCoverSelect}
1494
+ className="text-xs"
1495
+ data-testid="prepublish-cover-input"
1496
+ />
1497
+ <span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
1498
+ {/* Codex-image import (#301): convert a generated PNG (or any
1499
+ large image) to a compliant cover in-browser, save it as
1500
+ assets/cover.webp, and load it as the selected cover —
1501
+ no agent-side shell image tools. */}
1502
+ <input
1503
+ ref={coverImportInputRef}
1504
+ type="file"
1505
+ accept="image/png,image/webp,image/jpeg"
1506
+ onChange={handleCoverImport}
1507
+ className="hidden"
1508
+ data-testid="prepublish-cover-import-input"
1509
+ />
1510
+ <button
1511
+ type="button"
1512
+ onClick={() => coverImportInputRef.current?.click()}
1513
+ disabled={coverImporting}
1514
+ className="self-start px-2 py-1 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
1515
+ data-testid="prepublish-cover-import"
1516
+ >
1517
+ {coverImporting ? "Importing…" : "Import generated image (PNG ok)"}
1518
+ </button>
1519
+ {/* #312: make the generated-cover → PlotLink-cover connection
1520
+ explicit. Whenever a cover is selected (auto-detected,
1521
+ imported, or manually picked) it WILL be uploaded as the
1522
+ storyline cover at publish; an invalid or missing generated
1523
+ cover gets a clear action. */}
1524
+ {coverFile && (
1525
+ <span className="text-green-700 text-xs" data-testid="prepublish-cover-will-upload">
1526
+ This cover will be uploaded as the PlotLink storyline cover when you publish.
1527
+ </span>
1528
+ )}
1529
+ {detectedCover && (
1530
+ <span className="text-accent text-xs" data-testid="prepublish-cover-detected">
1531
+ Auto-detected generated cover {detectedCover} — pick a file to override.
1532
+ </span>
1533
+ )}
1534
+ {detectedCoverWarning && (
1535
+ <span className="text-amber-700 text-xs" data-testid="prepublish-cover-detected-warning">
1536
+ {detectedCoverWarning} Use &ldquo;Import generated image&rdquo; below to convert/compress it, or pick a file.
1537
+ </span>
1538
+ )}
1539
+ {contentType === "cartoon" && coverStatus === "none" && !coverFile && (
1540
+ <span className="text-muted text-xs" data-testid="prepublish-cover-none">
1541
+ 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.
1542
+ </span>
1543
+ )}
1544
+ {editError && <span className="text-error text-xs" data-testid="prepublish-cover-error">{editError}</span>}
1545
+ </div>
1546
+ </div>
1547
+ </div>
1548
+ )}
1549
+ {/* Public title shown + validated before publish (#358). #461: moved
1550
+ to the Publish tab for cartoon — fiction keeps it inline. */}
1551
+ {!isCartoonEpisode && renderPublishTitle()}
1552
+ {/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
1553
+ the Publish tab; never rendered in the cartoon episode view. */}
1554
+ {!isCartoonEpisode && renderGenesisReadiness()}
1555
+ {/* #461: the genre/language selects + publish button + publish-disabled
1556
+ reasons are publish controls — for cartoon they live on the Publish
1557
+ tab. Fiction keeps the inline publish controls unchanged. */}
1558
+ {!isCartoonEpisode && (
772
1559
  <div className="flex items-center gap-2">
773
- {(isGenesis) && (
1560
+ {/* Genre/language are edited in Story Info for cartoon (#439/#450);
1561
+ the inline selects are fiction-only so the cartoon cut workspace
1562
+ isn't a metadata form. Cartoon publish still reads the persisted
1563
+ genre/language (seeded into these values from .story.json). */}
1564
+ {isGenesis && contentType !== "cartoon" && (
774
1565
  <>
775
1566
  <select
776
1567
  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"
1568
+ data-testid="publish-genre-select"
1569
+ onChange={(e) => {
1570
+ setSelectedGenre(e.target.value);
1571
+ if (e.target.value) persistPublishMeta({ genre: e.target.value });
1572
+ }}
1573
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedGenre ? "border-border" : "border-amber-500"}`}
779
1574
  >
1575
+ {/* Explicit unset state — no silent Romance default (#424). */}
1576
+ {!selectedGenre && <option value="" disabled>Needs metadata — select genre</option>}
780
1577
  {GENRES.map((g) => (
781
1578
  <option key={g} value={g}>{g}</option>
782
1579
  ))}
783
1580
  </select>
784
1581
  <select
785
1582
  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"
1583
+ data-testid="publish-language-select"
1584
+ onChange={(e) => {
1585
+ setSelectedLanguage(e.target.value);
1586
+ if (e.target.value) persistPublishMeta({ language: e.target.value });
1587
+ }}
1588
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
788
1589
  >
1590
+ {/* Explicit unset state — no silent English default (#424). */}
1591
+ {!selectedLanguage && <option value="" disabled>Needs metadata — select language</option>}
789
1592
  {LANGUAGES.map((l) => (
790
1593
  <option key={l} value={l}>{l}</option>
791
1594
  ))}
@@ -793,23 +1596,90 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
793
1596
  </>
794
1597
  )}
795
1598
  <button
796
- onClick={() => {
1599
+ onClick={async () => {
797
1600
  if (!storyName || !fileName) return;
798
1601
  if (imageValidation.count > 0) {
799
1602
  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
1603
  if (!window.confirm(msg)) return;
801
1604
  }
802
- onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1605
+ // Genesis carries the optional pre-publish cover (#284); plot
1606
+ // files never do. Only pass the 6th arg when a cover is
1607
+ // actually selected, so the no-cover call signature (and
1608
+ // existing fiction/plot publish behavior) is unchanged.
1609
+ // The cover may be a manual pick OR an auto-detected
1610
+ // assets/cover.webp loaded into coverFile (#296) — both flow
1611
+ // through the same attach path.
1612
+ const cover = isGenesis ? coverFile : null;
1613
+ if (cover) {
1614
+ // Drop the local cover selection ONLY on a confirmed-successful
1615
+ // publish (onPublish resolves truthy). A publish blocked before
1616
+ // the stream (#375) or one that opens then fails before `done`
1617
+ // (#376) resolves falsy, so the writer's selected/auto-detected
1618
+ // cover stays put for the retry.
1619
+ const published = await onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw, cover);
1620
+ if (published) {
1621
+ coverUserTouchedRef.current = true;
1622
+ setDetectedCover(null);
1623
+ setDetectedCoverWarning(null);
1624
+ setCoverStatus("unknown");
1625
+ setCoverFile(null);
1626
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
1627
+ if (coverInputRef.current) coverInputRef.current.value = "";
1628
+ }
1629
+ } else {
1630
+ onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1631
+ }
803
1632
  }}
804
- disabled={!!publishingFile || overLimit}
1633
+ disabled={!!publishingFile || overLimit || titleBlocked || genesisBlocked || (isGenesis && (!selectedGenre || !selectedLanguage)) || (isCartoonPlot && cartoonStage !== "ready")}
805
1634
  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
1635
  >
807
1636
  {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
808
1637
  </button>
1638
+ {/* Cartoon edits these in Story Info, so point there instead of the
1639
+ hidden inline selects (#450); fiction keeps the inline guidance. */}
1640
+ {isGenesis && contentType === "cartoon" && (!selectedGenre || !selectedLanguage) && (
1641
+ <span className="text-amber-600 text-xs" data-testid="cartoon-metadata-needs-story-info">
1642
+ Set the genre and language in Story Info before publishing
1643
+ </span>
1644
+ )}
1645
+ {isGenesis && contentType !== "cartoon" && !selectedGenre && (
1646
+ <span className="text-amber-600 text-xs" data-testid="genre-needs-metadata">
1647
+ Needs metadata — choose a genre before publishing
1648
+ </span>
1649
+ )}
1650
+ {isGenesis && contentType !== "cartoon" && selectedGenre && !selectedLanguage && (
1651
+ <span className="text-amber-600 text-xs" data-testid="language-needs-metadata">
1652
+ Needs metadata — choose a language before publishing
1653
+ </span>
1654
+ )}
809
1655
  {overLimit && (
810
1656
  <span className="text-error text-xs">Reduce content to publish</span>
811
1657
  )}
1658
+ {isCartoonPlot && cartoonStage === "error" && (
1659
+ <span className="text-error text-xs" data-testid="publish-disabled-reason">Fix the issues below before publishing</span>
1660
+ )}
1661
+ {isCartoonPlot && cartoonStage === "planning" && (
1662
+ <span className="text-muted text-xs" data-testid="publish-disabled-reason">Prepare the episode for publish to continue</span>
1663
+ )}
1664
+ {isCartoonPlot && cartoonStage === "awaiting-upload" && (
1665
+ <span className="text-muted text-xs" data-testid="publish-disabled-reason">
1666
+ Upload all final images, then “Prepare episode for publish” — {cartoonAwaitingCount} of {cartoonTotalCuts} still need an uploaded image
1667
+ </span>
1668
+ )}
812
1669
  </div>
1670
+ )}
1671
+ {/* #461: the grouped publish-readiness issues card (#360/#421) is a
1672
+ publish checklist — it now renders on the Publish tab. The cartoon
1673
+ episode view links there via the compact CTA below. */}
1674
+ {isCartoonEpisode && (
1675
+ <button
1676
+ onClick={() => onViewPublish?.()}
1677
+ data-testid="cartoon-review-publish"
1678
+ className="self-start rounded border border-accent/40 px-3 py-1 text-xs text-accent hover:bg-accent/5 transition-colors"
1679
+ >
1680
+ Review publish checklist →
1681
+ </button>
1682
+ )}
813
1683
  {imageValidation.warnings.length > 0 && (
814
1684
  <div className="flex flex-col gap-0.5">
815
1685
  {imageValidation.warnings.map((w, i) => (
@@ -817,13 +1687,17 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
817
1687
  ))}
818
1688
  </div>
819
1689
  )}
820
- {(isGenesis) && (
1690
+ {/* Adult-content flag is edited in Story Info for cartoon (#450). */}
1691
+ {isGenesis && contentType !== "cartoon" && (
821
1692
  <div className="flex items-center gap-2">
822
1693
  <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
823
1694
  <input
824
1695
  type="checkbox"
825
1696
  checked={isNsfw}
826
- onChange={(e) => setIsNsfw(e.target.checked)}
1697
+ onChange={(e) => {
1698
+ setIsNsfw(e.target.checked);
1699
+ persistPublishMeta({ isNsfw: e.target.checked });
1700
+ }}
827
1701
  className="rounded border-border"
828
1702
  />
829
1703
  This story contains adult content (18+)