plotlink-ows 1.0.33 → 1.2.94

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 (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -2,6 +2,16 @@ 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 { LANGUAGES, GENRES } from "../../../lib/genres";
11
+ import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
12
+ import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
13
+ import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
14
+ import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
5
15
 
6
16
  interface StoriesPageProps {
7
17
  token: string;
@@ -36,13 +46,55 @@ function clampRatio(r: number, available: number): number {
36
46
  export function StoriesPage({ token, authFetch }: StoriesPageProps) {
37
47
  const [selectedStory, setSelectedStory] = useState<string | null>(null);
38
48
  const [selectedFile, setSelectedFile] = useState<string | null>(null);
49
+ // Cartoon right-panel workflow nav (#439): a non-file workflow page is open
50
+ // (Story Info / Episodes). null ⇒ the view follows selectedFile (or Progress).
51
+ const [cartoonView, setCartoonView] = useState<"story-info" | "episodes" | "publish" | null>(null);
39
52
  const [publishingFile, setPublishingFile] = useState<string | null>(null);
53
+ // Bumped on a confirmed publish so the cartoon Publish page re-reads readiness
54
+ // and advances to the next episode (#461).
55
+ const [cartoonPublishRefresh, setCartoonPublishRefresh] = useState(0);
40
56
  const [publishProgress, setPublishProgress] = useState<string>("");
57
+ // Durable publish blocker (#375): unlike the transient publishProgress text,
58
+ // this stays visible until the writer dismisses it or starts a new publish, so
59
+ // an insufficient-balance preflight block doesn't silently vanish.
60
+ const [publishError, setPublishError] = useState<string | null>(null);
41
61
  const [walletAddress, setWalletAddress] = useState<string | null>(null);
42
62
  const [ratio, setRatio] = useState(loadRatio);
43
63
  const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
64
+ const [showNewStoryModal, setShowNewStoryModal] = useState(false);
65
+ const [newStoryTitle, setNewStoryTitle] = useState("");
66
+ const [newStoryDescription, setNewStoryDescription] = useState("");
67
+ const [newStoryGenre, setNewStoryGenre] = useState("");
68
+ const [newStoryLanguage, setNewStoryLanguage] = useState("English");
69
+ const [newStoryAgentMode, setNewStoryAgentMode] = useState<"normal" | "bypass">("normal");
70
+ const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<"claude" | "codex">("claude");
71
+ const [readiness, setReadiness] = useState<AgentReadiness | null>(null);
72
+ const [codexEnableCopied, setCodexEnableCopied] = useState(false);
73
+ const [bypassStories, setBypassStories] = useState<Record<string, boolean>>({});
74
+ const [agentProviders, setAgentProviders] = useState<Record<string, "claude" | "codex">>({});
75
+ // Track confirmed stories (those with structure.md) for Archive gating
76
+ const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
77
+ // Stories that already have a genesis.md — so the outline footer can suggest
78
+ // reviewing Genesis rather than writing it again (#422).
79
+ const [genesisStories, setGenesisStories] = useState<Set<string>>(new Set());
80
+ const [storyContentTypes, setStoryContentTypes] = useState<Record<string, "fiction" | "cartoon">>({});
81
+ // `undefined` ⇒ language couldn't be determined for the story → the publish
82
+ // panel shows "Needs metadata" rather than defaulting to English (#424).
83
+ const [storyLanguages, setStoryLanguages] = useState<Record<string, string | undefined>>({});
84
+ // Publish metadata from .story.json (#424) so the publish controls seed real
85
+ // values. `undefined` ⇒ not set in .story.json → client shows "Needs metadata".
86
+ const [storyGenres, setStoryGenres] = useState<Record<string, string | undefined>>({});
87
+ const [storyNsfw, setStoryNsfw] = useState<Record<string, boolean | undefined>>({});
88
+ const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
89
+ // Provider recorded on each persisted story (read-only, from /api/stories).
90
+ // Absent ⇒ legacy story with no provider (defaults to Claude at launch).
91
+ const [storyProviders, setStoryProviders] = useState<Record<string, "claude" | "codex" | undefined>>({});
92
+ const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
93
+ const languageMap = useRef<Map<string, string>>(new Map());
94
+ const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
95
+ const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
44
96
  const knownStoriesRef = useRef<Set<string>>(new Set());
45
- const renameRef = useRef<((oldName: string, newName: string) => Promise<boolean>) | null>(null);
97
+ const renameRef = useRef<((oldName: string, newName: string, meta?: { contentType?: "fiction" | "cartoon"; language?: string; agentMode?: "normal" | "bypass"; agentProvider?: "claude" | "codex" }) => Promise<boolean>) | null>(null);
46
98
  const containerRef = useRef<HTMLDivElement>(null);
47
99
  const dragging = useRef(false);
48
100
 
@@ -54,6 +106,15 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
54
106
  .catch(() => {});
55
107
  }, [authFetch]);
56
108
 
109
+ // Best-effort agent-readiness probe for cartoon-mode guidance. Failures leave
110
+ // readiness null (no warning shown); this never blocks fiction or cartoon.
111
+ useEffect(() => {
112
+ authFetch("/api/agent/readiness")
113
+ .then((res) => res.ok ? res.json() : null)
114
+ .then((data) => { if (data) setReadiness(data); })
115
+ .catch(() => {});
116
+ }, [authFetch]);
117
+
57
118
  // Persist ratio to localStorage
58
119
  useEffect(() => {
59
120
  try { localStorage.setItem(STORAGE_KEY, String(ratio)); } catch { /* ignore */ }
@@ -73,12 +134,51 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
73
134
  }, []);
74
135
 
75
136
  const handleNewStory = useCallback(() => {
76
- const id = `_new_${Date.now()}`;
77
- setUntitledSessions((prev) => [...prev, id]);
78
- setSelectedStory(id);
79
- setSelectedFile(null);
137
+ setNewStoryTitle("");
138
+ setNewStoryDescription("");
139
+ setNewStoryGenre("");
140
+ setNewStoryAgentMode("normal");
141
+ setNewStoryAgentProvider("claude");
142
+ setShowNewStoryModal(true);
80
143
  }, []);
81
144
 
145
+ // Guided New Story (#423): create the named story up front from the chosen
146
+ // title/metadata (server writes .story.json + CLAUDE.md), then land on the
147
+ // story progress overview (#418) so the user sees what's next + a copy-paste
148
+ // first prompt — no need to ask the agent to rename an "Untitled" project.
149
+ const handleCreateStory = useCallback(async (contentType: "fiction" | "cartoon", language: string, agentMode: "normal" | "bypass", agentProvider: "claude" | "codex") => {
150
+ const title = newStoryTitle.trim();
151
+ if (!title) return; // guarded by the disabled Create buttons
152
+ const provider = contentType === "cartoon" ? "codex" : agentProvider;
153
+ try {
154
+ const res = await authFetch("/api/stories/create", {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json" },
157
+ body: JSON.stringify({
158
+ title,
159
+ description: newStoryDescription.trim() || undefined,
160
+ language,
161
+ genre: newStoryGenre || undefined,
162
+ contentType,
163
+ agentMode,
164
+ agentProvider: provider,
165
+ }),
166
+ });
167
+ if (!res.ok) return;
168
+ const data = await res.json();
169
+ setShowNewStoryModal(false);
170
+ // Prime the client maps so gating/labels are right before the next poll.
171
+ setStoryContentTypes((prev) => ({ ...prev, [data.name]: contentType }));
172
+ setStoryLanguages((prev) => ({ ...prev, [data.name]: language }));
173
+ if (newStoryGenre) setStoryGenres((prev) => ({ ...prev, [data.name]: newStoryGenre }));
174
+ setAgentProviders((prev) => ({ ...prev, [data.name]: provider }));
175
+ if (agentMode === "bypass") setBypassStories((prev) => ({ ...prev, [data.name]: true }));
176
+ // Land on the progress overview for the named story.
177
+ setSelectedStory(data.name);
178
+ setSelectedFile(null);
179
+ } catch { /* leave the modal open on failure */ }
180
+ }, [authFetch, newStoryTitle, newStoryDescription, newStoryGenre]);
181
+
82
182
  // Poll for new stories and auto-transition untitled sessions
83
183
  useEffect(() => {
84
184
  if (untitledSessions.length === 0) return;
@@ -97,12 +197,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
97
197
  if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
98
198
  // New story appeared — rename the oldest untitled session to the story name
99
199
  const oldName = untitledSessions[0];
200
+ // Read the pending session's metadata BEFORE the rename so it can be
201
+ // persisted atomically with the rename server-side (#295).
202
+ const ct = contentTypeMap.current.get(oldName) || "fiction";
203
+ const lang = languageMap.current.get(oldName) || "English";
204
+ const mode = agentModeMap.current.get(oldName) || "normal";
205
+ const provider = agentProviderMap.current.get(oldName) || "claude";
100
206
  let renamed = false;
101
207
  if (renameRef.current) {
102
- renamed = await renameRef.current(oldName, name).catch(() => false);
208
+ renamed = await renameRef.current(oldName, name, {
209
+ contentType: ct, language: lang, agentMode: mode, agentProvider: provider,
210
+ }).catch(() => false);
103
211
  }
104
212
  if (renamed) {
105
213
  setUntitledSessions((prev) => prev.slice(1));
214
+ contentTypeMap.current.delete(oldName);
215
+ languageMap.current.delete(oldName);
216
+ agentModeMap.current.delete(oldName);
217
+ agentProviderMap.current.delete(oldName);
218
+ if (mode === "bypass") {
219
+ setBypassStories((prev) => {
220
+ const next = { ...prev, [name]: true };
221
+ delete next[oldName];
222
+ return next;
223
+ });
224
+ }
225
+ setAgentProviders((prev) => {
226
+ const next = { ...prev, [name]: provider };
227
+ delete next[oldName];
228
+ return next;
229
+ });
230
+ authFetch(`/api/stories/${name}/metadata`, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json" },
233
+ body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode, agentProvider: provider }),
234
+ }).catch(() => {});
106
235
  }
107
236
  setSelectedStory(name);
108
237
  setSelectedFile(null);
@@ -132,6 +261,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
132
261
  const handleSelectFile = useCallback((storyName: string, fileName: string) => {
133
262
  setSelectedStory(storyName);
134
263
  setSelectedFile(fileName);
264
+ setCartoonView(null); // a file view supersedes a non-file workflow page
135
265
  }, []);
136
266
 
137
267
  const latestStoryRef = useRef<string | null>(null);
@@ -140,13 +270,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
140
270
  latestStoryRef.current = name;
141
271
  setSelectedStory(name);
142
272
  setSelectedFile(null);
143
- // Auto-select latest file for this story
273
+ setCartoonView(null);
274
+ // Cartoon stories land on the story-level progress overview (#418). Fiction
275
+ // PRESERVES the existing auto-open-latest-file behavior (fiction can still
276
+ // reach the overview via the "Progress" button).
144
277
  try {
145
278
  const res = await authFetch(`/api/stories/${name}`);
146
279
  if (res.ok && latestStoryRef.current === name) {
147
280
  const data = await res.json();
281
+ if (data.contentType === "cartoon") return; // overview
148
282
  const files: { file: string }[] = data.files || [];
149
- // Priority: highest plot → genesis → structure → first
150
283
  const plots = files
151
284
  .map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
152
285
  .filter((p) => p.num != null)
@@ -157,7 +290,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
157
290
  ?? files[0]?.file;
158
291
  if (latest && latestStoryRef.current === name) setSelectedFile(latest);
159
292
  }
160
- } catch { /* ignore */ }
293
+ } catch { /* ignore — stays on overview */ }
161
294
  }, [authFetch]);
162
295
 
163
296
  const handleMouseDown = useCallback((e: React.MouseEvent) => {
@@ -186,9 +319,21 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
186
319
  window.addEventListener("mouseup", onMouseUp);
187
320
  }, []);
188
321
 
189
- const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean) => {
322
+ const handlePublish = useCallback(async (storyName: string, fileName: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => {
190
323
  setPublishingFile(fileName);
191
324
  setPublishProgress("Reading file...");
325
+ setPublishError(null); // clear any prior durable block on a fresh attempt (#375)
326
+ let coverAttachFailed = false;
327
+ // Durable #379 public-title verification warning, set after indexing if
328
+ // PlotLink indexed a raw/generic public title (surfaced even though the
329
+ // publish itself succeeded — the metadata is immutable).
330
+ let titleVerifyWarning: string | null = null;
331
+ // Whether the publish actually SUCCEEDED on-chain (the SSE `done` event with a
332
+ // txHash). Returned to the caller so PreviewPanel drops the selected genesis
333
+ // cover ONLY on a confirmed-successful publish. A publish that is blocked
334
+ // before the stream (#375) OR opens then fails/errors before `done` (#376)
335
+ // leaves this false, so the writer's cover stays selected for the retry.
336
+ let succeeded = false;
192
337
 
193
338
  try {
194
339
  // Get file content
@@ -196,13 +341,93 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
196
341
  if (!fileRes.ok) throw new Error("Failed to read file");
197
342
  const fileData = await fileRes.json();
198
343
 
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", "");
344
+ // Derive the publish title (#331). The storyline title is set once at
345
+ // genesis publish and is immutable on-chain, so a headingless genesis.md
346
+ // must not fall back to the bare "genesis" filename. For genesis, fetch
347
+ // structure.md so its `# Title` H1 can stand in, with a prettified folder
348
+ // slug as the last resort. Best-effort: structure.md may be absent.
349
+ const publishContentType = storyContentTypes[storyName];
350
+ let structureContent: string | null = null;
351
+ let episodeTitle: string | null = null;
352
+ if (fileName === "genesis.md") {
353
+ try {
354
+ const structRes = await authFetch(`/api/stories/${storyName}/structure.md`);
355
+ if (structRes.ok) structureContent = (await structRes.json()).content ?? null;
356
+ } catch { /* best effort — fall back to the prettified slug */ }
357
+ } else if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)) {
358
+ // Cartoon publish markdown is image-only (no H1), so read the cut plan's
359
+ // episode title to avoid publishing the raw "plot-NN" filename (#347).
360
+ try {
361
+ const cutsRes = await authFetch(`/api/stories/${storyName}/cuts/${fileName.replace(/\.md$/, "")}`);
362
+ if (cutsRes.ok) episodeTitle = (await cutsRes.json()).title ?? null;
363
+ } catch { /* best effort — fall back to a friendly "Episode NN" */ }
364
+ }
365
+ const title = derivePublishTitle({
366
+ fileName,
367
+ fileContent: fileData.content,
368
+ storySlug: storyName,
369
+ structureContent,
370
+ contentType: publishContentType,
371
+ episodeTitle,
372
+ });
373
+
374
+ // Defense-in-depth (#358): never publish a cartoon story/episode whose
375
+ // public title is still a raw filename label ("genesis"/"plot-NN"). The
376
+ // publish panel already blocks this, but guard the action too.
377
+ if (publishContentType === "cartoon" && isRawFilenameTitle(title, fileName)) {
378
+ setPublishProgress(
379
+ fileName === "genesis.md"
380
+ ? "Add a real “# Title” heading to genesis.md before publishing — it would otherwise publish as a raw filename."
381
+ : "Set an episode title in the cut plan before publishing — it would otherwise publish as a raw filename.",
382
+ );
383
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
384
+ return false;
385
+ }
386
+
387
+ // Defense-in-depth (#365, tightened #368): a cartoon plot must have an
388
+ // explicit reader-facing title (cut-plan title or a real H1) that is NOT a
389
+ // generic "Episode NN"/"Chapter NN"/"plot-NN" placeholder. Block the action
390
+ // too, not just the panel.
391
+ if (publishContentType === "cartoon" && fileName.match(/^plot-\d+\.md$/)
392
+ && !hasExplicitEpisodeTitle({ fileContent: fileData.content, episodeTitle })) {
393
+ setPublishProgress(
394
+ "Set a real episode title in the cut plan (or add a “# Title” to the episode) before publishing — a generic “Episode NN” placeholder can’t be published.",
395
+ );
396
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
397
+ return false;
398
+ }
399
+
400
+ // Defense-in-depth (#359, hardened in #400): a cartoon Genesis is the
401
+ // reader-facing opening, so block publish when it isn't a real story
402
+ // opening (missing H1, synopsis/outline shape, too short, or a single dense
403
+ // block) even if the panel guard is bypassed. Surface the specific blocker.
404
+ if (publishContentType === "cartoon" && fileName === "genesis.md") {
405
+ const genesisBlockers = cartoonGenesisReadiness(fileData.content).blockers;
406
+ if (genesisBlockers.length > 0) {
407
+ setPublishProgress(
408
+ `Genesis is the reader-facing Story opening — fix it before publishing: ${genesisBlockers[0]}`,
409
+ );
410
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
411
+ return false;
412
+ }
413
+ }
202
414
 
203
415
  // For plot files, find the storylineId from the genesis publish status
204
416
  let storylineId: number | undefined;
205
417
  if (fileName.match(/^plot-\d+\.md$/)) {
418
+ // #332: never mint a second chainPlot for a plot that already has an
419
+ // on-chain chapter recorded — a duplicate chainPlot creates a permanent
420
+ // extra chapter on PlotLink. fileData carries the retained txHash/
421
+ // plotIndex even when a later content edit reset status to "pending".
422
+ // The published-not-indexed recovery path is exempt (handled in the
423
+ // preview UI behind an explicit duplicate-risk confirm).
424
+ if (shouldBlockDuplicatePlotPublish(fileData)) {
425
+ setPublishProgress(
426
+ "Already published on PlotLink — republishing would create a duplicate chapter. Open it on PlotLink instead (or use Retry Index if it isn't showing yet).",
427
+ );
428
+ setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 6000);
429
+ return;
430
+ }
206
431
  try {
207
432
  const storyRes = await authFetch(`/api/stories/${storyName}`);
208
433
  if (storyRes.ok) {
@@ -215,16 +440,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
215
440
  if (!storylineId) {
216
441
  setPublishProgress("Error: Publish genesis first to create the storyline");
217
442
  setTimeout(() => { setPublishingFile(null); setPublishProgress(""); }, 3000);
218
- return;
443
+ return false;
219
444
  }
220
445
  }
221
446
 
447
+ // #375: gate on wallet balance BEFORE opening the publish stream. The
448
+ // pilot's publish proceeded into "Broadcasting transaction..." despite
449
+ // preflight already reporting insufficient ETH, then returned to draft with
450
+ // no durable error. Run preflight here and, if the OWS wallet can't cover at
451
+ // least the creation fee (or is otherwise not ready), block with a durable,
452
+ // obvious inline error instead of calling /api/publish/file. A preflight
453
+ // network/HTTP error is NOT treated as a block — fall through so a flaky
454
+ // preflight can't stop an otherwise-fundable publish (the stream surfaces
455
+ // its own error).
456
+ setPublishProgress("Checking wallet balance...");
457
+ try {
458
+ const preRes = await authFetch("/api/publish/preflight");
459
+ if (preRes.ok) {
460
+ const pre = await preRes.json();
461
+ if (isPreflightBlocked(pre)) {
462
+ setPublishError(formatPreflightBlock(pre));
463
+ setPublishingFile(null);
464
+ setPublishProgress("");
465
+ return false;
466
+ }
467
+ }
468
+ } catch { /* preflight unreachable — don't hard-block; let the publish stream report */ }
469
+
222
470
  // Run publish flow via SSE
223
471
  setPublishProgress("Publishing...");
224
472
  const publishRes = await authFetch("/api/publish/file", {
225
473
  method: "POST",
226
474
  headers: { "Content-Type": "application/json" },
227
- body: JSON.stringify({ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId }),
475
+ body: JSON.stringify({
476
+ storyName, fileName, title, content: fileData.content, genre, language, isNsfw, storylineId,
477
+ ...(getContentTypeForPublish(storyContentTypes, storyName, storylineId) ? { contentType: getContentTypeForPublish(storyContentTypes, storyName, storylineId) } : {}),
478
+ }),
228
479
  });
229
480
 
230
481
  if (!publishRes.ok) {
@@ -247,6 +498,12 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
247
498
  const data = JSON.parse(line.slice(6));
248
499
  if (data.step) setPublishProgress(data.message || data.step);
249
500
  if (data.step === "done" && data.txHash) {
501
+ // Publish confirmed on-chain — the only point at which the cover
502
+ // selection may be dropped (#376). Anything short of this (a
503
+ // pre-stream block, a non-ok response, an error before `done`, or
504
+ // a stream that ends without `done`) leaves `succeeded` false so
505
+ // PreviewPanel keeps the selected/auto-detected cover for retry.
506
+ succeeded = true;
250
507
  // Update publish status with gasCost
251
508
  await authFetch(`/api/stories/${storyName}/${fileName}/publish-status`, {
252
509
  method: "POST",
@@ -261,13 +518,66 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
261
518
  authorAddress: walletAddress,
262
519
  }),
263
520
  });
521
+ // Advance the cartoon Publish page to the next episode (#461).
522
+ setCartoonPublishRefresh((n) => n + 1);
523
+
524
+ // Pre-publish cover (#284): a new genesis can't carry a cover
525
+ // through createStoryline, so once the storyline exists, attach
526
+ // the selected cover via upload-cover + update-storyline. Best-
527
+ // effort — a failure leaves the storyline published with no
528
+ // cover, settable later via Edit Story.
529
+ if (coverFile && fileName === "genesis.md" && data.storylineId) {
530
+ setPublishProgress("Uploading cover...");
531
+ let coverCid: string | null = null;
532
+ try {
533
+ coverCid = await attachCoverToStoryline(authFetch, data.storylineId, coverFile);
534
+ } catch { /* non-fatal: storyline is already published */ }
535
+ // A null result means the cover was not attached (upload or
536
+ // update-storyline failed). The storyline is published either
537
+ // way; tell the user so they can retry from Edit Story.
538
+ if (!coverCid) coverAttachFailed = true;
539
+ }
540
+
541
+ // #379: end-to-end public-title verification. Local guards ensure
542
+ // OWS sends a reader-facing title, but the pilot showed PlotLink
543
+ // can still index a raw "genesis"/"plot-NN" title. There is no
544
+ // public JSON read endpoint, so an OWS server route reads the
545
+ // rendered public page's og:title (no CORS) and returns the
546
+ // indexed title; verify it here. Inconclusive reads (page
547
+ // unreachable / no title) never warn — only a confirmed
548
+ // raw/generic public title does. The publish is already on-chain +
549
+ // immutable, so this can only warn.
550
+ if (publishContentType === "cartoon" && data.storylineId) {
551
+ try {
552
+ const isPlot = fileName !== "genesis.md";
553
+ const q = `storylineId=${data.storylineId}` +
554
+ (isPlot && data.plotIndex != null ? `&plotIndex=${data.plotIndex}` : "");
555
+ const pubRes = await authFetch(`/api/publish/public-title?${q}`);
556
+ if (pubRes.ok) {
557
+ const pub = await pubRes.json();
558
+ const detail = isPlot
559
+ ? { plots: pub.plotTitle != null ? [{ plotIndex: data.plotIndex, title: pub.plotTitle }] : [] }
560
+ : { title: pub.storylineTitle };
561
+ const verdict = verifyPublicCartoonTitle({ fileName, detail, plotIndex: data.plotIndex });
562
+ if (!verdict.ok) titleVerifyWarning = publicTitleWarning(verdict);
563
+ }
564
+ } catch { /* inconclusive — don't false-warn on a read failure */ }
565
+ }
264
566
  }
265
567
  } catch { /* ignore partial SSE */ }
266
568
  }
267
569
  }
268
570
  }
269
571
 
270
- setPublishProgress("Published!");
572
+ // A failed public-title verification (#379) is a durable warning that
573
+ // outranks the transient "Published!" line — the metadata is immutable, so
574
+ // the writer must know the next publish needs corrected metadata.
575
+ if (titleVerifyWarning) setPublishError(titleVerifyWarning);
576
+ setPublishProgress(
577
+ coverAttachFailed
578
+ ? "Published, but cover upload failed — set it later from Edit Story."
579
+ : "Published!",
580
+ );
271
581
  } catch (err: unknown) {
272
582
  const message = err instanceof Error ? err.message : "Publish failed";
273
583
  setPublishProgress(`Error: ${message}`);
@@ -277,42 +587,130 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
277
587
  setPublishProgress("");
278
588
  }, 3000);
279
589
  }
280
- }, [authFetch]);
590
+ // Tell PreviewPanel whether it may drop the selected cover. Clear ONLY when
591
+ // the publish is confirmed on-chain AND the cover was actually attached:
592
+ // - pre-stream block (#375) or failed/aborted publish (#376) → succeeded false → keep,
593
+ // - published on-chain but the cover upload/attach failed (#376/re1) →
594
+ // coverAttachFailed true → keep, so the writer doesn't silently lose the
595
+ // cover that never made it onto the storyline (settable via Edit Story).
596
+ // A publish with no selected cover (coverAttachFailed stays false) clears as
597
+ // before once it succeeds.
598
+ return succeeded && !coverAttachFailed;
599
+ }, [authFetch, storyContentTypes, walletAddress]);
281
600
 
282
601
  const handleDestroySession = useCallback((name: string) => {
283
602
  if (name.startsWith("_new_")) {
284
603
  setUntitledSessions((prev) => prev.filter((id) => id !== name));
604
+ contentTypeMap.current.delete(name);
605
+ languageMap.current.delete(name);
606
+ agentModeMap.current.delete(name);
607
+ agentProviderMap.current.delete(name);
608
+ setBypassStories((prev) => {
609
+ if (!(name in prev)) return prev;
610
+ const next = { ...prev };
611
+ delete next[name];
612
+ return next;
613
+ });
614
+ setAgentProviders((prev) => {
615
+ if (!(name in prev)) return prev;
616
+ const next = { ...prev };
617
+ delete next[name];
618
+ return next;
619
+ });
285
620
  }
286
621
  }, []);
287
622
 
288
- // Track confirmed stories (those with structure.md) for Archive gating
289
- const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
290
623
  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
- ));
624
+ const updateFromStories = (stories: { name: string; title?: string | null; hasStructure: boolean; hasGenesis?: boolean; contentType?: "fiction" | "cartoon"; language?: string; genre?: string; isNsfw?: boolean; agentProvider?: "claude" | "codex" }[]) => {
625
+ setConfirmedStories(new Set(stories.filter((s) => s.hasStructure).map((s) => s.name)));
626
+ setGenesisStories(new Set(stories.filter((s) => s.hasGenesis).map((s) => s.name)));
627
+ const ct: Record<string, "fiction" | "cartoon"> = {};
628
+ const lang: Record<string, string | undefined> = {};
629
+ const genre: Record<string, string | undefined> = {};
630
+ const nsfw: Record<string, boolean | undefined> = {};
631
+ const prov: Record<string, "claude" | "codex" | undefined> = {};
632
+ const titles: Record<string, string> = {};
633
+ for (const s of stories) {
634
+ ct[s.name] = s.contentType || "fiction";
635
+ // Preserve absence (vs. defaulting to English/Romance) so the publish
636
+ // panel can tell "set to X" from "not set yet" and show Needs metadata (#424).
637
+ lang[s.name] = s.language;
638
+ genre[s.name] = s.genre;
639
+ nsfw[s.name] = s.isNsfw;
640
+ prov[s.name] = s.agentProvider;
641
+ if (s.title) titles[s.name] = s.title;
298
642
  }
643
+ setStoryContentTypes(ct);
644
+ setStoryLanguages(lang);
645
+ setStoryGenres(genre);
646
+ setStoryNsfw(nsfw);
647
+ setStoryProviders(prov);
648
+ setStoryTitles(titles);
649
+ };
650
+ authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
651
+ if (data?.stories) updateFromStories(data.stories);
299
652
  }).catch(() => {});
300
653
  const interval = setInterval(async () => {
301
654
  try {
302
655
  const res = await authFetch("/api/stories");
303
656
  if (res.ok) {
304
657
  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
- ));
658
+ updateFromStories(data.stories);
310
659
  }
311
660
  } catch { /* ignore */ }
312
661
  }, 5000);
313
662
  return () => clearInterval(interval);
314
663
  }, [authFetch]);
315
664
 
665
+ // Codex readiness for cartoon gating. `codexReady` requires Codex installed
666
+ // AND image_generation effectively enabled. `cartoonBlocked` only disables
667
+ // create once readiness has actually loaded and is not ready — when readiness
668
+ // is still null (loading or probe-endpoint failure) we DO NOT block, to avoid
669
+ // permanently bricking cartoon if the probe errors.
670
+ const codexReady =
671
+ !!readiness && readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
672
+ const cartoonBlocked = !!readiness && !codexReady;
673
+
674
+ const copyCodexEnable = useCallback(async () => {
675
+ try {
676
+ await navigator.clipboard.writeText("codex features enable image_generation");
677
+ setCodexEnableCopied(true);
678
+ setTimeout(() => setCodexEnableCopied(false), 2000);
679
+ } catch { /* clipboard unavailable */ }
680
+ }, []);
681
+
682
+ // Explicit, scoped repair for a legacy cartoon story with no recorded
683
+ // provider: set THIS story's `agentProvider` to "codex". Reuses the metadata
684
+ // route, whose `...existing` spread preserves language/agentMode. Does NOT
685
+ // touch fiction, does NOT bulk-migrate. Optimistically updates local provider
686
+ // state so launch gating sees codex immediately, then re-fetches.
687
+ const handleRepairProvider = useCallback(async () => {
688
+ if (!selectedStory || selectedStory.startsWith("_new_")) return;
689
+ const name = selectedStory;
690
+ const res = await authFetch(`/api/stories/${name}/metadata`, {
691
+ method: "POST",
692
+ headers: { "Content-Type": "application/json" },
693
+ body: JSON.stringify({ contentType: "cartoon", agentProvider: "codex" }),
694
+ });
695
+ if (!res.ok) return;
696
+ setStoryProviders((prev) => ({ ...prev, [name]: "codex" }));
697
+ setAgentProviders((prev) => ({ ...prev, [name]: "codex" }));
698
+ // Re-fetch so the list state reflects the persisted provider.
699
+ try {
700
+ const listRes = await authFetch("/api/stories");
701
+ if (listRes.ok) {
702
+ const data = await listRes.json();
703
+ if (data?.stories) {
704
+ const prov: Record<string, "claude" | "codex" | undefined> = {};
705
+ for (const s of data.stories as { name: string; agentProvider?: "claude" | "codex" }[]) {
706
+ prov[s.name] = s.agentProvider;
707
+ }
708
+ setStoryProviders(prov);
709
+ }
710
+ }
711
+ } catch { /* ignore */ }
712
+ }, [authFetch, selectedStory]);
713
+
316
714
  const handleArchiveStory = useCallback((name: string) => {
317
715
  // Archive API already called by TerminalPanel — just clear selection
318
716
  if (selectedStory === name) {
@@ -321,6 +719,58 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
321
719
  }
322
720
  }, [selectedStory]);
323
721
 
722
+ // Resolve the effective provider for the selected story: an optimistic/new
723
+ // session value (agentProviders) wins, else the persisted list value.
724
+ const selectedProvider = selectedStory
725
+ ? (agentProviders[selectedStory] ?? storyProviders[selectedStory])
726
+ : undefined;
727
+ const selectedContentType = resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current);
728
+ const selectedNeedsProviderRepair = needsLegacyProviderRepair(
729
+ selectedContentType,
730
+ selectedProvider,
731
+ selectedStory,
732
+ );
733
+
734
+ // Cartoon-only right-panel workflow nav (#439). The active tab follows the
735
+ // open non-file view (Story Info / Episodes) or the closest file: structure.md
736
+ // ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
737
+ const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
738
+ const activeCartoonTab: CartoonWorkflowTab =
739
+ cartoonView === "story-info" ? "story-info"
740
+ : cartoonView === "episodes" ? "episodes"
741
+ : cartoonView === "publish" ? "publish"
742
+ : selectedFile === "structure.md" ? "whitepaper"
743
+ : selectedFile === "genesis.md" ? "genesis"
744
+ : selectedFile && /^plot-\d+\.md$/.test(selectedFile) ? "episodes"
745
+ : "progress";
746
+
747
+ const handleCartoonNav = useCallback((tab: CartoonWorkflowTab) => {
748
+ // Use the current selected story, not latestStoryRef — that ref is only set
749
+ // by handleSelectStory, so opening another story's file via the LEFT tree
750
+ // (handleSelectFile) would leave it stale and route file tabs to the wrong
751
+ // story (#445 RE1). `selectedStory` always reflects the visible story.
752
+ const story = selectedStory;
753
+ if (!story) return;
754
+ switch (tab) {
755
+ case "progress": setCartoonView(null); setSelectedFile(null); break;
756
+ case "story-info": setCartoonView("story-info"); break;
757
+ case "episodes": setCartoonView("episodes"); break;
758
+ case "whitepaper": handleSelectFile(story, "structure.md"); break;
759
+ case "genesis": handleSelectFile(story, "genesis.md"); break;
760
+ // Publish opens its own readiness page and stays on the Publish tab (#449),
761
+ // instead of visually routing to the Genesis file view.
762
+ case "publish": setCartoonView("publish"); break;
763
+ }
764
+ }, [selectedStory, handleSelectFile]);
765
+
766
+ // Keep the publish-control seeds in sync after a Story Info save (#439).
767
+ const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
768
+ if (!selectedStory) return;
769
+ if (patch.genre !== undefined) setStoryGenres((prev) => ({ ...prev, [selectedStory]: patch.genre || undefined }));
770
+ if (patch.language !== undefined) setStoryLanguages((prev) => ({ ...prev, [selectedStory]: patch.language || undefined }));
771
+ if (patch.isNsfw !== undefined) setStoryNsfw((prev) => ({ ...prev, [selectedStory]: patch.isNsfw }));
772
+ }, [selectedStory]);
773
+
324
774
  return (
325
775
  <div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
326
776
  {/* Story Browser Sidebar */}
@@ -337,7 +787,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
337
787
 
338
788
  {/* Terminal — sized by ratio of available space */}
339
789
  <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} />
790
+ <TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} bypassStories={bypassStories} agentProviders={agentProviders} readiness={readiness} contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current)} needsProviderRepair={selectedNeedsProviderRepair} onRepairProvider={handleRepairProvider} />
341
791
  </div>
342
792
 
343
793
  {/* Drag Handle */}
@@ -353,8 +803,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
353
803
  </div>
354
804
  </div>
355
805
 
356
- {/* Preview — takes remaining space */}
806
+ {/* Preview — takes remaining space. With a story but no file selected, show
807
+ the story-level progress overview (#418) instead of the empty state. */}
357
808
  <div className="min-w-0 flex flex-col" style={{ flex: `${1 - ratio} 0 0` }}>
809
+ {/* Cartoon workflow nav (#439) — persistent above the right-panel content. */}
810
+ {isCartoonStory && selectedStory && (
811
+ <CartoonWorkflowNav
812
+ storyTitle={storyTitles[selectedStory] || selectedStory}
813
+ active={activeCartoonTab}
814
+ onSelect={handleCartoonNav}
815
+ />
816
+ )}
817
+ {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
818
+ <StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
819
+ ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
820
+ <EpisodesPage storyName={selectedStory} authFetch={authFetch} onOpenFile={handleSelectFile} />
821
+ ) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
822
+ <CartoonPublishPage
823
+ storyName={selectedStory}
824
+ authFetch={authFetch}
825
+ onOpenFile={handleSelectFile}
826
+ onOpenStoryInfo={() => setCartoonView("story-info")}
827
+ onPublish={handlePublish}
828
+ publishingFile={publishingFile}
829
+ genre={storyGenres[selectedStory]}
830
+ language={storyLanguages[selectedStory]}
831
+ isNsfw={storyNsfw[selectedStory]}
832
+ refreshKey={cartoonPublishRefresh}
833
+ />
834
+ ) : selectedStory && !selectedFile ? (
835
+ <StoryProgressPanel
836
+ storyName={selectedStory}
837
+ authFetch={authFetch}
838
+ onOpenFile={handleSelectFile}
839
+ />
840
+ ) : (
358
841
  <PreviewPanel
359
842
  storyName={selectedStory}
360
843
  fileName={selectedFile}
@@ -362,13 +845,207 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
362
845
  onPublish={handlePublish}
363
846
  publishingFile={publishingFile}
364
847
  walletAddress={walletAddress}
848
+ contentType={resolveSelectedContentType(selectedStory, storyContentTypes, contentTypeMap.current) || "fiction"}
849
+ language={selectedStory ? storyLanguages[selectedStory] : undefined}
850
+ genre={selectedStory ? storyGenres[selectedStory] : undefined}
851
+ isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
852
+ hasGenesis={selectedStory ? genesisStories.has(selectedStory) : false}
853
+ onViewProgress={() => setSelectedFile(null)}
854
+ onOpenFile={(file) => selectedStory && handleSelectFile(selectedStory, file)}
855
+ onViewPublish={() => setCartoonView("publish")}
365
856
  />
857
+ )}
366
858
  {publishProgress && (
367
859
  <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
368
860
  {publishProgress}
369
861
  </div>
370
862
  )}
863
+ {/* Durable publish blocker (#375) — stays until dismissed or the next
864
+ publish attempt, so an insufficient-balance block is obvious and
865
+ doesn't disappear on a timer. */}
866
+ {publishError && (
867
+ <div
868
+ className="px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
869
+ data-testid="publish-block-error"
870
+ role="alert"
871
+ >
872
+ <span>{publishError}</span>
873
+ <button
874
+ type="button"
875
+ onClick={() => setPublishError(null)}
876
+ className="shrink-0 text-error/70 hover:text-error underline"
877
+ data-testid="publish-block-error-dismiss"
878
+ >
879
+ Dismiss
880
+ </button>
881
+ </div>
882
+ )}
371
883
  </div>
884
+
885
+ {showNewStoryModal && (
886
+ <div className="fixed inset-0 z-50 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
887
+ <div className="bg-surface border border-border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
888
+ <h3 className="text-sm font-serif font-medium text-foreground text-center">New Story</h3>
889
+ <label className="block space-y-1">
890
+ <span className="text-[10px] font-medium text-muted">Title <span className="text-accent">*</span></span>
891
+ <input
892
+ type="text"
893
+ value={newStoryTitle}
894
+ onChange={(e) => setNewStoryTitle(e.target.value)}
895
+ placeholder="e.g. 신의 세포"
896
+ data-testid="new-story-title"
897
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
898
+ />
899
+ </label>
900
+ <label className="block space-y-1">
901
+ <span className="text-[10px] font-medium text-muted">Short description (optional)</span>
902
+ <input
903
+ type="text"
904
+ value={newStoryDescription}
905
+ onChange={(e) => setNewStoryDescription(e.target.value)}
906
+ placeholder="One line about the story"
907
+ data-testid="new-story-description"
908
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
909
+ />
910
+ </label>
911
+ <label className="block space-y-1">
912
+ <span className="text-[10px] font-medium text-muted">Genre (optional)</span>
913
+ <select
914
+ value={newStoryGenre}
915
+ onChange={(e) => setNewStoryGenre(e.target.value)}
916
+ data-testid="new-story-genre"
917
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
918
+ >
919
+ <option value="">— Select later —</option>
920
+ {GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
921
+ </select>
922
+ </label>
923
+ <label className="block space-y-1">
924
+ <span className="text-[10px] font-medium text-muted">Language</span>
925
+ <select
926
+ value={newStoryLanguage}
927
+ onChange={(e) => setNewStoryLanguage(e.target.value)}
928
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
929
+ >
930
+ {LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
931
+ </select>
932
+ </label>
933
+ <label className="block space-y-1">
934
+ <span className="text-[10px] font-medium text-muted">Agent mode</span>
935
+ <select
936
+ value={newStoryAgentMode}
937
+ onChange={(e) => setNewStoryAgentMode(e.target.value as "normal" | "bypass")}
938
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
939
+ data-testid="agent-mode-select"
940
+ >
941
+ <option value="normal">Normal (approve each action)</option>
942
+ <option value="bypass">Permissions Bypass (advanced)</option>
943
+ </select>
944
+ {newStoryAgentMode === "bypass" && (
945
+ <p className="text-[10px] text-amber-700" data-testid="agent-mode-warning">
946
+ Less safe: Claude can run actions without per-command approval.
947
+ </p>
948
+ )}
949
+ </label>
950
+ <label className="block space-y-1">
951
+ <span className="text-[10px] font-medium text-muted">Provider</span>
952
+ <select
953
+ value={newStoryAgentProvider}
954
+ onChange={(e) => setNewStoryAgentProvider(e.target.value as "claude" | "codex")}
955
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
956
+ data-testid="agent-provider-select"
957
+ >
958
+ <option value="claude">🤖 Claude (default)</option>
959
+ <option value="codex">🎨 Codex</option>
960
+ </select>
961
+ <p className="text-[10px] text-muted" data-testid="agent-provider-helper">
962
+ {newStoryAgentProvider === "codex"
963
+ ? "Codex can generate clean cartoon images directly in the terminal."
964
+ : "Claude prepares image prompts; you generate and upload clean images externally."}
965
+ </p>
966
+ </label>
967
+ <p className="text-xs text-muted text-center">Choose a content type to create</p>
968
+ {!newStoryTitle.trim() && (
969
+ <p className="text-[10px] text-amber-700 text-center" data-testid="new-story-title-required">Enter a title to create your story.</p>
970
+ )}
971
+ <div className="grid grid-cols-2 gap-3">
972
+ <button
973
+ onClick={() => handleCreateStory("fiction", newStoryLanguage, newStoryAgentMode, newStoryAgentProvider)}
974
+ disabled={!newStoryTitle.trim()}
975
+ data-testid="create-fiction"
976
+ className="border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
977
+ >
978
+ <p className="text-sm font-serif font-medium text-foreground">Fiction</p>
979
+ <p className="text-[11px] text-muted">Novels, short stories, poetry</p>
980
+ </button>
981
+ <div className="space-y-1">
982
+ <button
983
+ onClick={() => handleCreateStory("cartoon", newStoryLanguage, newStoryAgentMode, "codex")}
984
+ disabled={cartoonBlocked || !newStoryTitle.trim()}
985
+ data-testid="create-cartoon"
986
+ className="w-full border border-border rounded-lg p-4 hover:border-accent hover:bg-accent/5 transition-colors text-center space-y-1 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-border disabled:hover:bg-transparent"
987
+ >
988
+ <p className="text-sm font-serif font-medium text-foreground">Cartoon</p>
989
+ <p className="text-[11px] text-muted">Comics, manga, webtoons</p>
990
+ <p className="text-[11px] text-muted" data-testid="cartoon-codex-note">
991
+ Cartoon mode requires Codex because the clean-image step needs image generation support.
992
+ </p>
993
+ </button>
994
+ {/* Warnings/copy live OUTSIDE the button: a disabled button would
995
+ otherwise swallow clicks on the Copy control. */}
996
+ {readiness && !readiness.codex.installed && (
997
+ <p
998
+ className="text-[11px] text-amber-700 text-left"
999
+ data-testid="cartoon-codex-warning"
1000
+ >
1001
+ Codex was not detected. Install the Codex CLI and sign in
1002
+ (e.g. <span className="font-mono">npm i -g @openai/codex</span> then{" "}
1003
+ <span className="font-mono">codex login</span>) to create cartoons.
1004
+ </p>
1005
+ )}
1006
+ {isCodexAuthUnclear(readiness) && (
1007
+ <p
1008
+ className="text-[11px] text-amber-700 text-left"
1009
+ data-testid="cartoon-codex-auth-unknown"
1010
+ >
1011
+ {CODEX_AUTH_UNCLEAR_MESSAGE}
1012
+ </p>
1013
+ )}
1014
+ {readiness &&
1015
+ readiness.codex.installed &&
1016
+ !isCodexAuthUnclear(readiness) &&
1017
+ readiness.codex.imageGeneration !== "enabled" && (
1018
+ <div data-testid="cartoon-codex-warning">
1019
+ <p className="text-[11px] text-amber-700 text-left">
1020
+ Codex is installed but image generation isn&apos;t enabled.
1021
+ Enable it, then reopen this dialog:
1022
+ </p>
1023
+ <div className="mt-1 flex items-center gap-1">
1024
+ <code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
1025
+ codex features enable image_generation
1026
+ </code>
1027
+ <button
1028
+ type="button"
1029
+ data-testid="copy-codex-enable"
1030
+ onClick={copyCodexEnable}
1031
+ className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors"
1032
+ >
1033
+ {codexEnableCopied ? "Copied!" : "Copy"}
1034
+ </button>
1035
+ </div>
1036
+ </div>
1037
+ )}
1038
+ </div>
1039
+ </div>
1040
+ <button
1041
+ onClick={() => setShowNewStoryModal(false)}
1042
+ className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded text-center"
1043
+ >
1044
+ Cancel
1045
+ </button>
1046
+ </div>
1047
+ </div>
1048
+ )}
372
1049
  </div>
373
1050
  );
374
1051
  }