plotlink-ows 1.2.94 → 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.
@@ -1,7 +1,7 @@
1
- import { useState, useEffect, useCallback, useRef } from "react";
1
+ import { Fragment, useState, useEffect, useCallback, useRef } from "react";
2
2
  import { LetteringEditor } from "./LetteringEditor";
3
3
  import { AssetImage, assetUrl } from "./asset-image";
4
- import { buildCodexTaskPrompt, buildLetteringPrompt } from "@app-lib/cartoon-prompt";
4
+ import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt";
5
5
  import type { Cut as LibCut } from "@app-lib/cuts";
6
6
  import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
7
7
  import { withRateLimitRetry, createUploadThrottle, type RetryDeps } from "../lib/upload-retry";
@@ -141,6 +141,25 @@ function boardStatus(cut: Cut, needsConversion: boolean, hasStale: boolean): Boa
141
141
  return { key: "needs-image", label: "Needs image", tone: "muted" };
142
142
  }
143
143
 
144
+ function letteringReviewState(cut: Cut): { label: string; detail: string; tone: BoardTone } {
145
+ if (cut.uploadedCid || cut.uploadedUrl) {
146
+ return { label: "Complete", detail: "Final image uploaded", tone: "green" };
147
+ }
148
+ if (cut.finalImagePath || cut.exportedAt) {
149
+ return { label: "Exported", detail: "Ready to upload", tone: "green" };
150
+ }
151
+ if ((cut.overlays?.length ?? 0) > 0) {
152
+ return { label: "Draft saved", detail: `${cut.overlays.length} overlay${cut.overlays.length === 1 ? "" : "s"} placed`, tone: "amber" };
153
+ }
154
+ if (isTextPanel(cut)) {
155
+ return { label: "Between-scene card", detail: "Open to add narration or title text", tone: "accent" };
156
+ }
157
+ if (cut.cleanImagePath) {
158
+ return { label: "Unlettered", detail: "Clean art ready for bubble placement", tone: "muted" };
159
+ }
160
+ return { label: "Needs artwork", detail: "Add or sync clean art first", tone: "muted" };
161
+ }
162
+
144
163
  function CutRow({
145
164
  cut,
146
165
  storyName,
@@ -190,10 +209,6 @@ function CutRow({
190
209
  // generated PNG into this cut without hunting through a hidden folder.
191
210
  const [showCodexPicker, setShowCodexPicker] = useState(false);
192
211
  const [convertingThis, setConvertingThis] = useState(false);
193
- // Lettering is a first-class step (#442): an intentional Manual vs AI-draft
194
- // choice per cut, surfaced on the card (not hidden under Edit).
195
- const [letteringMode, setLetteringMode] = useState<"manual" | "ai">("manual");
196
- const [letteringCopied, setLetteringCopied] = useState(false);
197
212
  const status = getCutStatus(cut);
198
213
  // A recorded cleanImagePath/finalImagePath whose file is missing/invalid (#302):
199
214
  // surface it precisely rather than letting the field-based status claim the cut
@@ -267,18 +282,13 @@ function CutRow({
267
282
  !isTextPanel(cut) && !!cut.cleanImagePath && !cut.finalImagePath &&
268
283
  !cut.uploadedCid && !cut.uploadedUrl && !hasStale && !needsConversion;
269
284
 
270
- const copyLetteringPrompt = useCallback(() => {
271
- navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
272
- setLetteringCopied(true);
273
- setTimeout(() => setLetteringCopied(false), 2000);
274
- }, [cut, plotFile]);
275
-
276
285
  const primary: { label: string; onClick: () => void; testid: string } | null =
277
286
  board.key === "convert" ? { label: convertingThis ? "Converting…" : "Convert image", onClick: handleConvertThis, testid: `card-convert-${cut.id}` }
278
287
  : board.key === "review" ? { label: "Review cut", onClick: onOpenEditor, testid: `card-review-${cut.id}` }
279
288
  : board.key === "text" ? { label: "Add captions", onClick: onOpenEditor, testid: `card-letter-${cut.id}` }
280
289
  : board.key === "needs-image" ? { label: "Add artwork", onClick: onToggle, testid: `card-addart-${cut.id}` }
281
290
  : null; // exported / uploaded — the next action is the episode-level upload/publish
291
+ const reviewState = letteringReviewState(cut);
282
292
 
283
293
  return (
284
294
  <div
@@ -303,13 +313,20 @@ function CutRow({
303
313
  assetPath={thumbPath}
304
314
  authFetch={authFetch}
305
315
  alt={`Cut ${cut.id} artwork`}
306
- className="w-full max-h-44 object-contain rounded border border-border bg-white"
316
+ className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
307
317
  />
308
318
  ) : (
309
- <div className="w-full h-20 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted" data-testid={`cut-card-noart-${cut.id}`}>
319
+ <div className="w-full min-h-28 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted" data-testid={`cut-card-noart-${cut.id}`}>
310
320
  {isTextPanel(cut) ? "Text panel — no artwork needed" : "No artwork yet"}
311
321
  </div>
312
322
  )}
323
+ <div
324
+ className={`rounded border border-border/70 bg-surface/50 px-2 py-1.5 text-[11px] ${BOARD_TONE_TEXT[reviewState.tone]}`}
325
+ data-testid={`lettering-review-state-${cut.id}`}
326
+ >
327
+ <span className="font-semibold">{reviewState.label}</span>
328
+ <span className="text-muted"> · {reviewState.detail}</span>
329
+ </div>
313
330
  <button
314
331
  onClick={onToggle}
315
332
  data-testid={`cut-desc-${cut.id}`}
@@ -317,51 +334,15 @@ function CutRow({
317
334
  >
318
335
  {cut.description || "No description"}
319
336
  </button>
320
- {/* Lettering is a first-class, visible step (#442): an intentional
321
- Manual vs AI-draft choice on the card, then the matching CTA. */}
322
- {atLetteringStage && (
323
- <div className="space-y-1" data-testid={`lettering-${cut.id}`}>
324
- <div className="text-[10px] font-medium text-muted uppercase tracking-wider">Lettering</div>
325
- <label className="flex items-center gap-1.5 text-[11px] text-foreground">
326
- <input
327
- type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "manual"}
328
- onChange={() => setLetteringMode("manual")} data-testid={`lettering-mode-manual-${cut.id}`}
329
- />
330
- Manual — I place bubbles myself
331
- </label>
332
- <label className="flex items-center gap-1.5 text-[11px] text-foreground">
333
- <input
334
- type="radio" name={`lettering-mode-${cut.id}`} checked={letteringMode === "ai"}
335
- onChange={() => setLetteringMode("ai")} data-testid={`lettering-mode-ai-${cut.id}`}
336
- />
337
- AI draft — ask the agent to place initial bubbles
338
- </label>
339
- {letteringMode === "ai" && (
340
- <p className="text-[10px] text-muted">
341
- Paste it to your agent, then review the draft bubbles here and export the final cut.
342
- </p>
343
- )}
344
- </div>
345
- )}
346
337
  <div className="flex items-center gap-2 flex-wrap">
347
338
  {atLetteringStage ? (
348
- letteringMode === "manual" ? (
349
- <button
350
- onClick={onOpenEditor}
351
- data-testid={`add-bubbles-${cut.id}`}
352
- className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
353
- >
354
- {bubblesPlaced > 0 ? "Review lettering" : "Add speech bubbles"}
355
- </button>
356
- ) : (
357
- <button
358
- onClick={copyLetteringPrompt}
359
- data-testid={`copy-lettering-${cut.id}`}
360
- className="px-2.5 py-1 text-[11px] font-medium rounded border border-accent/40 text-accent hover:bg-accent/5"
361
- >
362
- {letteringCopied ? "Copied!" : "Copy AI lettering prompt"}
363
- </button>
364
- )
339
+ <button
340
+ onClick={onOpenEditor}
341
+ data-testid={`add-bubbles-${cut.id}`}
342
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
343
+ >
344
+ {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
345
+ </button>
365
346
  ) : primary ? (
366
347
  <button
367
348
  onClick={primary.onClick}
@@ -924,10 +905,10 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
924
905
  setRepairing(false);
925
906
  }, [authFetch, storyName, plotFile, loadCuts, loadDetect]);
926
907
 
927
- // Append a text/interstitial panel to the cut plan (#352) — a one-click way to
928
- // add a narration/title card between image cuts without hand-editing cuts.json.
908
+ // Insert a text/interstitial panel to the cut plan (#352/#488) — a one-click way
909
+ // to add a narration/title card between image cuts without hand-editing cuts.json.
929
910
  const [addingPanel, setAddingPanel] = useState(false);
930
- const addTextPanel = useCallback(async () => {
911
+ const addTextPanelAt = useCallback(async (insertIndex: number, openEditor = true) => {
931
912
  if (!cutsFile) return;
932
913
  setAddingPanel(true);
933
914
  try {
@@ -939,14 +920,17 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
939
920
  uploadedCid: null, uploadedUrl: null, overlays: [],
940
921
  kind: "text" as const, background: "#101820", aspectRatio: "4:5",
941
922
  };
942
- const updated = { ...cutsFile, cuts: [...cutsFile.cuts, panel] };
923
+ const nextCuts = [...cutsFile.cuts];
924
+ nextCuts.splice(Math.max(0, Math.min(insertIndex, nextCuts.length)), 0, panel);
925
+ const updated = { ...cutsFile, cuts: nextCuts };
943
926
  const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
944
927
  method: "PUT",
945
928
  headers: { "Content-Type": "application/json" },
946
929
  body: JSON.stringify(updated),
947
930
  });
948
931
  if (res.ok) {
949
- setExpandedCut(nextId);
932
+ if (openEditor) setEditingCutId(nextId);
933
+ else setExpandedCut(nextId);
950
934
  await loadCuts();
951
935
  } else {
952
936
  const data = await res.json().catch(() => ({}));
@@ -957,6 +941,7 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
957
941
  }
958
942
  setAddingPanel(false);
959
943
  }, [cutsFile, authFetch, storyName, plotFile, loadCuts]);
944
+ const addTextPanel = useCallback(() => addTextPanelAt(cutsFile?.cuts.length ?? 0, true), [addTextPanelAt, cutsFile]);
960
945
 
961
946
  useEffect(() => {
962
947
  loadCuts();
@@ -1000,6 +985,8 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1000
985
  plotFile={plotFile}
1001
986
  language={language}
1002
987
  authFetch={authFetch}
988
+ targetLabel={isTextPanel(editingCut) ? `Between-scene card ${editingCut.id}` : `Cut ${String(editingCut.id).padStart(2, "0")}`}
989
+ returnOnSave
1003
990
  onSave={async (overlays: Overlay[]) => {
1004
991
  const updated = { ...cutsFile, cuts: cutsFile.cuts.map((c) => c.id === editingCutId ? { ...c, overlays } : c) };
1005
992
  const res = await authFetch(`/api/stories/${storyName}/cuts/${plotFile}`, {
@@ -1268,32 +1255,83 @@ export function CutListPanel({ storyName, fileName, authFetch, language, uploadR
1268
1255
  published={episodeState.published}
1269
1256
  />
1270
1257
 
1271
- {/* Cut list */}
1272
- <div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-2" data-testid="cut-list-scroll">
1273
- {cutsFile.cuts.map((cut) => (
1274
- <CutRow
1275
- key={cut.id}
1276
- cut={cut}
1277
- storyName={storyName}
1278
- plotFile={plotFile}
1279
- expanded={expandedCut === cut.id}
1280
- onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1281
- authFetch={authFetch}
1282
- onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1283
- onOpenEditor={() => setEditingCutId(cut.id)}
1284
- detectedLocalClean={detected.has(cut.id)}
1285
- onSyncClean={syncCleanImages}
1286
- syncing={syncing}
1287
- staleMessages={staleByCut.get(cut.id) ?? []}
1288
- onRepairStale={repairStalePaths}
1289
- repairing={repairing}
1290
- conversionPng={conversionByCut.get(cut.id) ?? null}
1291
- onConvert={convertCut}
1292
- converting={converting}
1293
- rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1294
- />
1258
+ {/* Full cut review (#488): all clean cuts are shown vertically first, with
1259
+ explicit between-scene slots for narration/title cards. */}
1260
+ <div className="flex-1 min-h-56 overflow-y-auto p-3 space-y-3" data-testid="lettering-review-board">
1261
+ {cutsFile.cuts.map((cut, index) => (
1262
+ <Fragment key={cut.id}>
1263
+ <BetweenSceneSlot
1264
+ index={index}
1265
+ beforeLabel={index === 0 ? "Episode opening" : `After cut ${cutsFile.cuts[index - 1]?.id}`}
1266
+ afterLabel={`Before cut ${cut.id}`}
1267
+ disabled={addingPanel}
1268
+ onAdd={() => addTextPanelAt(index)}
1269
+ />
1270
+ <CutRow
1271
+ cut={cut}
1272
+ storyName={storyName}
1273
+ plotFile={plotFile}
1274
+ expanded={expandedCut === cut.id}
1275
+ onToggle={() => setExpandedCut(expandedCut === cut.id ? null : cut.id)}
1276
+ authFetch={authFetch}
1277
+ onUpdated={() => { loadCuts(); loadDetect(); loadDiagnostics(); }}
1278
+ onOpenEditor={() => setEditingCutId(cut.id)}
1279
+ detectedLocalClean={detected.has(cut.id)}
1280
+ onSyncClean={syncCleanImages}
1281
+ syncing={syncing}
1282
+ staleMessages={staleByCut.get(cut.id) ?? []}
1283
+ onRepairStale={repairStalePaths}
1284
+ repairing={repairing}
1285
+ conversionPng={conversionByCut.get(cut.id) ?? null}
1286
+ onConvert={convertCut}
1287
+ converting={converting}
1288
+ rowRef={(el) => { if (el) rowRefs.current.set(cut.id, el); else rowRefs.current.delete(cut.id); }}
1289
+ />
1290
+ </Fragment>
1295
1291
  ))}
1292
+ <BetweenSceneSlot
1293
+ index={cutsFile.cuts.length}
1294
+ beforeLabel={`After cut ${cutsFile.cuts[cutsFile.cuts.length - 1]?.id}`}
1295
+ afterLabel="Episode ending"
1296
+ disabled={addingPanel}
1297
+ onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
1298
+ />
1296
1299
  </div>
1297
1300
  </div>
1298
1301
  );
1299
1302
  }
1303
+
1304
+ function BetweenSceneSlot({
1305
+ index,
1306
+ beforeLabel,
1307
+ afterLabel,
1308
+ disabled,
1309
+ onAdd,
1310
+ }: {
1311
+ index: number;
1312
+ beforeLabel: string;
1313
+ afterLabel: string;
1314
+ disabled: boolean;
1315
+ onAdd: () => void;
1316
+ }) {
1317
+ return (
1318
+ <div
1319
+ className="rounded border border-dashed border-border bg-surface/35 px-3 py-2 text-[11px] text-muted flex items-center gap-3"
1320
+ data-testid={`between-scene-slot-${index}`}
1321
+ >
1322
+ <span className="min-w-0 flex-1">
1323
+ <span className="font-medium text-foreground">Between-scene lettering</span>
1324
+ <span className="block truncate">{beforeLabel} · {afterLabel}</span>
1325
+ </span>
1326
+ <button
1327
+ type="button"
1328
+ onClick={onAdd}
1329
+ disabled={disabled}
1330
+ className="flex-shrink-0 rounded border border-accent/40 px-2.5 py-1 text-[11px] font-medium text-accent hover:bg-accent/5 disabled:opacity-50"
1331
+ data-testid={`add-between-scene-${index}`}
1332
+ >
1333
+ Add card
1334
+ </button>
1335
+ </div>
1336
+ );
1337
+ }
@@ -1,8 +1,10 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useCallback, useState, useEffect } from "react";
2
2
 
3
3
  const API_BASE = "http://localhost:7777";
4
4
 
5
5
  interface WalletInfo {
6
+ walletId?: string;
7
+ name?: string;
6
8
  address: string;
7
9
  ethBalance: string;
8
10
  ethFormatted: string;
@@ -48,16 +50,17 @@ interface DashboardData {
48
50
 
49
51
  export function Dashboard({ token }: { token: string }) {
50
52
  const [data, setData] = useState<DashboardData | null>(null);
51
- const authFetch = (url: string, opts?: RequestInit) =>
52
- fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
53
+ const authFetch = useCallback((url: string, opts?: RequestInit) =>
54
+ fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
55
+ [token]);
53
56
 
54
- const loadDashboard = () => {
57
+ const loadDashboard = useCallback(() => {
55
58
  authFetch(`${API_BASE}/api/dashboard`)
56
59
  .then((r) => r.json())
57
60
  .then(setData);
58
- };
61
+ }, [authFetch]);
59
62
 
60
- useEffect(() => { loadDashboard(); }, []);
63
+ useEffect(() => { loadDashboard(); }, [loadDashboard]);
61
64
 
62
65
  const truncate = (addr: string) => `${addr.slice(0, 6)}...${addr.slice(-4)}`;
63
66
  const formatDate = (d: string | undefined | null) => {
@@ -104,6 +107,12 @@ export function Dashboard({ token }: { token: string }) {
104
107
  <div className="border-border rounded border p-4">
105
108
  <h3 className="text-accent mb-3 text-xs font-bold uppercase tracking-wider">Wallet</h3>
106
109
  <div className="space-y-1.5">
110
+ {data.wallet.name && (
111
+ <div className="flex justify-between text-xs">
112
+ <span className="text-muted">Active wallet</span>
113
+ <span className="text-foreground truncate pl-3 font-mono text-[10px]">{data.wallet.name}</span>
114
+ </div>
115
+ )}
107
116
  <div className="flex justify-between text-xs">
108
117
  <span className="text-muted">Address</span>
109
118
  <code className="text-foreground font-mono text-[10px]">{truncate(data.wallet.address)}</code>
@@ -22,6 +22,8 @@ import {
22
22
  import { layoutBubbleText } from "@app-lib/bubble-text";
23
23
  import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status";
24
24
  import { textPanelDimensions } from "@app-lib/cuts";
25
+ import { buildLetteringPrompt } from "@app-lib/cartoon-prompt";
26
+ import type { Cut as LibCut } from "@app-lib/cuts";
25
27
  import { useAuthedAsset } from "./asset-image";
26
28
 
27
29
  function toPixel(norm: number, size: number): number {
@@ -72,6 +74,10 @@ interface LetteringEditorProps {
72
74
  onExported?: () => void;
73
75
  language?: string;
74
76
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
77
+ /** Focused-editor header label supplied by the review board (#488). */
78
+ targetLabel?: string;
79
+ /** When true, the Save button returns to the review board after persisting. */
80
+ returnOnSave?: boolean;
75
81
  }
76
82
 
77
83
  const TYPE_LABEL: Record<OverlayType, string> = {
@@ -106,7 +112,7 @@ function clamp(v: number, min: number, max: number): number {
106
112
  return Math.min(max, Math.max(min, v));
107
113
  }
108
114
 
109
- export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch }: LetteringEditorProps) {
115
+ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch, targetLabel, returnOnSave = false }: LetteringEditorProps) {
110
116
  const bodyFont = getDefaultFont(language);
111
117
  const displayFont = getDisplayFont();
112
118
  const bodyFontFamily = getFontFamily(bodyFont);
@@ -176,6 +182,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
176
182
  const [confirmDelete, setConfirmDelete] = useState(false);
177
183
  const [exporting, setExporting] = useState(false);
178
184
  const [exportError, setExportError] = useState<string | null>(null);
185
+ const [saveError, setSaveError] = useState<string | null>(null);
186
+ const [aiCopied, setAiCopied] = useState(false);
179
187
  const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
180
188
  const containerRef = useRef<HTMLDivElement>(null);
181
189
  const imgRef = useRef<HTMLImageElement>(null);
@@ -355,9 +363,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
355
363
  };
356
364
  }, [imageBounds, updateOverlay]);
357
365
 
358
- const handleSave = useCallback(() => {
359
- onSave(overlays);
360
- }, [overlays, onSave]);
366
+ const handleSave = useCallback(async () => {
367
+ setSaveError(null);
368
+ try {
369
+ await onSave(overlays);
370
+ if (returnOnSave) onClose();
371
+ } catch (err) {
372
+ setSaveError(err instanceof Error ? err.message : "Failed to save overlays");
373
+ }
374
+ }, [overlays, onSave, onClose, returnOnSave]);
375
+
376
+ const copyAiDraftPrompt = useCallback(() => {
377
+ navigator.clipboard?.writeText(buildLetteringPrompt(cut as unknown as LibCut, plotFile));
378
+ setAiCopied(true);
379
+ setTimeout(() => setAiCopied(false), 2000);
380
+ }, [cut, plotFile]);
361
381
 
362
382
  const handleExport = useCallback(async () => {
363
383
  // Block export when the cut plan contained overlays that could not be placed
@@ -515,25 +535,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
515
535
  }
516
536
 
517
537
  return (
518
- <div className="h-full flex flex-col">
538
+ <div className="h-full flex flex-col" data-testid="focused-lettering-editor">
519
539
  {/* Toolbar */}
520
- <div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
521
- <div className="flex items-center gap-2">
522
- <span className="text-xs font-mono text-muted">Cut #{cut.id}</span>
540
+ <div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
541
+ <div className="min-w-0">
542
+ <div className="flex items-center gap-2">
543
+ <span className="text-[10px] font-bold uppercase tracking-[0.16em] text-accent">Focused lettering editor</span>
544
+ <span className="text-xs font-mono text-muted">{targetLabel ?? `Cut #${cut.id}`}</span>
545
+ </div>
546
+ <p className="mt-0.5 text-[11px] text-muted">
547
+ Place bubbles, captions, SFX, or between-scene card text, then save back to the full cut review.
548
+ </p>
523
549
  <span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
550
+ </div>
551
+ <div className="flex items-center gap-2 flex-wrap justify-end">
524
552
  <div className="flex items-center gap-1 ml-2">
525
553
  <button onClick={() => addOverlay("speech")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-speech">Speech</button>
526
554
  <button onClick={() => addOverlay("narration")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-narration">Narration</button>
527
555
  <button onClick={() => addOverlay("sfx")} className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5" data-testid="add-sfx">SFX</button>
528
556
  </div>
529
- </div>
530
- <div className="flex items-center gap-2">
531
557
  {exportError && <span className="text-[10px] text-error">{exportError}</span>}
558
+ {saveError && <span className="text-[10px] text-error">{saveError}</span>}
532
559
  <button onClick={handleExport} disabled={exporting} className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50" data-testid="export-btn">
533
560
  {exporting ? "Exporting..." : "Export"}
534
561
  </button>
535
- <button onClick={handleSave} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim">Save</button>
536
- <button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded">Close</button>
562
+ <button onClick={() => { void handleSave(); }} className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim" data-testid="save-lettering-btn">Save</button>
563
+ <button onClick={onClose} className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded" data-testid="cancel-lettering-btn">Cancel</button>
537
564
  </div>
538
565
  </div>
539
566
 
@@ -635,7 +662,7 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
635
662
  <div className="flex-1 min-h-0 flex">
636
663
  <div
637
664
  ref={containerRef}
638
- className="flex-1 min-w-0 relative overflow-hidden"
665
+ className="flex-1 min-w-0 relative overflow-hidden bg-[#f8f5ef]"
639
666
  onClick={handleBackgroundClick}
640
667
  data-testid="editor-surface"
641
668
  >
@@ -830,7 +857,21 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
830
857
  </div>
831
858
 
832
859
  {/* Inspector panel */}
833
- <div className="w-52 border-l border-border p-3 overflow-y-auto flex-shrink-0">
860
+ <div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
861
+ <div className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1.5" data-testid="ai-draft-current-target">
862
+ <p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">AI draft assist</p>
863
+ <p className="text-[11px] text-muted">
864
+ Copy a prompt scoped to {targetLabel ?? `cut ${cut.id}`}. Review and edit any drafted bubbles here before saving.
865
+ </p>
866
+ <button
867
+ type="button"
868
+ onClick={copyAiDraftPrompt}
869
+ className="rounded border border-accent/40 px-2 py-1 text-[11px] font-medium text-accent hover:bg-accent/10"
870
+ data-testid="copy-ai-lettering-current"
871
+ >
872
+ {aiCopied ? "Copied!" : "Copy AI draft prompt"}
873
+ </button>
874
+ </div>
834
875
  {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
835
876
  SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
836
877
  {scriptLines.length > 0 && (
@@ -1015,6 +1015,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1015
1015
  authFetch={authFetch}
1016
1016
  refreshKey={cutsRefreshKey}
1017
1017
  onAction={handleCoachAction}
1018
+ showEmptyState
1018
1019
  />
1019
1020
  )}
1020
1021
 
@@ -1334,7 +1335,7 @@ export function PreviewPanel({ storyName, fileName, authFetch, onPublish, publis
1334
1335
  <div className="flex items-center flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted" data-testid="cartoon-status-summary">
1335
1336
  <span>Cuts: <span className="text-foreground font-medium">{cartoonCutProgress.total}</span></span>
1336
1337
  <span>Clean: <span className="text-foreground font-medium">{cartoonCutProgress.withClean}/{cartoonCutProgress.needClean}</span></span>
1337
- <span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.needClean}</span></span>
1338
+ <span>Lettered: <span className="text-foreground font-medium">{cartoonCutProgress.withText}/{cartoonCutProgress.total}</span></span>
1338
1339
  <span>Uploaded: <span className="text-foreground font-medium">{cartoonCutProgress.uploaded}/{cartoonCutProgress.total}</span></span>
1339
1340
  {onViewProgress && (
1340
1341
  <button onClick={onViewProgress} className="ml-auto text-accent hover:underline" data-testid="status-view-progress">
@@ -7,11 +7,13 @@ import { CartoonWorkflowNav, type CartoonWorkflowTab } from "./CartoonWorkflowNa
7
7
  import { StoryInfoPage } from "./StoryInfoPage";
8
8
  import { EpisodesPage } from "./EpisodesPage";
9
9
  import { CartoonPublishPage } from "./CartoonPublishPage";
10
+ import { CartoonNextAction } from "./CartoonNextAction";
10
11
  import { LANGUAGES, GENRES } from "../../../lib/genres";
11
12
  import { getContentTypeForPublish, resolveSelectedContentType, needsLegacyProviderRepair, attachCoverToStoryline, derivePublishTitle, shouldBlockDuplicatePlotPublish, isRawFilenameTitle, hasExplicitEpisodeTitle, isPreflightBlocked, formatPreflightBlock } from "../lib/publish-helpers";
12
13
  import { verifyPublicCartoonTitle, publicTitleWarning } from "../lib/verify-public-title";
13
14
  import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
14
15
  import { cartoonGenesisReadiness } from "@app-lib/cartoon-readiness";
16
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
15
17
 
16
18
  interface StoriesPageProps {
17
19
  token: string;
@@ -763,6 +765,27 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
763
765
  }
764
766
  }, [selectedStory, handleSelectFile]);
765
767
 
768
+ const handleWorkflowNextAction = useCallback((action: CoachUiAction, episodeFile: string | null) => {
769
+ const story = selectedStory;
770
+ if (!story) return;
771
+ switch (action) {
772
+ case "view-progress":
773
+ setCartoonView(null);
774
+ setSelectedFile(null);
775
+ break;
776
+ case "publish":
777
+ setCartoonView("publish");
778
+ break;
779
+ case "open-cuts":
780
+ case "open-lettering":
781
+ case "upload":
782
+ case "refresh-assets":
783
+ case "generate-markdown":
784
+ if (episodeFile) handleSelectFile(story, episodeFile);
785
+ break;
786
+ }
787
+ }, [selectedStory, handleSelectFile]);
788
+
766
789
  // Keep the publish-control seeds in sync after a Story Info save (#439).
767
790
  const handleStoryInfoSaved = useCallback((patch: { genre?: string; language?: string; isNsfw?: boolean }) => {
768
791
  if (!selectedStory) return;
@@ -814,6 +837,17 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
814
837
  onSelect={handleCartoonNav}
815
838
  />
816
839
  )}
840
+ {isCartoonStory && selectedStory && cartoonView !== null && (
841
+ <div className="flex-shrink-0 border-b border-border" data-testid="workflow-context-next-action">
842
+ <CartoonNextAction
843
+ storyName={selectedStory}
844
+ authFetch={authFetch}
845
+ refreshKey={cartoonPublishRefresh}
846
+ onCoachAction={handleWorkflowNextAction}
847
+ onOpenStoryInfo={() => setCartoonView("story-info")}
848
+ />
849
+ </div>
850
+ )}
817
851
  {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
818
852
  <StoryInfoPage storyName={selectedStory} authFetch={authFetch} onSaved={handleStoryInfoSaved} />
819
853
  ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
@@ -836,6 +870,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
836
870
  storyName={selectedStory}
837
871
  authFetch={authFetch}
838
872
  onOpenFile={handleSelectFile}
873
+ onOpenStoryInfo={() => setCartoonView("story-info")}
839
874
  />
840
875
  ) : (
841
876
  <PreviewPanel