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
@@ -0,0 +1,407 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
3
+ import { cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
4
+ import type { Cut } from "@app-lib/cuts";
5
+ import { derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
6
+
7
+ interface CartoonPublishPageProps {
8
+ storyName: string;
9
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
10
+ /** Open the episode's cut workspace to finish production (letter / export / upload). */
11
+ onOpenFile: (storyName: string, file: string) => void;
12
+ /** Switch to the Story Info page (to add a missing cover / set genre+language). */
13
+ onOpenStoryInfo: () => void;
14
+ /** Trigger the on-chain publish for the active episode (same flow the episode used
15
+ * to host). The page loads the imported cover for Genesis and hands it through. */
16
+ onPublish?: (storyName: string, file: string, genre: string, language: string, isNsfw: boolean, coverFile?: File | null) => void | Promise<boolean | void>;
17
+ /** The file currently mid-publish (disables the button + shows progress). */
18
+ publishingFile?: string | null;
19
+ /** Story metadata from Story Info — Genesis can't publish without genre+language. */
20
+ genre?: string;
21
+ language?: string;
22
+ isNsfw?: boolean;
23
+ refreshKey?: number;
24
+ }
25
+
26
+ type CheckState = "done" | "todo";
27
+ interface PublishCheck { label: string; status: CheckState; detail?: string | null }
28
+
29
+ /**
30
+ * Dedicated cartoon "Publish" workflow page (#449, spec §10).
31
+ *
32
+ * The Publish nav tab opens THIS page (the nav stays on Publish) instead of
33
+ * visually routing to the Genesis file view. It consolidates the finalization
34
+ * prerequisites for the active episode — opening text, cut plan, converted clean
35
+ * images, lettering, exported + uploaded finals, cover, and the on-chain publish
36
+ * — into one readiness summary, then launches the existing publish/finish
37
+ * controls in the episode (so the cover handling, preflight, and SSE publish flow
38
+ * are unchanged). Raw validator lines stay collapsed under technical details.
39
+ *
40
+ * Cartoon-only: mounted from the cartoon workflow nav, so fiction publish is
41
+ * untouched.
42
+ */
43
+ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenStoryInfo, onPublish, publishingFile, genre, language, isNsfw, refreshKey = 0 }: CartoonPublishPageProps) {
44
+ const [progress, setProgress] = useState<StoryProgress | null>(null);
45
+ const [loading, setLoading] = useState(true);
46
+ const [loadError, setLoadError] = useState(false);
47
+ const [publishError, setPublishError] = useState<string | null>(null);
48
+ // Diagnostics inputs for the active episode (#461): the migrated publish-title,
49
+ // genesis-readiness, and grouped-issues panels that used to live in the episode
50
+ // view. The episode's markdown content + cut plan + (genesis) structure.md drive
51
+ // the same pure helpers PreviewPanel used, so the diagnostics read identically.
52
+ const [activeContent, setActiveContent] = useState<string | null>(null);
53
+ const [activeCuts, setActiveCuts] = useState<Cut[] | null>(null);
54
+ const [activeEpisodeTitle, setActiveEpisodeTitle] = useState<string | null>(null);
55
+ const [structureContent, setStructureContent] = useState<string | null>(null);
56
+
57
+ // Load the imported Genesis cover (assets/cover.webp) as a File so the publish
58
+ // flow attaches it on createStoryline — the same auto-detect the episode used to
59
+ // run. Best-effort: a missing/invalid cover just publishes without one (#461).
60
+ const loadCoverFile = async (): Promise<File | null> => {
61
+ try {
62
+ const res = await authFetch(`/api/stories/${storyName}/cover-asset`);
63
+ const data = res.ok ? await res.json() : null;
64
+ if (!data?.found || !data.valid || !data.path) return null;
65
+ const assetRes = await authFetch(`/api/stories/${storyName}/asset/${String(data.path).replace(/^assets\//, "")}`);
66
+ if (!assetRes.ok) return null;
67
+ const blob = await assetRes.blob();
68
+ return new File([blob], String(data.path).split("/").pop() || "cover.webp", { type: data.type || blob.type });
69
+ } catch {
70
+ return null;
71
+ }
72
+ };
73
+
74
+ useEffect(() => {
75
+ let cancelled = false;
76
+ const load = async () => {
77
+ setLoading(true);
78
+ setLoadError(false);
79
+ try {
80
+ const res = await authFetch(`/api/stories/${storyName}/progress`);
81
+ const data = res.ok ? await res.json() : null;
82
+ if (cancelled) return;
83
+ if (!data || !Array.isArray(data.episodes)) { setLoadError(true); setProgress(null); }
84
+ else setProgress(data);
85
+ setLoading(false);
86
+ } catch {
87
+ if (!cancelled) { setLoadError(true); setProgress(null); setLoading(false); }
88
+ }
89
+ };
90
+ load();
91
+ return () => { cancelled = true; };
92
+ }, [storyName, authFetch, refreshKey]);
93
+
94
+ // The active episode file to diagnose: first unpublished (Genesis first).
95
+ const activeFile = progress?.episodes?.find((e) => !e.published)?.file ?? null;
96
+ const activeIsGenesis = activeFile === "genesis.md";
97
+
98
+ // Reset the per-episode diagnostics state DURING RENDER whenever the active
99
+ // episode (or refresh) changes, so a stale episode's content/cuts/structure
100
+ // never leak beside another and the publish gate doesn't read prior data while
101
+ // the new fetch is in flight (#461). Reset-during-render (via a loaded-key
102
+ // useState, mirroring WorkflowCoach) avoids the setState-in-effect cascade the
103
+ // ESLint rule flags. The effect below only performs the async fetch + assigns.
104
+ const diagKey = JSON.stringify([activeFile ?? "", refreshKey]);
105
+ const [loadedDiagKey, setLoadedDiagKey] = useState<string | null>(null);
106
+ if (loadedDiagKey !== diagKey) {
107
+ setLoadedDiagKey(diagKey);
108
+ setActiveContent(null);
109
+ setActiveCuts(null);
110
+ setActiveEpisodeTitle(null);
111
+ setStructureContent(null);
112
+ }
113
+
114
+ // Fetch the active episode's markdown + cut plan (+ structure.md for Genesis)
115
+ // so the migrated diagnostics can recompute with the same helpers PreviewPanel
116
+ // used (#461). Best-effort: missing cuts (404) ⇒ null.
117
+ useEffect(() => {
118
+ if (!activeFile) return;
119
+ let cancelled = false;
120
+ const plotKey = activeFile.replace(/\.md$/, "");
121
+ (async () => {
122
+ try {
123
+ const reqs: Promise<Response>[] = [
124
+ authFetch(`/api/stories/${storyName}/${activeFile}`),
125
+ authFetch(`/api/stories/${storyName}/cuts/${plotKey}`),
126
+ ];
127
+ if (activeIsGenesis) reqs.push(authFetch(`/api/stories/${storyName}/structure.md`));
128
+ const [fileRes, cutsRes, structRes] = await Promise.all(reqs);
129
+ if (cancelled) return;
130
+ setActiveContent(fileRes.ok ? (await fileRes.json()).content ?? "" : "");
131
+ if (cutsRes.ok) {
132
+ const cutsData = await cutsRes.json();
133
+ if (cancelled) return;
134
+ setActiveCuts(Array.isArray(cutsData.cuts) ? cutsData.cuts : []);
135
+ setActiveEpisodeTitle(typeof cutsData.title === "string" ? cutsData.title : null);
136
+ } else {
137
+ setActiveCuts(null);
138
+ setActiveEpisodeTitle(null);
139
+ }
140
+ if (activeIsGenesis && structRes) {
141
+ setStructureContent(structRes.ok ? (await structRes.json())?.content ?? null : null);
142
+ } else {
143
+ setStructureContent(null);
144
+ }
145
+ } catch {
146
+ if (!cancelled) { setActiveContent(""); setActiveCuts(null); setActiveEpisodeTitle(null); setStructureContent(null); }
147
+ }
148
+ })();
149
+ return () => { cancelled = true; };
150
+ }, [activeFile, activeIsGenesis, storyName, authFetch, refreshKey]);
151
+
152
+ if (loading) {
153
+ return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="publish-page-loading">Loading publish readiness…</div>;
154
+ }
155
+ if (loadError || !progress) {
156
+ return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load publish readiness.</div>;
157
+ }
158
+
159
+ // The active episode to finalize: the first unpublished one (Genesis first).
160
+ const active: EpisodeProgress | undefined = progress.episodes.find((e) => !e.published);
161
+
162
+ if (!active) {
163
+ return (
164
+ <div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
165
+ <h2 className="text-base font-serif text-foreground">Publish</h2>
166
+ <p className="mt-2 text-xs text-green-700" data-testid="publish-all-done">
167
+ {progress.episodes.length > 0
168
+ ? "All episodes are published to PlotLink. Plan the next episode to continue."
169
+ : "No episodes yet — write the Genesis (Episode 1) to begin."}
170
+ </p>
171
+ </div>
172
+ );
173
+ }
174
+
175
+ const c = active.cuts;
176
+ const coverDone = progress.cover === "present";
177
+ const checks: PublishCheck[] = [
178
+ { label: "Opening text ready", status: "done" }, // the episode exists once it appears here
179
+ { label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
180
+ { label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
181
+ { label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
182
+ { label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
183
+ { label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
184
+ { label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
185
+ { label: "Publish to PlotLink", status: active.published ? "done" : "todo" },
186
+ ];
187
+
188
+ const ready = active.state === "ready";
189
+ const blocked = active.state === "blocked";
190
+ // Genesis publishes via createStoryline and needs genre+language (set in Story
191
+ // Info); plots inherit the storyline, so they don't.
192
+ const isGenesisActive = active.file === "genesis.md";
193
+ const metaReady = !isGenesisActive || (!!genre && !!language);
194
+ const isPublishing = !!publishingFile && publishingFile === active.file;
195
+
196
+ // ── Migrated episode diagnostics (#461) ──────────────────────────────────
197
+ // The same pure helpers PreviewPanel used, now driven by the active episode's
198
+ // fetched markdown/cuts/structure. Computed only once the content is loaded so
199
+ // a still-loading panel doesn't flash a false "raw title" block.
200
+ const diagLoaded = activeContent !== null;
201
+ // #358: the exact public title this episode will publish with, plus its block
202
+ // states (raw filename, or — for plots — only a generic "Episode NN" fallback).
203
+ const resolvedPublishTitle = diagLoaded
204
+ ? derivePublishTitle({
205
+ fileName: active.file,
206
+ fileContent: activeContent ?? "",
207
+ storySlug: storyName,
208
+ structureContent,
209
+ contentType: "cartoon",
210
+ episodeTitle: activeEpisodeTitle,
211
+ })
212
+ : null;
213
+ const rawTitleBlocked = !!resolvedPublishTitle && isRawFilenameTitle(resolvedPublishTitle, active.file);
214
+ const episodeTitleMissing = !isGenesisActive && diagLoaded
215
+ && !hasExplicitEpisodeTitle({ fileContent: activeContent ?? "", episodeTitle: activeEpisodeTitle });
216
+ const titleBlocked = rawTitleBlocked || episodeTitleMissing;
217
+ // #359: cartoon Genesis prologue readiness (blockers disable publish).
218
+ const genesisReadiness = isGenesisActive && diagLoaded ? cartoonGenesisReadiness(activeContent ?? "") : null;
219
+ const genesisBlocked = !!genesisReadiness && genesisReadiness.blockers.length > 0;
220
+ // #360: grouped publish-readiness issues for a blocked plot (shown only when
221
+ // there are issues).
222
+ const readinessReport = !isGenesisActive && diagLoaded && activeCuts !== null
223
+ ? classifyCartoonReadiness(activeContent ?? "", activeCuts)
224
+ : null;
225
+ const cartoonIssues = readinessReport && readinessReport.stage === "error" ? readinessReport.issues : [];
226
+
227
+ // The diagnostics also gate publish (mirror PreviewPanel's titleBlocked /
228
+ // genesisBlocked), so a raw title / weak Genesis can't publish from here either.
229
+ // Require the diagnostics to have LOADED first (#461, re1): until the episode
230
+ // content (+ cut plan for plots) is fetched, titleBlocked/genesisBlocked are
231
+ // both false, so a ready episode with metadata could otherwise publish in the
232
+ // load window before the raw-title / weak-Genesis checks have run.
233
+ const diagReady = diagLoaded && (isGenesisActive || activeCuts !== null);
234
+ const canPublish = ready && metaReady && diagReady && !titleBlocked && !genesisBlocked && !isPublishing && !!onPublish;
235
+
236
+ const handlePublish = async () => {
237
+ if (!canPublish || !onPublish) return;
238
+ setPublishError(null);
239
+ try {
240
+ const cover = isGenesisActive ? await loadCoverFile() : null;
241
+ await onPublish(storyName, active.file, genre ?? "", language ?? "", !!isNsfw, cover);
242
+ } catch {
243
+ setPublishError("Publish could not be started. Please try again.");
244
+ }
245
+ };
246
+
247
+ return (
248
+ <div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
249
+ <h2 className="text-base font-serif text-foreground">Publish {active.label}</h2>
250
+ <p className="mt-0.5 text-[11px] text-muted">Finalize this episode: convert, letter, export, upload, then publish to PlotLink.</p>
251
+
252
+ <ul className="mt-3 flex flex-col gap-1.5 max-w-xl" data-testid="publish-checklist">
253
+ {checks.map((ck, i) => (
254
+ <li key={i} className="flex items-baseline gap-2 text-xs" data-testid="publish-check" data-status={ck.status}>
255
+ <span className={`flex-shrink-0 ${ck.status === "done" ? "text-green-700" : "text-muted"}`} aria-hidden>{ck.status === "done" ? "✓" : "○"}</span>
256
+ <span className={ck.status === "done" ? "text-foreground" : "text-muted"}>{ck.label}</span>
257
+ {ck.detail && <span className="text-muted">· {ck.detail}</span>}
258
+ </li>
259
+ ))}
260
+ </ul>
261
+
262
+ {/* Migrated episode diagnostics (#461): the publish title (#358), Genesis
263
+ prologue readiness (#359), and grouped publish issues (#360) that used
264
+ to render in the episode action bar — same helpers, same data-testids. */}
265
+ {resolvedPublishTitle && (
266
+ <div
267
+ className="mt-4 flex flex-col gap-0.5 max-w-xl"
268
+ data-testid="publish-title-preview"
269
+ data-raw={rawTitleBlocked ? "true" : "false"}
270
+ data-blocked={titleBlocked ? "true" : "false"}
271
+ >
272
+ <span className="text-[11px] text-foreground">
273
+ <span className="font-medium">{isGenesisActive ? "Story title" : "Episode title"}:</span>{" "}
274
+ <span className={titleBlocked ? "text-error font-medium" : "text-foreground"}>{resolvedPublishTitle}</span>
275
+ </span>
276
+ {rawTitleBlocked ? (
277
+ <span className="text-[10px] text-error" data-testid="publish-title-raw-error">
278
+ This would publish as a raw filename. {isGenesisActive
279
+ ? "Add a real “# Title” heading to genesis.md"
280
+ : "Set a title in the cut plan (or add a “# Title” to the episode)"} before publishing.
281
+ </span>
282
+ ) : episodeTitleMissing ? (
283
+ <span className="text-[10px] text-error" data-testid="publish-title-episode-required">
284
+ “{resolvedPublishTitle}” is a generic placeholder, not a reader-facing title, so it can’t be published. Set a real episode title in the cut plan (or add a “# Title” to the episode) — e.g. “Episode 01 — The Couple Coupon” — before publishing.
285
+ </span>
286
+ ) : null}
287
+ </div>
288
+ )}
289
+
290
+ {genesisReadiness && (
291
+ <div
292
+ className="mt-4 flex flex-col gap-1 rounded border border-border bg-surface/50 p-2 max-w-xl"
293
+ data-testid="cartoon-genesis-readiness"
294
+ data-blocked={genesisBlocked ? "true" : "false"}
295
+ >
296
+ <span className="text-[11px] font-medium text-foreground">Story opening (Prologue)</span>
297
+ <span className="text-[10px] text-muted" data-testid="genesis-readiness-hint">
298
+ Genesis is the first thing readers see. Write it as the story opening/prologue, not a synopsis — set up the premise and stakes, then bridge into Episode 01.
299
+ </span>
300
+ {genesisReadiness.blockers.map((b, i) => (
301
+ <span key={`b-${i}`} className="text-[10px] text-error" data-testid="genesis-readiness-blocker">{b}</span>
302
+ ))}
303
+ {genesisReadiness.warnings.map((w, i) => (
304
+ <span key={`w-${i}`} className="text-[10px] text-amber-600" data-testid="genesis-readiness-warning">{w}</span>
305
+ ))}
306
+ </div>
307
+ )}
308
+
309
+ {cartoonIssues.length > 0 && (
310
+ <div
311
+ className="mt-4 flex flex-col gap-2 rounded-xl border border-error/30 bg-error/5 px-3 py-3 max-w-xl"
312
+ data-testid="cartoon-publish-issues"
313
+ >
314
+ <div className="flex items-center gap-2">
315
+ <span className="rounded-full bg-error px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-white">Before publish</span>
316
+ <span className="text-xs font-medium text-foreground">Finish these workflow steps</span>
317
+ </div>
318
+ {groupCartoonIssues(cartoonIssues).map((g) => (
319
+ <div
320
+ key={g.key}
321
+ className="rounded-lg border border-error/15 bg-background/70 px-2.5 py-2"
322
+ data-testid={`cartoon-issue-group-${g.key}`}
323
+ >
324
+ <span className="text-[11px] font-medium text-foreground">{g.title}</span>
325
+ </div>
326
+ ))}
327
+ <details className="text-[10px] text-muted" data-testid="cartoon-technical-details">
328
+ <summary className="cursor-pointer select-none">Technical details</summary>
329
+ <ul className="mt-1 ml-3 list-disc">
330
+ {cartoonIssues.map((issue, i) => (
331
+ <li key={i} className="font-mono break-words">{issue}</li>
332
+ ))}
333
+ </ul>
334
+ </details>
335
+ </div>
336
+ )}
337
+
338
+ <div className="mt-4 flex flex-col gap-2 max-w-xl">
339
+ {!coverDone && (
340
+ <button
341
+ onClick={onOpenStoryInfo}
342
+ data-testid="publish-add-cover"
343
+ className="self-start rounded border border-border px-3 py-1.5 text-xs text-foreground hover:border-accent hover:text-accent transition-colors"
344
+ >
345
+ Add a cover image (Story Info)
346
+ </button>
347
+ )}
348
+ {isGenesisActive && !metaReady && (
349
+ <button
350
+ onClick={onOpenStoryInfo}
351
+ data-testid="publish-set-metadata"
352
+ className="self-start rounded border border-border px-3 py-1.5 text-xs text-foreground hover:border-accent hover:text-accent transition-colors"
353
+ >
354
+ Set genre &amp; language (Story Info)
355
+ </button>
356
+ )}
357
+ {!ready && (
358
+ <button
359
+ onClick={() => onOpenFile(storyName, active.file)}
360
+ data-testid="publish-open-episode"
361
+ className="self-start rounded border border-accent/40 px-3 py-1.5 text-xs text-accent hover:bg-accent/5 transition-colors"
362
+ >
363
+ Open {active.label} to finish {blocked ? "and fix issues" : "(letter / export / upload)"}
364
+ </button>
365
+ )}
366
+ <button
367
+ onClick={handlePublish}
368
+ disabled={!canPublish}
369
+ data-testid="publish-cta"
370
+ className="self-start rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim disabled:opacity-50 transition-colors"
371
+ title={canPublish ? undefined : "Finish the remaining steps above first"}
372
+ >
373
+ {isPublishing ? "Publishing…" : `Publish ${active.label} to PlotLink`}
374
+ </button>
375
+ {!ready ? (
376
+ <p className="text-[11px] text-muted" data-testid="publish-blocked-reason">
377
+ {blocked
378
+ ? `Not publishable yet — ${active.summary.toLowerCase()}. Open the episode to fix the flagged cuts.`
379
+ : `Not ready yet — ${active.summary.toLowerCase()}.`}
380
+ </p>
381
+ ) : !metaReady ? (
382
+ <p className="text-[11px] text-amber-700" data-testid="publish-needs-metadata">
383
+ Set the genre and language in Story Info before publishing.
384
+ </p>
385
+ ) : titleBlocked || genesisBlocked ? (
386
+ <p className="text-[11px] text-error" data-testid="publish-title-blocked-reason">
387
+ {genesisBlocked
388
+ ? "Fix the Story opening issues above before publishing."
389
+ : "Set a real reader-facing title above before publishing."}
390
+ </p>
391
+ ) : null}
392
+ {publishError && (
393
+ <p className="text-[11px] text-error" data-testid="publish-error">{publishError}</p>
394
+ )}
395
+ </div>
396
+
397
+ <details className="mt-4 max-w-xl" data-testid="publish-technical-details">
398
+ <summary className="text-[11px] text-muted cursor-pointer hover:text-foreground">Technical validation details</summary>
399
+ <div className="mt-1 text-[10px] text-muted space-y-0.5">
400
+ <p>Episode file: <span className="font-mono">{active.file}</span></p>
401
+ <p>State: {active.state} — {active.summary}</p>
402
+ <p>Per-cut production (cut plan, clean images, lettering, export, upload) happens in the episode’s cut workspace; open it above to finish any remaining step.</p>
403
+ </div>
404
+ </details>
405
+ </div>
406
+ );
407
+ }
@@ -0,0 +1,121 @@
1
+ import ReactMarkdown from "react-markdown";
2
+ import remarkBreaks from "remark-breaks";
3
+ import remarkGfm from "remark-gfm";
4
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
5
+ import { summarizeCartoonMarkdown, PROSE_PREVIEW_LIMIT } from "../lib/cartoon-publish-summary";
6
+ import { cartoonPublishVerdict, type CartoonReadinessStage } from "@app-lib/cartoon-readiness";
7
+
8
+ /** Custom sanitizer matching plotlink.xyz — allows img with src, alt, title. */
9
+ const sanitizeSchema = {
10
+ ...defaultSchema,
11
+ attributes: {
12
+ ...defaultSchema.attributes,
13
+ img: ["src", "alt", "title"],
14
+ },
15
+ };
16
+
17
+ const VERDICT_TONE: Record<"ok" | "info" | "warning" | "blocker", string> = {
18
+ ok: "border-green-300 bg-green-50 text-green-800",
19
+ info: "border-accent/30 bg-accent/5 text-foreground",
20
+ warning: "border-amber-300 bg-amber-50 text-amber-800",
21
+ blocker: "border-error/30 bg-error/5 text-error",
22
+ };
23
+
24
+ interface CartoonPublishPreviewProps {
25
+ /** The exact plot-NN.md markdown that will be sent to PlotLink. */
26
+ content: string;
27
+ /** Current readiness stage (from classifyCartoonReadiness), if known. */
28
+ stage: CartoonReadinessStage | null;
29
+ }
30
+
31
+ /**
32
+ * Publish Preview: renders EXACTLY the markdown PlotLink will publish (image
33
+ * blocks plus any prose actually in the markdown), with a compact pre-publish
34
+ * summary — image count, char count, readiness, and any non-image prose that
35
+ * will be published. This is deliberately NOT the cuts.json planning view (see
36
+ * CartoonPreview / Cut Inspector); planning prose must not masquerade as publish
37
+ * content (#289).
38
+ */
39
+ export function CartoonPublishPreview({ content, stage }: CartoonPublishPreviewProps) {
40
+ const summary = summarizeCartoonMarkdown(content);
41
+ const truncated = summary.nonImageProse.length > PROSE_PREVIEW_LIMIT;
42
+ // Two-axis verdict (#421): "Publish possible?" (hard) vs "Recommended?" (soft),
43
+ // so a placeholder is never shown as simply "Ready to publish".
44
+ const verdict = cartoonPublishVerdict({
45
+ stage,
46
+ imageCount: summary.imageCount,
47
+ hasNonImageProse: summary.nonImageProse.length > 0,
48
+ });
49
+
50
+ return (
51
+ <div className="h-full overflow-y-auto" data-testid="cartoon-publish-preview">
52
+ {/* Compact pre-publish content summary */}
53
+ <div
54
+ className="px-4 py-2 border-b border-border text-[10px] text-muted flex flex-wrap items-center gap-x-3 gap-y-1"
55
+ data-testid="cartoon-publish-summary"
56
+ >
57
+ <span>{summary.imageCount} image{summary.imageCount === 1 ? "" : "s"}</span>
58
+ <span>{summary.charCount.toLocaleString()} / 10,000 chars</span>
59
+ <span
60
+ className={`rounded-full px-2 py-0.5 font-medium ${verdict.possible ? "bg-green-100 text-green-800" : "bg-background text-muted"}`}
61
+ data-testid="publish-possible"
62
+ >
63
+ {verdict.possible ? "Publish possible" : "Publish not possible yet"}
64
+ </span>
65
+ <span
66
+ className={`rounded-full px-2 py-0.5 font-medium ${verdict.recommended ? "bg-green-100 text-green-800" : verdict.tone === "warning" ? "bg-amber-100 text-amber-800" : "bg-background text-muted"}`}
67
+ data-testid="publish-recommended"
68
+ >
69
+ {verdict.recommended ? "Recommended" : "Not recommended yet"}
70
+ </span>
71
+ </div>
72
+
73
+ {/* Plain-language verdict headline + the single next action (#421), so the
74
+ writer sees what to do instead of decoding validator strings. */}
75
+ <div
76
+ className={`px-4 py-2 border-b text-[11px] ${VERDICT_TONE[verdict.tone]}`}
77
+ data-testid="cartoon-publish-verdict"
78
+ >
79
+ <p className="font-medium">{verdict.headline}</p>
80
+ {verdict.detail && <p className="mt-0.5 opacity-90">{verdict.detail}</p>}
81
+ {verdict.action && <p className="mt-0.5 opacity-90">→ {verdict.action}</p>}
82
+ </div>
83
+
84
+ {/* Any non-image text in the markdown WILL be published verbatim. Surface
85
+ it explicitly so leftover planning/placeholder prose can't slip past. */}
86
+ {summary.nonImageProse && (
87
+ <div
88
+ className="px-4 py-2 border-b border-amber-300 bg-amber-50 text-[11px] text-amber-800"
89
+ data-testid="cartoon-nonimage-prose"
90
+ >
91
+ <p className="font-medium">⚠ Non-image text in the published markdown:</p>
92
+ <p className="font-mono mt-1 whitespace-pre-wrap break-words">
93
+ {summary.nonImageProsePreview}{truncated ? "…" : ""}
94
+ </p>
95
+ <p className="mt-1">
96
+ This text publishes verbatim around the comic images. Remove it (or re-run
97
+ “Prepare episode for publish”) if it is planning or placeholder prose.
98
+ </p>
99
+ </div>
100
+ )}
101
+
102
+ {/* Exactly what PlotLink renders from the published markdown */}
103
+ <div className="max-w-lg mx-auto px-4 py-6">
104
+ {content.trim() ? (
105
+ <div className="prose max-w-none">
106
+ <ReactMarkdown
107
+ remarkPlugins={[remarkBreaks, remarkGfm]}
108
+ rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
109
+ >
110
+ {content}
111
+ </ReactMarkdown>
112
+ </div>
113
+ ) : (
114
+ <p className="text-muted italic text-sm" data-testid="cartoon-publish-empty">
115
+ No publish markdown yet — build it from the cut plan (Edit → Upload &amp; Prepare for Publish).
116
+ </p>
117
+ )}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,90 @@
1
+ import { CARTOON_CLEAN_IMAGE_HELP, type CartoonChecklist } from "@app-lib/cartoon-readiness";
2
+
3
+ interface CartoonStepGuideProps {
4
+ checklist: CartoonChecklist | null;
5
+ }
6
+
7
+ const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
8
+ done: "✓",
9
+ current: "▸",
10
+ todo: "○",
11
+ };
12
+
13
+ /**
14
+ * Granular step checklist for the cartoon plot workspace (#335). Renders the six
15
+ * production steps a creator actually performs — plan cuts → create clean images
16
+ * → add bubbles → export → upload → publish — each with real per-cut status and
17
+ * a plain-language "next step" line, so a first-time writer can tell what to do
18
+ * next without knowing what "markdown generation" means. The checklist is
19
+ * computed upstream (it needs cuts.json + asset/upload/publish state); this just
20
+ * renders it. Renders nothing when there is no checklist (e.g. a fiction plot),
21
+ * so it never appears outside the cartoon flow.
22
+ */
23
+ export function CartoonStepGuide({ checklist }: CartoonStepGuideProps) {
24
+ if (!checklist || checklist.steps.length === 0) return null;
25
+ const { steps, nextStep } = checklist;
26
+
27
+ return (
28
+ <div
29
+ className="w-full max-w-[32rem] flex flex-col gap-3 rounded-xl border border-border bg-surface/70 p-3"
30
+ data-testid="cartoon-step-guide"
31
+ data-layout="diagram"
32
+ >
33
+ <div className="flex items-center justify-between gap-2">
34
+ <span className="text-xs font-medium text-foreground">Episode steps</span>
35
+ <span className="text-[10px] uppercase tracking-[0.18em] text-muted">Flow</span>
36
+ </div>
37
+ <ol className="grid grid-cols-2 gap-2 sm:grid-cols-3">
38
+ {steps.map((s, i) => (
39
+ <li
40
+ key={s.key}
41
+ data-testid={`cartoon-step-${s.key}`}
42
+ data-status={s.status}
43
+ className={`rounded-lg border px-2.5 py-2 text-xs ${
44
+ s.status === "current"
45
+ ? "border-accent/40 bg-accent/10 text-accent"
46
+ : s.status === "done"
47
+ ? "border-border bg-background/70 text-foreground"
48
+ : "border-border/80 bg-background/50 text-muted"
49
+ }`}
50
+ >
51
+ <div className="flex items-start gap-2">
52
+ <span
53
+ aria-hidden
54
+ className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-medium ${
55
+ s.status === "current"
56
+ ? "bg-accent text-white"
57
+ : s.status === "done"
58
+ ? "bg-foreground text-background"
59
+ : "bg-surface text-muted"
60
+ }`}
61
+ >
62
+ {STATUS_MARK[s.status]}
63
+ </span>
64
+ <span className="flex min-w-0 flex-col gap-0.5">
65
+ <span className="leading-tight">
66
+ {i + 1}. {s.label}
67
+ </span>
68
+ {s.detail && (
69
+ <span className="font-normal text-[10px] text-muted" data-testid={`cartoon-step-${s.key}-detail`}>
70
+ {s.detail}
71
+ </span>
72
+ )}
73
+ </span>
74
+ </div>
75
+ </li>
76
+ ))}
77
+ </ol>
78
+ <div className="rounded-lg border border-border/80 bg-background/60 px-3 py-2">
79
+ {nextStep && (
80
+ <span className="block text-xs text-foreground mt-0.5" data-testid="cartoon-next-step">
81
+ Next: {nextStep}
82
+ </span>
83
+ )}
84
+ <span className="mt-1 block text-[11px] text-muted" data-testid="cartoon-clean-image-help">
85
+ {CARTOON_CLEAN_IMAGE_HELP}
86
+ </span>
87
+ </div>
88
+ </div>
89
+ );
90
+ }