plotlink-ows 1.0.33 → 1.2.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. package/scripts/e2e-verify.ts +0 -1100
@@ -2,6 +2,18 @@ import { useState, useCallback, useRef, useEffect } from "react";
2
2
  import { StoryBrowser } from "./StoryBrowser";
3
3
  import { TerminalPanel } from "./TerminalPanel";
4
4
  import { PreviewPanel } from "./PreviewPanel";
5
+ import { StoryProgressPanel } from "./StoryProgressPanel";
6
+ import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNav";
7
+ import { StoryInfoPage } from "./StoryInfoPage";
8
+ import { EpisodesPage } from "./EpisodesPage";
9
+ import { CartoonPublishPage } from "./CartoonPublishPage";
10
+ import { CartoonNextAction } from "./CartoonNextAction";
11
+ 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 { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
16
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
5
17
 
6
18
  interface StoriesPageProps {
7
19
  token: string;
@@ -36,13 +48,55 @@ function clampRatio(r: number, available: number): number {
36
48
  export function StoriesPage({ token, authFetch }: StoriesPageProps) {
37
49
  const [selectedStory, setSelectedStory] = useState<string | null>(null);
38
50
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
51
+ // Cartoon right-panel workflow nav (#439): a non-file workflow page is open
52
+ // (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
53
+ const [cartoonView, setCartoonView] = useState<"story-info" | "episodes" | "publish" | null>(null);
39
54
  const [publishingFile, setPublishingFile] = useState<string | null>(null);
55
+ // Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
56
+ // and advances to the next episode (#461).
57
+ const [cartoonPublishRefresh, setCartoonPublishRefresh] = useState(0);
40
58
  const [publishProgress, setPublishProgress] = useState<string>("");
59
+ // Durable publish blocker (#375): unlike the transient publishProgress text,
60
+ // this stays visible until the writer dismisses it or starts a new publish, so
61
+ // an insufficient-balance preflight block doesn't silently vanish.
62
+ const [publishError, setPublishError] = useState<string | null>(null);
41
63
  const [walletAddress, setWalletAddress] = useState<string | null>(null);
42
64
  const [ratio, setRatio] = useState(loadRatio);
43
65
  const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
66
+ const [showNewStoryModal, setShowNewStoryModal] = useState(false);
67
+ const [newStoryTitle, setNewStoryTitle] = useState("");
68
+ const [newStoryDescription, setNewStoryDescription] = useState("");
69
+ const [newStoryGenre, setNewStoryGenre] = useState("");
70
+ const [newStoryLanguage, setNewStoryLanguage] = useState("English");
71
+ const [newStoryAgentMode, setNewStoryAgentMode] = useState<"normal" | "bypass">("normal");
72
+ const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<"claude" | "codex">("claude");
73
+ const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
74
+ const [codexEnableCopied, setCodexEnableCopied] = useState(false);
75
+ const [bypassStories, setBypassStories] = useState<Record<string, boolean>>({});
76
+ const [agentProviders, setAgentProviders] = useState<Record<string, "claude" | "codex">>({});
77
+ // Track confirmed stories (those with structure.md) for Archive gating
78
+ const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
79
+ // Stories that already have a genesis.md — so the outline footer can suggest
80
+ // reviewing Genesis rather than writing it again (#422).
81
+ const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
82
+ const [storyContentTypes, setStoryContentTypes] = useState<Record<string, "fiction" | "cartoon">>({});
83
+ // `undefined` ⇒ language couldn't be determined for the story → the publish
84
+ // panel shows "Needs metadata" rather than defaulting to English (#424).
85
+ const [storyLanguages, setStoryLanguages] = useState<Record<string, string | undefined>>({});
86
+ // Publish metadata from .story.json (#424) so the publish controls seed real
87
+ // 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>>({});
90
+ const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
91
+ // Provider recorded on each persisted story (read-only, from /api/stories).
92
+ // Absent ⇒ legacy story with no provider (defaults to Claude at launch).
93
+ const [storyProviders, setStoryProviders] = useState<Record<string, "claude" | "codex" | undefined>>({});
94
+ const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
95
+ const languageMap = useRef<Map<string, string>>(new Map());
96
+ const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
97
+ const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
44
98
  const knownStoriesRef = useRef<Set<string>>(new Set());
45
- const renameRef = useRef<((oldName: string, newName: string) => Promise<boolean>) | null>(null);
99
+ const renameRef = useRef<((oldName: string, newName: string, meta?: { contentType?: "fiction" | "cartoon"; language?: string; agentMode?: "normal" | "bypass"; agentProvider?: "claude" | "codex" }) => Promise<boolean>) | null>(null);
46
100
  const containerRef = useRef<HTMLDivElement>(null);
47
101
  const dragging = useRef(false);
48
102
 
@@ -54,6 +108,15 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
54
108
  .catch(() => {});
55
109
  }, [authFetch]);
56
110
 
111
+ // Best-effort agent-readiness probe for cartoon-mode guidance. Failures leave
112
+ // readiness null (no warning shown); this never blocks fiction or cartoon.
113
+ useEffect(() => {
114
+ authFetch("/api/agent/readiness")
115
+ .then((res) => res.ok ? res.json() : null)
116
+ .then((data) => { if (data) setReadiness(data); })
117
+ .catch(() => {});
118
+ }, [authFetch]);
119
+
57
120
  // Persist ratio to localStorage
58
121
  useEffect(() => {
59
122
  try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
@@ -73,12 +136,51 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
73
136
  }, []);
74
137
 
75
138
  const handleNewStory = useCallback(() => {
76
- const id = `_new_${Date.now()}`;
77
- setUntitledSessions((prev) => [...prev, id]);
78
- setSelectedStory(id);
79
- setSelectedFile(null);
139
+ setNewStoryTitle("");
140
+ setNewStoryDescription("");
141
+ setNewStoryGenre("");
142
+ setNewStoryAgentMode("normal");
143
+ setNewStoryAgentProvider("claude");
144
+ setShowNewStoryModal(true);
80
145
  }, []);
81
146
 
147
+ // Guided New Story (#423): create the named story up front from the chosen
148
+ // title/metadata (server writes .story.json + CLAUDE.md), then land on the
149
+ // story progress overview (#418) so the user sees what's next + a copy-paste
150
+ // 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]);
183
+
82
184
  // Poll for new stories and auto-transition untitled sessions
83
185
  useEffect(() => {
84
186
  if (untitledSessions.length === 0) return;
@@ -97,12 +199,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
97
199
  if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
98
200
  // New story appeared — rename the oldest untitled session to the story name
99
201
  const oldName = untitledSessions[0];
202
+ // Read the pending session's metadata BEFORE the rename so it can be
203
+ // persisted atomically with the rename server-side (#295).
204
+ const ct = contentTypeMap.current.get(oldName) || "fiction";
205
+ const lang = languageMap.current.get(oldName) || "English";
206
+ const mode = agentModeMap.current.get(oldName) || "normal";
207
+ const provider = agentProviderMap.current.get(oldName) || "claude";
100
208
  let renamed = false;
101
209
  if (renameRef.current) {
102
- renamed = await renameRef.current(oldName, name).catch(() => false);
210
+ renamed = await renameRef.current(oldName, name, {
211
+ contentType: ct, language: lang, agentMode: mode, agentProvider: provider,
212
+ }).catch(() => false);
103
213
  }
104
214
  if (renamed) {
105
215
  setUntitledSessions((prev) => prev.slice(1));
216
+ contentTypeMap.current.delete(oldName);
217
+ languageMap.current.delete(oldName);
218
+ agentModeMap.current.delete(oldName);
219
+ agentProviderMap.current.delete(oldName);
220
+ if (mode === "bypass") {
221
+ setBypassStories((prev) => {
222
+ const next = { ...prev, [name]: true };
223
+ delete next[oldName];
224
+ return next;
225
+ });
226
+ }
227
+ setAgentProviders((prev) => {
228
+ const next = { ...prev, [name]: provider };
229
+ delete next[oldName];
230
+ return next;
231
+ });
232
+ authFetch(`/api/stories/${name}/metadata`, {
233
+ method: "POST",
234
+ headers: { "Content-Type": "application/json" },
235
+ body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode, agentProvider: provider }),
236
+ }).catch(() => {});
106
237
  }
107
238
  setSelectedStory(name);
108
239
  setSelectedFile(null);
@@ -132,6 +263,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
132
263
  const handleSelectFile = useCallback((storyName: string, fileName: string) => {
133
264
  setSelectedStory(storyName);
134
265
  setSelectedFile(fileName);
266
+ setCartoonView(null); // a file view supersedes a non-file workflow page
135
267
  }, []);
136
268
 
137
269
  const latestStoryRef = useRef<string | null>(null);
@@ -140,13 +272,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
140
272
  latestStoryRef.current = name;
141
273
  setSelectedStory(name);
142
274
  setSelectedFile(null);
143
- // Auto-select latest file for this story
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).
144
279
  try {
145
280
  const res = await authFetch(`/api/stories/${name}`);
146
281
  if (res.ok && latestStoryRef.current === name) {
147
282
  const data = await res.json();
283
+ if (data.contentType === "cartoon") return; // overview
148
284
  const files: { file: string }[] = data.files || [];
149
- // Priority: highest plot → genesis → structure → first
150
285
  const plots = files
151
286
  .map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
152
287
  .filter((p) => p.num != null)
@@ -157,7 +292,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
157
292
  ?? files[0]?.file;
158
293
  if (latest && latestStoryRef.current === name) setSelectedFile(latest);
159
294
  }
160
- } catch { /* ignore */ }
295
+ } catch { /* ignore — stays on overview */ }
161
296
  }, [authFetch]);
162
297
 
163
298
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
@@ -186,9 +321,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
186
321
  window.addEventListener("mouseup", onMouseUp);
187
322
  }, []);
188
323
 
189
- const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => {
324
+ const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => {
190
325
  setPublishingFile(fileName);
191
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;
192
339
 
193
340
  try {
194
341
  // Get file content
@@ -196,13 +343,93 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
196
343
  if (!fileRes.ok) throw new Error("Failed to read file");
197
344
  const fileData = await fileRes.json();
198
345
 
199
- // Extract title from first heading or filename
200
- const titleMatch = fileData.content.match(/^#\s+(.+)$/m);
201
- const title = titleMatch ? titleMatch[1].slice(0, 60) : fileName.replace(".md", "");
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.",
384
+ );
385
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
386
+ return false;
387
+ }
388
+
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
+ }
401
+
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) {
409
+ setPublishProgress(
410
+ `Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
411
+ );
412
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
413
+ return false;
414
+ }
415
+ }
202
416
 
203
417
  // For plot files, find the storylineId from the genesis publish status
204
418
  let storylineId: number | undefined;
205
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)) {
427
+ 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).",
429
+ );
430
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
431
+ return;
432
+ }
206
433
  try {
207
434
  const storyRes = await authFetch(`/api/stories/${storyName}`);
208
435
  if (storyRes.ok) {
@@ -215,16 +442,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
215
442
  if (!storylineId) {
216
443
  setPublishProgress("Error: Publish genesis first to create the storyline");
217
444
  setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
218
- return;
445
+ return false;
219
446
  }
220
447
  }
221
448
 
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("");
467
+ return false;
468
+ }
469
+ }
470
+ } catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
471
+
222
472
  // Run publish flow via SSE
223
473
  setPublishProgress("Publishing...");
224
474
  const publishRes = await authFetch("/api/publish/file", {
225
475
  method: "POST",
226
476
  headers: { "Content-Type": "application/json" },
227
- body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId }),
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
+ }),
228
481
  });
229
482
 
230
483
  if (!publishRes.ok) {
@@ -247,6 +500,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
247
500
  const data = JSON.parse(line.slice(6));
248
501
  if (data.step) setPublishProgress(data.message || data.step);
249
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;
250
509
  // Update publish status with gasCost
251
510
  await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
252
511
  method: "POST",
@@ -261,13 +520,66 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
261
520
  authorAddress: walletAddress,
262
521
  }),
263
522
  });
523
+ // Advance the cartoon Publish page to the next episode (#461).
524
+ setCartoonPublishRefresh((n) => n + 1);
525
+
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
+ }
542
+
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);
565
+ }
566
+ } catch { /* inconclusive — don't false-warn on a read failure */ }
567
+ }
264
568
  }
265
569
  } catch { /* ignore partial SSE */ }
266
570
  }
267
571
  }
268
572
  }
269
573
 
270
- setPublishProgress("Published!");
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
+ );
271
583
  } catch (err: unknown) {
272
584
  const message = err instanceof Error ? err.message : "Publish failed";
273
585
  setPublishProgress(`Error: ${message}`);
@@ -277,42 +589,130 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
277
589
  setPublishProgress("");
278
590
  }, 3000);
279
591
  }
280
- }, [authFetch]);
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]);
281
602
 
282
603
  const handleDestroySession = useCallback((name: string) => {
283
604
  if (name.startsWith("_new_")) {
284
605
  setUntitledSessions((prev) => prev.filter((id) => id !== name));
606
+ contentTypeMap.current.delete(name);
607
+ languageMap.current.delete(name);
608
+ agentModeMap.current.delete(name);
609
+ agentProviderMap.current.delete(name);
610
+ setBypassStories((prev) => {
611
+ if (!(name in prev)) return prev;
612
+ const next = { ...prev };
613
+ delete next[name];
614
+ return next;
615
+ });
616
+ setAgentProviders((prev) => {
617
+ if (!(name in prev)) return prev;
618
+ const next = { ...prev };
619
+ delete next[name];
620
+ return next;
621
+ });
285
622
  }
286
623
  }, []);
287
624
 
288
- // Track confirmed stories (those with structure.md) for Archive gating
289
- const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
290
625
  useEffect(() => {
291
- authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
292
- if (data?.stories) {
293
- setConfirmedStories(new Set(
294
- (data.stories as { name: string; hasStructure: boolean }[])
295
- .filter((s) => s.hasStructure)
296
- .map((s) => s.name)
297
- ));
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)));
629
+ const ct: Record<string, "fiction" | "cartoon"> = {};
630
+ const lang: Record<string, string | undefined> = {};
631
+ const genre: Record<string, string | undefined> = {};
632
+ const nsfw: Record<string, boolean | undefined> = {};
633
+ const prov: Record<string, "claude" | "codex" | undefined> = {};
634
+ const titles: Record<string, string> = {};
635
+ for (const s of stories) {
636
+ ct[s.name] = s.contentType || "fiction";
637
+ // Preserve absence (vs. defaulting to English/Romance) so the publish
638
+ // panel can tell "set to X" from "not set yet" and show Needs metadata (#424).
639
+ lang[s.name] = s.language;
640
+ genre[s.name] = s.genre;
641
+ nsfw[s.name] = s.isNsfw;
642
+ prov[s.name] = s.agentProvider;
643
+ if (s.title) titles[s.name] = s.title;
298
644
  }
645
+ setStoryContentTypes(ct);
646
+ setStoryLanguages(lang);
647
+ setStoryGenres(genre);
648
+ setStoryNsfw(nsfw);
649
+ setStoryProviders(prov);
650
+ setStoryTitles(titles);
651
+ };
652
+ authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
653
+ if (data?.stories) updateFromStories(data.stories);
299
654
  }).catch(() => {});
300
655
  const interval = setInterval(async () => {
301
656
  try {
302
657
  const res = await authFetch("/api/stories");
303
658
  if (res.ok) {
304
659
  const data = await res.json();
305
- setConfirmedStories(new Set(
306
- (data.stories as { name: string; hasStructure: boolean }[])
307
- .filter((s) => s.hasStructure)
308
- .map((s) => s.name)
309
- ));
660
+ updateFromStories(data.stories);
310
661
  }
311
662
  } catch { /* ignore */ }
312
663
  }, 5000);
313
664
  return () => clearInterval(interval);
314
665
  }, [authFetch]);
315
666
 
667
+ // Codex readiness for cartoon gating. `codexReady` requires Codex installed
668
+ // AND image_generation effectively enabled. `cartoonBlocked` only disables
669
+ // create once readiness has actually loaded and is not ready — when readiness
670
+ // is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
671
+ // permanently bricking cartoon if the probe errors.
672
+ const codexReady =
673
+ !!readiness && readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
674
+ const cartoonBlocked = !!readiness && !codexReady;
675
+
676
+ const copyCodexEnable = useCallback(async () => {
677
+ try {
678
+ await navigator.clipboard.writeText("codex features enable image_generation");
679
+ setCodexEnableCopied(true);
680
+ setTimeout(() => setCodexEnableCopied(false), 2000);
681
+ } catch { /* clipboard unavailable */ }
682
+ }, []);
683
+
684
+ // Explicit, scoped repair for a legacy cartoon story with no recorded
685
+ // provider: set THIS story's `agentProvider` to "codex". Reuses the metadata
686
+ // route, whose `...existing` spread preserves language/agentMode. Does NOT
687
+ // touch fiction, does NOT bulk-migrate. Optimistically updates local provider
688
+ // state so launch gating sees codex immediately, then re-fetches.
689
+ const handleRepairProvider = useCallback(async () => {
690
+ if (!selectedStory || selectedStory.startsWith("_new_")) return;
691
+ const name = selectedStory;
692
+ const res = await authFetch(`/api/stories/${name}/metadata`, {
693
+ method: "POST",
694
+ headers: { "Content-Type": "application/json" },
695
+ body: JSON.stringify({ contentType: "cartoon", agentProvider: "codex" }),
696
+ });
697
+ if (!res.ok) return;
698
+ setStoryProviders((prev) => ({ ...prev, [name]: "codex" }));
699
+ setAgentProviders((prev) => ({ ...prev, [name]: "codex" }));
700
+ // Re-fetch so the list state reflects the persisted provider.
701
+ try {
702
+ const listRes = await authFetch("/api/stories");
703
+ if (listRes.ok) {
704
+ const data = await listRes.json();
705
+ if (data?.stories) {
706
+ const prov: Record<string, "claude" | "codex" | undefined> = {};
707
+ for (const s of data.stories as { name: string; agentProvider?: "claude" | "codex" }[]) {
708
+ prov[s.name] = s.agentProvider;
709
+ }
710
+ setStoryProviders(prov);
711
+ }
712
+ }
713
+ } catch { /* ignore */ }
714
+ }, [authFetch, selectedStory]);
715
+
316
716
  const handleArchiveStory = useCallback((name: string) => {
317
717
  // Archive API already called by TerminalPanel — just clear selection
318
718
  if (selectedStory === name) {
@@ -321,6 +721,79 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
321
721
  }
322
722
  }, [selectedStory]);
323
723
 
724
+ // Resolve the effective provider for the selected story: an optimistic/new
725
+ // session value (agentProviders) wins, else the persisted list value.
726
+ const selectedProvider = selectedStory
727
+ ? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
728
+ : undefined;
729
+ const selectedContentType = resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current);
730
+ const selectedNeedsProviderRepair = needsLegacyProviderRepair(
731
+ selectedContentType,
732
+ selectedProvider,
733
+ selectedStory,
734
+ );
735
+
736
+ // Cartoon-only right-panel workflow nav (#439). The active tab follows the
737
+ // open non-file view (Story Info / Episodes) or the closest file: structure.md
738
+ // ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
739
+ const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
740
+ 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";
748
+
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]);
767
+
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]);
788
+
789
+ // 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]);
796
+
324
797
  return (
325
798
  <div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
326
799
  {/* Story Browser Sidebar */}
@@ -337,7 +810,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
337
810
 
338
811
  {/* Terminal — sized by ratio of available space */}
339
812
  <div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
340
- <TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} />
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} />
341
814
  </div>
342
815
 
343
816
  {/* Drag Handle */}
@@ -353,8 +826,53 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
353
826
  </div>
354
827
  </div>
355
828
 
356
- {/* Preview — takes remaining space */}
829
+ {/* Preview — takes remaining space. With a story but no file selected, show
830
+ the story-level progress overview (#418) instead of the empty state. */}
357
831
  <div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
832
+ {/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
833
+ {isCartoonStory && selectedStory && (
834
+ <CartoonWorkflowNav
835
+ storyTitle={storyTitles[selectedStory] || selectedStory}
836
+ active={activeCartoonTab}
837
+ onSelect={handleCartoonNav}
838
+ />
839
+ )}
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
+ )}
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
+ ) : (
358
876
  <PreviewPanel
359
877
  storyName={selectedStory}
360
878
  fileName={selectedFile}
@@ -362,13 +880,207 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
362
880
  onPublish={handlePublish}
363
881
  publishingFile={publishingFile}
364
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")}
365
891
  />
892
+ )}
366
893
  {publishProgress && (
367
894
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
368
895
  {publishProgress}
369
896
  </div>
370
897
  )}
898
+ {/* Durable publish blocker (#375) — stays until dismissed or the next
899
+ publish attempt, so an insufficient-balance block is obvious and
900
+ doesn't disappear on a timer. */}
901
+ {publishError && (
902
+ <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"
904
+ data-testid="publish-block-error"
905
+ role="alert"
906
+ >
907
+ <span>{publishError}</span>
908
+ <button
909
+ type="button"
910
+ onClick={() => setPublishError(null)}
911
+ className="shrink-0 text-error/70 hover:text-error underline"
912
+ data-testid="publish-block-error-dismiss"
913
+ >
914
+ Dismiss
915
+ </button>
916
+ </div>
917
+ )}
371
918
  </div>
919
+
920
+ {showNewStoryModal && (
921
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
922
+ <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>
924
+ <label className="block space-y-1">
925
+ <span className="text-[10px] font-medium text-muted">Title <span className="text-accent">*</span></span>
926
+ <input
927
+ type="text"
928
+ value={newStoryTitle}
929
+ onChange={(e) => setNewStoryTitle(e.target.value)}
930
+ placeholder="e.g. 신의 세포"
931
+ data-testid="new-story-title"
932
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
933
+ />
934
+ </label>
935
+ <label className="block space-y-1">
936
+ <span className="text-[10px] font-medium text-muted">Short description (optional)</span>
937
+ <input
938
+ type="text"
939
+ value={newStoryDescription}
940
+ onChange={(e) => setNewStoryDescription(e.target.value)}
941
+ placeholder="One line about the story"
942
+ data-testid="new-story-description"
943
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
944
+ />
945
+ </label>
946
+ <label className="block space-y-1">
947
+ <span className="text-[10px] font-medium text-muted">Genre (optional)</span>
948
+ <select
949
+ value={newStoryGenre}
950
+ onChange={(e) => setNewStoryGenre(e.target.value)}
951
+ data-testid="new-story-genre"
952
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
953
+ >
954
+ <option value="">— Select later —</option>
955
+ {GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
956
+ </select>
957
+ </label>
958
+ <label className="block space-y-1">
959
+ <span className="text-[10px] font-medium text-muted">Language</span>
960
+ <select
961
+ value={newStoryLanguage}
962
+ onChange={(e) => setNewStoryLanguage(e.target.value)}
963
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
964
+ >
965
+ {LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
966
+ </select>
967
+ </label>
968
+ <label className="block space-y-1">
969
+ <span className="text-[10px] font-medium text-muted">Agent mode</span>
970
+ <select
971
+ value={newStoryAgentMode}
972
+ onChange={(e) => setNewStoryAgentMode(e.target.value as "normal" | "bypass")}
973
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
974
+ data-testid="agent-mode-select"
975
+ >
976
+ <option value="normal">Normal (approve each action)</option>
977
+ <option value="bypass">Permissions Bypass (advanced)</option>
978
+ </select>
979
+ {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.
982
+ </p>
983
+ )}
984
+ </label>
985
+ <label className="block space-y-1">
986
+ <span className="text-[10px] font-medium text-muted">Provider</span>
987
+ <select
988
+ value={newStoryAgentProvider}
989
+ onChange={(e) => setNewStoryAgentProvider(e.target.value as "claude" | "codex")}
990
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
991
+ data-testid="agent-provider-select"
992
+ >
993
+ <option value="claude">🤖 Claude (default)</option>
994
+ <option value="codex">🎨 Codex</option>
995
+ </select>
996
+ <p className="text-[10px] text-muted" data-testid="agent-provider-helper">
997
+ {newStoryAgentProvider === "codex"
998
+ ? "Codex can generate clean cartoon images directly in the terminal."
999
+ : "Claude prepares image prompts; you generate and upload clean images externally."}
1000
+ </p>
1001
+ </label>
1002
+ <p className="text-xs text-muted text-center">Choose a content type to create</p>
1003
+ {!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>
1005
+ )}
1006
+ <div className="grid grid-cols-2 gap-3">
1007
+ <button
1008
+ onClick={() => handleCreateStory("fiction", newStoryLanguage, newStoryAgentMode, newStoryAgentProvider)}
1009
+ disabled={!newStoryTitle.trim()}
1010
+ data-testid="create-fiction"
1011
+ 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
+ >
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>
1015
+ </button>
1016
+ <div className="space-y-1">
1017
+ <button
1018
+ onClick={() => handleCreateStory("cartoon", newStoryLanguage, newStoryAgentMode, "codex")}
1019
+ disabled={cartoonBlocked || !newStoryTitle.trim()}
1020
+ data-testid="create-cartoon"
1021
+ 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
+ >
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.
1027
+ </p>
1028
+ </button>
1029
+ {/* Warnings/copy live OUTSIDE the button: a disabled button would
1030
+ otherwise swallow clicks on the Copy control. */}
1031
+ {readiness && !readiness.codex.installed && (
1032
+ <p
1033
+ className="text-[11px] text-amber-700 text-left"
1034
+ data-testid="cartoon-codex-warning"
1035
+ >
1036
+ 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.
1039
+ </p>
1040
+ )}
1041
+ {isCodexAuthUnclear(readiness) && (
1042
+ <p
1043
+ className="text-[11px] text-amber-700 text-left"
1044
+ data-testid="cartoon-codex-auth-unknown"
1045
+ >
1046
+ {CODEX_AUTH_UNCLEAR_MESSAGE}
1047
+ </p>
1048
+ )}
1049
+ {readiness &&
1050
+ readiness.codex.installed &&
1051
+ !isCodexAuthUnclear(readiness) &&
1052
+ readiness.codex.imageGeneration !== "enabled" && (
1053
+ <div data-testid="cartoon-codex-warning">
1054
+ <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:
1057
+ </p>
1058
+ <div className="mt-1 flex items-center gap-1">
1059
+ <code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
1060
+ codex features enable image_generation
1061
+ </code>
1062
+ <button
1063
+ type="button"
1064
+ data-testid="copy-codex-enable"
1065
+ onClick={copyCodexEnable}
1066
+ className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors"
1067
+ >
1068
+ {codexEnableCopied ? "Copied!" : "Copy"}
1069
+ </button>
1070
+ </div>
1071
+ </div>
1072
+ )}
1073
+ </div>
1074
+ </div>
1075
+ <button
1076
+ onClick={() => setShowNewStoryModal(false)}
1077
+ className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded text-center"
1078
+ >
1079
+ Cancel
1080
+ </button>
1081
+ </div>
1082
+ </div>
1083
+ )}
372
1084
  </div>
373
1085
  );
374
1086
  }