plotlink-ows 1.2.94 → 1.2.96

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.
@@ -3,15 +3,38 @@ import { StoryBrowser } from "./StoryBrowser";
3
3
  import { TerminalPanel } from "./TerminalPanel";
4
4
  import { PreviewPanel } from "./PreviewPanel";
5
5
  import { StoryProgressPanel } from "./StoryProgressPanel";
6
- import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNav";
6
+ import {
7
+ CartoonWorkflowNav,
8
+ type CartoonWorkflowTab,
9
+ } from "./CartoonWorkflowNav";
7
10
  import { StoryInfoPage } from "./StoryInfoPage";
8
11
  import { EpisodesPage } from "./EpisodesPage";
9
12
  import { CartoonPublishPage } from "./CartoonPublishPage";
13
+ import { CartoonNextAction } from "./CartoonNextAction";
10
14
  import { LANGUAGES, GENRES } from "../../../lib/genres";
11
- import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
12
- import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
13
- import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
15
+ import {
16
+ getContentTypeForPublish,
17
+ resolveSelectedContentType,
18
+ needsLegacyProviderRepair,
19
+ attachCoverToStoryline,
20
+ derivePublishTitle,
21
+ shouldBlockDuplicatePlotPublish,
22
+ isRawFilenameTitle,
23
+ hasExplicitEpisodeTitle,
24
+ isPreflightBlocked,
25
+ formatPreflightBlock,
26
+ } from "../lib/publish-helpers";
27
+ import {
28
+ verifyPublicCartoonTitle,
29
+ publicTitleWarning,
30
+ } from "../lib/verify-public-title";
31
+ import {
32
+ isCodexAuthUnclear,
33
+ CODEX_AUTH_UNCLEAR_MESSAGE,
34
+ type AgentReadiness,
35
+ } from "@app-lib/agent-readiness";
14
36
  import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
37
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
15
38
 
16
39
  interface StoriesPageProps {
17
40
  token: string;
@@ -31,7 +54,9 @@ function loadRatio(): number {
31
54
  const n = parseFloat(v);
32
55
  if (n > 0 && n < 1) return n;
33
56
  }
34
- } catch { /* ignore */ }
57
+ } catch {
58
+ /* ignore */
59
+ }
35
60
  return DEFAULT_RATIO;
36
61
  }
37
62
 
@@ -48,7 +73,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
48
73
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
49
74
  // Cartoon right-panel workflow nav (#439): a non-file workflow page is open
50
75
  // (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
51
- const [cartoonView, setCartoonView] = useState<"story-info" | "episodes" | "publish" | null>(null);
76
+ const [cartoonView, setCartoonView] = useState<
77
+ "story-info" | "episodes" | "publish" | null
78
+ >(null);
52
79
  const [publishingFile, setPublishingFile] = useState<string | null>(null);
53
80
  // Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
54
81
  // and advances to the next episode (#461).
@@ -66,43 +93,84 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
66
93
  const [newStoryDescription, setNewStoryDescription] = useState("");
67
94
  const [newStoryGenre, setNewStoryGenre] = useState("");
68
95
  const [newStoryLanguage, setNewStoryLanguage] = useState("English");
69
- const [newStoryAgentMode, setNewStoryAgentMode] = useState<"normal" | "bypass">("normal");
70
- const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<"claude" | "codex">("claude");
96
+ const [newStoryAgentMode, setNewStoryAgentMode] = useState<
97
+ "normal" | "bypass"
98
+ >("normal");
99
+ const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<
100
+ "claude" | "codex"
101
+ >("claude");
71
102
  const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
72
103
  const [codexEnableCopied, setCodexEnableCopied] = useState(false);
73
- const [bypassStories, setBypassStories] = useState<Record<string, boolean>>({});
74
- const [agentProviders, setAgentProviders] = useState<Record<string, "claude" | "codex">>({});
104
+ const [bypassStories, setBypassStories] = useState<Record<string, boolean>>(
105
+ {},
106
+ );
107
+ const [agentProviders, setAgentProviders] = useState<
108
+ Record<string, "claude" | "codex">
109
+ >({});
75
110
  // Track confirmed stories (those with structure.md) for Archive gating
76
- const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
111
+ const [confirmedStories, setConfirmedStories] = useState<Set<string>>(
112
+ new Set(),
113
+ );
77
114
  // Stories that already have a genesis.md — so the outline footer can suggest
78
115
  // reviewing Genesis rather than writing it again (#422).
79
116
  const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
80
- const [storyContentTypes, setStoryContentTypes] = useState<Record<string, "fiction" | "cartoon">>({});
117
+ const [storyContentTypes, setStoryContentTypes] = useState<
118
+ Record<string, "fiction" | "cartoon">
119
+ >({});
81
120
  // `undefined` ⇒ language couldn't be determined for the story → the publish
82
121
  // panel shows "Needs metadata" rather than defaulting to English (#424).
83
- const [storyLanguages, setStoryLanguages] = useState<Record<string, string | undefined>>({});
122
+ const [storyLanguages, setStoryLanguages] = useState<
123
+ Record<string, string | undefined>
124
+ >({});
84
125
  // Publish metadata from .story.json (#424) so the publish controls seed real
85
126
  // values. `undefined` ⇒ not set in .story.json → client shows "Needs metadata".
86
- const [storyGenres, setStoryGenres] = useState<Record<string, string | undefined>>({});
87
- const [storyNsfw, setStoryNsfw] = useState<Record<string, boolean | undefined>>({});
127
+ const [storyGenres, setStoryGenres] = useState<
128
+ Record<string, string | undefined>
129
+ >({});
130
+ const [storyNsfw, setStoryNsfw] = useState<
131
+ Record<string, boolean | undefined>
132
+ >({});
88
133
  const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
89
134
  // Provider recorded on each persisted story (read-only, from /api/stories).
90
135
  // Absent ⇒ legacy story with no provider (defaults to Claude at launch).
91
- const [storyProviders, setStoryProviders] = useState<Record<string, "claude" | "codex" | undefined>>({});
136
+ const [storyProviders, setStoryProviders] = useState<
137
+ Record<string, "claude" | "codex" | undefined>
138
+ >({});
139
+ // #493: cartoon lettering can temporarily collapse the wider work area so the
140
+ // editor gets a true task-mode surface, then restore it on demand.
141
+ const [focusedLetteringMode, setFocusedLetteringMode] = useState(false);
142
+ const [
143
+ focusedLetteringWorkspaceVisible,
144
+ setFocusedLetteringWorkspaceVisible,
145
+ ] = useState(true);
92
146
  const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
93
147
  const languageMap = useRef<Map<string, string>>(new Map());
94
148
  const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
95
149
  const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
96
150
  const knownStoriesRef = useRef<Set<string>>(new Set());
97
- const renameRef = useRef<((oldName: string, newName: string, meta?: { contentType?: "fiction" | "cartoon"; language?: string; agentMode?: "normal" | "bypass"; agentProvider?: "claude" | "codex" }) => Promise<boolean>) | null>(null);
151
+ const renameRef = useRef<
152
+ | ((
153
+ oldName: string,
154
+ newName: string,
155
+ meta?: {
156
+ contentType?: "fiction" | "cartoon";
157
+ language?: string;
158
+ agentMode?: "normal" | "bypass";
159
+ agentProvider?: "claude" | "codex";
160
+ },
161
+ ) => Promise<boolean>)
162
+ | null
163
+ >(null);
98
164
  const containerRef = useRef<HTMLDivElement>(null);
99
165
  const dragging = useRef(false);
100
166
 
101
167
  // Fetch wallet address for edit panel authorship check
102
168
  useEffect(() => {
103
169
  authFetch("/api/wallet")
104
- .then((res) => res.ok ? res.json() : null)
105
- .then((data) => { if (data?.address) setWalletAddress(data.address); })
170
+ .then((res) => (res.ok ? res.json() : null))
171
+ .then((data) => {
172
+ if (data?.address) setWalletAddress(data.address);
173
+ })
106
174
  .catch(() => {});
107
175
  }, [authFetch]);
108
176
 
@@ -110,21 +178,30 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
110
178
  // readiness null (no warning shown); this never blocks fiction or cartoon.
111
179
  useEffect(() => {
112
180
  authFetch("/api/agent/readiness")
113
- .then((res) => res.ok ? res.json() : null)
114
- .then((data) => { if (data) setReadiness(data); })
181
+ .then((res) => (res.ok ? res.json() : null))
182
+ .then((data) => {
183
+ if (data) setReadiness(data);
184
+ })
115
185
  .catch(() => {});
116
186
  }, [authFetch]);
117
187
 
118
188
  // Persist ratio to localStorage
119
189
  useEffect(() => {
120
- try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
190
+ try {
191
+ localStorage.setItem(STORAGE_KEY, String(ratio));
192
+ } catch {
193
+ /* ignore */
194
+ }
121
195
  }, [ratio]);
122
196
 
123
197
  // Clamp ratio on window resize so panels stay above MIN_PANEL_PX
124
198
  useEffect(() => {
125
199
  const onResize = () => {
126
200
  if (!containerRef.current) return;
127
- const available = containerRef.current.getBoundingClientRect().width - SIDEBAR_PX - HANDLE_PX;
201
+ const available =
202
+ containerRef.current.getBoundingClientRect().width -
203
+ SIDEBAR_PX -
204
+ HANDLE_PX;
128
205
  setRatio((prev) => clampRatio(prev, available));
129
206
  };
130
207
  window.addEventListener("resize", onResize);
@@ -146,38 +223,50 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
146
223
  // title/metadata (server writes .story.json + CLAUDE.md), then land on the
147
224
  // story progress overview (#418) so the user sees what's next + a copy-paste
148
225
  // first prompt — no need to ask the agent to rename an "Untitled" project.
149
- const handleCreateStory = useCallback(async (contentType: "fiction" | "cartoon", language: string, agentMode: "normal" | "bypass", agentProvider: "claude" | "codex") => {
150
- const title = newStoryTitle.trim();
151
- if (!title) return; // guarded by the disabled Create buttons
152
- const provider = contentType === "cartoon" ? "codex" : agentProvider;
153
- try {
154
- const res = await authFetch("/api/stories/create", {
155
- method: "POST",
156
- headers: { "Content-Type": "application/json" },
157
- body: JSON.stringify({
158
- title,
159
- description: newStoryDescription.trim() || undefined,
160
- language,
161
- genre: newStoryGenre || undefined,
162
- contentType,
163
- agentMode,
164
- agentProvider: provider,
165
- }),
166
- });
167
- if (!res.ok) return;
168
- const data = await res.json();
169
- setShowNewStoryModal(false);
170
- // Prime the client maps so gating/labels are right before the next poll.
171
- setStoryContentTypes((prev) => ({ ...prev, [data.name]: contentType }));
172
- setStoryLanguages((prev) => ({ ...prev, [data.name]: language }));
173
- if (newStoryGenre) setStoryGenres((prev) => ({ ...prev, [data.name]: newStoryGenre }));
174
- setAgentProviders((prev) => ({ ...prev, [data.name]: provider }));
175
- if (agentMode === "bypass") setBypassStories((prev) => ({ ...prev, [data.name]: true }));
176
- // Land on the progress overview for the named story.
177
- setSelectedStory(data.name);
178
- setSelectedFile(null);
179
- } catch { /* leave the modal open on failure */ }
180
- }, [authFetch, newStoryTitle, newStoryDescription, newStoryGenre]);
226
+ const handleCreateStory = useCallback(
227
+ async (
228
+ contentType: "fiction" | "cartoon",
229
+ language: string,
230
+ agentMode: "normal" | "bypass",
231
+ agentProvider: "claude" | "codex",
232
+ ) => {
233
+ const title = newStoryTitle.trim();
234
+ if (!title) return; // guarded by the disabled Create buttons
235
+ const provider = contentType === "cartoon" ? "codex" : agentProvider;
236
+ try {
237
+ const res = await authFetch("/api/stories/create", {
238
+ method: "POST",
239
+ headers: { "Content-Type": "application/json" },
240
+ body: JSON.stringify({
241
+ title,
242
+ description: newStoryDescription.trim() || undefined,
243
+ language,
244
+ genre: newStoryGenre || undefined,
245
+ contentType,
246
+ agentMode,
247
+ agentProvider: provider,
248
+ }),
249
+ });
250
+ if (!res.ok) return;
251
+ const data = await res.json();
252
+ setShowNewStoryModal(false);
253
+ // Prime the client maps so gating/labels are right before the next poll.
254
+ setStoryContentTypes((prev) => ({ ...prev, [data.name]: contentType }));
255
+ setStoryLanguages((prev) => ({ ...prev, [data.name]: language }));
256
+ if (newStoryGenre)
257
+ setStoryGenres((prev) => ({ ...prev, [data.name]: newStoryGenre }));
258
+ setAgentProviders((prev) => ({ ...prev, [data.name]: provider }));
259
+ if (agentMode === "bypass")
260
+ setBypassStories((prev) => ({ ...prev, [data.name]: true }));
261
+ // Land on the progress overview for the named story.
262
+ setSelectedStory(data.name);
263
+ setSelectedFile(null);
264
+ } catch {
265
+ /* leave the modal open on failure */
266
+ }
267
+ },
268
+ [authFetch, newStoryTitle, newStoryDescription, newStoryGenre],
269
+ );
181
270
 
182
271
  // Poll for new stories and auto-transition untitled sessions
183
272
  useEffect(() => {
@@ -190,11 +279,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
190
279
  const currentNames = new Set<string>(
191
280
  (data.stories as { name: string }[])
192
281
  .filter((s) => s.name !== "_example")
193
- .map((s) => s.name)
282
+ .map((s) => s.name),
194
283
  );
195
284
  // Detect newly appeared stories
196
285
  for (const name of currentNames) {
197
- if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
286
+ if (
287
+ !knownStoriesRef.current.has(name) &&
288
+ untitledSessions.length > 0
289
+ ) {
198
290
  // New story appeared — rename the oldest untitled session to the story name
199
291
  const oldName = untitledSessions[0];
200
292
  // Read the pending session's metadata BEFORE the rename so it can be
@@ -205,9 +297,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
205
297
  const provider = agentProviderMap.current.get(oldName) || "claude";
206
298
  let renamed = false;
207
299
  if (renameRef.current) {
208
- renamed = await renameRef.current(oldName, name, {
209
- contentType: ct, language: lang, agentMode: mode, agentProvider: provider,
210
- }).catch(() => false);
300
+ renamed = await renameRef
301
+ .current(oldName, name, {
302
+ contentType: ct,
303
+ language: lang,
304
+ agentMode: mode,
305
+ agentProvider: provider,
306
+ })
307
+ .catch(() => false);
211
308
  }
212
309
  if (renamed) {
213
310
  setUntitledSessions((prev) => prev.slice(1));
@@ -230,7 +327,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
230
327
  authFetch(`/api/stories/${name}/metadata`, {
231
328
  method: "POST",
232
329
  headers: { "Content-Type": "application/json" },
233
- body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode, agentProvider: provider }),
330
+ body: JSON.stringify({
331
+ contentType: ct,
332
+ language: lang,
333
+ agentMode: mode,
334
+ agentProvider: provider,
335
+ }),
234
336
  }).catch(() => {});
235
337
  }
236
338
  setSelectedStory(name);
@@ -238,60 +340,78 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
238
340
  }
239
341
  }
240
342
  knownStoriesRef.current = currentNames;
241
- } catch { /* ignore */ }
343
+ } catch {
344
+ /* ignore */
345
+ }
242
346
  }, 3000);
243
347
  return () => clearInterval(interval);
244
348
  }, [authFetch, untitledSessions]);
245
349
 
246
350
  // Initialize known stories on mount
247
351
  useEffect(() => {
248
- authFetch("/api/stories").then((res) => {
249
- if (res.ok) return res.json();
250
- }).then((data) => {
251
- if (data?.stories) {
252
- knownStoriesRef.current = new Set(
253
- (data.stories as { name: string }[])
254
- .filter((s) => s.name !== "_example")
255
- .map((s) => s.name)
256
- );
257
- }
258
- }).catch(() => {});
352
+ authFetch("/api/stories")
353
+ .then((res) => {
354
+ if (res.ok) return res.json();
355
+ })
356
+ .then((data) => {
357
+ if (data?.stories) {
358
+ knownStoriesRef.current = new Set(
359
+ (data.stories as { name: string }[])
360
+ .filter((s) => s.name !== "_example")
361
+ .map((s) => s.name),
362
+ );
363
+ }
364
+ })
365
+ .catch(() => {});
259
366
  }, [authFetch]);
260
367
 
261
- const handleSelectFile = useCallback((storyName: string, fileName: string) => {
262
- setSelectedStory(storyName);
263
- setSelectedFile(fileName);
264
- setCartoonView(null); // a file view supersedes a non-file workflow page
265
- }, []);
368
+ const handleSelectFile = useCallback(
369
+ (storyName: string, fileName: string) => {
370
+ setSelectedStory(storyName);
371
+ setSelectedFile(fileName);
372
+ setCartoonView(null); // a file view supersedes a non-file workflow page
373
+ },
374
+ [],
375
+ );
266
376
 
267
377
  const latestStoryRef = useRef<string | null>(null);
268
378
 
269
- const handleSelectStory = useCallback(async (name: string) => {
270
- latestStoryRef.current = name;
271
- setSelectedStory(name);
272
- setSelectedFile(null);
273
- setCartoonView(null);
274
- // Cartoon stories land on the story-level progress overview (#418). Fiction
275
- // PRESERVES the existing auto-open-latest-file behavior (fiction can still
276
- // reach the overview via the "Progress" button).
277
- try {
278
- const res = await authFetch(`/api/stories/${name}`);
279
- if (res.ok && latestStoryRef.current === name) {
280
- const data = await res.json();
281
- if (data.contentType === "cartoon") return; // overview
282
- const files: { file: string }[] = data.files || [];
283
- const plots = files
284
- .map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
285
- .filter((p) => p.num != null)
286
- .sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
287
- const latest = plots[0]?.file
288
- ?? (files.find((f) => f.file === "genesis.md")?.file)
289
- ?? (files.find((f) => f.file === "structure.md")?.file)
290
- ?? files[0]?.file;
291
- if (latest && latestStoryRef.current === name) setSelectedFile(latest);
379
+ const handleSelectStory = useCallback(
380
+ async (name: string) => {
381
+ latestStoryRef.current = name;
382
+ setSelectedStory(name);
383
+ setSelectedFile(null);
384
+ setCartoonView(null);
385
+ // Cartoon stories land on the story-level progress overview (#418). Fiction
386
+ // PRESERVES the existing auto-open-latest-file behavior (fiction can still
387
+ // reach the overview via the "Progress" button).
388
+ try {
389
+ const res = await authFetch(`/api/stories/${name}`);
390
+ if (res.ok && latestStoryRef.current === name) {
391
+ const data = await res.json();
392
+ if (data.contentType === "cartoon") return; // overview
393
+ const files: { file: string }[] = data.files || [];
394
+ const plots = files
395
+ .map((f) => ({
396
+ file: f.file,
397
+ num: f.file.match(/^plot-(\d+)\.md$/)?.[1],
398
+ }))
399
+ .filter((p) => p.num != null)
400
+ .sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
401
+ const latest =
402
+ plots[0]?.file ??
403
+ files.find((f) => f.file === "genesis.md")?.file ??
404
+ files.find((f) => f.file === "structure.md")?.file ??
405
+ files[0]?.file;
406
+ if (latest && latestStoryRef.current === name)
407
+ setSelectedFile(latest);
408
+ }
409
+ } catch {
410
+ /* ignore — stays on overview */
292
411
  }
293
- } catch { /* ignore — stays on overview */ }
294
- }, [authFetch]);
412
+ },
413
+ [authFetch],
414
+ );
295
415
 
296
416
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
297
417
  e.preventDefault();
@@ -319,284 +439,400 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
319
439
  window.addEventListener("mouseup", onMouseUp);
320
440
  }, []);
321
441
 
322
- const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => {
323
- setPublishingFile(fileName);
324
- setPublishProgress("Reading file...");
325
- setPublishError(null); // clear any prior durable block on a fresh attempt (#375)
326
- let coverAttachFailed = false;
327
- // Durable #379 public-title verification warning, set after indexing if
328
- // PlotLink indexed a raw/generic public title (surfaced even though the
329
- // publish itself succeeded — the metadata is immutable).
330
- let titleVerifyWarning: string | null = null;
331
- // Whether the publish actually SUCCEEDED on-chain (the SSE `done` event with a
332
- // txHash). Returned to the caller so PreviewPanel drops the selected genesis
333
- // cover ONLY on a confirmed-successful publish. A publish that is blocked
334
- // before the stream (#375) OR opens then fails/errors before `done` (#376)
335
- // leaves this false, so the writer's cover stays selected for the retry.
336
- let succeeded = false;
442
+ const handlePublish = useCallback(
443
+ async (
444
+ storyName: string,
445
+ fileName: string,
446
+ genre: string,
447
+ language: string,
448
+ isNsfw: boolean,
449
+ coverFile?: File | null,
450
+ ) => {
451
+ setPublishingFile(fileName);
452
+ setPublishProgress("Reading file...");
453
+ setPublishError(null); // clear any prior durable block on a fresh attempt (#375)
454
+ let coverAttachFailed = false;
455
+ // Durable #379 public-title verification warning, set after indexing if
456
+ // PlotLink indexed a raw/generic public title (surfaced even though the
457
+ // publish itself succeeded — the metadata is immutable).
458
+ let titleVerifyWarning: string | null = null;
459
+ // Whether the publish actually SUCCEEDED on-chain (the SSE `done` event with a
460
+ // txHash). Returned to the caller so PreviewPanel drops the selected genesis
461
+ // cover ONLY on a confirmed-successful publish. A publish that is blocked
462
+ // before the stream (#375) OR opens then fails/errors before `done` (#376)
463
+ // leaves this false, so the writer's cover stays selected for the retry.
464
+ let succeeded = false;
337
465
 
338
- try {
339
- // Get file content
340
- const fileRes = await authFetch(`/api/stories/${storyName}/${fileName}`);
341
- if (!fileRes.ok) throw new Error("Failed to read file");
342
- const fileData = await fileRes.json();
343
-
344
- // Derive the publish title (#331). The storyline title is set once at
345
- // genesis publish and is immutable on-chain, so a headingless genesis.md
346
- // must not fall back to the bare "genesis" filename. For genesis, fetch
347
- // structure.md so its `# Title` H1 can stand in, with a prettified folder
348
- // slug as the last resort. Best-effort: structure.md may be absent.
349
- const publishContentType = storyContentTypes[storyName];
350
- let structureContent: string | null = null;
351
- let episodeTitle: string | null = null;
352
- if (fileName === "genesis.md") {
353
- try {
354
- const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
355
- if (structRes.ok) structureContent = (await structRes.json()).content ?? null;
356
- } catch { /* best effort — fall back to the prettified slug */ }
357
- } else if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)) {
358
- // Cartoon publish markdown is image-only (no H1), so read the cut plan's
359
- // episode title to avoid publishing the raw "plot-NN" filename (#347).
360
- try {
361
- const cutsRes = await authFetch(`/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`);
362
- if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
363
- } catch { /* best effort — fall back to a friendly "Episode NN" */ }
364
- }
365
- const title = derivePublishTitle({
366
- fileName,
367
- fileContent: fileData.content,
368
- storySlug: storyName,
369
- structureContent,
370
- contentType: publishContentType,
371
- episodeTitle,
372
- });
373
-
374
- // Defense-in-depth (#358): never publish a cartoon story/episode whose
375
- // public title is still a raw filename label ("genesis"/"plot-NN"). The
376
- // publish panel already blocks this, but guard the action too.
377
- if (publishContentType === "cartoon" && isRawFilenameTitle(title, fileName)) {
378
- setPublishProgress(
379
- fileName === "genesis.md"
380
- ? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
381
- : "Set an episode title in the cut plan before publishing — it would otherwise publish as a raw filename.",
466
+ try {
467
+ // Get file content
468
+ const fileRes = await authFetch(
469
+ `/api/stories/${storyName}/${fileName}`,
382
470
  );
383
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
384
- return false;
385
- }
471
+ if (!fileRes.ok) throw new Error("Failed to read file");
472
+ const fileData = await fileRes.json();
386
473
 
387
- // Defense-in-depth (#365, tightened #368): a cartoon plot must have an
388
- // explicit reader-facing title (cut-plan title or a real H1) that is NOT a
389
- // generic "Episode NN"/"Chapter NN"/"plot-NN" placeholder. Block the action
390
- // too, not just the panel.
391
- if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)
392
- && !hasExplicitEpisodeTitle({ fileContent: fileData.content, episodeTitle })) {
393
- setPublishProgress(
394
- "Set a real episode title in the cut plan (or add a “# Title” to the episode) before publishing — a generic “Episode NN” placeholder can’t be published.",
395
- );
396
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
397
- return false;
398
- }
474
+ // Derive the publish title (#331). The storyline title is set once at
475
+ // genesis publish and is immutable on-chain, so a headingless genesis.md
476
+ // must not fall back to the bare "genesis" filename. For genesis, fetch
477
+ // structure.md so its `# Title` H1 can stand in, with a prettified folder
478
+ // slug as the last resort. Best-effort: structure.md may be absent.
479
+ const publishContentType = storyContentTypes[storyName];
480
+ let structureContent: string | null = null;
481
+ let episodeTitle: string | null = null;
482
+ if (fileName === "genesis.md") {
483
+ try {
484
+ const structRes = await authFetch(
485
+ `/api/stories/${storyName}/structure.md`,
486
+ );
487
+ if (structRes.ok)
488
+ structureContent = (await structRes.json()).content ?? null;
489
+ } catch {
490
+ /* best effort — fall back to the prettified slug */
491
+ }
492
+ } else if (
493
+ publishContentType === "cartoon" &&
494
+ fileName.match(/^plot-\d+\.md$/)
495
+ ) {
496
+ // Cartoon publish markdown is image-only (no H1), so read the cut plan's
497
+ // episode title to avoid publishing the raw "plot-NN" filename (#347).
498
+ try {
499
+ const cutsRes = await authFetch(
500
+ `/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`,
501
+ );
502
+ if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
503
+ } catch {
504
+ /* best effort — fall back to a friendly "Episode NN" */
505
+ }
506
+ }
507
+ const title = derivePublishTitle({
508
+ fileName,
509
+ fileContent: fileData.content,
510
+ storySlug: storyName,
511
+ structureContent,
512
+ contentType: publishContentType,
513
+ episodeTitle,
514
+ });
399
515
 
400
- // Defense-in-depth (#359, hardened in #400): a cartoon Genesis is the
401
- // reader-facing opening, so block publish when it isn't a real story
402
- // opening (missing H1, synopsis/outline shape, too short, or a single dense
403
- // block) even if the panel guard is bypassed. Surface the specific blocker.
404
- if (publishContentType === "cartoon" && fileName === "genesis.md") {
405
- const genesisBlockers = cartoonGenesisReadiness(fileData.content).blockers;
406
- if (genesisBlockers.length > 0) {
516
+ // Defense-in-depth (#358): never publish a cartoon story/episode whose
517
+ // public title is still a raw filename label ("genesis"/"plot-NN"). The
518
+ // publish panel already blocks this, but guard the action too.
519
+ if (
520
+ publishContentType === "cartoon" &&
521
+ isRawFilenameTitle(title, fileName)
522
+ ) {
407
523
  setPublishProgress(
408
- `Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
524
+ fileName === "genesis.md"
525
+ ? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
526
+ : "Set an episode title in the cut plan before publishing — it would otherwise publish as a raw filename.",
409
527
  );
410
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
528
+ setTimeout(() => {
529
+ setPublishingFile(null);
530
+ setPublishProgress("");
531
+ }, 6000);
411
532
  return false;
412
533
  }
413
- }
414
534
 
415
- // For plot files, find the storylineId from the genesis publish status
416
- let storylineId: number | undefined;
417
- if (fileName.match(/^plot-\d+\.md$/)) {
418
- // #332: never mint a second chainPlot for a plot that already has an
419
- // on-chain chapter recorded — a duplicate chainPlot creates a permanent
420
- // extra chapter on PlotLink. fileData carries the retained txHash/
421
- // plotIndex even when a later content edit reset status to "pending".
422
- // The published-not-indexed recovery path is exempt (handled in the
423
- // preview UI behind an explicit duplicate-risk confirm).
424
- if (shouldBlockDuplicatePlotPublish(fileData)) {
535
+ // Defense-in-depth (#365, tightened #368): a cartoon plot must have an
536
+ // explicit reader-facing title (cut-plan title or a real H1) that is NOT a
537
+ // generic "Episode NN"/"Chapter NN"/"plot-NN" placeholder. Block the action
538
+ // too, not just the panel.
539
+ if (
540
+ publishContentType === "cartoon" &&
541
+ fileName.match(/^plot-\d+\.md$/) &&
542
+ !hasExplicitEpisodeTitle({
543
+ fileContent: fileData.content,
544
+ episodeTitle,
545
+ })
546
+ ) {
425
547
  setPublishProgress(
426
- "Already published on PlotLink republishing would create a duplicate chapter. Open it on PlotLink instead (or use Retry Index if it isn't showing yet).",
548
+ "Set a real episode title in the cut plan (or add a “# Title” to the episode) before publishing a generic “Episode NN” placeholder can’t be published.",
427
549
  );
428
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
429
- return;
550
+ setTimeout(() => {
551
+ setPublishingFile(null);
552
+ setPublishProgress("");
553
+ }, 6000);
554
+ return false;
430
555
  }
431
- try {
432
- const storyRes = await authFetch(`/api/stories/${storyName}`);
433
- if (storyRes.ok) {
434
- const storyData = await storyRes.json();
435
- const genesis = storyData.files.find((f: { file: string; storylineId?: number }) =>
436
- f.file === "genesis.md" && f.storylineId);
437
- storylineId = genesis?.storylineId;
556
+
557
+ // Defense-in-depth (#359, hardened in #400): a cartoon Genesis is the
558
+ // reader-facing opening, so block publish when it isn't a real story
559
+ // opening (missing H1, synopsis/outline shape, too short, or a single dense
560
+ // block) even if the panel guard is bypassed. Surface the specific blocker.
561
+ if (publishContentType === "cartoon" && fileName === "genesis.md") {
562
+ const genesisBlockers = cartoonGenesisReadiness(
563
+ fileData.content,
564
+ ).blockers;
565
+ if (genesisBlockers.length > 0) {
566
+ setPublishProgress(
567
+ `Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
568
+ );
569
+ setTimeout(() => {
570
+ setPublishingFile(null);
571
+ setPublishProgress("");
572
+ }, 6000);
573
+ return false;
438
574
  }
439
- } catch { /* ignore */ }
440
- if (!storylineId) {
441
- setPublishProgress("Error: Publish genesis first to create the storyline");
442
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
443
- return false;
444
575
  }
445
- }
446
576
 
447
- // #375: gate on wallet balance BEFORE opening the publish stream. The
448
- // pilot's publish proceeded into "Broadcasting transaction..." despite
449
- // preflight already reporting insufficient ETH, then returned to draft with
450
- // no durable error. Run preflight here and, if the OWS wallet can't cover at
451
- // least the creation fee (or is otherwise not ready), block with a durable,
452
- // obvious inline error instead of calling /api/publish/file. A preflight
453
- // network/HTTP error is NOT treated as a block fall through so a flaky
454
- // preflight can't stop an otherwise-fundable publish (the stream surfaces
455
- // its own error).
456
- setPublishProgress("Checking wallet balance...");
457
- try {
458
- const preRes = await authFetch("/api/publish/preflight");
459
- if (preRes.ok) {
460
- const pre = await preRes.json();
461
- if (isPreflightBlocked(pre)) {
462
- setPublishError(formatPreflightBlock(pre));
463
- setPublishingFile(null);
464
- setPublishProgress("");
577
+ // For plot files, find the storylineId from the genesis publish status
578
+ let storylineId: number | undefined;
579
+ if (fileName.match(/^plot-\d+\.md$/)) {
580
+ // #332: never mint a second chainPlot for a plot that already has an
581
+ // on-chain chapter recorded a duplicate chainPlot creates a permanent
582
+ // extra chapter on PlotLink. fileData carries the retained txHash/
583
+ // plotIndex even when a later content edit reset status to "pending".
584
+ // The published-not-indexed recovery path is exempt (handled in the
585
+ // preview UI behind an explicit duplicate-risk confirm).
586
+ if (shouldBlockDuplicatePlotPublish(fileData)) {
587
+ setPublishProgress(
588
+ "Already published on PlotLink — republishing would create a duplicate chapter. Open it on PlotLink instead (or use Retry Index if it isn't showing yet).",
589
+ );
590
+ setTimeout(() => {
591
+ setPublishingFile(null);
592
+ setPublishProgress("");
593
+ }, 6000);
594
+ return;
595
+ }
596
+ try {
597
+ const storyRes = await authFetch(`/api/stories/${storyName}`);
598
+ if (storyRes.ok) {
599
+ const storyData = await storyRes.json();
600
+ const genesis = storyData.files.find(
601
+ (f: { file: string; storylineId?: number }) =>
602
+ f.file === "genesis.md" && f.storylineId,
603
+ );
604
+ storylineId = genesis?.storylineId;
605
+ }
606
+ } catch {
607
+ /* ignore */
608
+ }
609
+ if (!storylineId) {
610
+ setPublishProgress(
611
+ "Error: Publish genesis first to create the storyline",
612
+ );
613
+ setTimeout(() => {
614
+ setPublishingFile(null);
615
+ setPublishProgress("");
616
+ }, 3000);
465
617
  return false;
466
618
  }
467
619
  }
468
- } catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
469
620
 
470
- // Run publish flow via SSE
471
- setPublishProgress("Publishing...");
472
- const publishRes = await authFetch("/api/publish/file", {
473
- method: "POST",
474
- headers: { "Content-Type": "application/json" },
475
- body: JSON.stringify({
476
- storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId,
477
- ...(getContentTypeForPublish(storyContentTypes, storyName, storylineId) ? { contentType: getContentTypeForPublish(storyContentTypes, storyName, storylineId) } : {}),
478
- }),
479
- });
621
+ // #375: gate on wallet balance BEFORE opening the publish stream. The
622
+ // pilot's publish proceeded into "Broadcasting transaction..." despite
623
+ // preflight already reporting insufficient ETH, then returned to draft with
624
+ // no durable error. Run preflight here and, if the OWS wallet can't cover at
625
+ // least the creation fee (or is otherwise not ready), block with a durable,
626
+ // obvious inline error instead of calling /api/publish/file. A preflight
627
+ // network/HTTP error is NOT treated as a block — fall through so a flaky
628
+ // preflight can't stop an otherwise-fundable publish (the stream surfaces
629
+ // its own error).
630
+ setPublishProgress("Checking wallet balance...");
631
+ try {
632
+ const preRes = await authFetch("/api/publish/preflight");
633
+ if (preRes.ok) {
634
+ const pre = await preRes.json();
635
+ if (isPreflightBlocked(pre)) {
636
+ setPublishError(formatPreflightBlock(pre));
637
+ setPublishingFile(null);
638
+ setPublishProgress("");
639
+ return false;
640
+ }
641
+ }
642
+ } catch {
643
+ /* preflight unreachable — don't hard-block; let the publish stream report */
644
+ }
480
645
 
481
- if (!publishRes.ok) {
482
- const err = await publishRes.json();
483
- throw new Error(err.error || "Publish failed");
484
- }
646
+ // Run publish flow via SSE
647
+ setPublishProgress("Publishing...");
648
+ const publishRes = await authFetch("/api/publish/file", {
649
+ method: "POST",
650
+ headers: { "Content-Type": "application/json" },
651
+ body: JSON.stringify({
652
+ storyName,
653
+ fileName,
654
+ title,
655
+ content: fileData.content,
656
+ genre,
657
+ language,
658
+ isNsfw,
659
+ storylineId,
660
+ ...(getContentTypeForPublish(
661
+ storyContentTypes,
662
+ storyName,
663
+ storylineId,
664
+ )
665
+ ? {
666
+ contentType: getContentTypeForPublish(
667
+ storyContentTypes,
668
+ storyName,
669
+ storylineId,
670
+ ),
671
+ }
672
+ : {}),
673
+ }),
674
+ });
485
675
 
486
- // Read SSE stream
487
- const reader = publishRes.body?.getReader();
488
- const decoder = new TextDecoder();
676
+ if (!publishRes.ok) {
677
+ const err = await publishRes.json();
678
+ throw new Error(err.error || "Publish failed");
679
+ }
489
680
 
490
- if (reader) {
491
- while (true) {
492
- const { done, value } = await reader.read();
493
- if (done) break;
494
- const text = decoder.decode(value);
495
- const lines = text.split("\n").filter((l) => l.startsWith("data: "));
496
- for (const line of lines) {
497
- try {
498
- const data = JSON.parse(line.slice(6));
499
- if (data.step) setPublishProgress(data.message || data.step);
500
- if (data.step === "done" && data.txHash) {
501
- // Publish confirmed on-chain — the only point at which the cover
502
- // selection may be dropped (#376). Anything short of this (a
503
- // pre-stream block, a non-ok response, an error before `done`, or
504
- // a stream that ends without `done`) leaves `succeeded` false so
505
- // PreviewPanel keeps the selected/auto-detected cover for retry.
506
- succeeded = true;
507
- // Update publish status with gasCost
508
- await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
509
- method: "POST",
510
- headers: { "Content-Type": "application/json" },
511
- body: JSON.stringify({
512
- txHash: data.txHash,
513
- storylineId: data.storylineId,
514
- plotIndex: data.plotIndex,
515
- contentCid: data.contentCid,
516
- gasCost: data.gasCost,
517
- indexError: data.indexError,
518
- authorAddress: walletAddress,
519
- }),
520
- });
521
- // Advance the cartoon Publish page to the next episode (#461).
522
- setCartoonPublishRefresh((n) => n + 1);
681
+ // Read SSE stream
682
+ const reader = publishRes.body?.getReader();
683
+ const decoder = new TextDecoder();
523
684
 
524
- // Pre-publish cover (#284): a new genesis can't carry a cover
525
- // through createStoryline, so once the storyline exists, attach
526
- // the selected cover via upload-cover + update-storyline. Best-
527
- // effort — a failure leaves the storyline published with no
528
- // cover, settable later via Edit Story.
529
- if (coverFile && fileName === "genesis.md" && data.storylineId) {
530
- setPublishProgress("Uploading cover...");
531
- let coverCid: string | null = null;
532
- try {
533
- coverCid = await attachCoverToStoryline(authFetch, data.storylineId, coverFile);
534
- } catch { /* non-fatal: storyline is already published */ }
535
- // A null result means the cover was not attached (upload or
536
- // update-storyline failed). The storyline is published either
537
- // way; tell the user so they can retry from Edit Story.
538
- if (!coverCid) coverAttachFailed = true;
539
- }
685
+ if (reader) {
686
+ while (true) {
687
+ const { done, value } = await reader.read();
688
+ if (done) break;
689
+ const text = decoder.decode(value);
690
+ const lines = text
691
+ .split("\n")
692
+ .filter((l) => l.startsWith("data: "));
693
+ for (const line of lines) {
694
+ try {
695
+ const data = JSON.parse(line.slice(6));
696
+ if (data.step) setPublishProgress(data.message || data.step);
697
+ if (data.step === "done" && data.txHash) {
698
+ // Publish confirmed on-chain the only point at which the cover
699
+ // selection may be dropped (#376). Anything short of this (a
700
+ // pre-stream block, a non-ok response, an error before `done`, or
701
+ // a stream that ends without `done`) leaves `succeeded` false so
702
+ // PreviewPanel keeps the selected/auto-detected cover for retry.
703
+ succeeded = true;
704
+ // Update publish status with gasCost
705
+ await authFetch(
706
+ `/api/stories/${storyName}/${fileName}/publish-status`,
707
+ {
708
+ method: "POST",
709
+ headers: { "Content-Type": "application/json" },
710
+ body: JSON.stringify({
711
+ txHash: data.txHash,
712
+ storylineId: data.storylineId,
713
+ plotIndex: data.plotIndex,
714
+ contentCid: data.contentCid,
715
+ gasCost: data.gasCost,
716
+ indexError: data.indexError,
717
+ authorAddress: walletAddress,
718
+ }),
719
+ },
720
+ );
721
+ // Advance the cartoon Publish page to the next episode (#461).
722
+ setCartoonPublishRefresh((n) => n + 1);
723
+
724
+ // Pre-publish cover (#284): a new genesis can't carry a cover
725
+ // through createStoryline, so once the storyline exists, attach
726
+ // the selected cover via upload-cover + update-storyline. Best-
727
+ // effort — a failure leaves the storyline published with no
728
+ // cover, settable later via Edit Story.
729
+ if (
730
+ coverFile &&
731
+ fileName === "genesis.md" &&
732
+ data.storylineId
733
+ ) {
734
+ setPublishProgress("Uploading cover...");
735
+ let coverCid: string | null = null;
736
+ try {
737
+ coverCid = await attachCoverToStoryline(
738
+ authFetch,
739
+ data.storylineId,
740
+ coverFile,
741
+ );
742
+ } catch {
743
+ /* non-fatal: storyline is already published */
744
+ }
745
+ // A null result means the cover was not attached (upload or
746
+ // update-storyline failed). The storyline is published either
747
+ // way; tell the user so they can retry from Edit Story.
748
+ if (!coverCid) coverAttachFailed = true;
749
+ }
540
750
 
541
- // #379: end-to-end public-title verification. Local guards ensure
542
- // OWS sends a reader-facing title, but the pilot showed PlotLink
543
- // can still index a raw "genesis"/"plot-NN" title. There is no
544
- // public JSON read endpoint, so an OWS server route reads the
545
- // rendered public page's og:title (no CORS) and returns the
546
- // indexed title; verify it here. Inconclusive reads (page
547
- // unreachable / no title) never warn — only a confirmed
548
- // raw/generic public title does. The publish is already on-chain +
549
- // immutable, so this can only warn.
550
- if (publishContentType === "cartoon" && data.storylineId) {
551
- try {
552
- const isPlot = fileName !== "genesis.md";
553
- const q = `storylineId=${data.storylineId}` +
554
- (isPlot && data.plotIndex != null ? `&plotIndex=${data.plotIndex}` : "");
555
- const pubRes = await authFetch(`/api/publish/public-title?${q}`);
556
- if (pubRes.ok) {
557
- const pub = await pubRes.json();
558
- const detail = isPlot
559
- ? { plots: pub.plotTitle != null ? [{ plotIndex: data.plotIndex, title: pub.plotTitle }] : [] }
560
- : { title: pub.storylineTitle };
561
- const verdict = verifyPublicCartoonTitle({ fileName, detail, plotIndex: data.plotIndex });
562
- if (!verdict.ok) titleVerifyWarning = publicTitleWarning(verdict);
751
+ // #379: end-to-end public-title verification. Local guards ensure
752
+ // OWS sends a reader-facing title, but the pilot showed PlotLink
753
+ // can still index a raw "genesis"/"plot-NN" title. There is no
754
+ // public JSON read endpoint, so an OWS server route reads the
755
+ // rendered public page's og:title (no CORS) and returns the
756
+ // indexed title; verify it here. Inconclusive reads (page
757
+ // unreachable / no title) never warn — only a confirmed
758
+ // raw/generic public title does. The publish is already on-chain +
759
+ // immutable, so this can only warn.
760
+ if (publishContentType === "cartoon" && data.storylineId) {
761
+ try {
762
+ const isPlot = fileName !== "genesis.md";
763
+ const q =
764
+ `storylineId=${data.storylineId}` +
765
+ (isPlot && data.plotIndex != null
766
+ ? `&plotIndex=${data.plotIndex}`
767
+ : "");
768
+ const pubRes = await authFetch(
769
+ `/api/publish/public-title?${q}`,
770
+ );
771
+ if (pubRes.ok) {
772
+ const pub = await pubRes.json();
773
+ const detail = isPlot
774
+ ? {
775
+ plots:
776
+ pub.plotTitle != null
777
+ ? [
778
+ {
779
+ plotIndex: data.plotIndex,
780
+ title: pub.plotTitle,
781
+ },
782
+ ]
783
+ : [],
784
+ }
785
+ : { title: pub.storylineTitle };
786
+ const verdict = verifyPublicCartoonTitle({
787
+ fileName,
788
+ detail,
789
+ plotIndex: data.plotIndex,
790
+ });
791
+ if (!verdict.ok)
792
+ titleVerifyWarning = publicTitleWarning(verdict);
793
+ }
794
+ } catch {
795
+ /* inconclusive — don't false-warn on a read failure */
563
796
  }
564
- } catch { /* inconclusive — don't false-warn on a read failure */ }
797
+ }
565
798
  }
799
+ } catch {
800
+ /* ignore partial SSE */
566
801
  }
567
- } catch { /* ignore partial SSE */ }
802
+ }
568
803
  }
569
804
  }
570
- }
571
805
 
572
- // A failed public-title verification (#379) is a durable warning that
573
- // outranks the transient "Published!" line — the metadata is immutable, so
574
- // the writer must know the next publish needs corrected metadata.
575
- if (titleVerifyWarning) setPublishError(titleVerifyWarning);
576
- setPublishProgress(
577
- coverAttachFailed
578
- ? "Published, but cover upload failed — set it later from Edit Story."
579
- : "Published!",
580
- );
581
- } catch (err: unknown) {
582
- const message = err instanceof Error ? err.message : "Publish failed";
583
- setPublishProgress(`Error: ${message}`);
584
- } finally {
585
- setTimeout(() => {
586
- setPublishingFile(null);
587
- setPublishProgress("");
588
- }, 3000);
589
- }
590
- // Tell PreviewPanel whether it may drop the selected cover. Clear ONLY when
591
- // the publish is confirmed on-chain AND the cover was actually attached:
592
- // - pre-stream block (#375) or failed/aborted publish (#376) → succeeded false → keep,
593
- // - published on-chain but the cover upload/attach failed (#376/re1) →
594
- // coverAttachFailed true → keep, so the writer doesn't silently lose the
595
- // cover that never made it onto the storyline (settable via Edit Story).
596
- // A publish with no selected cover (coverAttachFailed stays false) clears as
597
- // before once it succeeds.
598
- return succeeded && !coverAttachFailed;
599
- }, [authFetch, storyContentTypes, walletAddress]);
806
+ // A failed public-title verification (#379) is a durable warning that
807
+ // outranks the transient "Published!" line — the metadata is immutable, so
808
+ // the writer must know the next publish needs corrected metadata.
809
+ if (titleVerifyWarning) setPublishError(titleVerifyWarning);
810
+ setPublishProgress(
811
+ coverAttachFailed
812
+ ? "Published, but cover upload failed — set it later from Edit Story."
813
+ : "Published!",
814
+ );
815
+ } catch (err: unknown) {
816
+ const message = err instanceof Error ? err.message : "Publish failed";
817
+ setPublishProgress(`Error: ${message}`);
818
+ } finally {
819
+ setTimeout(() => {
820
+ setPublishingFile(null);
821
+ setPublishProgress("");
822
+ }, 3000);
823
+ }
824
+ // Tell PreviewPanel whether it may drop the selected cover. Clear ONLY when
825
+ // the publish is confirmed on-chain AND the cover was actually attached:
826
+ // - pre-stream block (#375) or failed/aborted publish (#376) → succeeded false → keep,
827
+ // - published on-chain but the cover upload/attach failed (#376/re1) →
828
+ // coverAttachFailed true → keep, so the writer doesn't silently lose the
829
+ // cover that never made it onto the storyline (settable via Edit Story).
830
+ // A publish with no selected cover (coverAttachFailed stays false) clears as
831
+ // before once it succeeds.
832
+ return succeeded && !coverAttachFailed;
833
+ },
834
+ [authFetch, storyContentTypes, walletAddress],
835
+ );
600
836
 
601
837
  const handleDestroySession = useCallback((name: string) => {
602
838
  if (name.startsWith("_new_")) {
@@ -621,9 +857,25 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
621
857
  }, []);
622
858
 
623
859
  useEffect(() => {
624
- const updateFromStories = (stories: { name: string; title?: string | null; hasStructure: boolean; hasGenesis?: boolean; contentType?: "fiction" | "cartoon"; language?: string; genre?: string; isNsfw?: boolean; agentProvider?: "claude" | "codex" }[]) => {
625
- setConfirmedStories(new Set(stories.filter((s) => s.hasStructure).map((s) => s.name)));
626
- setGenesisStories(new Set(stories.filter((s) => s.hasGenesis).map((s) => s.name)));
860
+ const updateFromStories = (
861
+ stories: {
862
+ name: string;
863
+ title?: string | null;
864
+ hasStructure: boolean;
865
+ hasGenesis?: boolean;
866
+ contentType?: "fiction" | "cartoon";
867
+ language?: string;
868
+ genre?: string;
869
+ isNsfw?: boolean;
870
+ agentProvider?: "claude" | "codex";
871
+ }[],
872
+ ) => {
873
+ setConfirmedStories(
874
+ new Set(stories.filter((s) => s.hasStructure).map((s) => s.name)),
875
+ );
876
+ setGenesisStories(
877
+ new Set(stories.filter((s) => s.hasGenesis).map((s) => s.name)),
878
+ );
627
879
  const ct: Record<string, "fiction" | "cartoon"> = {};
628
880
  const lang: Record<string, string | undefined> = {};
629
881
  const genre: Record<string, string | undefined> = {};
@@ -647,9 +899,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
647
899
  setStoryProviders(prov);
648
900
  setStoryTitles(titles);
649
901
  };
650
- authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
651
- if (data?.stories) updateFromStories(data.stories);
652
- }).catch(() => {});
902
+ authFetch("/api/stories")
903
+ .then((res) => (res.ok ? res.json() : null))
904
+ .then((data) => {
905
+ if (data?.stories) updateFromStories(data.stories);
906
+ })
907
+ .catch(() => {});
653
908
  const interval = setInterval(async () => {
654
909
  try {
655
910
  const res = await authFetch("/api/stories");
@@ -657,7 +912,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
657
912
  const data = await res.json();
658
913
  updateFromStories(data.stories);
659
914
  }
660
- } catch { /* ignore */ }
915
+ } catch {
916
+ /* ignore */
917
+ }
661
918
  }, 5000);
662
919
  return () => clearInterval(interval);
663
920
  }, [authFetch]);
@@ -668,15 +925,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
668
925
  // is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
669
926
  // permanently bricking cartoon if the probe errors.
670
927
  const codexReady =
671
- !!readiness && readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
928
+ !!readiness &&
929
+ readiness.codex.installed &&
930
+ readiness.codex.imageGeneration === "enabled";
672
931
  const cartoonBlocked = !!readiness && !codexReady;
673
932
 
674
933
  const copyCodexEnable = useCallback(async () => {
675
934
  try {
676
- await navigator.clipboard.writeText("codex features enable image_generation");
935
+ await navigator.clipboard.writeText(
936
+ "codex features enable image_generation",
937
+ );
677
938
  setCodexEnableCopied(true);
678
939
  setTimeout(() => setCodexEnableCopied(false), 2000);
679
- } catch { /* clipboard unavailable */ }
940
+ } catch {
941
+ /* clipboard unavailable */
942
+ }
680
943
  }, []);
681
944
 
682
945
  // Explicit, scoped repair for a legacy cartoon story with no recorded
@@ -702,29 +965,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
702
965
  const data = await listRes.json();
703
966
  if (data?.stories) {
704
967
  const prov: Record<string, "claude" | "codex" | undefined> = {};
705
- for (const s of data.stories as { name: string; agentProvider?: "claude" | "codex" }[]) {
968
+ for (const s of data.stories as {
969
+ name: string;
970
+ agentProvider?: "claude" | "codex";
971
+ }[]) {
706
972
  prov[s.name] = s.agentProvider;
707
973
  }
708
974
  setStoryProviders(prov);
709
975
  }
710
976
  }
711
- } catch { /* ignore */ }
977
+ } catch {
978
+ /* ignore */
979
+ }
712
980
  }, [authFetch, selectedStory]);
713
981
 
714
- const handleArchiveStory = useCallback((name: string) => {
715
- // Archive API already called by TerminalPanel — just clear selection
716
- if (selectedStory === name) {
717
- setSelectedStory(null);
718
- setSelectedFile(null);
719
- }
720
- }, [selectedStory]);
982
+ const handleArchiveStory = useCallback(
983
+ (name: string) => {
984
+ // Archive API already called by TerminalPanel — just clear selection
985
+ if (selectedStory === name) {
986
+ setSelectedStory(null);
987
+ setSelectedFile(null);
988
+ }
989
+ },
990
+ [selectedStory],
991
+ );
721
992
 
722
993
  // Resolve the effective provider for the selected story: an optimistic/new
723
994
  // session value (agentProviders) wins, else the persisted list value.
724
995
  const selectedProvider = selectedStory
725
996
  ? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
726
997
  : undefined;
727
- const selectedContentType = resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current);
998
+ const selectedContentType = resolveSelectedContentType(
999
+ selectedStory,
1000
+ storyContentTypes,
1001
+ contentTypeMap.current,
1002
+ );
728
1003
  const selectedNeedsProviderRepair = needsLegacyProviderRepair(
729
1004
  selectedContentType,
730
1005
  selectedProvider,
@@ -736,88 +1011,235 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
736
1011
  // ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
737
1012
  const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
738
1013
  const activeCartoonTab: CartoonWorkflowTab =
739
- cartoonView === "story-info" ? "story-info"
740
- : cartoonView === "episodes" ? "episodes"
741
- : cartoonView === "publish" ? "publish"
742
- : selectedFile === "structure.md" ? "whitepaper"
743
- : selectedFile === "genesis.md" ? "genesis"
744
- : selectedFile && /^plot-\d+\.md$/.test(selectedFile) ? "episodes"
745
- : "progress";
1014
+ cartoonView === "story-info"
1015
+ ? "story-info"
1016
+ : cartoonView === "episodes"
1017
+ ? "episodes"
1018
+ : cartoonView === "publish"
1019
+ ? "publish"
1020
+ : selectedFile === "structure.md"
1021
+ ? "whitepaper"
1022
+ : selectedFile === "genesis.md"
1023
+ ? "genesis"
1024
+ : selectedFile && /^plot-\d+\.md$/.test(selectedFile)
1025
+ ? "episodes"
1026
+ : "progress";
746
1027
 
747
- const handleCartoonNav = useCallback((tab: CartoonWorkflowTab) => {
748
- // Use the current selected story, not latestStoryRef — that ref is only set
749
- // by handleSelectStory, so opening another story's file via the LEFT tree
750
- // (handleSelectFile) would leave it stale and route file tabs to the wrong
751
- // story (#445 RE1). `selectedStory` always reflects the visible story.
752
- const story = selectedStory;
753
- if (!story) return;
754
- switch (tab) {
755
- case "progress": setCartoonView(null); setSelectedFile(null); break;
756
- case "story-info": setCartoonView("story-info"); break;
757
- case "episodes": setCartoonView("episodes"); break;
758
- case "whitepaper": handleSelectFile(story, "structure.md"); break;
759
- case "genesis": handleSelectFile(story, "genesis.md"); break;
760
- // Publish opens its own readiness page and stays on the Publish tab (#449),
761
- // instead of visually routing to the Genesis file view.
762
- case "publish": setCartoonView("publish"); break;
763
- }
764
- }, [selectedStory, handleSelectFile]);
1028
+ const handleCartoonNav = useCallback(
1029
+ (tab: CartoonWorkflowTab) => {
1030
+ // Use the current selected story, not latestStoryRef that ref is only set
1031
+ // by handleSelectStory, so opening another story's file via the LEFT tree
1032
+ // (handleSelectFile) would leave it stale and route file tabs to the wrong
1033
+ // story (#445 RE1). `selectedStory` always reflects the visible story.
1034
+ const story = selectedStory;
1035
+ if (!story) return;
1036
+ switch (tab) {
1037
+ case "progress":
1038
+ setCartoonView(null);
1039
+ setSelectedFile(null);
1040
+ break;
1041
+ case "story-info":
1042
+ setCartoonView("story-info");
1043
+ break;
1044
+ case "episodes":
1045
+ setCartoonView("episodes");
1046
+ break;
1047
+ case "whitepaper":
1048
+ handleSelectFile(story, "structure.md");
1049
+ break;
1050
+ case "genesis":
1051
+ handleSelectFile(story, "genesis.md");
1052
+ break;
1053
+ // Publish opens its own readiness page and stays on the Publish tab (#449),
1054
+ // instead of visually routing to the Genesis file view.
1055
+ case "publish":
1056
+ setCartoonView("publish");
1057
+ break;
1058
+ }
1059
+ },
1060
+ [selectedStory, handleSelectFile],
1061
+ );
1062
+
1063
+ const handleWorkflowNextAction = useCallback(
1064
+ (action: CoachUiAction, episodeFile: string | null) => {
1065
+ const story = selectedStory;
1066
+ if (!story) return;
1067
+ switch (action) {
1068
+ case "view-progress":
1069
+ setCartoonView(null);
1070
+ setSelectedFile(null);
1071
+ break;
1072
+ case "publish":
1073
+ setCartoonView("publish");
1074
+ break;
1075
+ case "open-cuts":
1076
+ case "open-lettering":
1077
+ case "upload":
1078
+ case "refresh-assets":
1079
+ case "generate-markdown":
1080
+ if (episodeFile) handleSelectFile(story, episodeFile);
1081
+ break;
1082
+ }
1083
+ },
1084
+ [selectedStory, handleSelectFile],
1085
+ );
765
1086
 
766
1087
  // Keep the publish-control seeds in sync after a Story Info save (#439).
767
- const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
768
- if (!selectedStory) return;
769
- if (patch.genre !== undefined) setStoryGenres((prev) => ({ ...prev, [selectedStory]: patch.genre || undefined }));
770
- if (patch.language !== undefined) setStoryLanguages((prev) => ({ ...prev, [selectedStory]: patch.language || undefined }));
771
- if (patch.isNsfw !== undefined) setStoryNsfw((prev) => ({ ...prev, [selectedStory]: patch.isNsfw }));
772
- }, [selectedStory]);
1088
+ const handleStoryInfoSaved = useCallback(
1089
+ (patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
1090
+ if (!selectedStory) return;
1091
+ if (patch.genre !== undefined)
1092
+ setStoryGenres((prev) => ({
1093
+ ...prev,
1094
+ [selectedStory]: patch.genre || undefined,
1095
+ }));
1096
+ if (patch.language !== undefined)
1097
+ setStoryLanguages((prev) => ({
1098
+ ...prev,
1099
+ [selectedStory]: patch.language || undefined,
1100
+ }));
1101
+ if (patch.isNsfw !== undefined)
1102
+ setStoryNsfw((prev) => ({ ...prev, [selectedStory]: patch.isNsfw }));
1103
+ },
1104
+ [selectedStory],
1105
+ );
1106
+
1107
+ const handleFocusedLetteringModeChange = useCallback((active: boolean) => {
1108
+ setFocusedLetteringMode(active);
1109
+ setFocusedLetteringWorkspaceVisible(active ? false : true);
1110
+ }, []);
1111
+
1112
+ const hideFocusedLetteringWorkspace =
1113
+ focusedLetteringMode && !focusedLetteringWorkspaceVisible;
773
1114
 
774
1115
  return (
775
- <div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
1116
+ <div
1117
+ ref={containerRef}
1118
+ className="h-[calc(100vh-3.5rem)] flex"
1119
+ data-testid={
1120
+ hideFocusedLetteringWorkspace
1121
+ ? "stories-focused-lettering-mode"
1122
+ : "stories-default-layout"
1123
+ }
1124
+ >
776
1125
  {/* Story Browser Sidebar */}
777
- <div className="w-56 border-r border-border flex-shrink-0">
778
- <StoryBrowser
779
- authFetch={authFetch}
780
- selectedStory={selectedStory}
781
- selectedFile={selectedFile}
782
- onSelectFile={handleSelectFile}
783
- onNewStory={handleNewStory}
784
- untitledSessions={untitledSessions}
785
- />
786
- </div>
1126
+ {!hideFocusedLetteringWorkspace && (
1127
+ <div className="w-56 border-r border-border flex-shrink-0">
1128
+ <StoryBrowser
1129
+ authFetch={authFetch}
1130
+ selectedStory={selectedStory}
1131
+ selectedFile={selectedFile}
1132
+ onSelectFile={handleSelectFile}
1133
+ onNewStory={handleNewStory}
1134
+ untitledSessions={untitledSessions}
1135
+ />
1136
+ </div>
1137
+ )}
787
1138
 
788
1139
  {/* Terminal — sized by ratio of available space */}
789
- <div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
790
- <TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} bypassStories={bypassStories} agentProviders={agentProviders} readiness={readiness} contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current)} needsProviderRepair={selectedNeedsProviderRepair} onRepairProvider={handleRepairProvider} />
791
- </div>
1140
+ {!hideFocusedLetteringWorkspace && (
1141
+ <div
1142
+ className="min-w-0 border-r border-border"
1143
+ style={{ flex: `${ratio} 0 0` }}
1144
+ >
1145
+ <TerminalPanel
1146
+ token={token}
1147
+ storyName={selectedStory}
1148
+ authFetch={authFetch}
1149
+ onSelectStory={handleSelectStory}
1150
+ onDestroySession={handleDestroySession}
1151
+ onArchiveStory={handleArchiveStory}
1152
+ confirmedStories={confirmedStories}
1153
+ renameRef={renameRef}
1154
+ bypassStories={bypassStories}
1155
+ agentProviders={agentProviders}
1156
+ readiness={readiness}
1157
+ contentType={resolveSelectedContentType(
1158
+ selectedStory,
1159
+ storyContentTypes,
1160
+ contentTypeMap.current,
1161
+ )}
1162
+ needsProviderRepair={selectedNeedsProviderRepair}
1163
+ onRepairProvider={handleRepairProvider}
1164
+ />
1165
+ </div>
1166
+ )}
792
1167
 
793
1168
  {/* Drag Handle */}
794
- <div
795
- onMouseDown={handleMouseDown}
796
- className="flex-shrink-0 flex items-center justify-center hover:bg-border/50 transition-colors"
797
- style={{ width: HANDLE_PX, cursor: "col-resize", background: "var(--border)" }}
798
- >
799
- <div className="flex flex-col gap-1">
800
- <div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
801
- <div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
802
- <div className="w-0.5 h-0.5 rounded-full" style={{ background: "var(--text-muted)" }} />
1169
+ {!hideFocusedLetteringWorkspace && (
1170
+ <div
1171
+ onMouseDown={handleMouseDown}
1172
+ className="flex-shrink-0 flex items-center justify-center hover:bg-border/50 transition-colors"
1173
+ style={{
1174
+ width: HANDLE_PX,
1175
+ cursor: "col-resize",
1176
+ background: "var(--border)",
1177
+ }}
1178
+ >
1179
+ <div className="flex flex-col gap-1">
1180
+ <div
1181
+ className="w-0.5 h-0.5 rounded-full"
1182
+ style={{ background: "var(--text-muted)" }}
1183
+ />
1184
+ <div
1185
+ className="w-0.5 h-0.5 rounded-full"
1186
+ style={{ background: "var(--text-muted)" }}
1187
+ />
1188
+ <div
1189
+ className="w-0.5 h-0.5 rounded-full"
1190
+ style={{ background: "var(--text-muted)" }}
1191
+ />
1192
+ </div>
803
1193
  </div>
804
- </div>
1194
+ )}
805
1195
 
806
1196
  {/* Preview — takes remaining space. With a story but no file selected, show
807
1197
  the story-level progress overview (#418) instead of the empty state. */}
808
- <div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
1198
+ <div
1199
+ className="min-w-0 flex flex-col"
1200
+ style={
1201
+ hideFocusedLetteringWorkspace
1202
+ ? { flex: "1 0 0" }
1203
+ : { flex: `${1 - ratio} 0 0` }
1204
+ }
1205
+ >
809
1206
  {/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
810
- {isCartoonStory && selectedStory && (
1207
+ {!focusedLetteringMode && isCartoonStory && selectedStory && (
811
1208
  <CartoonWorkflowNav
812
1209
  storyTitle={storyTitles[selectedStory] || selectedStory}
813
1210
  active={activeCartoonTab}
814
1211
  onSelect={handleCartoonNav}
815
1212
  />
816
1213
  )}
1214
+ {!focusedLetteringMode &&
1215
+ isCartoonStory &&
1216
+ selectedStory &&
1217
+ cartoonView !== null && (
1218
+ <div
1219
+ className="flex-shrink-0 border-b border-border"
1220
+ data-testid="workflow-context-next-action"
1221
+ >
1222
+ <CartoonNextAction
1223
+ storyName={selectedStory}
1224
+ authFetch={authFetch}
1225
+ refreshKey={cartoonPublishRefresh}
1226
+ onCoachAction={handleWorkflowNextAction}
1227
+ onOpenStoryInfo={() => setCartoonView("story-info")}
1228
+ />
1229
+ </div>
1230
+ )}
817
1231
  {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
818
- <StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
1232
+ <StoryInfoPage
1233
+ storyName={selectedStory}
1234
+ authFetch={authFetch}
1235
+ onSaved={handleStoryInfoSaved}
1236
+ />
819
1237
  ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
820
- <EpisodesPage storyName={selectedStory} authFetch={authFetch} onOpenFile={handleSelectFile} />
1238
+ <EpisodesPage
1239
+ storyName={selectedStory}
1240
+ authFetch={authFetch}
1241
+ onOpenFile={handleSelectFile}
1242
+ />
821
1243
  ) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
822
1244
  <CartoonPublishPage
823
1245
  storyName={selectedStory}
@@ -836,24 +1258,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
836
1258
  storyName={selectedStory}
837
1259
  authFetch={authFetch}
838
1260
  onOpenFile={handleSelectFile}
1261
+ onOpenStoryInfo={() => setCartoonView("story-info")}
839
1262
  />
840
1263
  ) : (
841
- <PreviewPanel
842
- storyName={selectedStory}
843
- fileName={selectedFile}
844
- authFetch={authFetch}
845
- onPublish={handlePublish}
846
- publishingFile={publishingFile}
847
- walletAddress={walletAddress}
848
- contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current) || "fiction"}
849
- language={selectedStory ? storyLanguages[selectedStory] : undefined}
850
- genre={selectedStory ? storyGenres[selectedStory] : undefined}
851
- isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
852
- hasGenesis={selectedStory ? genesisStories.has(selectedStory) : false}
853
- onViewProgress={() => setSelectedFile(null)}
854
- onOpenFile={(file) => selectedStory && handleSelectFile(selectedStory, file)}
855
- onViewPublish={() => setCartoonView("publish")}
856
- />
1264
+ <PreviewPanel
1265
+ storyName={selectedStory}
1266
+ fileName={selectedFile}
1267
+ authFetch={authFetch}
1268
+ onPublish={handlePublish}
1269
+ publishingFile={publishingFile}
1270
+ walletAddress={walletAddress}
1271
+ contentType={
1272
+ resolveSelectedContentType(
1273
+ selectedStory,
1274
+ storyContentTypes,
1275
+ contentTypeMap.current,
1276
+ ) || "fiction"
1277
+ }
1278
+ language={selectedStory ? storyLanguages[selectedStory] : undefined}
1279
+ genre={selectedStory ? storyGenres[selectedStory] : undefined}
1280
+ isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
1281
+ hasGenesis={
1282
+ selectedStory ? genesisStories.has(selectedStory) : false
1283
+ }
1284
+ onViewProgress={() => setSelectedFile(null)}
1285
+ onOpenFile={(file) =>
1286
+ selectedStory && handleSelectFile(selectedStory, file)
1287
+ }
1288
+ onViewPublish={() => setCartoonView("publish")}
1289
+ focusedLetteringMode={focusedLetteringMode}
1290
+ focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
1291
+ onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
1292
+ onFocusedLetteringWorkspaceVisibleChange={
1293
+ setFocusedLetteringWorkspaceVisible
1294
+ }
1295
+ />
857
1296
  )}
858
1297
  {publishProgress && (
859
1298
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
@@ -883,11 +1322,18 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
883
1322
  </div>
884
1323
 
885
1324
  {showNewStoryModal && (
886
- <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
1325
+ <div
1326
+ className="fixed inset-0 z-50 flex items-center justify-center"
1327
+ style={{ background: "rgba(240, 235, 225, 0.9)" }}
1328
+ >
887
1329
  <div className="bg-surface border border-border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
888
- <h3 className="text-sm font-serif font-medium text-foreground text-center">New Story</h3>
1330
+ <h3 className="text-sm font-serif font-medium text-foreground text-center">
1331
+ New Story
1332
+ </h3>
889
1333
  <label className="block space-y-1">
890
- <span className="text-[10px] font-medium text-muted">Title <span className="text-accent">*</span></span>
1334
+ <span className="text-[10px] font-medium text-muted">
1335
+ Title <span className="text-accent">*</span>
1336
+ </span>
891
1337
  <input
892
1338
  type="text"
893
1339
  value={newStoryTitle}
@@ -898,7 +1344,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
898
1344
  />
899
1345
  </label>
900
1346
  <label className="block space-y-1">
901
- <span className="text-[10px] font-medium text-muted">Short description (optional)</span>
1347
+ <span className="text-[10px] font-medium text-muted">
1348
+ Short description (optional)
1349
+ </span>
902
1350
  <input
903
1351
  type="text"
904
1352
  value={newStoryDescription}
@@ -909,7 +1357,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
909
1357
  />
910
1358
  </label>
911
1359
  <label className="block space-y-1">
912
- <span className="text-[10px] font-medium text-muted">Genre (optional)</span>
1360
+ <span className="text-[10px] font-medium text-muted">
1361
+ Genre (optional)
1362
+ </span>
913
1363
  <select
914
1364
  value={newStoryGenre}
915
1365
  onChange={(e) => setNewStoryGenre(e.target.value)}
@@ -917,24 +1367,38 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
917
1367
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
918
1368
  >
919
1369
  <option value="">— Select later —</option>
920
- {GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
1370
+ {GENRES.map((g) => (
1371
+ <option key={g} value={g}>
1372
+ {g}
1373
+ </option>
1374
+ ))}
921
1375
  </select>
922
1376
  </label>
923
1377
  <label className="block space-y-1">
924
- <span className="text-[10px] font-medium text-muted">Language</span>
1378
+ <span className="text-[10px] font-medium text-muted">
1379
+ Language
1380
+ </span>
925
1381
  <select
926
1382
  value={newStoryLanguage}
927
1383
  onChange={(e) => setNewStoryLanguage(e.target.value)}
928
1384
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
929
1385
  >
930
- {LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
1386
+ {LANGUAGES.map((l) => (
1387
+ <option key={l} value={l}>
1388
+ {l}
1389
+ </option>
1390
+ ))}
931
1391
  </select>
932
1392
  </label>
933
1393
  <label className="block space-y-1">
934
- <span className="text-[10px] font-medium text-muted">Agent mode</span>
1394
+ <span className="text-[10px] font-medium text-muted">
1395
+ Agent mode
1396
+ </span>
935
1397
  <select
936
1398
  value={newStoryAgentMode}
937
- onChange={(e) => setNewStoryAgentMode(e.target.value as "normal" | "bypass")}
1399
+ onChange={(e) =>
1400
+ setNewStoryAgentMode(e.target.value as "normal" | "bypass")
1401
+ }
938
1402
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
939
1403
  data-testid="agent-mode-select"
940
1404
  >
@@ -942,53 +1406,97 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
942
1406
  <option value="bypass">Permissions Bypass (advanced)</option>
943
1407
  </select>
944
1408
  {newStoryAgentMode === "bypass" && (
945
- <p className="text-[10px] text-amber-700" data-testid="agent-mode-warning">
946
- Less safe: Claude can run actions without per-command approval.
1409
+ <p
1410
+ className="text-[10px] text-amber-700"
1411
+ data-testid="agent-mode-warning"
1412
+ >
1413
+ Less safe: Claude can run actions without per-command
1414
+ approval.
947
1415
  </p>
948
1416
  )}
949
1417
  </label>
950
1418
  <label className="block space-y-1">
951
- <span className="text-[10px] font-medium text-muted">Provider</span>
1419
+ <span className="text-[10px] font-medium text-muted">
1420
+ Provider
1421
+ </span>
952
1422
  <select
953
1423
  value={newStoryAgentProvider}
954
- onChange={(e) => setNewStoryAgentProvider(e.target.value as "claude" | "codex")}
1424
+ onChange={(e) =>
1425
+ setNewStoryAgentProvider(e.target.value as "claude" | "codex")
1426
+ }
955
1427
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
956
1428
  data-testid="agent-provider-select"
957
1429
  >
958
1430
  <option value="claude">🤖 Claude (default)</option>
959
1431
  <option value="codex">🎨 Codex</option>
960
1432
  </select>
961
- <p className="text-[10px] text-muted" data-testid="agent-provider-helper">
1433
+ <p
1434
+ className="text-[10px] text-muted"
1435
+ data-testid="agent-provider-helper"
1436
+ >
962
1437
  {newStoryAgentProvider === "codex"
963
1438
  ? "Codex can generate clean cartoon images directly in the terminal."
964
1439
  : "Claude prepares image prompts; you generate and upload clean images externally."}
965
1440
  </p>
966
1441
  </label>
967
- <p className="text-xs text-muted text-center">Choose a content type to create</p>
1442
+ <p className="text-xs text-muted text-center">
1443
+ Choose a content type to create
1444
+ </p>
968
1445
  {!newStoryTitle.trim() && (
969
- <p className="text-[10px] text-amber-700 text-center" data-testid="new-story-title-required">Enter a title to create your story.</p>
1446
+ <p
1447
+ className="text-[10px] text-amber-700 text-center"
1448
+ data-testid="new-story-title-required"
1449
+ >
1450
+ Enter a title to create your story.
1451
+ </p>
970
1452
  )}
971
1453
  <div className="grid grid-cols-2 gap-3">
972
1454
  <button
973
- onClick={() => handleCreateStory("fiction", newStoryLanguage, newStoryAgentMode, newStoryAgentProvider)}
1455
+ onClick={() =>
1456
+ handleCreateStory(
1457
+ "fiction",
1458
+ newStoryLanguage,
1459
+ newStoryAgentMode,
1460
+ newStoryAgentProvider,
1461
+ )
1462
+ }
974
1463
  disabled={!newStoryTitle.trim()}
975
1464
  data-testid="create-fiction"
976
1465
  className="border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
977
1466
  >
978
- <p className="text-sm font-serif font-medium text-foreground">Fiction</p>
979
- <p className="text-[11px] text-muted">Novels, short stories, poetry</p>
1467
+ <p className="text-sm font-serif font-medium text-foreground">
1468
+ Fiction
1469
+ </p>
1470
+ <p className="text-[11px] text-muted">
1471
+ Novels, short stories, poetry
1472
+ </p>
980
1473
  </button>
981
1474
  <div className="space-y-1">
982
1475
  <button
983
- onClick={() => handleCreateStory("cartoon", newStoryLanguage, newStoryAgentMode, "codex")}
1476
+ onClick={() =>
1477
+ handleCreateStory(
1478
+ "cartoon",
1479
+ newStoryLanguage,
1480
+ newStoryAgentMode,
1481
+ "codex",
1482
+ )
1483
+ }
984
1484
  disabled={cartoonBlocked || !newStoryTitle.trim()}
985
1485
  data-testid="create-cartoon"
986
1486
  className="w-full border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
987
1487
  >
988
- <p className="text-sm font-serif font-medium text-foreground">Cartoon</p>
989
- <p className="text-[11px] text-muted">Comics, manga, webtoons</p>
990
- <p className="text-[11px] text-muted" data-testid="cartoon-codex-note">
991
- Cartoon mode requires Codex because the clean-image step needs image generation support.
1488
+ <p className="text-sm font-serif font-medium text-foreground">
1489
+ Cartoon
1490
+ </p>
1491
+ <p className="text-[11px] text-muted">
1492
+ Comics, manga, webtoons
1493
+ </p>
1494
+ <p
1495
+ className="text-[11px] text-muted"
1496
+ data-testid="cartoon-codex-note"
1497
+ >
1498
+ Cartoon mode requires Codex because the clean-image step
1499
+ needs image generation support.
992
1500
  </p>
993
1501
  </button>
994
1502
  {/* Warnings/copy live OUTSIDE the button: a disabled button would
@@ -999,8 +1507,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
999
1507
  data-testid="cartoon-codex-warning"
1000
1508
  >
1001
1509
  Codex was not detected. Install the Codex CLI and sign in
1002
- (e.g. <span className="font-mono">npm i -g @openai/codex</span> then{" "}
1003
- <span className="font-mono">codex login</span>) to create cartoons.
1510
+ (e.g.{" "}
1511
+ <span className="font-mono">npm i -g @openai/codex</span>{" "}
1512
+ then <span className="font-mono">codex login</span>) to
1513
+ create cartoons.
1004
1514
  </p>
1005
1515
  )}
1006
1516
  {isCodexAuthUnclear(readiness) && (
@@ -1017,8 +1527,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1017
1527
  readiness.codex.imageGeneration !== "enabled" && (
1018
1528
  <div data-testid="cartoon-codex-warning">
1019
1529
  <p className="text-[11px] text-amber-700 text-left">
1020
- Codex is installed but image generation isn&apos;t enabled.
1021
- Enable it, then reopen this dialog:
1530
+ Codex is installed but image generation isn&apos;t
1531
+ enabled. Enable it, then reopen this dialog:
1022
1532
  </p>
1023
1533
  <div className="mt-1 flex items-center gap-1">
1024
1534
  <code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">