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