plotlink-ows 1.0.32 → 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 +10 -3
  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 +209 -28
  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 +1017 -144
  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-BFw-v-OZ.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
@@ -15,6 +15,7 @@ interface StoryInfo {
15
15
  hasGenesis: boolean;
16
16
  plotCount: number;
17
17
  publishedCount: number;
18
+ contentType?: "fiction" | "cartoon";
18
19
  }
19
20
 
20
21
  interface StoryBrowserProps {
@@ -103,32 +104,36 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
103
104
  }
104
105
  }, [selectedStory]);
105
106
 
107
+ const toggleExpand = (name: string) => {
108
+ setExpanded((prev) => {
109
+ const next = new Set(prev);
110
+ if (next.has(name)) next.delete(name);
111
+ else next.add(name);
112
+ return next;
113
+ });
114
+ };
115
+
106
116
  const getLatestFile = (files: FileStatus[]): string | null => {
107
- // Latest plot by highest number
108
117
  const plots = files
109
118
  .map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
110
119
  .filter((p) => p.num != null)
111
120
  .sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
112
121
  if (plots.length > 0) return plots[0].file;
113
- // Fallback: genesis, then structure
114
122
  if (files.some((f) => f.file === "genesis.md")) return "genesis.md";
115
123
  if (files.some((f) => f.file === "structure.md")) return "structure.md";
116
124
  return files[0]?.file ?? null;
117
125
  };
118
126
 
119
- const toggleExpand = (name: string) => {
120
- setExpanded((prev) => {
121
- const next = new Set(prev);
122
- if (next.has(name)) next.delete(name);
123
- else next.add(name);
124
- return next;
125
- });
126
- };
127
-
128
127
  const handleStoryClick = (story: StoryInfo) => {
129
128
  toggleExpand(story.name);
130
- // Auto-select latest file when expanding (not when collapsing)
131
- if (!expanded.has(story.name)) {
129
+ // Cartoon: a root-row click opens the story-level progress overview (#418) on
130
+ // EVERY click (an empty file selection reveals it), incl. when already
131
+ // expanded with a file open. Fiction PRESERVES the existing auto-open-latest-
132
+ // file behavior — fiction can still reach the overview via the "Progress"
133
+ // button in the file header. File rows below open a specific file.
134
+ if (story.contentType === "cartoon") {
135
+ onSelectFile(story.name, "");
136
+ } else {
132
137
  const latest = getLatestFile(story.files);
133
138
  if (latest) onSelectFile(story.name, latest);
134
139
  }
@@ -230,7 +235,10 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
230
235
  >
231
236
  <span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
232
237
  <span className="font-medium truncate" title={story.name}>{story.title || story.name}</span>
233
- <span className="ml-auto text-xs text-muted">
238
+ {story.contentType === "cartoon" && (
239
+ <span className="bg-accent/10 text-accent rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0">Cartoon</span>
240
+ )}
241
+ <span className="ml-auto flex-shrink-0 text-xs text-muted">
234
242
  {story.publishedCount}/{story.files.length}
235
243
  </span>
236
244
  </button>
@@ -0,0 +1,266 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
3
+ import { importImageToCompliantBlob } from "../lib/import-image";
4
+
5
+ interface StoryInfoPageProps {
6
+ storyName: string;
7
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
8
+ /** Notify the parent of saved publish metadata so its maps stay in sync. */
9
+ onSaved?: (patch: { genre?: string; language?: string; isNsfw?: boolean }) => void;
10
+ }
11
+
12
+ type CoverState = "missing" | "present" | "invalid" | "unknown";
13
+
14
+ /**
15
+ * Dedicated "Define Story Info" page for cartoon stories (#439, spec §4).
16
+ *
17
+ * Centralizes the public story-token metadata — title, short description, genre,
18
+ * language, read-only content type, adult flag, and cover — into one clear page,
19
+ * so Genesis can stay about the reader-facing Episode 1 content instead of
20
+ * feeling like a publish form. All fields persist to `.story.json` through the
21
+ * existing `/publish-metadata` route (no new on-chain contract surface); the
22
+ * cover reuses the browser-convert → `/import-cover` flow the publish panel uses.
23
+ *
24
+ * Cartoon-only: the caller mounts this from the cartoon workflow nav, so fiction
25
+ * metadata/publish behavior is untouched.
26
+ */
27
+ export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPageProps) {
28
+ const [loading, setLoading] = useState(true);
29
+ const [loadError, setLoadError] = useState(false);
30
+
31
+ const [title, setTitle] = useState("");
32
+ const [description, setDescription] = useState("");
33
+ const [genre, setGenre] = useState("");
34
+ const [language, setLanguage] = useState("");
35
+ const [isNsfw, setIsNsfw] = useState(false);
36
+ const [contentType, setContentType] = useState<"fiction" | "cartoon">("cartoon");
37
+ const [cover, setCover] = useState<CoverState>("unknown");
38
+
39
+ const [saving, setSaving] = useState(false);
40
+ const [saved, setSaved] = useState(false);
41
+ const [saveError, setSaveError] = useState<string | null>(null);
42
+ const [importing, setImporting] = useState(false);
43
+ const [coverPreview, setCoverPreview] = useState<string | null>(null);
44
+ const [promptCopied, setPromptCopied] = useState(false);
45
+ const coverInputRef = useRef<HTMLInputElement>(null);
46
+
47
+ // Load current values: story detail for the text fields (incl. description),
48
+ // and the progress payload for the derived cover state. Reset display state on
49
+ // every exit path so a failed reload can never leave stale fields showing.
50
+ useEffect(() => {
51
+ let cancelled = false;
52
+ setLoading(true);
53
+ setLoadError(false);
54
+ setSaved(false);
55
+ setSaveError(null);
56
+ (async () => {
57
+ try {
58
+ const [detailRes, progressRes] = await Promise.all([
59
+ authFetch(`/api/stories/${storyName}`),
60
+ authFetch(`/api/stories/${storyName}/progress`),
61
+ ]);
62
+ if (!detailRes.ok) { if (!cancelled) { setLoadError(true); setLoading(false); } return; }
63
+ const detail = await detailRes.json();
64
+ const progress = progressRes.ok ? await progressRes.json().catch(() => null) : null;
65
+ if (cancelled) return;
66
+ setTitle(detail.title ?? "");
67
+ setDescription(detail.description ?? "");
68
+ setGenre(canonicalizeGenre(detail.genre) ?? "");
69
+ setLanguage((detail.language && LANGUAGES.find((l) => l.toLowerCase() === detail.language.toLowerCase())) || "");
70
+ setIsNsfw(!!detail.isNsfw);
71
+ setContentType(detail.contentType === "fiction" ? "fiction" : "cartoon");
72
+ setCover(progress?.cover ?? "unknown");
73
+ setLoading(false);
74
+ } catch {
75
+ if (!cancelled) { setLoadError(true); setLoading(false); }
76
+ }
77
+ })();
78
+ return () => { cancelled = true; };
79
+ }, [storyName, authFetch]);
80
+
81
+ const handleSave = useCallback(async () => {
82
+ setSaving(true);
83
+ setSaved(false);
84
+ setSaveError(null);
85
+ const patch = { title: title.trim(), description: description.trim(), genre, language, isNsfw };
86
+ try {
87
+ const res = await authFetch(`/api/stories/${storyName}/publish-metadata`, {
88
+ method: "POST",
89
+ headers: { "Content-Type": "application/json" },
90
+ body: JSON.stringify(patch),
91
+ });
92
+ if (res.ok) {
93
+ setSaved(true);
94
+ onSaved?.({ genre, language, isNsfw });
95
+ } else {
96
+ const data = await res.json().catch(() => ({}));
97
+ setSaveError(data.error || "Could not save story info.");
98
+ }
99
+ } catch {
100
+ setSaveError("Could not save story info.");
101
+ }
102
+ setSaving(false);
103
+ }, [storyName, authFetch, title, description, genre, language, isNsfw, onSaved]);
104
+
105
+ const handleCoverImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
106
+ const file = e.target.files?.[0];
107
+ if (coverInputRef.current) coverInputRef.current.value = "";
108
+ if (!file) return;
109
+ setImporting(true);
110
+ setSaveError(null);
111
+ try {
112
+ let blob: Blob;
113
+ try {
114
+ blob = await importImageToCompliantBlob(file);
115
+ } catch (err) {
116
+ setSaveError(err instanceof Error ? err.message : "Could not import image");
117
+ return;
118
+ }
119
+ const ext = blob.type === "image/jpeg" ? "jpg" : "webp";
120
+ const imported = new File([blob], `cover.${ext}`, { type: blob.type });
121
+ const formData = new FormData();
122
+ formData.append("file", imported);
123
+ const res = await authFetch(`/api/stories/${storyName}/import-cover`, { method: "POST", body: formData });
124
+ if (!res.ok) {
125
+ const data = await res.json().catch(() => ({}));
126
+ setSaveError(data.error || "Cover import failed.");
127
+ return;
128
+ }
129
+ setCover("present");
130
+ setCoverPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(imported); });
131
+ } catch {
132
+ setSaveError("Cover import failed.");
133
+ } finally {
134
+ setImporting(false);
135
+ }
136
+ }, [storyName, authFetch]);
137
+
138
+ const copyCoverPrompt = useCallback(() => {
139
+ const prompt = `Generate a cover image for this story (${title || storyName}) and save it as assets/cover.webp — portrait 600x900, WebP, under 1MB. Don't publish.`;
140
+ navigator.clipboard?.writeText(prompt).then(() => { setPromptCopied(true); }).catch(() => {});
141
+ }, [title, storyName]);
142
+
143
+ if (loading) {
144
+ return <div className="h-full flex items-center justify-center text-muted text-sm" data-testid="story-info-loading">Loading story info…</div>;
145
+ }
146
+ if (loadError) {
147
+ return <div className="h-full flex items-center justify-center text-muted text-sm">Could not load story info.</div>;
148
+ }
149
+
150
+ const coverLabel = cover === "present" ? "Cover set" : cover === "invalid" ? "Invalid cover — re-import a WebP/JPEG under 1MB" : "Missing cover";
151
+ const coverTone = cover === "present" ? "text-green-700" : cover === "invalid" ? "text-amber-700" : "text-muted";
152
+
153
+ return (
154
+ <div className="h-full overflow-y-auto px-4 py-4" data-testid="story-info-page">
155
+ <h2 className="text-base font-serif text-foreground">Story Info</h2>
156
+ <p className="mt-0.5 text-[11px] text-muted">These details appear on PlotLink when the story is published.</p>
157
+
158
+ <div className="mt-4 flex flex-col gap-4 max-w-xl">
159
+ <label className="flex flex-col gap-1">
160
+ <span className="text-[11px] font-medium text-muted">Public title</span>
161
+ <input
162
+ type="text" value={title} onChange={(e) => { setTitle(e.target.value); setSaved(false); }}
163
+ data-testid="story-info-title"
164
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
165
+ />
166
+ </label>
167
+
168
+ <label className="flex flex-col gap-1">
169
+ <span className="text-[11px] font-medium text-muted">Short description</span>
170
+ <textarea
171
+ value={description} onChange={(e) => { setDescription(e.target.value); setSaved(false); }}
172
+ rows={3} data-testid="story-info-description"
173
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none resize-y"
174
+ />
175
+ </label>
176
+
177
+ <div className="flex flex-wrap gap-4">
178
+ <label className="flex flex-col gap-1 min-w-[140px] flex-1">
179
+ <span className="text-[11px] font-medium text-muted">Genre</span>
180
+ <select
181
+ value={genre} onChange={(e) => { setGenre(e.target.value); setSaved(false); }}
182
+ data-testid="story-info-genre"
183
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
184
+ >
185
+ <option value="">Needs metadata</option>
186
+ {GENRES.map((g) => <option key={g} value={g}>{g}</option>)}
187
+ </select>
188
+ </label>
189
+
190
+ <label className="flex flex-col gap-1 min-w-[140px] flex-1">
191
+ <span className="text-[11px] font-medium text-muted">Language</span>
192
+ <select
193
+ value={language} onChange={(e) => { setLanguage(e.target.value); setSaved(false); }}
194
+ data-testid="story-info-language"
195
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
196
+ >
197
+ <option value="">Needs metadata</option>
198
+ {LANGUAGES.map((l) => <option key={l} value={l}>{l}</option>)}
199
+ </select>
200
+ </label>
201
+
202
+ <label className="flex flex-col gap-1 min-w-[140px] flex-1">
203
+ <span className="text-[11px] font-medium text-muted">Content type</span>
204
+ <span
205
+ className="w-full px-2 py-1.5 text-xs border border-border rounded bg-surface text-muted"
206
+ data-testid="story-info-content-type"
207
+ title="Content type is locked after creation."
208
+ >
209
+ {contentType === "cartoon" ? "Cartoon · locked" : "Fiction · locked"}
210
+ </span>
211
+ </label>
212
+ </div>
213
+
214
+ <div className="flex flex-col gap-1.5">
215
+ <span className="text-[11px] font-medium text-muted">Cover image</span>
216
+ <div className="flex items-start gap-3">
217
+ {coverPreview && (
218
+ <img src={coverPreview} alt="Cover preview" className="w-16 h-24 object-cover rounded border border-border" />
219
+ )}
220
+ <div className="flex flex-col gap-1.5">
221
+ <span className={`text-[11px] font-medium ${coverTone}`} data-testid="story-info-cover-status">{coverLabel}</span>
222
+ <span className="text-[10px] text-muted">WebP or JPEG, max 1MB, 600×900 recommended.</span>
223
+ <div className="flex items-center gap-2">
224
+ <button
225
+ type="button" onClick={() => coverInputRef.current?.click()} disabled={importing}
226
+ data-testid="story-info-import-cover"
227
+ className="rounded border border-border px-2.5 py-1 text-[11px] text-foreground hover:border-accent hover:text-accent transition-colors disabled:opacity-50"
228
+ >
229
+ {importing ? "Importing…" : "Import cover"}
230
+ </button>
231
+ <button
232
+ type="button" onClick={copyCoverPrompt}
233
+ data-testid="story-info-cover-prompt"
234
+ className="rounded border border-border px-2.5 py-1 text-[11px] text-muted hover:border-accent hover:text-accent transition-colors"
235
+ >
236
+ {promptCopied ? "Copied!" : "Ask agent for cover prompt"}
237
+ </button>
238
+ </div>
239
+ <input ref={coverInputRef} type="file" accept="image/*" onChange={handleCoverImport} className="hidden" />
240
+ </div>
241
+ </div>
242
+ </div>
243
+
244
+ <label className="flex items-center gap-2">
245
+ <input
246
+ type="checkbox" checked={isNsfw} onChange={(e) => { setIsNsfw(e.target.checked); setSaved(false); }}
247
+ data-testid="story-info-nsfw"
248
+ />
249
+ <span className="text-xs text-foreground">This story contains adult content (18+)</span>
250
+ </label>
251
+
252
+ <div className="flex items-center gap-3">
253
+ <button
254
+ type="button" onClick={handleSave} disabled={saving}
255
+ data-testid="story-info-save"
256
+ className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
257
+ >
258
+ {saving ? "Saving…" : "Save Story Info"}
259
+ </button>
260
+ {saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
261
+ {saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
262
+ </div>
263
+ </div>
264
+ </div>
265
+ );
266
+ }