plotlink-ows 1.2.95 → 1.2.97

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.
@@ -4,13 +4,26 @@ import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6
6
  import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
7
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
7
8
  import { CartoonPreview } from "./CartoonPreview";
8
9
  import { CartoonPublishPreview } from "./CartoonPublishPreview";
9
10
  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";
11
+ import {
12
+ classifyCartoonReadiness,
13
+ cartoonGenesisReadiness,
14
+ summarizeCutProgress,
15
+ previewFooterGuidance,
16
+ type CartoonReadinessStage as CartoonStage,
17
+ type CartoonCutProgress,
18
+ } from "@app-lib/cartoon-readiness";
19
+ import {
20
+ validateCoverImage,
21
+ cartoonCoverReadiness,
22
+ COVER_GUIDANCE,
23
+ derivePublishTitle,
24
+ isRawFilenameTitle,
25
+ hasExplicitEpisodeTitle,
26
+ } from "../lib/publish-helpers";
14
27
  import { importImageToCompliantBlob } from "../lib/import-image";
15
28
 
16
29
  /** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title */
@@ -25,7 +38,9 @@ const sanitizeSchema = {
25
38
  const IPFS_GATEWAY = "https://ipfs.filebase.io/ipfs/";
26
39
 
27
40
  /** Find all markdown image references in content */
28
- function findImageRefs(text: string): Array<{ full: string; alt: string; url: string }> {
41
+ function findImageRefs(
42
+ text: string,
43
+ ): Array<{ full: string; alt: string; url: string }> {
29
44
  const results: Array<{ full: string; alt: string; url: string }> = [];
30
45
  const re = /!\[([^\]]*)\]\(([^)]+)\)/g;
31
46
  let m;
@@ -36,18 +51,25 @@ function findImageRefs(text: string): Array<{ full: string; alt: string; url: st
36
51
  }
37
52
 
38
53
  /** Validate image references for publishing */
39
- function validateImageRefs(text: string): { count: number; warnings: string[] } {
54
+ function validateImageRefs(text: string): {
55
+ count: number;
56
+ warnings: string[];
57
+ } {
40
58
  const refs = findImageRefs(text);
41
59
  const warnings: string[] = [];
42
60
  for (const ref of refs) {
43
61
  if (!ref.url.startsWith(IPFS_GATEWAY)) {
44
- warnings.push(`Non-IPFS image URL: ${ref.url.length > 60 ? ref.url.slice(0, 60) + "..." : ref.url}`);
62
+ warnings.push(
63
+ `Non-IPFS image URL: ${ref.url.length > 60 ? ref.url.slice(0, 60) + "..." : ref.url}`,
64
+ );
45
65
  }
46
66
  }
47
67
  // Check for malformed image markdown (missing closing bracket/paren)
48
68
  const malformed = text.match(/!\[[^\]]*\]\([^)]*$|!\[[^\]]*$(?!\])/gm);
49
69
  if (malformed) {
50
- warnings.push("Malformed image markdown detected — check brackets and parentheses");
70
+ warnings.push(
71
+ "Malformed image markdown detected — check brackets and parentheses",
72
+ );
51
73
  }
52
74
  return { count: refs.length, warnings };
53
75
  }
@@ -59,7 +81,14 @@ interface PreviewPanelProps {
59
81
  // Resolves to true only on a confirmed-successful publish (so a selected cover
60
82
  // may be dropped); false/void when blocked before the stream (#375) or when the
61
83
  // 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>;
84
+ onPublish?: (
85
+ storyName: string,
86
+ fileName: string,
87
+ genre: string,
88
+ language: string,
89
+ isNsfw: boolean,
90
+ coverFile?: File | null,
91
+ ) => void | Promise<boolean | void>;
63
92
  publishingFile?: string | null;
64
93
  walletAddress?: string | null;
65
94
  contentType?: "fiction" | "cartoon";
@@ -82,6 +111,19 @@ interface PreviewPanelProps {
82
111
  // issue diagnostics + the publish action now live. Cartoon episode views show a
83
112
  // single compact CTA that calls this instead of hosting publish controls.
84
113
  onViewPublish?: () => void;
114
+ /** Whether the right panel is currently in focused cartoon lettering mode. */
115
+ focusedLetteringMode?: boolean;
116
+ /** Whether the wider app work area is restored while editing. */
117
+ focusedLetteringWorkspaceVisible?: boolean;
118
+ /** Enter/leave focused cartoon lettering mode. */
119
+ onFocusedLetteringModeChange?: (active: boolean) => void;
120
+ /** Restore/fold the wider app work area while staying in the editor. */
121
+ onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
122
+ workflowActionRequest?: {
123
+ action: CoachUiAction;
124
+ seq: number;
125
+ } | null;
126
+ onWorkflowActionHandled?: (seq: number) => void;
85
127
  }
86
128
 
87
129
  interface FileData {
@@ -98,26 +140,62 @@ interface FileData {
98
140
 
99
141
  type Tab = "preview" | "edit";
100
142
 
101
- export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publishingFile, walletAddress, contentType = "fiction", language, genre: genreMeta, isNsfw: nsfwMeta, hasGenesis = false, onViewProgress, onOpenFile, onViewPublish }: PreviewPanelProps) {
143
+ function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): boolean {
144
+ return action === "open-cuts"
145
+ || action === "open-lettering"
146
+ || action === "upload"
147
+ || action === "refresh-assets";
148
+ }
149
+
150
+ export function PreviewPanel({
151
+ storyName,
152
+ fileName,
153
+ authFetch,
154
+ onPublish,
155
+ publishingFile,
156
+ walletAddress,
157
+ contentType = "fiction",
158
+ language,
159
+ genre: genreMeta,
160
+ isNsfw: nsfwMeta,
161
+ hasGenesis = false,
162
+ onViewProgress,
163
+ onOpenFile,
164
+ onViewPublish,
165
+ focusedLetteringMode = false,
166
+ focusedLetteringWorkspaceVisible = false,
167
+ onFocusedLetteringModeChange,
168
+ onFocusedLetteringWorkspaceVisibleChange,
169
+ workflowActionRequest = null,
170
+ onWorkflowActionHandled,
171
+ }: PreviewPanelProps) {
102
172
  const [fileData, setFileData] = useState<FileData | null>(null);
103
173
  const [loading, setLoading] = useState(false);
104
174
  const [activeTab, setActiveTab] = useState<Tab>("preview");
105
175
  // Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
106
176
  // "inspect" = cuts.json planning inspector. Kept distinct so planning prose
107
177
  // does not masquerade as publish content (#289).
108
- const [cartoonPreviewMode, setCartoonPreviewMode] = useState<"publish" | "inspect">("publish");
178
+ const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
179
+ "publish" | "inspect"
180
+ >("publish");
109
181
  // Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
110
182
  // cuts), so its Edit tab offers two sub-views: the opening-text editor and the
111
183
  // cut workspace (#429). Plots use the cut workspace directly; fiction never sees
112
184
  // this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
113
185
  // coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
114
186
  // real, actionable workspace instead of the markdown editor.
115
- const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">("text");
187
+ const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
188
+ "text",
189
+ );
116
190
  // #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
117
191
  // tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
118
192
  // re-trigger the focus/expand effect in CutListPanel; it is cleared once
119
193
  // 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);
194
+ const [cutFocus, setCutFocus] = useState<{
195
+ cutId: number;
196
+ openEditor: boolean;
197
+ seq: number;
198
+ } | null>(null);
121
199
  const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
122
200
  setActiveTab("edit");
123
201
  setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
@@ -141,12 +219,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
141
219
  const [cartoonTotalCuts, setCartoonTotalCuts] = useState(0);
142
220
  // Per-cut production tallies (clean/lettered/exported/uploaded) for the compact
143
221
  // cartoon status summary in the bottom panel (#420).
144
- const [cartoonCutProgress, setCartoonCutProgress] = useState<CartoonCutProgress | null>(null);
222
+ const [cartoonCutProgress, setCartoonCutProgress] =
223
+ useState<CartoonCutProgress | null>(null);
145
224
  // Inputs for resolving + showing the public publish title before publish (#358):
146
225
  // the story structure.md content (genesis story title) and the cut plan's
147
226
  // episode title (cartoon plot).
148
227
  const [structureContent, setStructureContent] = useState<string | null>(null);
149
- const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(null);
228
+ const [cartoonEpisodeTitle, setCartoonEpisodeTitle] = useState<string | null>(
229
+ null,
230
+ );
150
231
  // Bumped whenever the embedded cut editor mutates the cut plan (export/upload/
151
232
  // save), so the readiness effect re-fetches and the Episode-steps panel stays
152
233
  // in sync with the cut cards after a lettering export (#343).
@@ -175,11 +256,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
175
256
  // detectedCover = the path actually loaded into the cover selection (status
176
257
  // label); detectedCoverWarning = an invalid/oversize detected asset we won't use.
177
258
  const [detectedCover, setDetectedCover] = useState<string | null>(null);
178
- const [detectedCoverWarning, setDetectedCoverWarning] = useState<string | null>(null);
259
+ const [detectedCoverWarning, setDetectedCoverWarning] = useState<
260
+ string | null
261
+ >(null);
179
262
  // Outcome of the generated-cover detection for an unpublished genesis (#312),
180
263
  // so the publish flow can state explicitly whether a generated assets/cover.webp
181
264
  // will be uploaded as the PlotLink cover, is invalid, or is missing.
182
- const [coverStatus, setCoverStatus] = useState<"unknown" | "detected" | "selected" | "invalid" | "none">("unknown");
265
+ const [coverStatus, setCoverStatus] = useState<
266
+ "unknown" | "detected" | "selected" | "invalid" | "none"
267
+ >("unknown");
183
268
  // Once the writer manually picks or removes a cover, stop auto-applying the
184
269
  // detected one (so removal/override sticks and detection doesn't loop).
185
270
  const coverUserTouchedRef = useRef(false);
@@ -187,15 +272,27 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
187
272
  // Inline illustration state
188
273
  const [showIllustrations, setShowIllustrations] = useState(false);
189
274
  const [illustrationUploading, setIllustrationUploading] = useState(false);
190
- const [illustrationError, setIllustrationError] = useState<string | null>(null);
191
- const [uploadedImages, setUploadedImages] = useState<Array<{ cid: string; url: string }>>([]);
275
+ const [illustrationError, setIllustrationError] = useState<string | null>(
276
+ null,
277
+ );
278
+ const [uploadedImages, setUploadedImages] = useState<
279
+ Array<{ cid: string; url: string }>
280
+ >([]);
192
281
  const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
193
282
  const illustrationInputRef = useRef<HTMLInputElement>(null);
194
283
 
195
284
  const prevFileRef = useRef<string | null>(null);
285
+ const appliedWorkflowSeqRef = useRef(0);
286
+ const pendingWorkflowCutsRef = useRef(false);
287
+ pendingWorkflowCutsRef.current = workflowActionNeedsCuts(
288
+ workflowActionRequest?.action,
289
+ );
196
290
 
197
291
  const loadFile = useCallback(async () => {
198
- if (!storyName || !fileName) { setFileData(null); return; }
292
+ if (!storyName || !fileName) {
293
+ setFileData(null);
294
+ return;
295
+ }
199
296
  const fileKey = `${storyName}/${fileName}`;
200
297
  const isNewFile = prevFileRef.current !== fileKey;
201
298
  if (isNewFile) {
@@ -209,10 +306,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
209
306
  // Update edit content on new file or when no unsaved changes
210
307
  if (isNewFile || !dirtyRef.current) {
211
308
  setEditContent(data.content ?? "");
212
- if (isNewFile) { setDirty(false); dirtyRef.current = false; }
309
+ if (isNewFile) {
310
+ setDirty(false);
311
+ dirtyRef.current = false;
312
+ }
213
313
  }
214
314
  }
215
- } catch { /* ignore */ }
315
+ } catch {
316
+ /* ignore */
317
+ }
216
318
  }, [storyName, fileName, authFetch]);
217
319
 
218
320
  // Initial load
@@ -232,20 +334,35 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
232
334
  // Genesis-as-Episode-1 cut progress (#422): discover + summarize
233
335
  // genesis.cuts.json so the Genesis view reflects its real cut/image state and
234
336
  // 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";
337
+ const [genesisCutProgress, setGenesisCutProgress] =
338
+ useState<CartoonCutProgress | null>(null);
339
+ const cartoonGenesisForCuts =
340
+ contentType === "cartoon" && fileName === "genesis.md";
237
341
  useEffect(() => {
238
- if (!cartoonGenesisForCuts || !storyName) { setGenesisCutProgress(null); return; }
342
+ if (!cartoonGenesisForCuts || !storyName) {
343
+ setGenesisCutProgress(null);
344
+ return;
345
+ }
239
346
  let cancelled = false;
240
347
  authFetch(`/api/stories/${storyName}/cuts/genesis`)
241
348
  .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; };
349
+ .then((data) => {
350
+ if (!cancelled)
351
+ setGenesisCutProgress(
352
+ data ? summarizeCutProgress(data.cuts || []) : null,
353
+ );
354
+ })
355
+ .catch(() => {
356
+ if (!cancelled) setGenesisCutProgress(null);
357
+ });
358
+ return () => {
359
+ cancelled = true;
360
+ };
245
361
  }, [cartoonGenesisForCuts, storyName, authFetch]);
246
362
 
247
363
  // Compute cartoon publish readiness for cartoon plot files
248
- const cartoonPlotForReadiness = contentType === "cartoon" && !!fileName && /^plot-\d+\.md$/.test(fileName);
364
+ const cartoonPlotForReadiness =
365
+ contentType === "cartoon" && !!fileName && /^plot-\d+\.md$/.test(fileName);
249
366
  useEffect(() => {
250
367
  if (!cartoonPlotForReadiness || !storyName || !fileName) {
251
368
  setCartoonStage(null);
@@ -275,7 +392,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
275
392
  }
276
393
  const cutsData = await cutsRes.json();
277
394
  const cuts = cutsData.cuts || [];
278
- const content = fileRes.ok ? (await fileRes.json()).content ?? "" : "";
395
+ const content = fileRes.ok
396
+ ? ((await fileRes.json()).content ?? "")
397
+ : "";
279
398
  const result = classifyCartoonReadiness(content, cuts);
280
399
  if (!cancelled) {
281
400
  setCartoonStage(result.stage);
@@ -283,7 +402,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
283
402
  setCartoonTotalCuts(result.totalCuts);
284
403
  setCartoonCutProgress(summarizeCutProgress(cuts));
285
404
  // Cut plan's episode title for the publish-title display (#358).
286
- setCartoonEpisodeTitle(typeof cutsData.title === "string" ? cutsData.title : null);
405
+ setCartoonEpisodeTitle(
406
+ typeof cutsData.title === "string" ? cutsData.title : null,
407
+ );
287
408
  }
288
409
  } catch {
289
410
  if (!cancelled) {
@@ -294,20 +415,37 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
294
415
  }
295
416
  }
296
417
  })();
297
- return () => { cancelled = true; };
298
- }, [cartoonPlotForReadiness, storyName, fileName, authFetch, fileData?.content, fileData?.status, cutsRefreshKey]);
418
+ return () => {
419
+ cancelled = true;
420
+ };
421
+ }, [
422
+ cartoonPlotForReadiness,
423
+ storyName,
424
+ fileName,
425
+ authFetch,
426
+ fileData?.content,
427
+ fileData?.status,
428
+ cutsRefreshKey,
429
+ ]);
299
430
 
300
431
  // Load structure.md once per story — used to resolve the public title before
301
432
  // publish (#358) and as a metadata fallback when .story.json lacks genre/
302
433
  // language (#424).
303
434
  useEffect(() => {
304
- if (!storyName) { setStructureContent(null); return; }
435
+ if (!storyName) {
436
+ setStructureContent(null);
437
+ return;
438
+ }
305
439
  let cancelled = false;
306
440
  authFetch(`/api/stories/${storyName}/structure.md`)
307
- .then((res) => res.ok ? res.json() : null)
308
- .then((data) => { if (!cancelled) setStructureContent(data?.content ?? null); })
441
+ .then((res) => (res.ok ? res.json() : null))
442
+ .then((data) => {
443
+ if (!cancelled) setStructureContent(data?.content ?? null);
444
+ })
309
445
  .catch(() => {});
310
- return () => { cancelled = true; };
446
+ return () => {
447
+ cancelled = true;
448
+ };
311
449
  }, [storyName, authFetch]);
312
450
 
313
451
  // Seed the publish metadata controls from the story's real values (#424).
@@ -324,16 +462,28 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
324
462
  const match = structureContent.match(/\*{0,2}genre\*{0,2}[:\s]+(.+)/i);
325
463
  // Canonicalize so a natural label like "Sci-Fi" preselects "Science
326
464
  // Fiction" instead of being silently dropped (#412).
327
- if (match) genreVal = canonicalizeGenre(match[1].replace(/\*+/g, "").trim()) ?? "";
465
+ if (match)
466
+ genreVal = canonicalizeGenre(match[1].replace(/\*+/g, "").trim()) ?? "";
328
467
  }
329
468
  setSelectedGenre(genreVal);
330
469
  // Language: the server-resolved story language (explicit .story.json or
331
470
  // script-detected), else structure.md, else unset ("Needs metadata" — no
332
471
  // misleading English default). `language` is undefined when undetermined.
333
- let langVal = (language && LANGUAGES.find((l) => l.toLowerCase() === language.toLowerCase())) || "";
472
+ let langVal =
473
+ (language &&
474
+ LANGUAGES.find((l) => l.toLowerCase() === language.toLowerCase())) ||
475
+ "";
334
476
  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()) || "";
477
+ const langMatch = structureContent.match(
478
+ /\*{0,2}language\*{0,2}[:\s]+(.+)/i,
479
+ );
480
+ if (langMatch)
481
+ langVal =
482
+ LANGUAGES.find(
483
+ (l) =>
484
+ l.toLowerCase() ===
485
+ langMatch[1].replace(/\*+/g, "").trim().toLowerCase(),
486
+ ) || "";
337
487
  }
338
488
  setSelectedLanguage(langVal);
339
489
  setIsNsfw(nsfwMeta ?? false);
@@ -342,14 +492,19 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
342
492
  // Persist a publish-control edit back to .story.json so it sticks across
343
493
  // refresh and the controls stay in sync with story metadata (#424). Best
344
494
  // 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]);
495
+ const persistPublishMeta = useCallback(
496
+ (patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
497
+ if (!storyName) return;
498
+ authFetch(`/api/stories/${storyName}/publish-metadata`, {
499
+ method: "POST",
500
+ headers: { "Content-Type": "application/json" },
501
+ body: JSON.stringify(patch),
502
+ }).catch(() => {
503
+ /* best-effort */
504
+ });
505
+ },
506
+ [storyName, authFetch],
507
+ );
353
508
 
354
509
  const handleSave = useCallback(async () => {
355
510
  if (!storyName || !fileName) return;
@@ -361,10 +516,15 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
361
516
  body: JSON.stringify({ content: editContent }),
362
517
  });
363
518
  if (res.ok) {
364
- setDirty(false); dirtyRef.current = false;
365
- setFileData((prev) => prev ? { ...prev, content: editContent } : prev);
519
+ setDirty(false);
520
+ dirtyRef.current = false;
521
+ setFileData((prev) =>
522
+ prev ? { ...prev, content: editContent } : prev,
523
+ );
366
524
  }
367
- } catch { /* ignore */ }
525
+ } catch {
526
+ /* ignore */
527
+ }
368
528
  setSaving(false);
369
529
  }, [storyName, fileName, authFetch, editContent]);
370
530
 
@@ -376,34 +536,35 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
376
536
  if (!storyName || !fileName) return;
377
537
  const plotFile = fileName.replace(/\.md$/, "");
378
538
  try {
379
- const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`, { method: "POST" });
539
+ const res = await authFetch(
540
+ `/api/stories/${storyName}/cuts/${plotFile}/generate-markdown`,
541
+ { method: "POST" },
542
+ );
380
543
  if (res.ok) {
381
544
  await loadFile();
382
545
  // Re-run readiness + reload the workflow coach so the next action moves
383
546
  // off "Prepare the episode for publish" once the layout is built (#429).
384
547
  setCutsRefreshKey((k) => k + 1);
385
548
  }
386
- } catch { /* ignore */ }
549
+ } catch {
550
+ /* ignore */
551
+ }
387
552
  }, [storyName, fileName, authFetch, loadFile]);
388
553
 
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) {
554
+ useEffect(() => {
555
+ if (!workflowActionRequest) return;
556
+ if (workflowActionRequest.seq === appliedWorkflowSeqRef.current) return;
557
+ appliedWorkflowSeqRef.current = workflowActionRequest.seq;
558
+
559
+ switch (workflowActionRequest.action) {
560
+ case "view-progress":
561
+ onViewProgress?.();
562
+ break;
399
563
  case "open-cuts":
400
564
  case "open-lettering":
401
565
  case "upload":
402
566
  case "refresh-assets":
403
567
  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
568
  setGenesisEditMode("cuts");
408
569
  break;
409
570
  case "generate-markdown":
@@ -413,38 +574,48 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
413
574
  setActiveTab("preview");
414
575
  break;
415
576
  }
416
- }, [fileName, onViewProgress, onOpenFile, handleGenerateMarkdown]);
577
+ onWorkflowActionHandled?.(workflowActionRequest.seq);
578
+ }, [workflowActionRequest, onViewProgress, handleGenerateMarkdown, onWorkflowActionHandled]);
417
579
 
418
580
  // Handle cover image selection
419
- const handleCoverSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
420
- const file = e.target.files?.[0];
421
- if (!file) return;
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");
441
- return;
442
- }
443
- setCoverFile(file);
444
- setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
445
- setEditError(null);
446
- setCoverStatus("selected");
447
- }, []);
581
+ const handleCoverSelect = useCallback(
582
+ (e: React.ChangeEvent<HTMLInputElement>) => {
583
+ const file = e.target.files?.[0];
584
+ if (!file) return;
585
+ // A manual pick overrides any auto-detected cover and stops re-detection.
586
+ coverUserTouchedRef.current = true;
587
+ setDetectedCover(null);
588
+ setDetectedCoverWarning(null);
589
+ // Reject oversized / non-WebP-JPEG covers at selection so the writer gets
590
+ // immediate feedback instead of a late error at save (the server enforces
591
+ // the same WebP/JPEG ≤1MB constraint).
592
+ const error = validateCoverImage(file);
593
+ if (error) {
594
+ // Discard any previously-queued valid cover and clear the input, so an
595
+ // invalid re-selection can't leave a stale cover that Save would still
596
+ // upload contrary to the user's latest choice (#281 follow-up).
597
+ setCoverFile(null);
598
+ setCoverPreview((prev) => {
599
+ if (prev) URL.revokeObjectURL(prev);
600
+ return null;
601
+ });
602
+ if (coverInputRef.current) coverInputRef.current.value = "";
603
+ setEditError(error);
604
+ // Surface the rejected pick in the cartoon cover-status badge (#337), not
605
+ // just the inline error, so the cover step clearly reads "can't be used".
606
+ setCoverStatus("invalid");
607
+ return;
608
+ }
609
+ setCoverFile(file);
610
+ setCoverPreview((prev) => {
611
+ if (prev) URL.revokeObjectURL(prev);
612
+ return URL.createObjectURL(file);
613
+ });
614
+ setEditError(null);
615
+ setCoverStatus("selected");
616
+ },
617
+ [],
618
+ );
448
619
 
449
620
  // Import a Codex-generated image (e.g. a large PNG) as the cover (#301). The
450
621
  // browser converts/compresses it to a compliant WebP/JPEG <=1MB, then OWS
@@ -452,88 +623,111 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
452
623
  // import-cover and loads it into the same coverFile the manual picker uses, so
453
624
  // the existing publish flow attaches it with no special casing. A source that
454
625
  // 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;
626
+ const handleCoverImport = useCallback(
627
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
628
+ const file = e.target.files?.[0];
629
+ if (coverImportInputRef.current) coverImportInputRef.current.value = "";
630
+ if (!file || !storyName) return;
631
+ // A deliberate import overrides any auto-detected cover, like a manual pick.
632
+ coverUserTouchedRef.current = true;
633
+ setDetectedCover(null);
634
+ setCoverImporting(true);
635
+ setEditError(null);
466
636
  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");
637
+ let blob: Blob;
638
+ try {
639
+ blob = await importImageToCompliantBlob(file);
640
+ } catch (err) {
641
+ setCoverFile(null);
642
+ setCoverPreview((prev) => {
643
+ if (prev) URL.revokeObjectURL(prev);
644
+ return null;
645
+ });
646
+ setEditError(
647
+ err instanceof Error ? err.message : "Could not import image",
648
+ );
649
+ return;
650
+ }
651
+ const ext = blob.type === "image/jpeg" ? "jpg" : "webp";
652
+ const imported = new File([blob], `cover.${ext}`, { type: blob.type });
653
+ const formData = new FormData();
654
+ formData.append("file", imported);
655
+ const res = await authFetch(`/api/stories/${storyName}/import-cover`, {
656
+ method: "POST",
657
+ body: formData,
658
+ });
659
+ if (!res.ok) {
660
+ const data = await res.json().catch(() => ({}));
661
+ setEditError(data.error || "Cover import failed");
662
+ return;
663
+ }
664
+ setCoverFile(imported);
665
+ setCoverPreview((prev) => {
666
+ if (prev) URL.revokeObjectURL(prev);
667
+ return URL.createObjectURL(imported);
668
+ });
669
+ setDetectedCoverWarning(null);
670
+ setCoverStatus("selected");
671
+ setEditError(null);
672
+ } catch {
673
+ setEditError("Cover import failed");
674
+ } finally {
675
+ setCoverImporting(false);
676
+ }
677
+ },
678
+ [storyName, authFetch],
679
+ );
680
+
681
+ // Handle illustration image upload from File object
682
+ const uploadIllustration = useCallback(
683
+ async (file: File) => {
684
+ if (file.size > 1024 * 1024) {
685
+ setIllustrationError("Image exceeds 1MB limit");
472
686
  return;
473
687
  }
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");
688
+ const allowedTypes = ["image/webp", "image/jpeg"];
689
+ if (!allowedTypes.includes(file.type)) {
690
+ setIllustrationError("Only WebP and JPEG images are accepted");
485
691
  return;
486
692
  }
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
-
499
- // Handle illustration image upload from File object
500
- const uploadIllustration = useCallback(async (file: File) => {
501
- if (file.size > 1024 * 1024) {
502
- setIllustrationError("Image exceeds 1MB limit");
503
- return;
504
- }
505
- const allowedTypes = ["image/webp", "image/jpeg"];
506
- if (!allowedTypes.includes(file.type)) {
507
- setIllustrationError("Only WebP and JPEG images are accepted");
508
- return;
509
- }
510
- setIllustrationUploading(true);
511
- setIllustrationError(null);
512
- try {
513
- const formData = new FormData();
514
- formData.append("file", file);
515
- const res = await authFetch("/api/publish/upload-plot-image", {
516
- method: "POST",
517
- body: formData,
518
- });
519
- if (!res.ok) {
520
- const err = await res.json();
521
- throw new Error(err.error || "Upload failed");
693
+ setIllustrationUploading(true);
694
+ setIllustrationError(null);
695
+ try {
696
+ const formData = new FormData();
697
+ formData.append("file", file);
698
+ const res = await authFetch("/api/publish/upload-plot-image", {
699
+ method: "POST",
700
+ body: formData,
701
+ });
702
+ if (!res.ok) {
703
+ const err = await res.json();
704
+ throw new Error(err.error || "Upload failed");
705
+ }
706
+ const data = await res.json();
707
+ setUploadedImages((prev) => [
708
+ ...prev,
709
+ { cid: data.cid, url: data.url },
710
+ ]);
711
+ } catch (err) {
712
+ setIllustrationError(
713
+ err instanceof Error ? err.message : "Upload failed",
714
+ );
715
+ } finally {
716
+ setIllustrationUploading(false);
717
+ if (illustrationInputRef.current)
718
+ illustrationInputRef.current.value = "";
522
719
  }
523
- const data = await res.json();
524
- setUploadedImages((prev) => [...prev, { cid: data.cid, url: data.url }]);
525
- } catch (err) {
526
- setIllustrationError(err instanceof Error ? err.message : "Upload failed");
527
- } finally {
528
- setIllustrationUploading(false);
529
- if (illustrationInputRef.current) illustrationInputRef.current.value = "";
530
- }
531
- }, [authFetch]);
720
+ },
721
+ [authFetch],
722
+ );
532
723
 
533
- const handleIllustrationInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
534
- const file = e.target.files?.[0];
535
- if (file) uploadIllustration(file);
536
- }, [uploadIllustration]);
724
+ const handleIllustrationInput = useCallback(
725
+ (e: React.ChangeEvent<HTMLInputElement>) => {
726
+ const file = e.target.files?.[0];
727
+ if (file) uploadIllustration(file);
728
+ },
729
+ [uploadIllustration],
730
+ );
537
731
 
538
732
  // Save storyline edits (cover upload + metadata update)
539
733
  const handleEditSave = useCallback(async () => {
@@ -586,7 +780,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
586
780
  // local preview so the status reads "attached", not "selected".
587
781
  if (coverCid !== undefined) {
588
782
  setEditHasCover(true);
589
- setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
783
+ setCoverPreview((prev) => {
784
+ if (prev) URL.revokeObjectURL(prev);
785
+ return null;
786
+ });
590
787
  setCoverStatus("unknown");
591
788
  if (coverInputRef.current) coverInputRef.current.value = "";
592
789
  }
@@ -596,7 +793,14 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
596
793
  } finally {
597
794
  setEditSaving(false);
598
795
  }
599
- }, [fileData?.storylineId, coverFile, editGenre, editLanguage, editNsfw, authFetch]);
796
+ }, [
797
+ fileData?.storylineId,
798
+ coverFile,
799
+ editGenre,
800
+ editLanguage,
801
+ editNsfw,
802
+ authFetch,
803
+ ]);
600
804
 
601
805
  // Reset edit panel state when changing files
602
806
  useEffect(() => {
@@ -613,7 +817,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
613
817
  setDetectedCoverWarning(null);
614
818
  setCoverStatus("unknown");
615
819
  coverUserTouchedRef.current = false;
616
- setGenesisEditMode("text");
820
+ setGenesisEditMode(pendingWorkflowCutsRef.current ? "cuts" : "text");
617
821
  }, [storyName, fileName]);
618
822
 
619
823
  // Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
@@ -628,7 +832,12 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
628
832
  // null, so without this an auto-detected cover could be set before the file
629
833
  // load resolves and then leak into the published Edit Story panel (re1).
630
834
  if (!fileData) return;
631
- if (fileData.storylineId || fileData.status === "published" || fileData.status === "published-not-indexed") return;
835
+ if (
836
+ fileData.storylineId ||
837
+ fileData.status === "published" ||
838
+ fileData.status === "published-not-indexed"
839
+ )
840
+ return;
632
841
  if (coverUserTouchedRef.current) return; // a manual pick/removal wins
633
842
  let cancelled = false;
634
843
  (async () => {
@@ -637,26 +846,56 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
637
846
  if (cancelled || !res.ok) return;
638
847
  const data = await res.json();
639
848
  if (cancelled) return;
640
- if (!data?.found) { setCoverStatus("none"); return; }
849
+ if (!data?.found) {
850
+ setCoverStatus("none");
851
+ return;
852
+ }
641
853
  if (!data.valid) {
642
- setDetectedCoverWarning(data.error || "Detected cover asset is invalid and was not used");
854
+ setDetectedCoverWarning(
855
+ data.error || "Detected cover asset is invalid and was not used",
856
+ );
643
857
  setCoverStatus("invalid");
644
858
  return;
645
859
  }
646
- const assetRes = await authFetch(`/api/stories/${storyName}/asset/${data.path.replace(/^assets\//, "")}`);
860
+ const assetRes = await authFetch(
861
+ `/api/stories/${storyName}/asset/${data.path.replace(/^assets\//, "")}`,
862
+ );
647
863
  if (cancelled || !assetRes.ok) return;
648
864
  const blob = await assetRes.blob();
649
- const file = new File([blob], data.path.split("/").pop() || "cover.webp", { type: data.type });
865
+ const file = new File(
866
+ [blob],
867
+ data.path.split("/").pop() || "cover.webp",
868
+ { type: data.type },
869
+ );
650
870
  // Reuse the exact client validation the manual picker uses.
651
- if (validateCoverImage(file) || cancelled || coverUserTouchedRef.current) return;
871
+ if (
872
+ validateCoverImage(file) ||
873
+ cancelled ||
874
+ coverUserTouchedRef.current
875
+ )
876
+ return;
652
877
  setCoverFile(file);
653
- setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(file); });
878
+ setCoverPreview((prev) => {
879
+ if (prev) URL.revokeObjectURL(prev);
880
+ return URL.createObjectURL(file);
881
+ });
654
882
  setDetectedCover(data.path);
655
883
  setCoverStatus("detected");
656
- } catch { /* best-effort: no detected cover */ }
884
+ } catch {
885
+ /* best-effort: no detected cover */
886
+ }
657
887
  })();
658
- return () => { cancelled = true; };
659
- }, [storyName, fileName, fileData, fileData?.status, fileData?.storylineId, authFetch]);
888
+ return () => {
889
+ cancelled = true;
890
+ };
891
+ }, [
892
+ storyName,
893
+ fileName,
894
+ fileData,
895
+ fileData?.status,
896
+ fileData?.storylineId,
897
+ authFetch,
898
+ ]);
660
899
 
661
900
  // Fetch current storyline metadata when edit panel opens
662
901
  useEffect(() => {
@@ -665,7 +904,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
665
904
  const PLOTLINK_URL = "https://plotlink.xyz";
666
905
  let cancelled = false;
667
906
  fetch(`${PLOTLINK_URL}/api/storyline/${fileData.storylineId}`)
668
- .then((res) => res.ok ? res.json() : null)
907
+ .then((res) => (res.ok ? res.json() : null))
669
908
  .then((data) => {
670
909
  if (cancelled) return;
671
910
  if (!data) {
@@ -677,7 +916,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
677
916
  if (found) setEditGenre(found);
678
917
  }
679
918
  if (data.language) {
680
- const found = LANGUAGES.find((l) => l.toLowerCase() === data.language.toLowerCase());
919
+ const found = LANGUAGES.find(
920
+ (l) => l.toLowerCase() === data.language.toLowerCase(),
921
+ );
681
922
  if (found) setEditLanguage(found);
682
923
  }
683
924
  if (data.isNsfw !== undefined) setEditNsfw(!!data.isNsfw);
@@ -689,7 +930,9 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
689
930
  .catch(() => {
690
931
  if (!cancelled) setEditError("Could not load current story metadata");
691
932
  });
692
- return () => { cancelled = true; };
933
+ return () => {
934
+ cancelled = true;
935
+ };
693
936
  }, [showEditPanel, fileData?.storylineId]);
694
937
 
695
938
  // Ctrl+S / Cmd+S to save
@@ -722,9 +965,10 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
722
965
  }, [fileData?.status, fileData?.publishedAt]);
723
966
 
724
967
  const indexExpired = indexTimeLeft !== null && indexTimeLeft <= 0;
725
- const indexCountdown = indexTimeLeft !== null && indexTimeLeft > 0
726
- ? `${Math.floor(indexTimeLeft / 60000)}:${String(Math.floor((indexTimeLeft % 60000) / 1000)).padStart(2, "0")}`
727
- : null;
968
+ const indexCountdown =
969
+ indexTimeLeft !== null && indexTimeLeft > 0
970
+ ? `${Math.floor(indexTimeLeft / 60000)}:${String(Math.floor((indexTimeLeft % 60000) / 1000)).padStart(2, "0")}`
971
+ : null;
728
972
 
729
973
  if (!storyName || !fileName) {
730
974
  return (
@@ -745,7 +989,8 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
745
989
  );
746
990
  }
747
991
 
748
- const content = activeTab === "edit" ? editContent : (fileData?.content ?? "");
992
+ const content =
993
+ activeTab === "edit" ? editContent : (fileData?.content ?? "");
749
994
  const charCount = content.length;
750
995
  const isGenesis = fileName === "genesis.md";
751
996
  const isPlot = fileName ? /^plot-\d+\.md$/.test(fileName) : false;
@@ -755,16 +1000,23 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
755
1000
  // readiness block — those move to the Publish tab — and show only the opening
756
1001
  // content, production next-step guidance, and a compact "Review publish" CTA.
757
1002
  const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
758
- const isPublished = fileData?.status === "published" || fileData?.status === "published-not-indexed";
1003
+ const isPublished =
1004
+ fileData?.status === "published" ||
1005
+ fileData?.status === "published-not-indexed";
1006
+ const hideFocusedEditorChrome = focusedLetteringMode && isCartoonEpisode;
759
1007
 
760
1008
  // State-aware preview footer guidance (#422). Cut count for the selected
761
1009
  // episode: Genesis from genesis.cuts.json, a plot from its readiness scan
762
1010
  // (null until loaded, so we don't flash "not started"). Drives outline/
763
1011
  // genesis/placeholder-plot guidance; null ⇒ let the per-stage UI speak.
764
1012
  const episodeCutCount = isCartoonGenesis
765
- ? (genesisCutProgress ? genesisCutProgress.total : null)
1013
+ ? genesisCutProgress
1014
+ ? genesisCutProgress.total
1015
+ : null
766
1016
  : isCartoonPlot
767
- ? (cartoonStage === null ? null : cartoonTotalCuts)
1017
+ ? cartoonStage === null
1018
+ ? null
1019
+ : cartoonTotalCuts
768
1020
  : null;
769
1021
  const footerGuidance = previewFooterGuidance({
770
1022
  fileName: fileName ?? "",
@@ -793,23 +1045,33 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
793
1045
  episodeTitle: cartoonEpisodeTitle,
794
1046
  })
795
1047
  : null;
796
- const rawTitleBlocked = !!resolvedPublishTitle && isRawFilenameTitle(resolvedPublishTitle, fileName!);
1048
+ const rawTitleBlocked =
1049
+ !!resolvedPublishTitle &&
1050
+ isRawFilenameTitle(resolvedPublishTitle, fileName!);
797
1051
  // #365: a cartoon plot must have an EXPLICIT reader-facing title — a real
798
1052
  // `# Title` H1 or a non-empty cut-plan title. The friendly "Episode NN"
799
1053
  // fallback (derivePublishTitle) is diagnostic only and must NOT be published,
800
1054
  // so a missing explicit title blocks publish (tightens #358's plot path).
801
- const episodeTitleMissing = isCartoonPlot && !isPublished
802
- && !hasExplicitEpisodeTitle({ fileContent: fileData?.content ?? "", episodeTitle: cartoonEpisodeTitle });
1055
+ const episodeTitleMissing =
1056
+ isCartoonPlot &&
1057
+ !isPublished &&
1058
+ !hasExplicitEpisodeTitle({
1059
+ fileContent: fileData?.content ?? "",
1060
+ episodeTitle: cartoonEpisodeTitle,
1061
+ });
803
1062
 
804
1063
  // Cartoon Genesis prologue readiness (#359, hardened in #400). Genesis is the
805
1064
  // reader-facing opening readers meet before plot-01, so block a missing H1
806
1065
  // title AND a too-short / synopsis-shaped / single-dense-block opening that
807
1066
  // 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";
1067
+ const genesisReadiness =
1068
+ isCartoonGenesis && !isPublished
1069
+ ? cartoonGenesisReadiness(fileData?.content ?? "")
1070
+ : null;
1071
+ const genesisBlocked =
1072
+ !!genesisReadiness && genesisReadiness.blockers.length > 0;
1073
+ const cartoonStatusCardClass =
1074
+ "w-full max-w-[32rem] rounded-xl border px-3 py-3";
813
1075
 
814
1076
  // Cartoon cover readiness badge + requirements (#337). Shown wherever a
815
1077
  // cartoon writer manages the cover (pre-publish picker and the published Edit
@@ -828,13 +1090,21 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
828
1090
  attached,
829
1091
  });
830
1092
  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>
1093
+ <div
1094
+ className="flex flex-col gap-0.5"
1095
+ data-testid="cartoon-cover-status"
1096
+ data-state={r.state}
1097
+ >
1098
+ <span className={`text-[11px] font-medium ${COVER_TONE[r.tone]}`}>
1099
+ {r.label}
1100
+ </span>
833
1101
  {/* Long cover spec/tips collapsed by default (#420) so the panel isn't a
834
1102
  wall of text; the concise status line above is always visible. */}
835
1103
  <details className="text-[10px] text-muted" data-testid="cover-details">
836
1104
  <summary className="cursor-pointer select-none">Cover tips</summary>
837
- <span className="block mt-0.5" data-testid="cartoon-cover-guidance">{COVER_GUIDANCE}</span>
1105
+ <span className="block mt-0.5" data-testid="cartoon-cover-guidance">
1106
+ {COVER_GUIDANCE}
1107
+ </span>
838
1108
  </details>
839
1109
  </div>
840
1110
  );
@@ -857,17 +1127,34 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
857
1127
  >
858
1128
  <span className="text-[11px] text-foreground">
859
1129
  <span className="font-medium">{label}:</span>{" "}
860
- <span className={titleBlocked ? "text-error font-medium" : "text-foreground"}>{resolvedPublishTitle}</span>
1130
+ <span
1131
+ className={
1132
+ titleBlocked ? "text-error font-medium" : "text-foreground"
1133
+ }
1134
+ >
1135
+ {resolvedPublishTitle}
1136
+ </span>
861
1137
  </span>
862
1138
  {rawTitleBlocked ? (
863
- <span className="text-[10px] text-error" data-testid="publish-title-raw-error">
864
- This would publish as a raw filename. {isGenesis
1139
+ <span
1140
+ className="text-[10px] text-error"
1141
+ data-testid="publish-title-raw-error"
1142
+ >
1143
+ This would publish as a raw filename.{" "}
1144
+ {isGenesis
865
1145
  ? "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.
1146
+ : "Set a title in the cut plan (or add a “# Title” to the episode)"}{" "}
1147
+ before publishing.
867
1148
  </span>
868
1149
  ) : 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.
1150
+ <span
1151
+ className="text-[10px] text-error"
1152
+ data-testid="publish-title-episode-required"
1153
+ >
1154
+ “{resolvedPublishTitle}” is a generic placeholder, not a
1155
+ reader-facing title, so it can’t be published. Set a real episode
1156
+ title in the cut plan (or add a “# Title” to the episode) — e.g.
1157
+ “Episode 01 — The Couple Coupon” — before publishing.
871
1158
  </span>
872
1159
  ) : null}
873
1160
  </div>
@@ -887,34 +1174,63 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
887
1174
  data-testid="cartoon-genesis-readiness"
888
1175
  data-blocked={genesisBlocked ? "true" : "false"}
889
1176
  >
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.
1177
+ <span className="text-[11px] font-medium text-foreground">
1178
+ Story opening (Prologue)
1179
+ </span>
1180
+ <span
1181
+ className="text-[10px] text-muted"
1182
+ data-testid="genesis-readiness-hint"
1183
+ >
1184
+ Genesis is the first thing readers see. Write it as the story
1185
+ opening/prologue, not a synopsis — set up the premise and stakes, then
1186
+ bridge into Episode 01.
893
1187
  </span>
894
1188
  {genesisReadiness.blockers.map((b, i) => (
895
- <span key={`b-${i}`} className="text-[10px] text-error" data-testid="genesis-readiness-blocker">{b}</span>
1189
+ <span
1190
+ key={`b-${i}`}
1191
+ className="text-[10px] text-error"
1192
+ data-testid="genesis-readiness-blocker"
1193
+ >
1194
+ {b}
1195
+ </span>
896
1196
  ))}
897
1197
  {genesisReadiness.warnings.map((w, i) => (
898
- <span key={`w-${i}`} className="text-[10px] text-amber-600" data-testid="genesis-readiness-warning">{w}</span>
1198
+ <span
1199
+ key={`w-${i}`}
1200
+ className="text-[10px] text-amber-600"
1201
+ data-testid="genesis-readiness-warning"
1202
+ >
1203
+ {w}
1204
+ </span>
899
1205
  ))}
900
1206
  </div>
901
1207
  );
902
1208
  };
903
- const charLimit = (isGenesis || isPlot) ? 10000 : null;
1209
+ const charLimit = isGenesis || isPlot ? 10000 : null;
904
1210
  // Don't show over-limit warning for already-published files
905
1211
  const overLimit = !isPublished && charLimit !== null && charCount > charLimit;
906
1212
 
907
1213
  // Pre-publish image validation for pending content
908
1214
  const publishContent = fileData?.content ?? "";
909
- const imageValidation = !isPublished ? validateImageRefs(publishContent) : { count: 0, warnings: [] };
1215
+ const imageValidation = !isPublished
1216
+ ? validateImageRefs(publishContent)
1217
+ : { count: 0, warnings: [] };
910
1218
 
911
1219
  // Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
912
1220
  const proseEditor = (
913
- <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
1221
+ <div
1222
+ className="flex-1 min-h-0 flex flex-col overflow-hidden"
1223
+ data-testid="prose-editor-shell"
1224
+ style={{ background: "var(--paper-bg)" }}
1225
+ >
914
1226
  <textarea
915
1227
  ref={textareaRef}
916
1228
  value={editContent}
917
- onChange={(e) => { setEditContent(e.target.value); setDirty(true); dirtyRef.current = true; }}
1229
+ onChange={(e) => {
1230
+ setEditContent(e.target.value);
1231
+ setDirty(true);
1232
+ dirtyRef.current = true;
1233
+ }}
918
1234
  className="flex-1 min-h-0 w-full resize-none px-4 py-3 text-sm leading-relaxed focus:outline-none"
919
1235
  style={{
920
1236
  fontFamily: '"Geist Mono", ui-monospace, monospace',
@@ -922,8 +1238,12 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
922
1238
  color: "var(--text)",
923
1239
  }}
924
1240
  spellCheck={false}
1241
+ data-testid="prose-editor-textarea"
925
1242
  />
926
- <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
1243
+ <div
1244
+ className="shrink-0 px-3 py-1.5 border-t border-border flex items-center justify-between bg-surface/95"
1245
+ data-testid="prose-editor-savebar"
1246
+ >
927
1247
  <span className="text-xs text-muted">
928
1248
  {dirty ? "Unsaved changes" : "No changes"}
929
1249
  </span>
@@ -939,90 +1259,91 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
939
1259
  );
940
1260
 
941
1261
  return (
942
- <div className="h-full flex flex-col">
1262
+ <div className="h-full min-h-0 flex flex-col">
943
1263
  {/* Header with file path + tabs */}
944
- <div className="border-b border-border">
945
- <div className="px-3 py-1.5 flex items-center justify-between">
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"
1264
+ {!hideFocusedEditorChrome && (
1265
+ <div className="border-b border-border">
1266
+ <div className="px-3 py-1.5 flex items-center justify-between">
1267
+ <div className="flex items-center gap-2 text-xs font-mono text-muted">
1268
+ {onViewProgress && (
1269
+ <button
1270
+ onClick={onViewProgress}
1271
+ data-testid="view-progress-btn"
1272
+ className="text-accent hover:underline font-sans"
1273
+ title="Story progress overview"
1274
+ >
1275
+ ← Progress
1276
+ </button>
1277
+ )}
1278
+ <span>
1279
+ {storyName}/{fileName}
1280
+ </span>
1281
+ {fileData?.status === "published" && (
1282
+ <span className="text-green-700 font-medium">Published</span>
1283
+ )}
1284
+ {fileData?.status === "published-not-indexed" && (
1285
+ <span
1286
+ className="text-amber-700 font-medium"
1287
+ title={fileData.indexError}
1288
+ >
1289
+ Published (not indexed)
1290
+ </span>
1291
+ )}
1292
+ {fileData?.status === "pending" && (
1293
+ <span className="text-amber-700 font-medium">Pending</span>
1294
+ )}
1295
+ </div>
1296
+ <div className="flex items-center gap-2">
1297
+ <span
1298
+ className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
953
1299
  >
954
- ← Progress
955
- </button>
956
- )}
957
- <span>{storyName}/{fileName}</span>
958
- {fileData?.status === "published" && (
959
- <span className="text-green-700 font-medium">Published</span>
960
- )}
961
- {fileData?.status === "published-not-indexed" && (
962
- <span className="text-amber-700 font-medium" title={fileData.indexError}>Published (not indexed)</span>
963
- )}
964
- {fileData?.status === "pending" && (
965
- <span className="text-amber-700 font-medium">Pending</span>
966
- )}
967
- </div>
968
- <div className="flex items-center gap-2">
969
- <span className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}>
970
- {charCount.toLocaleString()}{charLimit !== null ? `/${charLimit.toLocaleString()}` : " chars"}
971
- </span>
972
- {overLimit && (
973
- <span className="text-error text-xs font-medium">
974
- {(charCount - charLimit).toLocaleString()} over limit
1300
+ {charCount.toLocaleString()}
1301
+ {charLimit !== null
1302
+ ? `/${charLimit.toLocaleString()}`
1303
+ : " chars"}
975
1304
  </span>
976
- )}
1305
+ {overLimit && (
1306
+ <span className="text-error text-xs font-medium">
1307
+ {(charCount - charLimit).toLocaleString()} over limit
1308
+ </span>
1309
+ )}
1310
+ </div>
977
1311
  </div>
978
- </div>
979
1312
 
980
- {/* Tabs */}
981
- <div className="flex px-3 gap-1">
982
- <button
983
- onClick={() => setActiveTab("preview")}
984
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
985
- activeTab === "preview"
986
- ? "border-accent text-accent"
987
- : "border-transparent text-muted hover:text-foreground"
988
- }`}
989
- >
990
- Preview
991
- </button>
992
- <button
993
- onClick={() => setActiveTab("edit")}
994
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
995
- activeTab === "edit"
996
- ? "border-accent text-accent"
997
- : "border-transparent text-muted hover:text-foreground"
998
- }`}
999
- >
1000
- Edit
1001
- {dirty && <span className="ml-1 text-amber-600">*</span>}
1002
- </button>
1313
+ {/* Tabs */}
1314
+ <div className="flex px-3 gap-1">
1315
+ <button
1316
+ onClick={() => setActiveTab("preview")}
1317
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1318
+ activeTab === "preview"
1319
+ ? "border-accent text-accent"
1320
+ : "border-transparent text-muted hover:text-foreground"
1321
+ }`}
1322
+ >
1323
+ Preview
1324
+ </button>
1325
+ <button
1326
+ onClick={() => setActiveTab("edit")}
1327
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1328
+ activeTab === "edit"
1329
+ ? "border-accent text-accent"
1330
+ : "border-transparent text-muted hover:text-foreground"
1331
+ }`}
1332
+ >
1333
+ Edit
1334
+ {dirty && <span className="ml-1 text-amber-600">*</span>}
1335
+ </button>
1336
+ </div>
1003
1337
  </div>
1004
- </div>
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
1338
  )}
1021
1339
 
1022
1340
  {/* Content area */}
1023
1341
  {activeTab === "preview" ? (
1024
1342
  isCartoonPlot ? (
1025
- <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
1343
+ <div
1344
+ className="flex-1 min-h-0 flex flex-col"
1345
+ style={{ background: "var(--paper-bg)" }}
1346
+ >
1026
1347
  {/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
1027
1348
  Cut Inspector (cuts.json planning metadata) — see #289. */}
1028
1349
  <div className="flex gap-1 px-3 py-1 border-b border-border">
@@ -1043,14 +1364,25 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1043
1364
  </div>
1044
1365
  <div className="flex-1 min-h-0">
1045
1366
  {cartoonPreviewMode === "publish" ? (
1046
- <CartoonPublishPreview content={fileData?.content ?? ""} stage={cartoonStage} />
1367
+ <CartoonPublishPreview
1368
+ content={fileData?.content ?? ""}
1369
+ stage={cartoonStage}
1370
+ />
1047
1371
  ) : (
1048
- <CartoonPreview storyName={storyName!} fileName={fileName!} authFetch={authFetch} onEditCut={handleEditCut} />
1372
+ <CartoonPreview
1373
+ storyName={storyName!}
1374
+ fileName={fileName!}
1375
+ authFetch={authFetch}
1376
+ onEditCut={handleEditCut}
1377
+ />
1049
1378
  )}
1050
1379
  </div>
1051
1380
  </div>
1052
1381
  ) : (
1053
- <div className="flex-1 min-h-0 overflow-y-auto px-6 py-4" style={{ background: "var(--paper-bg)" }}>
1382
+ <div
1383
+ className="flex-1 min-h-0 overflow-y-auto px-6 py-4"
1384
+ style={{ background: "var(--paper-bg)" }}
1385
+ >
1054
1386
  {fileData?.content ? (
1055
1387
  <div className="prose max-w-none">
1056
1388
  <ReactMarkdown
@@ -1066,15 +1398,32 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1066
1398
  </div>
1067
1399
  )
1068
1400
  ) : 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)} />
1401
+ <div
1402
+ className="flex-1 min-h-[22rem] overflow-hidden"
1403
+ style={{ background: "var(--paper-bg)" }}
1404
+ >
1405
+ <CutListPanel
1406
+ storyName={storyName!}
1407
+ fileName={fileName!}
1408
+ authFetch={authFetch}
1409
+ language={language}
1410
+ onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1411
+ focusRequest={cutFocus}
1412
+ onFocusHandled={() => setCutFocus(null)}
1413
+ onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1414
+ workspaceVisible={focusedLetteringWorkspaceVisible}
1415
+ onWorkspaceVisibleChange={onFocusedLetteringWorkspaceVisibleChange}
1416
+ />
1071
1417
  </div>
1072
1418
  ) : isCartoonGenesis ? (
1073
1419
  // Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
1074
1420
  // the coach's lettering/upload/refresh actions for Episode 1 are actionable
1075
1421
  // and Genesis cuts get the same workspace as plots — without losing the
1076
1422
  // hand-written opening prose editor.
1077
- <div className="flex-1 min-h-0 flex flex-col" style={{ background: "var(--paper-bg)" }}>
1423
+ <div
1424
+ className="flex-1 min-h-0 flex flex-col"
1425
+ style={{ background: "var(--paper-bg)" }}
1426
+ >
1078
1427
  <div className="flex gap-1 px-3 py-1 border-b border-border">
1079
1428
  <button
1080
1429
  data-testid="genesis-edit-mode-text"
@@ -1091,9 +1440,22 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1091
1440
  Cuts
1092
1441
  </button>
1093
1442
  </div>
1094
- <div className="flex-1 min-h-0">
1443
+ <div className="flex-1 min-h-0 flex flex-col">
1095
1444
  {genesisEditMode === "cuts" ? (
1096
- <CutListPanel storyName={storyName!} fileName={fileName!} authFetch={authFetch} language={language} onCutsChanged={() => setCutsRefreshKey((k) => k + 1)} focusRequest={cutFocus} onFocusHandled={() => setCutFocus(null)} />
1445
+ <CutListPanel
1446
+ storyName={storyName!}
1447
+ fileName={fileName!}
1448
+ authFetch={authFetch}
1449
+ language={language}
1450
+ onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1451
+ focusRequest={cutFocus}
1452
+ onFocusHandled={() => setCutFocus(null)}
1453
+ onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1454
+ workspaceVisible={focusedLetteringWorkspaceVisible}
1455
+ onWorkspaceVisibleChange={
1456
+ onFocusedLetteringWorkspaceVisibleChange
1457
+ }
1458
+ />
1097
1459
  ) : (
1098
1460
  proseEditor
1099
1461
  )}
@@ -1104,356 +1466,471 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1104
1466
  )}
1105
1467
 
1106
1468
  {/* Action bar */}
1107
- <div className="px-3 py-2 border-t border-border flex items-center justify-between">
1108
- {fileName === "structure.md" ? (
1109
- <p className="text-muted text-xs italic" data-testid="footer-guidance">{footerGuidance}</p>
1110
- ) : fileData?.status === "published-not-indexed" ? (
1111
- <div className="flex flex-col gap-1">
1112
- <div className="flex items-center gap-2 text-xs">
1113
- <span className="text-amber-700">Published on-chain but not indexed on PlotLink</span>
1114
- {!indexExpired && (
1115
- <button
1116
- onClick={async () => {
1117
- if (!storyName || !fileName || !fileData.txHash) return;
1118
- setRetrying(true);
1119
- try {
1120
- const res = await authFetch("/api/publish/retry-index", {
1121
- method: "POST",
1122
- headers: { "Content-Type": "application/json" },
1123
- body: JSON.stringify({
1124
- storyName, fileName,
1125
- txHash: fileData.txHash,
1126
- content: fileData.content,
1127
- storylineId: fileData.storylineId,
1128
- }),
1129
- });
1130
- const data = await res.json();
1131
- if (data.ok) {
1132
- await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
1133
- method: "POST",
1134
- headers: { "Content-Type": "application/json" },
1135
- body: JSON.stringify({
1136
- txHash: fileData.txHash,
1137
- storylineId: fileData.storylineId,
1138
- contentCid: "",
1139
- gasCost: "",
1140
- }),
1141
- });
1142
- loadFile();
1143
- }
1144
- } catch { /* ignore */ }
1145
- setRetrying(false);
1146
- }}
1147
- disabled={retrying}
1148
- className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
1149
- >
1150
- {retrying ? "Retrying..." : `Retry Index${indexCountdown ? ` (${indexCountdown})` : ""}`}
1151
- </button>
1152
- )}
1153
- {isPlot && (
1154
- <button
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
- }}
1170
- disabled={!!publishingFile}
1171
- data-testid="retry-publish-btn"
1172
- className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
1173
- >
1174
- {publishingFile === fileName ? "Publishing..." : "Retry Publish"}
1175
- </button>
1176
- )}
1177
- {fileData.txHash && (
1178
- <a
1179
- href={`https://basescan.org/tx/${fileData.txHash}`}
1180
- target="_blank"
1181
- rel="noopener noreferrer"
1182
- className="text-muted underline"
1183
- >
1184
- BaseScan
1185
- </a>
1186
- )}
1187
- </div>
1188
- <p className="text-muted text-xs">
1189
- {indexExpired
1190
- ? isPlot
1191
- ? "Index window expired. Use Retry Publish to create a new on-chain tx."
1192
- : "Index window expired. Contact support or re-publish manually."
1193
- : isPlot
1194
- ? "Try Retry Index first (available for 5 min after publish). If that fails, Retry Publish creates a new on-chain tx."
1195
- : "Retry Index is available for 5 min after publish."}
1469
+ {!hideFocusedEditorChrome && (
1470
+ <div
1471
+ className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
1472
+ data-testid="preview-panel-footer"
1473
+ >
1474
+ {fileName === "structure.md" ? (
1475
+ <p
1476
+ className="text-muted text-xs italic"
1477
+ data-testid="footer-guidance"
1478
+ >
1479
+ {footerGuidance}
1196
1480
  </p>
1197
- {fileData.indexError && (
1198
- <p className="text-error text-xs">{fileData.indexError}</p>
1199
- )}
1200
- </div>
1201
- ) : fileData?.status === "published" ? (
1202
- <div className="flex flex-col gap-2">
1203
- <div className="flex items-center gap-2 text-xs">
1204
- <span className="text-green-700">Published</span>
1205
- {fileData.storylineId && (
1206
- <a
1207
- href={(() => {
1208
- const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
1209
- if (!isPlot) return base;
1210
- const idx = fileData.plotIndex != null && fileData.plotIndex > 0
1211
- ? fileData.plotIndex
1212
- : parseInt(fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1");
1213
- return `${base}/${idx}`;
1214
- })()}
1215
- target="_blank"
1216
- rel="noopener noreferrer"
1217
- className="text-accent underline"
1218
- >
1219
- View on PlotLink
1220
- </a>
1221
- )}
1222
- {fileData.txHash && (
1223
- <a
1224
- href={`https://basescan.org/tx/${fileData.txHash}`}
1225
- target="_blank"
1226
- rel="noopener noreferrer"
1227
- className="text-muted underline"
1228
- >
1229
- BaseScan
1230
- </a>
1231
- )}
1232
- {isGenesis && walletAddress && fileData.storylineId && (!fileData.authorAddress || fileData.authorAddress.toLowerCase() === walletAddress.toLowerCase()) && (
1233
- <button
1234
- onClick={() => setShowEditPanel((v) => !v)}
1235
- className="px-2 py-0.5 border border-border text-xs rounded hover:bg-surface"
1236
- >
1237
- {showEditPanel ? "Close Edit" : "Edit Story"}
1238
- </button>
1481
+ ) : fileData?.status === "published-not-indexed" ? (
1482
+ <div className="flex flex-col gap-1">
1483
+ <div className="flex items-center gap-2 text-xs">
1484
+ <span className="text-amber-700">
1485
+ Published on-chain but not indexed on PlotLink
1486
+ </span>
1487
+ {!indexExpired && (
1488
+ <button
1489
+ onClick={async () => {
1490
+ if (!storyName || !fileName || !fileData.txHash) return;
1491
+ setRetrying(true);
1492
+ try {
1493
+ const res = await authFetch(
1494
+ "/api/publish/retry-index",
1495
+ {
1496
+ method: "POST",
1497
+ headers: { "Content-Type": "application/json" },
1498
+ body: JSON.stringify({
1499
+ storyName,
1500
+ fileName,
1501
+ txHash: fileData.txHash,
1502
+ content: fileData.content,
1503
+ storylineId: fileData.storylineId,
1504
+ }),
1505
+ },
1506
+ );
1507
+ const data = await res.json();
1508
+ if (data.ok) {
1509
+ await authFetch(
1510
+ `/api/stories/${storyName}/${fileName}/publish-status`,
1511
+ {
1512
+ method: "POST",
1513
+ headers: { "Content-Type": "application/json" },
1514
+ body: JSON.stringify({
1515
+ txHash: fileData.txHash,
1516
+ storylineId: fileData.storylineId,
1517
+ contentCid: "",
1518
+ gasCost: "",
1519
+ }),
1520
+ },
1521
+ );
1522
+ loadFile();
1523
+ }
1524
+ } catch {
1525
+ /* ignore */
1526
+ }
1527
+ setRetrying(false);
1528
+ }}
1529
+ disabled={retrying}
1530
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
1531
+ >
1532
+ {retrying
1533
+ ? "Retrying..."
1534
+ : `Retry Index${indexCountdown ? ` (${indexCountdown})` : ""}`}
1535
+ </button>
1536
+ )}
1537
+ {isPlot && (
1538
+ <button
1539
+ onClick={() => {
1540
+ if (!storyName || !fileName) return;
1541
+ // #332: Retry Publish mints a NEW on-chain chainPlot. The
1542
+ // tx for this episode already exists (status is
1543
+ // published-not-indexed), so this is only for the rare case
1544
+ // where indexing never recovers — require an explicit
1545
+ // duplicate-risk confirm so it can't be clicked by reflex
1546
+ // instead of Retry Index, which would create a permanent
1547
+ // duplicate chapter on PlotLink.
1548
+ const ok = window.confirm(
1549
+ "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?",
1550
+ );
1551
+ if (!ok) return;
1552
+ onPublish?.(
1553
+ storyName,
1554
+ fileName,
1555
+ selectedGenre,
1556
+ selectedLanguage,
1557
+ isNsfw,
1558
+ );
1559
+ }}
1560
+ disabled={!!publishingFile}
1561
+ data-testid="retry-publish-btn"
1562
+ className="px-3 py-1 border border-border text-xs rounded hover:bg-surface disabled:opacity-50"
1563
+ >
1564
+ {publishingFile === fileName
1565
+ ? "Publishing..."
1566
+ : "Retry Publish"}
1567
+ </button>
1568
+ )}
1569
+ {fileData.txHash && (
1570
+ <a
1571
+ href={`https://basescan.org/tx/${fileData.txHash}`}
1572
+ target="_blank"
1573
+ rel="noopener noreferrer"
1574
+ className="text-muted underline"
1575
+ >
1576
+ BaseScan
1577
+ </a>
1578
+ )}
1579
+ </div>
1580
+ <p className="text-muted text-xs">
1581
+ {indexExpired
1582
+ ? isPlot
1583
+ ? "Index window expired. Use Retry Publish to create a new on-chain tx."
1584
+ : "Index window expired. Contact support or re-publish manually."
1585
+ : isPlot
1586
+ ? "Try Retry Index first (available for 5 min after publish). If that fails, Retry Publish creates a new on-chain tx."
1587
+ : "Retry Index is available for 5 min after publish."}
1588
+ </p>
1589
+ {fileData.indexError && (
1590
+ <p className="text-error text-xs">{fileData.indexError}</p>
1239
1591
  )}
1240
1592
  </div>
1241
- {/* Edit panel for published genesis files */}
1242
- {showEditPanel && isGenesis && fileData.storylineId && (
1243
- <div className="border border-border rounded p-3 flex flex-col gap-3 bg-surface">
1244
- {/* Cover image upload */}
1245
- <div className="flex flex-col gap-1.5">
1246
- <span className="text-xs font-medium text-foreground">Cover Image</span>
1247
- {/* Attached/selected/invalid cover status for the published
1593
+ ) : fileData?.status === "published" ? (
1594
+ <div className="flex flex-col gap-2">
1595
+ <div className="flex items-center gap-2 text-xs">
1596
+ <span className="text-green-700">Published</span>
1597
+ {fileData.storylineId && (
1598
+ <a
1599
+ href={(() => {
1600
+ const base = `https://plotlink.xyz/story/${fileData.storylineId}`;
1601
+ if (!isPlot) return base;
1602
+ const idx =
1603
+ fileData.plotIndex != null && fileData.plotIndex > 0
1604
+ ? fileData.plotIndex
1605
+ : parseInt(
1606
+ fileName?.match(/^plot-(\d+)\.md$/)?.[1] ?? "1",
1607
+ );
1608
+ return `${base}/${idx}`;
1609
+ })()}
1610
+ target="_blank"
1611
+ rel="noopener noreferrer"
1612
+ className="text-accent underline"
1613
+ >
1614
+ View on PlotLink
1615
+ </a>
1616
+ )}
1617
+ {fileData.txHash && (
1618
+ <a
1619
+ href={`https://basescan.org/tx/${fileData.txHash}`}
1620
+ target="_blank"
1621
+ rel="noopener noreferrer"
1622
+ className="text-muted underline"
1623
+ >
1624
+ BaseScan
1625
+ </a>
1626
+ )}
1627
+ {isGenesis &&
1628
+ walletAddress &&
1629
+ fileData.storylineId &&
1630
+ (!fileData.authorAddress ||
1631
+ fileData.authorAddress.toLowerCase() ===
1632
+ walletAddress.toLowerCase()) && (
1633
+ <button
1634
+ onClick={() => setShowEditPanel((v) => !v)}
1635
+ className="px-2 py-0.5 border border-border text-xs rounded hover:bg-surface"
1636
+ >
1637
+ {showEditPanel ? "Close Edit" : "Edit Story"}
1638
+ </button>
1639
+ )}
1640
+ </div>
1641
+ {/* Edit panel for published genesis files */}
1642
+ {showEditPanel && isGenesis && fileData.storylineId && (
1643
+ <div className="border border-border rounded p-3 flex flex-col gap-3 bg-surface">
1644
+ {/* Cover image upload */}
1645
+ <div className="flex flex-col gap-1.5">
1646
+ <span className="text-xs font-medium text-foreground">
1647
+ Cover Image
1648
+ </span>
1649
+ {/* Attached/selected/invalid cover status for the published
1248
1650
  cartoon story (#337). */}
1249
- {renderCoverStatus(editHasCover)}
1250
- <div className="flex items-start gap-3">
1251
- {coverPreview && (
1252
- <div className="relative">
1253
- <img
1254
- src={coverPreview}
1255
- alt="Cover preview"
1256
- className="w-16 h-24 object-cover rounded border border-border"
1651
+ {renderCoverStatus(editHasCover)}
1652
+ <div className="flex items-start gap-3">
1653
+ {coverPreview && (
1654
+ <div className="relative">
1655
+ <img
1656
+ src={coverPreview}
1657
+ alt="Cover preview"
1658
+ className="w-16 h-24 object-cover rounded border border-border"
1659
+ />
1660
+ <button
1661
+ onClick={() => {
1662
+ setCoverFile(null);
1663
+ setCoverPreview(null);
1664
+ setDetectedCoverWarning(null);
1665
+ setCoverStatus("unknown");
1666
+ if (coverInputRef.current)
1667
+ coverInputRef.current.value = "";
1668
+ }}
1669
+ 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"
1670
+ >
1671
+ x
1672
+ </button>
1673
+ </div>
1674
+ )}
1675
+ <div className="flex flex-col gap-1">
1676
+ <input
1677
+ ref={coverInputRef}
1678
+ type="file"
1679
+ accept="image/webp,image/jpeg"
1680
+ onChange={handleCoverSelect}
1681
+ className="text-xs"
1682
+ data-testid="cover-input"
1257
1683
  />
1258
- <button
1259
- onClick={() => { setCoverFile(null); setCoverPreview(null); setDetectedCoverWarning(null); setCoverStatus("unknown"); if (coverInputRef.current) coverInputRef.current.value = ""; }}
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"
1261
- >
1262
- x
1263
- </button>
1684
+ <span className="text-xs text-muted">
1685
+ WebP/JPEG, max 1MB, 600x900px recommended
1686
+ </span>
1264
1687
  </div>
1265
- )}
1266
- <div className="flex flex-col gap-1">
1267
- <input
1268
- ref={coverInputRef}
1269
- type="file"
1270
- accept="image/webp,image/jpeg"
1271
- onChange={handleCoverSelect}
1272
- className="text-xs"
1273
- data-testid="cover-input"
1274
- />
1275
- <span className="text-xs text-muted">WebP/JPEG, max 1MB, 600x900px recommended</span>
1276
1688
  </div>
1277
1689
  </div>
1690
+ {/* Genre & Language */}
1691
+ <div className="flex items-center gap-2">
1692
+ <select
1693
+ value={editGenre}
1694
+ onChange={(e) => setEditGenre(e.target.value)}
1695
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1696
+ >
1697
+ {GENRES.map((g) => (
1698
+ <option key={g} value={g}>
1699
+ {g}
1700
+ </option>
1701
+ ))}
1702
+ </select>
1703
+ <select
1704
+ value={editLanguage}
1705
+ onChange={(e) => setEditLanguage(e.target.value)}
1706
+ className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1707
+ >
1708
+ {LANGUAGES.map((l) => (
1709
+ <option key={l} value={l}>
1710
+ {l}
1711
+ </option>
1712
+ ))}
1713
+ </select>
1714
+ </div>
1715
+ {/* NSFW toggle */}
1716
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
1717
+ <input
1718
+ type="checkbox"
1719
+ checked={editNsfw}
1720
+ onChange={(e) => setEditNsfw(e.target.checked)}
1721
+ className="rounded border-border"
1722
+ />
1723
+ This story contains adult content (18+)
1724
+ </label>
1725
+ {/* Save / status */}
1726
+ <div className="flex items-center gap-2">
1727
+ <button
1728
+ onClick={handleEditSave}
1729
+ disabled={editSaving || !editMetaLoaded}
1730
+ className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
1731
+ >
1732
+ {editSaving
1733
+ ? "Saving..."
1734
+ : !editMetaLoaded
1735
+ ? "Loading..."
1736
+ : "Save Changes"}
1737
+ </button>
1738
+ {editSuccess && (
1739
+ <span className="text-green-700 text-xs">Updated!</span>
1740
+ )}
1741
+ {editError && (
1742
+ <span className="text-error text-xs">{editError}</span>
1743
+ )}
1744
+ </div>
1278
1745
  </div>
1279
- {/* Genre & Language */}
1280
- <div className="flex items-center gap-2">
1281
- <select
1282
- value={editGenre}
1283
- onChange={(e) => setEditGenre(e.target.value)}
1284
- className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1285
- >
1286
- {GENRES.map((g) => (
1287
- <option key={g} value={g}>{g}</option>
1288
- ))}
1289
- </select>
1290
- <select
1291
- value={editLanguage}
1292
- onChange={(e) => setEditLanguage(e.target.value)}
1293
- className="px-2 py-1.5 text-xs border border-border rounded bg-surface text-foreground"
1294
- >
1295
- {LANGUAGES.map((l) => (
1296
- <option key={l} value={l}>{l}</option>
1297
- ))}
1298
- </select>
1299
- </div>
1300
- {/* NSFW toggle */}
1301
- <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
1302
- <input
1303
- type="checkbox"
1304
- checked={editNsfw}
1305
- onChange={(e) => setEditNsfw(e.target.checked)}
1306
- className="rounded border-border"
1307
- />
1308
- This story contains adult content (18+)
1309
- </label>
1310
- {/* Save / status */}
1311
- <div className="flex items-center gap-2">
1312
- <button
1313
- onClick={handleEditSave}
1314
- disabled={editSaving || !editMetaLoaded}
1315
- className="px-3 py-1 bg-accent text-white text-xs rounded hover:bg-accent-dim disabled:opacity-50"
1316
- >
1317
- {editSaving ? "Saving..." : !editMetaLoaded ? "Loading..." : "Save Changes"}
1318
- </button>
1319
- {editSuccess && <span className="text-green-700 text-xs">Updated!</span>}
1320
- {editError && <span className="text-error text-xs">{editError}</span>}
1321
- </div>
1322
- </div>
1323
- )}
1324
- </div>
1325
- ) : (
1326
- <div className="flex flex-col gap-2">
1327
- {/* Creator-facing 6-step production checklist so a first-time user
1746
+ )}
1747
+ </div>
1748
+ ) : (
1749
+ <div className="flex flex-col gap-2">
1750
+ {/* Creator-facing 6-step production checklist so a first-time user
1328
1751
  can see which step is current/next without internal jargon
1329
1752
  (#320, expanded to per-cut granularity in #335). */}
1330
- {/* Compact cartoon production status (#420): one scannable line of
1753
+ {/* Compact cartoon production status (#420): one scannable line of
1331
1754
  cut/clean/lettered/uploaded tallies, with a link to the full
1332
1755
  story progress overview (#418). The detailed 6-step guide stays
1333
1756
  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>
1757
+ {isCartoonPlot &&
1758
+ cartoonCutProgress &&
1759
+ cartoonCutProgress.total > 0 && (
1760
+ <div
1761
+ className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted"
1762
+ data-testid="cartoon-status-summary"
1763
+ >
1764
+ <span>
1765
+ Cuts:{" "}
1766
+ <span className="text-foreground font-medium">
1767
+ {cartoonCutProgress.total}
1768
+ </span>
1769
+ </span>
1770
+ <span>
1771
+ Clean:{" "}
1772
+ <span className="text-foreground font-medium">
1773
+ {cartoonCutProgress.withClean}/
1774
+ {cartoonCutProgress.needClean}
1775
+ </span>
1776
+ </span>
1777
+ <span>
1778
+ Lettered:{" "}
1779
+ <span className="text-foreground font-medium">
1780
+ {cartoonCutProgress.withText}/{cartoonCutProgress.total}
1781
+ </span>
1782
+ </span>
1783
+ <span>
1784
+ Uploaded:{" "}
1785
+ <span className="text-foreground font-medium">
1786
+ {cartoonCutProgress.uploaded}/{cartoonCutProgress.total}
1787
+ </span>
1788
+ </span>
1789
+ {onViewProgress && (
1790
+ <button
1791
+ onClick={onViewProgress}
1792
+ className="ml-auto text-accent hover:underline"
1793
+ data-testid="status-view-progress"
1794
+ >
1795
+ View progress →
1796
+ </button>
1797
+ )}
1798
+ </div>
1344
1799
  )}
1345
- </div>
1346
- )}
1347
- {/* #461: the 6-step production checklist is a publish/production
1800
+ {/* #461: the 6-step production checklist is a publish/production
1348
1801
  checklist — it now lives on the Publish tab + the cut workspace's
1349
1802
  FinishEpisodePanel, so it no longer renders under the episode. */}
1350
- {/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
1803
+ {/* Genesis-as-Episode-1 cut summary (#422): discover + summarize
1351
1804
  genesis.cuts.json so a writer sees its real cut/image state in the
1352
1805
  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
1806
+ {isCartoonGenesis && genesisCutProgress && (
1807
+ <div
1808
+ className="text-xs text-muted"
1809
+ data-testid="genesis-cuts-summary"
1810
+ >
1811
+ {/* Distinguish clean art / lettering / final-export / upload so the
1356
1812
  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-
1813
+ Episode 1 (Genesis) cuts: {genesisCutProgress.total} planned
1814
+ {genesisCutProgress.total > 0 && (
1815
+ <>
1816
+ {" "}
1817
+ · {genesisCutProgress.withClean} clean ·{" "}
1818
+ {genesisCutProgress.withText} lettered ·{" "}
1819
+ {genesisCutProgress.exported} exported ·{" "}
1820
+ {genesisCutProgress.uploaded} uploaded
1821
+ </>
1822
+ )}
1823
+ </div>
1824
+ )}
1825
+ {/* State-aware guidance for a not-yet-produced Genesis or a future-
1369
1826
  episode placeholder (#422): plan cuts / generate clean images /
1370
1827
  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>
1828
+ {(isCartoonGenesis || isCartoonPlot) && footerGuidance && (
1829
+ <div
1830
+ className={`${cartoonStatusCardClass} flex flex-col gap-1 border-border bg-surface/50`}
1831
+ data-testid="cartoon-not-started"
1832
+ >
1833
+ <div className="flex items-center gap-2">
1834
+ <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted">
1835
+ {episodeCutCount === 0 ? "Not started" : "Next step"}
1836
+ </span>
1837
+ <span className="text-xs font-medium text-foreground">
1838
+ {isCartoonGenesis
1839
+ ? "Genesis (Episode 1)"
1840
+ : "Future episode"}
1841
+ </span>
1842
+ </div>
1843
+ <span className="text-xs text-muted">{footerGuidance}</span>
1383
1844
  </div>
1384
- <span className="text-xs text-muted">{footerGuidance}</span>
1385
- </div>
1386
- )}
1387
- {/* #461: the planning-stage "Prepare episode for publish" callout and
1845
+ )}
1846
+ {/* #461: the planning-stage "Prepare episode for publish" callout and
1388
1847
  the awaiting-upload pending callout are publish-readiness states —
1389
1848
  they now live on the Publish tab. The cartoon episode keeps only
1390
1849
  production next-step guidance (cartoon-not-started / cut summaries)
1391
1850
  plus the compact "Review publish checklist" CTA below. */}
1392
- {/* Inline illustration upload for plot files (Preview tab only) */}
1393
- {isPlot && !isCartoonPlot && activeTab === "preview" && (
1394
- <div>
1395
- <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
1396
- <input
1397
- type="checkbox"
1398
- checked={showIllustrations}
1399
- onChange={(e) => setShowIllustrations(e.target.checked)}
1400
- className="rounded border-border"
1401
- />
1402
- Add illustrations in the plot
1403
- </label>
1404
- {showIllustrations && (
1405
- <div className="mt-2 flex flex-col gap-2">
1406
- <div
1407
- className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
1408
- onClick={() => illustrationInputRef.current?.click()}
1409
- onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
1410
- onDrop={(e) => {
1411
- e.preventDefault();
1412
- e.stopPropagation();
1413
- const file = e.dataTransfer.files?.[0];
1414
- if (file) uploadIllustration(file);
1415
- }}
1416
- >
1417
- <input
1418
- ref={illustrationInputRef}
1419
- type="file"
1420
- accept="image/webp,image/jpeg"
1421
- onChange={handleIllustrationInput}
1422
- className="hidden"
1423
- />
1424
- <span className="text-xs text-muted">
1425
- {illustrationUploading ? "Uploading..." : "Drop image here or click to browse"}
1426
- </span>
1427
- <span className="text-xs text-muted">WebP/JPEG, max 1MB</span>
1428
- </div>
1429
- {illustrationError && (
1430
- <span className="text-error text-xs">{illustrationError}</span>
1431
- )}
1432
- {uploadedImages.map((img, i) => (
1433
- <div key={img.cid} className="border border-border rounded p-2 flex flex-col gap-1 bg-surface">
1434
- <span className="text-xs text-green-700">Image uploaded! Copy the markdown below and paste it where you want the illustration to appear in your plot:</span>
1435
- <div className="flex items-center gap-1.5">
1436
- <code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
1437
- ![Scene description]({img.url})
1438
- </code>
1439
- <button
1440
- onClick={() => {
1441
- navigator.clipboard.writeText(`![Scene description](${img.url})`);
1442
- setCopiedIndex(i);
1443
- setTimeout(() => setCopiedIndex(null), 2000);
1444
- }}
1445
- className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
1446
- >
1447
- {copiedIndex === i ? "Copied!" : "Copy"}
1448
- </button>
1449
- </div>
1851
+ {/* Inline illustration upload for plot files (Preview tab only) */}
1852
+ {isPlot && !isCartoonPlot && activeTab === "preview" && (
1853
+ <div>
1854
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
1855
+ <input
1856
+ type="checkbox"
1857
+ checked={showIllustrations}
1858
+ onChange={(e) => setShowIllustrations(e.target.checked)}
1859
+ className="rounded border-border"
1860
+ />
1861
+ Add illustrations in the plot
1862
+ </label>
1863
+ {showIllustrations && (
1864
+ <div className="mt-2 flex flex-col gap-2">
1865
+ <div
1866
+ className="border-2 border-dashed border-border rounded p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-accent transition-colors"
1867
+ onClick={() => illustrationInputRef.current?.click()}
1868
+ onDragOver={(e) => {
1869
+ e.preventDefault();
1870
+ e.stopPropagation();
1871
+ }}
1872
+ onDrop={(e) => {
1873
+ e.preventDefault();
1874
+ e.stopPropagation();
1875
+ const file = e.dataTransfer.files?.[0];
1876
+ if (file) uploadIllustration(file);
1877
+ }}
1878
+ >
1879
+ <input
1880
+ ref={illustrationInputRef}
1881
+ type="file"
1882
+ accept="image/webp,image/jpeg"
1883
+ onChange={handleIllustrationInput}
1884
+ className="hidden"
1885
+ />
1886
+ <span className="text-xs text-muted">
1887
+ {illustrationUploading
1888
+ ? "Uploading..."
1889
+ : "Drop image here or click to browse"}
1890
+ </span>
1891
+ <span className="text-xs text-muted">
1892
+ WebP/JPEG, max 1MB
1893
+ </span>
1450
1894
  </div>
1451
- ))}
1452
- </div>
1453
- )}
1454
- </div>
1455
- )}
1456
- {/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
1895
+ {illustrationError && (
1896
+ <span className="text-error text-xs">
1897
+ {illustrationError}
1898
+ </span>
1899
+ )}
1900
+ {uploadedImages.map((img, i) => (
1901
+ <div
1902
+ key={img.cid}
1903
+ className="border border-border rounded p-2 flex flex-col gap-1 bg-surface"
1904
+ >
1905
+ <span className="text-xs text-green-700">
1906
+ Image uploaded! Copy the markdown below and paste it
1907
+ where you want the illustration to appear in your
1908
+ plot:
1909
+ </span>
1910
+ <div className="flex items-center gap-1.5">
1911
+ <code className="flex-1 text-xs bg-background px-2 py-1 rounded font-mono break-all">
1912
+ ![Scene description]({img.url})
1913
+ </code>
1914
+ <button
1915
+ onClick={() => {
1916
+ navigator.clipboard.writeText(
1917
+ `![Scene description](${img.url})`,
1918
+ );
1919
+ setCopiedIndex(i);
1920
+ setTimeout(() => setCopiedIndex(null), 2000);
1921
+ }}
1922
+ className="px-2 py-1 text-xs border border-border rounded hover:bg-surface shrink-0"
1923
+ >
1924
+ {copiedIndex === i ? "Copied!" : "Copy"}
1925
+ </button>
1926
+ </div>
1927
+ </div>
1928
+ ))}
1929
+ </div>
1930
+ )}
1931
+ </div>
1932
+ )}
1933
+ {/* Pre-publish cover picker (#284): a new genesis (esp. cartoon)
1457
1934
  gets a cover before its first createStoryline. Reuses the same
1458
1935
  validation/stale-clear as the published Edit Story panel; the
1459
1936
  selected file is handed to the publish flow, which uploads it and
@@ -1462,254 +1939,386 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1462
1939
  cut/lettering editor gets the height — the cover stays available
1463
1940
  in the Opening-text/Preview view, Story Info, and the Publish page,
1464
1941
  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
1942
+ {isGenesis &&
1943
+ contentType !== "cartoon" &&
1944
+ !(activeTab === "edit" && genesisEditMode === "cuts") && (
1945
+ <div
1946
+ className="flex flex-col gap-1.5"
1947
+ data-testid="prepublish-cover"
1948
+ >
1949
+ <span className="text-xs font-medium text-foreground">
1950
+ Cover Image{" "}
1951
+ <span className="text-muted font-normal">(optional)</span>
1952
+ </span>
1953
+ {/* Cartoon cover readiness + requirements (#337): keep the cover
1469
1954
  step visible before genesis publish so a pilot story isn't
1470
1955
  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
1956
+ {renderCoverStatus(false)}
1957
+ <div className="flex items-start gap-3">
1958
+ {coverPreview && (
1959
+ <div className="relative">
1960
+ <img
1961
+ src={coverPreview}
1962
+ alt="Cover preview"
1963
+ className="w-16 h-24 object-cover rounded border border-border"
1964
+ />
1965
+ <button
1966
+ onClick={() => {
1967
+ coverUserTouchedRef.current = true;
1968
+ setDetectedCover(null);
1969
+ setDetectedCoverWarning(null);
1970
+ setCoverStatus("unknown");
1971
+ setCoverFile(null);
1972
+ setCoverPreview((prev) => {
1973
+ if (prev) URL.revokeObjectURL(prev);
1974
+ return null;
1975
+ });
1976
+ if (coverInputRef.current)
1977
+ coverInputRef.current.value = "";
1978
+ }}
1979
+ 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"
1980
+ >
1981
+ x
1982
+ </button>
1983
+ </div>
1984
+ )}
1985
+ <div className="flex flex-col gap-1">
1986
+ <input
1987
+ ref={coverInputRef}
1988
+ type="file"
1989
+ accept="image/webp,image/jpeg"
1990
+ onChange={handleCoverSelect}
1991
+ className="text-xs"
1992
+ data-testid="prepublish-cover-input"
1993
+ />
1994
+ <span className="text-xs text-muted">
1995
+ WebP/JPEG, max 1MB, 600x900px recommended
1996
+ </span>
1997
+ {/* Codex-image import (#301): convert a generated PNG (or any
1499
1998
  large image) to a compliant cover in-browser, save it as
1500
1999
  assets/cover.webp, and load it as the selected cover —
1501
2000
  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
2001
+ <input
2002
+ ref={coverImportInputRef}
2003
+ type="file"
2004
+ accept="image/png,image/webp,image/jpeg"
2005
+ onChange={handleCoverImport}
2006
+ className="hidden"
2007
+ data-testid="prepublish-cover-import-input"
2008
+ />
2009
+ <button
2010
+ type="button"
2011
+ onClick={() => coverImportInputRef.current?.click()}
2012
+ disabled={coverImporting}
2013
+ className="self-start px-2 py-1 text-xs border border-border rounded hover:border-accent hover:bg-accent/5 disabled:opacity-50"
2014
+ data-testid="prepublish-cover-import"
2015
+ >
2016
+ {coverImporting
2017
+ ? "Importing…"
2018
+ : "Import generated image (PNG ok)"}
2019
+ </button>
2020
+ {/* #312: make the generated-cover → PlotLink-cover connection
1520
2021
  explicit. Whenever a cover is selected (auto-detected,
1521
2022
  imported, or manually picked) it WILL be uploaded as the
1522
2023
  storyline cover at publish; an invalid or missing generated
1523
2024
  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>}
2025
+ {coverFile && (
2026
+ <span
2027
+ className="text-green-700 text-xs"
2028
+ data-testid="prepublish-cover-will-upload"
2029
+ >
2030
+ This cover will be uploaded as the PlotLink
2031
+ storyline cover when you publish.
2032
+ </span>
2033
+ )}
2034
+ {detectedCover && (
2035
+ <span
2036
+ className="text-accent text-xs"
2037
+ data-testid="prepublish-cover-detected"
2038
+ >
2039
+ Auto-detected generated cover {detectedCover} — pick
2040
+ a file to override.
2041
+ </span>
2042
+ )}
2043
+ {detectedCoverWarning && (
2044
+ <span
2045
+ className="text-amber-700 text-xs"
2046
+ data-testid="prepublish-cover-detected-warning"
2047
+ >
2048
+ {detectedCoverWarning} Use &ldquo;Import generated
2049
+ image&rdquo; below to convert/compress it, or pick a
2050
+ file.
2051
+ </span>
2052
+ )}
2053
+ {contentType === "cartoon" &&
2054
+ coverStatus === "none" &&
2055
+ !coverFile && (
2056
+ <span
2057
+ className="text-muted text-xs"
2058
+ data-testid="prepublish-cover-none"
2059
+ >
2060
+ No generated cover detected. Create{" "}
2061
+ <span className="font-mono">
2062
+ assets/cover.webp
2063
+ </span>{" "}
2064
+ or use &ldquo;Import generated image&rdquo; — it
2065
+ will be uploaded as the PlotLink storyline cover
2066
+ when you publish.
2067
+ </span>
2068
+ )}
2069
+ {editError && (
2070
+ <span
2071
+ className="text-error text-xs"
2072
+ data-testid="prepublish-cover-error"
2073
+ >
2074
+ {editError}
2075
+ </span>
2076
+ )}
2077
+ </div>
2078
+ </div>
1545
2079
  </div>
1546
- </div>
1547
- </div>
1548
- )}
1549
- {/* Public title shown + validated before publish (#358). #461: moved
2080
+ )}
2081
+ {/* Public title shown + validated before publish (#358). #461: moved
1550
2082
  to the Publish tab for cartoon — fiction keeps it inline. */}
1551
- {!isCartoonEpisode && renderPublishTitle()}
1552
- {/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
2083
+ {!isCartoonEpisode && renderPublishTitle()}
2084
+ {/* Cartoon Genesis prologue readiness checklist (#359). #461: moved to
1553
2085
  the Publish tab; never rendered in the cartoon episode view. */}
1554
- {!isCartoonEpisode && renderGenesisReadiness()}
1555
- {/* #461: the genre/language selects + publish button + publish-disabled
2086
+ {!isCartoonEpisode && renderGenesisReadiness()}
2087
+ {/* #461: the genre/language selects + publish button + publish-disabled
1556
2088
  reasons are publish controls — for cartoon they live on the Publish
1557
2089
  tab. Fiction keeps the inline publish controls unchanged. */}
1558
- {!isCartoonEpisode && (
1559
- <div className="flex items-center gap-2">
1560
- {/* Genre/language are edited in Story Info for cartoon (#439/#450);
2090
+ {!isCartoonEpisode && (
2091
+ <div className="flex items-center gap-2">
2092
+ {/* Genre/language are edited in Story Info for cartoon (#439/#450);
1561
2093
  the inline selects are fiction-only so the cartoon cut workspace
1562
2094
  isn't a metadata form. Cartoon publish still reads the persisted
1563
2095
  genre/language (seeded into these values from .story.json). */}
1564
- {isGenesis && contentType !== "cartoon" && (
1565
- <>
1566
- <select
1567
- value={selectedGenre}
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"}`}
1574
- >
1575
- {/* Explicit unset state — no silent Romance default (#424). */}
1576
- {!selectedGenre && <option value="" disabled>Needs metadata select genre</option>}
1577
- {GENRES.map((g) => (
1578
- <option key={g} value={g}>{g}</option>
1579
- ))}
1580
- </select>
1581
- <select
1582
- value={selectedLanguage}
1583
- data-testid="publish-language-select"
1584
- onChange={(e) => {
1585
- setSelectedLanguage(e.target.value);
1586
- if (e.target.value) persistPublishMeta({ language: e.target.value });
2096
+ {isGenesis && contentType !== "cartoon" && (
2097
+ <>
2098
+ <select
2099
+ value={selectedGenre}
2100
+ data-testid="publish-genre-select"
2101
+ onChange={(e) => {
2102
+ setSelectedGenre(e.target.value);
2103
+ if (e.target.value)
2104
+ persistPublishMeta({ genre: e.target.value });
2105
+ }}
2106
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedGenre ? "border-border" : "border-amber-500"}`}
2107
+ >
2108
+ {/* Explicit unset state no silent Romance default (#424). */}
2109
+ {!selectedGenre && (
2110
+ <option value="" disabled>
2111
+ Needs metadata — select genre
2112
+ </option>
2113
+ )}
2114
+ {GENRES.map((g) => (
2115
+ <option key={g} value={g}>
2116
+ {g}
2117
+ </option>
2118
+ ))}
2119
+ </select>
2120
+ <select
2121
+ value={selectedLanguage}
2122
+ data-testid="publish-language-select"
2123
+ onChange={(e) => {
2124
+ setSelectedLanguage(e.target.value);
2125
+ if (e.target.value)
2126
+ persistPublishMeta({ language: e.target.value });
2127
+ }}
2128
+ className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
2129
+ >
2130
+ {/* Explicit unset state — no silent English default (#424). */}
2131
+ {!selectedLanguage && (
2132
+ <option value="" disabled>
2133
+ Needs metadata — select language
2134
+ </option>
2135
+ )}
2136
+ {LANGUAGES.map((l) => (
2137
+ <option key={l} value={l}>
2138
+ {l}
2139
+ </option>
2140
+ ))}
2141
+ </select>
2142
+ </>
2143
+ )}
2144
+ <button
2145
+ onClick={async () => {
2146
+ if (!storyName || !fileName) return;
2147
+ if (imageValidation.count > 0) {
2148
+ 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?`;
2149
+ if (!window.confirm(msg)) return;
2150
+ }
2151
+ // Genesis carries the optional pre-publish cover (#284); plot
2152
+ // files never do. Only pass the 6th arg when a cover is
2153
+ // actually selected, so the no-cover call signature (and
2154
+ // existing fiction/plot publish behavior) is unchanged.
2155
+ // The cover may be a manual pick OR an auto-detected
2156
+ // assets/cover.webp loaded into coverFile (#296) — both flow
2157
+ // through the same attach path.
2158
+ const cover = isGenesis ? coverFile : null;
2159
+ if (cover) {
2160
+ // Drop the local cover selection ONLY on a confirmed-successful
2161
+ // publish (onPublish resolves truthy). A publish blocked before
2162
+ // the stream (#375) or one that opens then fails before `done`
2163
+ // (#376) resolves falsy, so the writer's selected/auto-detected
2164
+ // cover stays put for the retry.
2165
+ const published = await onPublish?.(
2166
+ storyName,
2167
+ fileName,
2168
+ selectedGenre,
2169
+ selectedLanguage,
2170
+ isNsfw,
2171
+ cover,
2172
+ );
2173
+ if (published) {
2174
+ coverUserTouchedRef.current = true;
2175
+ setDetectedCover(null);
2176
+ setDetectedCoverWarning(null);
2177
+ setCoverStatus("unknown");
2178
+ setCoverFile(null);
2179
+ setCoverPreview((prev) => {
2180
+ if (prev) URL.revokeObjectURL(prev);
2181
+ return null;
2182
+ });
2183
+ if (coverInputRef.current)
2184
+ coverInputRef.current.value = "";
2185
+ }
2186
+ } else {
2187
+ onPublish?.(
2188
+ storyName,
2189
+ fileName,
2190
+ selectedGenre,
2191
+ selectedLanguage,
2192
+ isNsfw,
2193
+ );
2194
+ }
1587
2195
  }}
1588
- className={`px-2 py-1.5 text-xs border rounded bg-surface text-foreground ${selectedLanguage ? "border-border" : "border-amber-500"}`}
1589
- >
1590
- {/* Explicit unset state — no silent English default (#424). */}
1591
- {!selectedLanguage && <option value="" disabled>Needs metadata — select language</option>}
1592
- {LANGUAGES.map((l) => (
1593
- <option key={l} value={l}>{l}</option>
1594
- ))}
1595
- </select>
1596
- </>
1597
- )}
1598
- <button
1599
- onClick={async () => {
1600
- if (!storyName || !fileName) return;
1601
- if (imageValidation.count > 0) {
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?`;
1603
- if (!window.confirm(msg)) return;
1604
- }
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 = "";
2196
+ disabled={
2197
+ !!publishingFile ||
2198
+ overLimit ||
2199
+ titleBlocked ||
2200
+ genesisBlocked ||
2201
+ (isGenesis && (!selectedGenre || !selectedLanguage)) ||
2202
+ (isCartoonPlot && cartoonStage !== "ready")
1628
2203
  }
1629
- } else {
1630
- onPublish?.(storyName, fileName, selectedGenre, selectedLanguage, isNsfw);
1631
- }
1632
- }}
1633
- disabled={!!publishingFile || overLimit || titleBlocked || genesisBlocked || (isGenesis && (!selectedGenre || !selectedLanguage)) || (isCartoonPlot && cartoonStage !== "ready")}
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"
1635
- >
1636
- {publishingFile === fileName ? "Publishing..." : "Publish to PlotLink"}
1637
- </button>
1638
- {/* Cartoon edits these in Story Info, so point there instead of the
2204
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
2205
+ >
2206
+ {publishingFile === fileName
2207
+ ? "Publishing..."
2208
+ : "Publish to PlotLink"}
2209
+ </button>
2210
+ {/* Cartoon edits these in Story Info, so point there instead of the
1639
2211
  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
- )}
1655
- {overLimit && (
1656
- <span className="text-error text-xs">Reduce content to publish</span>
2212
+ {isGenesis &&
2213
+ contentType === "cartoon" &&
2214
+ (!selectedGenre || !selectedLanguage) && (
2215
+ <span
2216
+ className="text-amber-600 text-xs"
2217
+ data-testid="cartoon-metadata-needs-story-info"
2218
+ >
2219
+ Set the genre and language in Story Info before
2220
+ publishing
2221
+ </span>
2222
+ )}
2223
+ {isGenesis && contentType !== "cartoon" && !selectedGenre && (
2224
+ <span
2225
+ className="text-amber-600 text-xs"
2226
+ data-testid="genre-needs-metadata"
2227
+ >
2228
+ Needs metadata choose a genre before publishing
2229
+ </span>
2230
+ )}
2231
+ {isGenesis &&
2232
+ contentType !== "cartoon" &&
2233
+ selectedGenre &&
2234
+ !selectedLanguage && (
2235
+ <span
2236
+ className="text-amber-600 text-xs"
2237
+ data-testid="language-needs-metadata"
2238
+ >
2239
+ Needs metadata — choose a language before publishing
2240
+ </span>
2241
+ )}
2242
+ {overLimit && (
2243
+ <span className="text-error text-xs">
2244
+ Reduce content to publish
2245
+ </span>
2246
+ )}
2247
+ {isCartoonPlot && cartoonStage === "error" && (
2248
+ <span
2249
+ className="text-error text-xs"
2250
+ data-testid="publish-disabled-reason"
2251
+ >
2252
+ Fix the issues below before publishing
2253
+ </span>
2254
+ )}
2255
+ {isCartoonPlot && cartoonStage === "planning" && (
2256
+ <span
2257
+ className="text-muted text-xs"
2258
+ data-testid="publish-disabled-reason"
2259
+ >
2260
+ Prepare the episode for publish to continue
2261
+ </span>
2262
+ )}
2263
+ {isCartoonPlot && cartoonStage === "awaiting-upload" && (
2264
+ <span
2265
+ className="text-muted text-xs"
2266
+ data-testid="publish-disabled-reason"
2267
+ >
2268
+ Upload all final images, then “Prepare episode for
2269
+ publish” — {cartoonAwaitingCount} of {cartoonTotalCuts}{" "}
2270
+ still need an uploaded image
2271
+ </span>
2272
+ )}
2273
+ </div>
1657
2274
  )}
1658
- {isCartoonPlot && cartoonStage === "error" && (
1659
- <span className="text-error text-xs" data-testid="publish-disabled-reason">Fix the issues below before publishing</span>
2275
+ {/* #461: the grouped publish-readiness issues card (#360/#421) is a
2276
+ publish checklist it now renders on the Publish tab. The cartoon
2277
+ episode view links there via the compact CTA below. */}
2278
+ {isCartoonEpisode && (
2279
+ <button
2280
+ onClick={() => onViewPublish?.()}
2281
+ data-testid="cartoon-review-publish"
2282
+ className="self-start rounded border border-accent/40 px-3 py-1 text-xs text-accent hover:bg-accent/5 transition-colors"
2283
+ >
2284
+ Review publish checklist →
2285
+ </button>
1660
2286
  )}
1661
- {isCartoonPlot && cartoonStage === "planning" && (
1662
- <span className="text-muted text-xs" data-testid="publish-disabled-reason">Prepare the episode for publish to continue</span>
2287
+ {imageValidation.warnings.length > 0 && (
2288
+ <div className="flex flex-col gap-0.5">
2289
+ {imageValidation.warnings.map((w, i) => (
2290
+ <span key={i} className="text-amber-600 text-xs">
2291
+ {w}
2292
+ </span>
2293
+ ))}
2294
+ </div>
1663
2295
  )}
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>
2296
+ {/* Adult-content flag is edited in Story Info for cartoon (#450). */}
2297
+ {isGenesis && contentType !== "cartoon" && (
2298
+ <div className="flex items-center gap-2">
2299
+ <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
2300
+ <input
2301
+ type="checkbox"
2302
+ checked={isNsfw}
2303
+ onChange={(e) => {
2304
+ setIsNsfw(e.target.checked);
2305
+ persistPublishMeta({ isNsfw: e.target.checked });
2306
+ }}
2307
+ className="rounded border-border"
2308
+ />
2309
+ This story contains adult content (18+)
2310
+ </label>
2311
+ {isNsfw && (
2312
+ <span className="text-xs text-amber-600">
2313
+ Adult content will be hidden from the default browse view.
2314
+ </span>
2315
+ )}
2316
+ </div>
1668
2317
  )}
1669
2318
  </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
- )}
1683
- {imageValidation.warnings.length > 0 && (
1684
- <div className="flex flex-col gap-0.5">
1685
- {imageValidation.warnings.map((w, i) => (
1686
- <span key={i} className="text-amber-600 text-xs">{w}</span>
1687
- ))}
1688
- </div>
1689
- )}
1690
- {/* Adult-content flag is edited in Story Info for cartoon (#450). */}
1691
- {isGenesis && contentType !== "cartoon" && (
1692
- <div className="flex items-center gap-2">
1693
- <label className="flex items-center gap-1.5 text-xs text-muted cursor-pointer">
1694
- <input
1695
- type="checkbox"
1696
- checked={isNsfw}
1697
- onChange={(e) => {
1698
- setIsNsfw(e.target.checked);
1699
- persistPublishMeta({ isNsfw: e.target.checked });
1700
- }}
1701
- className="rounded border-border"
1702
- />
1703
- This story contains adult content (18+)
1704
- </label>
1705
- {isNsfw && (
1706
- <span className="text-xs text-amber-600">Adult content will be hidden from the default browse view.</span>
1707
- )}
1708
- </div>
1709
- )}
1710
- </div>
1711
- )}
1712
- </div>
2319
+ )}
2320
+ </div>
2321
+ )}
1713
2322
  </div>
1714
2323
  );
1715
2324
  }