plotlink-ows 1.2.95 → 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,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,84 @@ 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);
94
146
  const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
95
147
  const languageMap = useRef<Map<string, string>>(new Map());
96
148
  const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
97
149
  const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
98
150
  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);
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);
100
164
  const containerRef = useRef<HTMLDivElement>(null);
101
165
  const dragging = useRef(false);
102
166
 
103
167
  // Fetch wallet address for edit panel authorship check
104
168
  useEffect(() => {
105
169
  authFetch("/api/wallet")
106
- .then((res) => res.ok ? res.json() : null)
107
- .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
+ })
108
174
  .catch(() => {});
109
175
  }, [authFetch]);
110
176
 
@@ -112,21 +178,30 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
112
178
  // readiness null (no warning shown); this never blocks fiction or cartoon.
113
179
  useEffect(() => {
114
180
  authFetch("/api/agent/readiness")
115
- .then((res) => res.ok ? res.json() : null)
116
- .then((data) => { if (data) setReadiness(data); })
181
+ .then((res) => (res.ok ? res.json() : null))
182
+ .then((data) => {
183
+ if (data) setReadiness(data);
184
+ })
117
185
  .catch(() => {});
118
186
  }, [authFetch]);
119
187
 
120
188
  // Persist ratio to localStorage
121
189
  useEffect(() => {
122
- try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
190
+ try {
191
+ localStorage.setItem(STORAGE_KEY, String(ratio));
192
+ } catch {
193
+ /* ignore */
194
+ }
123
195
  }, [ratio]);
124
196
 
125
197
  // Clamp ratio on window resize so panels stay above MIN_PANEL_PX
126
198
  useEffect(() => {
127
199
  const onResize = () => {
128
200
  if (!containerRef.current) return;
129
- const available = containerRef.current.getBoundingClientRect().width - SIDEBAR_PX - HANDLE_PX;
201
+ const available =
202
+ containerRef.current.getBoundingClientRect().width -
203
+ SIDEBAR_PX -
204
+ HANDLE_PX;
130
205
  setRatio((prev) => clampRatio(prev, available));
131
206
  };
132
207
  window.addEventListener("resize", onResize);
@@ -148,38 +223,50 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
148
223
  // title/metadata (server writes .story.json + CLAUDE.md), then land on the
149
224
  // story progress overview (#418) so the user sees what's next + a copy-paste
150
225
  // 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]);
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
+ );
183
270
 
184
271
  // Poll for new stories and auto-transition untitled sessions
185
272
  useEffect(() => {
@@ -192,11 +279,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
192
279
  const currentNames = new Set<string>(
193
280
  (data.stories as { name: string }[])
194
281
  .filter((s) => s.name !== "_example")
195
- .map((s) => s.name)
282
+ .map((s) => s.name),
196
283
  );
197
284
  // Detect newly appeared stories
198
285
  for (const name of currentNames) {
199
- if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
286
+ if (
287
+ !knownStoriesRef.current.has(name) &&
288
+ untitledSessions.length > 0
289
+ ) {
200
290
  // New story appeared — rename the oldest untitled session to the story name
201
291
  const oldName = untitledSessions[0];
202
292
  // Read the pending session's metadata BEFORE the rename so it can be
@@ -207,9 +297,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
207
297
  const provider = agentProviderMap.current.get(oldName) || "claude";
208
298
  let renamed = false;
209
299
  if (renameRef.current) {
210
- renamed = await renameRef.current(oldName, name, {
211
- contentType: ct, language: lang, agentMode: mode, agentProvider: provider,
212
- }).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);
213
308
  }
214
309
  if (renamed) {
215
310
  setUntitledSessions((prev) => prev.slice(1));
@@ -232,7 +327,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
232
327
  authFetch(`/api/stories/${name}/metadata`, {
233
328
  method: "POST",
234
329
  headers: { "Content-Type": "application/json" },
235
- 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
+ }),
236
336
  }).catch(() => {});
237
337
  }
238
338
  setSelectedStory(name);
@@ -240,60 +340,78 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
240
340
  }
241
341
  }
242
342
  knownStoriesRef.current = currentNames;
243
- } catch { /* ignore */ }
343
+ } catch {
344
+ /* ignore */
345
+ }
244
346
  }, 3000);
245
347
  return () => clearInterval(interval);
246
348
  }, [authFetch, untitledSessions]);
247
349
 
248
350
  // Initialize known stories on mount
249
351
  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(() => {});
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(() => {});
261
366
  }, [authFetch]);
262
367
 
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
- }, []);
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
+ );
268
376
 
269
377
  const latestStoryRef = useRef<string | null>(null);
270
378
 
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);
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 */
294
411
  }
295
- } catch { /* ignore — stays on overview */ }
296
- }, [authFetch]);
412
+ },
413
+ [authFetch],
414
+ );
297
415
 
298
416
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
299
417
  e.preventDefault();
@@ -321,284 +439,400 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
321
439
  window.addEventListener("mouseup", onMouseUp);
322
440
  }, []);
323
441
 
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;
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;
339
465
 
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
- });
375
-
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.",
466
+ try {
467
+ // Get file content
468
+ const fileRes = await authFetch(
469
+ `/api/stories/${storyName}/${fileName}`,
384
470
  );
385
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
386
- return false;
387
- }
471
+ if (!fileRes.ok) throw new Error("Failed to read file");
472
+ const fileData = await fileRes.json();
388
473
 
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
- }
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
+ });
401
515
 
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) {
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
+ ) {
409
523
  setPublishProgress(
410
- `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.",
411
527
  );
412
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
528
+ setTimeout(() => {
529
+ setPublishingFile(null);
530
+ setPublishProgress("");
531
+ }, 6000);
413
532
  return false;
414
533
  }
415
- }
416
534
 
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)) {
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
+ ) {
427
547
  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).",
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.",
429
549
  );
430
- setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
431
- return;
550
+ setTimeout(() => {
551
+ setPublishingFile(null);
552
+ setPublishProgress("");
553
+ }, 6000);
554
+ return false;
432
555
  }
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;
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;
440
574
  }
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
575
  }
447
- }
448
576
 
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("");
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);
467
617
  return false;
468
618
  }
469
619
  }
470
- } catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
471
620
 
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
- });
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
+ }
482
645
 
483
- if (!publishRes.ok) {
484
- const err = await publishRes.json();
485
- throw new Error(err.error || "Publish failed");
486
- }
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
+ });
487
675
 
488
- // Read SSE stream
489
- const reader = publishRes.body?.getReader();
490
- const decoder = new TextDecoder();
676
+ if (!publishRes.ok) {
677
+ const err = await publishRes.json();
678
+ throw new Error(err.error || "Publish failed");
679
+ }
491
680
 
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);
681
+ // Read SSE stream
682
+ const reader = publishRes.body?.getReader();
683
+ const decoder = new TextDecoder();
525
684
 
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
- }
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);
542
723
 
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);
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 */
565
744
  }
566
- } catch { /* inconclusive don't false-warn on a read failure */ }
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
+ }
750
+
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 */
796
+ }
797
+ }
567
798
  }
799
+ } catch {
800
+ /* ignore partial SSE */
568
801
  }
569
- } catch { /* ignore partial SSE */ }
802
+ }
570
803
  }
571
804
  }
572
- }
573
805
 
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]);
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
+ );
602
836
 
603
837
  const handleDestroySession = useCallback((name: string) => {
604
838
  if (name.startsWith("_new_")) {
@@ -623,9 +857,25 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
623
857
  }, []);
624
858
 
625
859
  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)));
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
+ );
629
879
  const ct: Record<string, "fiction" | "cartoon"> = {};
630
880
  const lang: Record<string, string | undefined> = {};
631
881
  const genre: Record<string, string | undefined> = {};
@@ -649,9 +899,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
649
899
  setStoryProviders(prov);
650
900
  setStoryTitles(titles);
651
901
  };
652
- authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
653
- if (data?.stories) updateFromStories(data.stories);
654
- }).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(() => {});
655
908
  const interval = setInterval(async () => {
656
909
  try {
657
910
  const res = await authFetch("/api/stories");
@@ -659,7 +912,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
659
912
  const data = await res.json();
660
913
  updateFromStories(data.stories);
661
914
  }
662
- } catch { /* ignore */ }
915
+ } catch {
916
+ /* ignore */
917
+ }
663
918
  }, 5000);
664
919
  return () => clearInterval(interval);
665
920
  }, [authFetch]);
@@ -670,15 +925,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
670
925
  // is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
671
926
  // permanently bricking cartoon if the probe errors.
672
927
  const codexReady =
673
- !!readiness && readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
928
+ !!readiness &&
929
+ readiness.codex.installed &&
930
+ readiness.codex.imageGeneration === "enabled";
674
931
  const cartoonBlocked = !!readiness && !codexReady;
675
932
 
676
933
  const copyCodexEnable = useCallback(async () => {
677
934
  try {
678
- await navigator.clipboard.writeText("codex features enable image_generation");
935
+ await navigator.clipboard.writeText(
936
+ "codex features enable image_generation",
937
+ );
679
938
  setCodexEnableCopied(true);
680
939
  setTimeout(() => setCodexEnableCopied(false), 2000);
681
- } catch { /* clipboard unavailable */ }
940
+ } catch {
941
+ /* clipboard unavailable */
942
+ }
682
943
  }, []);
683
944
 
684
945
  // Explicit, scoped repair for a legacy cartoon story with no recorded
@@ -704,29 +965,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
704
965
  const data = await listRes.json();
705
966
  if (data?.stories) {
706
967
  const prov: Record<string, "claude" | "codex" | undefined> = {};
707
- 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
+ }[]) {
708
972
  prov[s.name] = s.agentProvider;
709
973
  }
710
974
  setStoryProviders(prov);
711
975
  }
712
976
  }
713
- } catch { /* ignore */ }
977
+ } catch {
978
+ /* ignore */
979
+ }
714
980
  }, [authFetch, selectedStory]);
715
981
 
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]);
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
+ );
723
992
 
724
993
  // Resolve the effective provider for the selected story: an optimistic/new
725
994
  // session value (agentProviders) wins, else the persisted list value.
726
995
  const selectedProvider = selectedStory
727
996
  ? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
728
997
  : undefined;
729
- const selectedContentType = resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current);
998
+ const selectedContentType = resolveSelectedContentType(
999
+ selectedStory,
1000
+ storyContentTypes,
1001
+ contentTypeMap.current,
1002
+ );
730
1003
  const selectedNeedsProviderRepair = needsLegacyProviderRepair(
731
1004
  selectedContentType,
732
1005
  selectedProvider,
@@ -738,120 +1011,235 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
738
1011
  // ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
739
1012
  const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
740
1013
  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";
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";
748
1027
 
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]);
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
+ );
767
1062
 
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]);
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
+ );
788
1086
 
789
1087
  // 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]);
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;
796
1114
 
797
1115
  return (
798
- <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
+ >
799
1125
  {/* 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>
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
+ )}
810
1138
 
811
1139
  {/* 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>
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
+ )}
815
1167
 
816
1168
  {/* 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)" }} />
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>
826
1193
  </div>
827
- </div>
1194
+ )}
828
1195
 
829
1196
  {/* Preview — takes remaining space. With a story but no file selected, show
830
1197
  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` }}>
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
+ >
832
1206
  {/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
833
- {isCartoonStory && selectedStory && (
1207
+ {!focusedLetteringMode && isCartoonStory && selectedStory && (
834
1208
  <CartoonWorkflowNav
835
1209
  storyTitle={storyTitles[selectedStory] || selectedStory}
836
1210
  active={activeCartoonTab}
837
1211
  onSelect={handleCartoonNav}
838
1212
  />
839
1213
  )}
840
- {isCartoonStory && selectedStory && cartoonView !== null && (
841
- <div className="flex-shrink-0 border-b border-border" data-testid="workflow-context-next-action">
842
- <CartoonNextAction
843
- storyName={selectedStory}
844
- authFetch={authFetch}
845
- refreshKey={cartoonPublishRefresh}
846
- onCoachAction={handleWorkflowNextAction}
847
- onOpenStoryInfo={() => setCartoonView("story-info")}
848
- />
849
- </div>
850
- )}
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
+ )}
851
1231
  {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
852
- <StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
1232
+ <StoryInfoPage
1233
+ storyName={selectedStory}
1234
+ authFetch={authFetch}
1235
+ onSaved={handleStoryInfoSaved}
1236
+ />
853
1237
  ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
854
- <EpisodesPage storyName={selectedStory} authFetch={authFetch} onOpenFile={handleSelectFile} />
1238
+ <EpisodesPage
1239
+ storyName={selectedStory}
1240
+ authFetch={authFetch}
1241
+ onOpenFile={handleSelectFile}
1242
+ />
855
1243
  ) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
856
1244
  <CartoonPublishPage
857
1245
  storyName={selectedStory}
@@ -873,22 +1261,38 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
873
1261
  onOpenStoryInfo={() => setCartoonView("story-info")}
874
1262
  />
875
1263
  ) : (
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
- />
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
+ />
892
1296
  )}
893
1297
  {publishProgress && (
894
1298
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
@@ -918,11 +1322,18 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
918
1322
  </div>
919
1323
 
920
1324
  {showNewStoryModal && (
921
- <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
+ >
922
1329
  <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>
1330
+ <h3 className="text-sm font-serif font-medium text-foreground text-center">
1331
+ New Story
1332
+ </h3>
924
1333
  <label className="block space-y-1">
925
- <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>
926
1337
  <input
927
1338
  type="text"
928
1339
  value={newStoryTitle}
@@ -933,7 +1344,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
933
1344
  />
934
1345
  </label>
935
1346
  <label className="block space-y-1">
936
- <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>
937
1350
  <input
938
1351
  type="text"
939
1352
  value={newStoryDescription}
@@ -944,7 +1357,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
944
1357
  />
945
1358
  </label>
946
1359
  <label className="block space-y-1">
947
- <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>
948
1363
  <select
949
1364
  value={newStoryGenre}
950
1365
  onChange={(e) => setNewStoryGenre(e.target.value)}
@@ -952,24 +1367,38 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
952
1367
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
953
1368
  >
954
1369
  <option value="">— Select later —</option>
955
- {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
+ ))}
956
1375
  </select>
957
1376
  </label>
958
1377
  <label className="block space-y-1">
959
- <span className="text-[10px] font-medium text-muted">Language</span>
1378
+ <span className="text-[10px] font-medium text-muted">
1379
+ Language
1380
+ </span>
960
1381
  <select
961
1382
  value={newStoryLanguage}
962
1383
  onChange={(e) => setNewStoryLanguage(e.target.value)}
963
1384
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
964
1385
  >
965
- {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
+ ))}
966
1391
  </select>
967
1392
  </label>
968
1393
  <label className="block space-y-1">
969
- <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>
970
1397
  <select
971
1398
  value={newStoryAgentMode}
972
- onChange={(e) => setNewStoryAgentMode(e.target.value as "normal" | "bypass")}
1399
+ onChange={(e) =>
1400
+ setNewStoryAgentMode(e.target.value as "normal" | "bypass")
1401
+ }
973
1402
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
974
1403
  data-testid="agent-mode-select"
975
1404
  >
@@ -977,53 +1406,97 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
977
1406
  <option value="bypass">Permissions Bypass (advanced)</option>
978
1407
  </select>
979
1408
  {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.
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.
982
1415
  </p>
983
1416
  )}
984
1417
  </label>
985
1418
  <label className="block space-y-1">
986
- <span className="text-[10px] font-medium text-muted">Provider</span>
1419
+ <span className="text-[10px] font-medium text-muted">
1420
+ Provider
1421
+ </span>
987
1422
  <select
988
1423
  value={newStoryAgentProvider}
989
- onChange={(e) => setNewStoryAgentProvider(e.target.value as "claude" | "codex")}
1424
+ onChange={(e) =>
1425
+ setNewStoryAgentProvider(e.target.value as "claude" | "codex")
1426
+ }
990
1427
  className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
991
1428
  data-testid="agent-provider-select"
992
1429
  >
993
1430
  <option value="claude">🤖 Claude (default)</option>
994
1431
  <option value="codex">🎨 Codex</option>
995
1432
  </select>
996
- <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
+ >
997
1437
  {newStoryAgentProvider === "codex"
998
1438
  ? "Codex can generate clean cartoon images directly in the terminal."
999
1439
  : "Claude prepares image prompts; you generate and upload clean images externally."}
1000
1440
  </p>
1001
1441
  </label>
1002
- <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>
1003
1445
  {!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>
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>
1005
1452
  )}
1006
1453
  <div className="grid grid-cols-2 gap-3">
1007
1454
  <button
1008
- onClick={() => handleCreateStory("fiction", newStoryLanguage, newStoryAgentMode, newStoryAgentProvider)}
1455
+ onClick={() =>
1456
+ handleCreateStory(
1457
+ "fiction",
1458
+ newStoryLanguage,
1459
+ newStoryAgentMode,
1460
+ newStoryAgentProvider,
1461
+ )
1462
+ }
1009
1463
  disabled={!newStoryTitle.trim()}
1010
1464
  data-testid="create-fiction"
1011
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"
1012
1466
  >
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>
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>
1015
1473
  </button>
1016
1474
  <div className="space-y-1">
1017
1475
  <button
1018
- onClick={() => handleCreateStory("cartoon", newStoryLanguage, newStoryAgentMode, "codex")}
1476
+ onClick={() =>
1477
+ handleCreateStory(
1478
+ "cartoon",
1479
+ newStoryLanguage,
1480
+ newStoryAgentMode,
1481
+ "codex",
1482
+ )
1483
+ }
1019
1484
  disabled={cartoonBlocked || !newStoryTitle.trim()}
1020
1485
  data-testid="create-cartoon"
1021
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"
1022
1487
  >
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.
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.
1027
1500
  </p>
1028
1501
  </button>
1029
1502
  {/* Warnings/copy live OUTSIDE the button: a disabled button would
@@ -1034,8 +1507,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1034
1507
  data-testid="cartoon-codex-warning"
1035
1508
  >
1036
1509
  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.
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.
1039
1514
  </p>
1040
1515
  )}
1041
1516
  {isCodexAuthUnclear(readiness) && (
@@ -1052,8 +1527,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1052
1527
  readiness.codex.imageGeneration !== "enabled" && (
1053
1528
  <div data-testid="cartoon-codex-warning">
1054
1529
  <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:
1530
+ Codex is installed but image generation isn&apos;t
1531
+ enabled. Enable it, then reopen this dialog:
1057
1532
  </p>
1058
1533
  <div className="mt-1 flex items-center gap-1">
1059
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">