plotlink-ows 1.2.95 → 1.2.97

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.
@@ -20,10 +20,14 @@ import {
20
20
  type OverlayType,
21
21
  } from "@app-lib/overlays";
22
22
  import { layoutBubbleText } from "@app-lib/bubble-text";
23
- import { cutLetteringChecklist, cutScriptLines, isExportStale, overlaysSignature, type ScriptLine } from "@app-lib/lettering-status";
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";
23
+ import {
24
+ cutLetteringChecklist,
25
+ cutScriptLines,
26
+ isExportStale,
27
+ overlaysSignature,
28
+ type ScriptLine,
29
+ } from "@app-lib/lettering-status";
30
+ import { textPanelDimensions, type CutAiDraft } from "@app-lib/cuts";
27
31
  import { useAuthedAsset } from "./asset-image";
28
32
 
29
33
  function toPixel(norm: number, size: number): number {
@@ -63,13 +67,17 @@ interface Cut {
63
67
  kind?: "image" | "text";
64
68
  background?: string;
65
69
  aspectRatio?: string;
70
+ aiDraft?: CutAiDraft | null;
66
71
  }
67
72
 
68
73
  interface LetteringEditorProps {
69
74
  storyName: string;
70
75
  cut: Cut;
71
76
  plotFile: string;
72
- onSave: (overlays: Overlay[]) => void | Promise<void>;
77
+ onSave: (
78
+ overlays: Overlay[],
79
+ aiDraft?: CutAiDraft | null,
80
+ ) => void | Promise<void>;
73
81
  onClose: () => void;
74
82
  onExported?: () => void;
75
83
  language?: string;
@@ -78,6 +86,10 @@ interface LetteringEditorProps {
78
86
  targetLabel?: string;
79
87
  /** When true, the Save button returns to the review board after persisting. */
80
88
  returnOnSave?: boolean;
89
+ /** Whether the wider app work area / terminal is currently restored. */
90
+ workspaceVisible?: boolean;
91
+ /** Toggle the surrounding app work area while staying in the editor. */
92
+ onToggleWorkspaceVisible?: () => void;
81
93
  }
82
94
 
83
95
  const TYPE_LABEL: Record<OverlayType, string> = {
@@ -96,7 +108,8 @@ const TYPE_BORDER: Record<OverlayType, string> = {
96
108
  // a trimmed text snippet, falling back to the type name for empty bubbles.
97
109
  function overlapLabel(o: Overlay): string {
98
110
  const snippet = (o.speaker || o.text || "").trim().replace(/\s+/g, " ");
99
- if (snippet) return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
111
+ if (snippet)
112
+ return `“${snippet.length > 18 ? `${snippet.slice(0, 18)}…` : snippet}”`;
100
113
  return TYPE_LABEL[o.type];
101
114
  }
102
115
 
@@ -112,7 +125,20 @@ function clamp(v: number, min: number, max: number): number {
112
125
  return Math.min(max, Math.max(min, v));
113
126
  }
114
127
 
115
- export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onExported, language = "English", authFetch, targetLabel, returnOnSave = false }: LetteringEditorProps) {
128
+ export function LetteringEditor({
129
+ storyName,
130
+ cut,
131
+ plotFile,
132
+ onSave,
133
+ onClose,
134
+ onExported,
135
+ language = "English",
136
+ authFetch,
137
+ targetLabel,
138
+ returnOnSave = false,
139
+ workspaceVisible = false,
140
+ onToggleWorkspaceVisible,
141
+ }: LetteringEditorProps) {
116
142
  const bodyFont = getDefaultFont(language);
117
143
  const displayFont = getDisplayFont();
118
144
  const bodyFontFamily = getFontFamily(bodyFont);
@@ -132,10 +158,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
132
158
  try {
133
159
  const { ensureFontsReady } = await import("./export-cut");
134
160
  await ensureFontsReady([bodyFont.family, displayFont.family]);
135
- } catch { /* best effort — still render the preview */ }
161
+ } catch {
162
+ /* best effort — still render the preview */
163
+ }
136
164
  if (!cancelled) setFontsReady(true);
137
165
  })();
138
- return () => { cancelled = true; };
166
+ return () => {
167
+ cancelled = true;
168
+ };
139
169
  }, [bodyFont.family, displayFont.family]);
140
170
 
141
171
  // Clean image lives behind requireAuth, so a raw <img src> would 401. Load it
@@ -144,15 +174,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
144
174
  // Repair agent-authored overlays (e.g. semantic `position` strings with no
145
175
  // numeric geometry) on load so the bubbles actually render and export — and
146
176
  // surface a note when some could not be auto-placed (#309).
147
- const overlayNormalization = useMemo(() => normalizeOverlays(cut.overlays), [cut.overlays]);
177
+ const overlayNormalization = useMemo(
178
+ () => normalizeOverlays(cut.overlays),
179
+ [cut.overlays],
180
+ );
148
181
  const invalidOverlayCount = overlayNormalization.invalid.length;
149
182
  // Overlays that could not be placed (no geometry, no recognizable position)
150
183
  // are NOT exported. Exporting silently would produce a final missing that
151
184
  // bubble/text, so block export until the writer explicitly discards them (#309).
152
185
  const [acknowledgedInvalid, setAcknowledgedInvalid] = useState(false);
153
186
  const autoPlacedOverlays =
154
- invalidOverlayCount === 0 && overlayNormalization.changed && overlayNormalization.overlays.length > 0;
155
- const [overlays, setOverlays] = useState<Overlay[]>(() => overlayNormalization.overlays as Overlay[]);
187
+ invalidOverlayCount === 0 &&
188
+ overlayNormalization.changed &&
189
+ overlayNormalization.overlays.length > 0;
190
+ const [overlays, setOverlays] = useState<Overlay[]>(
191
+ () => overlayNormalization.overlays as Overlay[],
192
+ );
156
193
  // Signature of the overlays that match the current export/upload (#336, re1).
157
194
  // Captured (already normalized like the live `overlays`) when the cut opens so
158
195
  // a load-time normalization isn't mistaken for a user edit, and advanced to the
@@ -164,15 +201,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
164
201
  // Offscreen canvas to measure text exactly like the export canvas, so the
165
202
  // preview wraps/sizes bubble text identically to the final image (#310).
166
203
  const measureCanvasRef = useRef<HTMLCanvasElement | null>(null);
167
- const measureWidth = useCallback((fontFamily: string) => (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
168
- if (!measureCanvasRef.current && typeof document !== "undefined") {
169
- measureCanvasRef.current = document.createElement("canvas");
170
- }
171
- const mctx = measureCanvasRef.current?.getContext("2d");
172
- if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
173
- mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
174
- return mctx.measureText(text).width;
175
- }, []);
204
+ const measureWidth = useCallback(
205
+ (fontFamily: string) =>
206
+ (text: string, fontSize: number, fontWeight: 400 | 700 = 400): number => {
207
+ if (!measureCanvasRef.current && typeof document !== "undefined") {
208
+ measureCanvasRef.current = document.createElement("canvas");
209
+ }
210
+ const mctx = measureCanvasRef.current?.getContext("2d");
211
+ if (!mctx) return text.length * fontSize * 0.5; // jsdom fallback
212
+ mctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`;
213
+ return mctx.measureText(text).width;
214
+ },
215
+ [],
216
+ );
176
217
  // Gate the exact (canvas-measured) preview layout on the SAME font-readiness
177
218
  // signal export uses (ensureFontsReady), so the preview does not freeze line
178
219
  // breaks computed from fallback-font metrics that would diverge from the
@@ -183,11 +224,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
183
224
  const [exporting, setExporting] = useState(false);
184
225
  const [exportError, setExportError] = useState<string | null>(null);
185
226
  const [saveError, setSaveError] = useState<string | null>(null);
186
- const [aiCopied, setAiCopied] = useState(false);
187
- const [imageBounds, setImageBounds] = useState({ x: 0, y: 0, width: 0, height: 0 });
227
+ const [showHelp, setShowHelp] = useState(false);
228
+ const [imageBounds, setImageBounds] = useState({
229
+ x: 0,
230
+ y: 0,
231
+ width: 0,
232
+ height: 0,
233
+ });
188
234
  const containerRef = useRef<HTMLDivElement>(null);
189
235
  const imgRef = useRef<HTMLImageElement>(null);
190
- const dragRef = useRef<{ id: string; mode: "move" | "resize"; startX: number; startY: number; origX: number; origY: number; origW: number; origH: number } | null>(null);
236
+ const dragRef = useRef<{
237
+ id: string;
238
+ mode: "move" | "resize";
239
+ startX: number;
240
+ startY: number;
241
+ origX: number;
242
+ origY: number;
243
+ origW: number;
244
+ origH: number;
245
+ } | null>(null);
191
246
 
192
247
  const updateImageBounds = useCallback(() => {
193
248
  const container = containerRef.current;
@@ -199,7 +254,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
199
254
  if (cut.kind === "text") {
200
255
  // A text panel has no image — size the editor canvas from the SAME aspect
201
256
  // ratio the export uses, so lettering and the exported final agree (#351).
202
- const dims = textPanelDimensions(cut.aspectRatio) ?? { width: 800, height: 600 };
257
+ const dims = textPanelDimensions(cut.aspectRatio) ?? {
258
+ width: 800,
259
+ height: 600,
260
+ };
203
261
  iw = dims.width;
204
262
  ih = dims.height;
205
263
  } else {
@@ -212,7 +270,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
212
270
  const scale = Math.min(cw / iw, ch / ih);
213
271
  const rw = iw * scale;
214
272
  const rh = ih * scale;
215
- setImageBounds({ x: (cw - rw) / 2, y: (ch - rh) / 2, width: rw, height: rh });
273
+ setImageBounds({
274
+ x: (cw - rw) / 2,
275
+ y: (ch - rh) / 2,
276
+ width: rw,
277
+ height: rh,
278
+ });
216
279
  }, [cut.kind, cut.aspectRatio]);
217
280
 
218
281
  useEffect(() => {
@@ -229,76 +292,130 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
229
292
  // default font; without measurement it falls back to a generous default that
230
293
  // fits ordinary lines, instead of the tiny create-default. The writer can still
231
294
  // resize freely afterward, and the overflow warning stays useful if they shrink it.
232
- const fittedSize = useCallback((o: Overlay): { width: number; height: number } => {
233
- const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
234
- const width = comfortable.width;
235
- const maxH = Math.max(0.08, 1 - o.y);
236
- if (!o.text || !fontsReady || imageBounds.width <= 0) {
237
- return comfortable;
238
- }
239
- const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
240
- const wPx = toPixel(width, imageBounds.width);
241
- let height = o.type === "sfx" ? 0.08 : 0.12;
242
- for (let i = 0; i < 24; i++) {
243
- const h = Math.min(height, maxH);
244
- const hPx = toPixel(h, imageBounds.height);
245
- const layout = layoutBubbleText(
246
- measureWidth(fontFamily), o.text, wPx, hPx,
247
- bubbleLayoutOptionsForOverlay({ ...o, width, height: h }, imageBounds.height || 300, wPx, hPx),
248
- );
249
- if (!layout.overflow || h >= maxH) return { width, height: h };
250
- height += 0.03;
251
- }
252
- return { width, height: Math.min(height, maxH) };
253
- }, [fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
295
+ const fittedSize = useCallback(
296
+ (o: Overlay): { width: number; height: number } => {
297
+ const comfortable = comfortableOverlaySize(o.type, o.x, o.y);
298
+ const width = comfortable.width;
299
+ const maxH = Math.max(0.08, 1 - o.y);
300
+ if (!o.text || !fontsReady || imageBounds.width <= 0) {
301
+ return comfortable;
302
+ }
303
+ const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
304
+ const wPx = toPixel(width, imageBounds.width);
305
+ let height = o.type === "sfx" ? 0.08 : 0.12;
306
+ for (let i = 0; i < 24; i++) {
307
+ const h = Math.min(height, maxH);
308
+ const hPx = toPixel(h, imageBounds.height);
309
+ const layout = layoutBubbleText(
310
+ measureWidth(fontFamily),
311
+ o.text,
312
+ wPx,
313
+ hPx,
314
+ bubbleLayoutOptionsForOverlay(
315
+ { ...o, width, height: h },
316
+ imageBounds.height || 300,
317
+ wPx,
318
+ hPx,
319
+ ),
320
+ );
321
+ if (!layout.overflow || h >= maxH) return { width, height: h };
322
+ height += 0.03;
323
+ }
324
+ return { width, height: Math.min(height, maxH) };
325
+ },
326
+ [fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily],
327
+ );
254
328
 
255
- const addOverlay = useCallback((type: OverlayType) => {
256
- const o = createOverlay(type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
257
- const sized: Overlay = { ...o, ...fittedSize(o) };
258
- setOverlays((prev) => [...prev, sized]);
259
- setSelectedId(sized.id);
260
- }, [fittedSize]);
329
+ const addOverlay = useCallback(
330
+ (type: OverlayType) => {
331
+ const o = createOverlay(
332
+ type,
333
+ 0.1 + Math.random() * 0.3,
334
+ 0.1 + Math.random() * 0.3,
335
+ );
336
+ const sized: Overlay = { ...o, ...fittedSize(o) };
337
+ setOverlays((prev) => [...prev, sized]);
338
+ setSelectedId(sized.id);
339
+ },
340
+ [fittedSize],
341
+ );
261
342
 
262
343
  // Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
263
344
  // so the writer never has to hand-copy dialogue/narration/SFX out of the JSON.
264
- const addScriptLine = useCallback((line: ScriptLine) => {
265
- const o = createOverlay(line.type, 0.1 + Math.random() * 0.3, 0.1 + Math.random() * 0.3);
266
- const filled: Overlay = {
267
- ...o,
268
- text: line.text,
269
- ...(line.type === "speech" && line.speaker ? { speaker: line.speaker } : {}),
270
- };
271
- const sized: Overlay = { ...filled, ...fittedSize(filled) };
272
- setOverlays((prev) => [...prev, sized]);
273
- setSelectedId(sized.id);
274
- }, [fittedSize]);
345
+ const addScriptLine = useCallback(
346
+ (line: ScriptLine) => {
347
+ const o = createOverlay(
348
+ line.type,
349
+ 0.1 + Math.random() * 0.3,
350
+ 0.1 + Math.random() * 0.3,
351
+ );
352
+ const filled: Overlay = {
353
+ ...o,
354
+ text: line.text,
355
+ ...(line.type === "speech" && line.speaker
356
+ ? { speaker: line.speaker }
357
+ : {}),
358
+ };
359
+ const sized: Overlay = { ...filled, ...fittedSize(filled) };
360
+ setOverlays((prev) => [...prev, sized]);
361
+ setSelectedId(sized.id);
362
+ },
363
+ [fittedSize],
364
+ );
275
365
 
276
366
  const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
277
- setOverlays((prev) => prev.map((o) => o.id === id ? { ...o, ...changes } : o));
367
+ setOverlays((prev) =>
368
+ prev.map((o) => (o.id === id ? { ...o, ...changes } : o)),
369
+ );
278
370
  }, []);
279
371
 
280
- const enableManualTypography = useCallback((overlay: Overlay) => {
281
- const renderHeight = imageBounds.height || 300;
282
- const width = imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
283
- const height = imageBounds.height > 0 ? toPixel(overlay.height, imageBounds.height) : 100;
284
- const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
285
- const autoLayout = layoutBubbleText(
286
- measureWidth(fontFamily),
287
- overlay.text,
288
- width,
289
- height,
290
- bubbleLayoutOptionsForOverlay({ ...overlay, textStyle: undefined }, renderHeight, width, height),
291
- );
292
- updateOverlay(overlay.id, {
372
+ const enableManualTypography = useCallback(
373
+ (overlay: Overlay) => {
374
+ const renderHeight = imageBounds.height || 300;
375
+ const width =
376
+ imageBounds.width > 0 ? toPixel(overlay.width, imageBounds.width) : 200;
377
+ const height =
378
+ imageBounds.height > 0
379
+ ? toPixel(overlay.height, imageBounds.height)
380
+ : 100;
381
+ const fontFamily =
382
+ overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
383
+ const autoLayout = layoutBubbleText(
384
+ measureWidth(fontFamily),
385
+ overlay.text,
386
+ width,
387
+ height,
388
+ bubbleLayoutOptionsForOverlay(
389
+ { ...overlay, textStyle: undefined },
390
+ renderHeight,
391
+ width,
392
+ height,
393
+ ),
394
+ );
395
+ updateOverlay(overlay.id, {
293
396
  textStyle: {
294
397
  mode: "manual",
295
398
  fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
296
399
  fontWeight: overlay.textStyle?.fontWeight ?? 400,
297
- lineHeightFactor: autoLayout.fontSize > 0 ? autoLayout.lineHeight / autoLayout.fontSize : 1.2,
298
- speakerScale: autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0 ? autoLayout.speakerFontSize / autoLayout.fontSize : 0.8,
400
+ lineHeightFactor:
401
+ autoLayout.fontSize > 0
402
+ ? autoLayout.lineHeight / autoLayout.fontSize
403
+ : 1.2,
404
+ speakerScale:
405
+ autoLayout.fontSize > 0 && autoLayout.speakerFontSize > 0
406
+ ? autoLayout.speakerFontSize / autoLayout.fontSize
407
+ : 0.8,
299
408
  },
300
- });
301
- }, [imageBounds, displayFontFamily, bodyFontFamily, measureWidth, updateOverlay]);
409
+ });
410
+ },
411
+ [
412
+ imageBounds,
413
+ displayFontFamily,
414
+ bodyFontFamily,
415
+ measureWidth,
416
+ updateOverlay,
417
+ ],
418
+ );
302
419
 
303
420
  const deleteOverlay = useCallback((id: string) => {
304
421
  setOverlays((prev) => prev.filter((o) => o.id !== id));
@@ -317,23 +434,26 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
317
434
  setConfirmDelete(false);
318
435
  }, []);
319
436
 
320
- const handleMouseDown = useCallback((e: React.MouseEvent, id: string, mode: "move" | "resize") => {
321
- e.stopPropagation();
322
- e.preventDefault();
323
- const overlay = overlays.find((o) => o.id === id);
324
- if (!overlay) return;
325
- setSelectedId(id);
326
- dragRef.current = {
327
- id,
328
- mode,
329
- startX: e.clientX,
330
- startY: e.clientY,
331
- origX: overlay.x,
332
- origY: overlay.y,
333
- origW: overlay.width,
334
- origH: overlay.height,
335
- };
336
- }, [overlays]);
437
+ const handleMouseDown = useCallback(
438
+ (e: React.MouseEvent, id: string, mode: "move" | "resize") => {
439
+ e.stopPropagation();
440
+ e.preventDefault();
441
+ const overlay = overlays.find((o) => o.id === id);
442
+ if (!overlay) return;
443
+ setSelectedId(id);
444
+ dragRef.current = {
445
+ id,
446
+ mode,
447
+ startX: e.clientX,
448
+ startY: e.clientY,
449
+ origX: overlay.x,
450
+ origY: overlay.y,
451
+ origW: overlay.width,
452
+ origH: overlay.height,
453
+ };
454
+ },
455
+ [overlays],
456
+ );
337
457
 
338
458
  useEffect(() => {
339
459
  const onMouseMove = (e: MouseEvent) => {
@@ -353,7 +473,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
353
473
  }
354
474
  };
355
475
 
356
- const onMouseUp = () => { dragRef.current = null; };
476
+ const onMouseUp = () => {
477
+ dragRef.current = null;
478
+ };
357
479
 
358
480
  window.addEventListener("mousemove", onMouseMove);
359
481
  window.addEventListener("mouseup", onMouseUp);
@@ -366,18 +488,24 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
366
488
  const handleSave = useCallback(async () => {
367
489
  setSaveError(null);
368
490
  try {
369
- await onSave(overlays);
491
+ const currentSig = overlaysSignature(overlays);
492
+ const nextAiDraft =
493
+ cut.aiDraft?.status === "generated" &&
494
+ currentSig !== (cut.aiDraft.baseSig ?? "")
495
+ ? {
496
+ ...cut.aiDraft,
497
+ status: "edited" as const,
498
+ updatedAt: new Date().toISOString(),
499
+ }
500
+ : (cut.aiDraft ?? undefined);
501
+ await onSave(overlays, nextAiDraft ?? null);
370
502
  if (returnOnSave) onClose();
371
503
  } catch (err) {
372
- setSaveError(err instanceof Error ? err.message : "Failed to save overlays");
504
+ setSaveError(
505
+ err instanceof Error ? err.message : "Failed to save overlays",
506
+ );
373
507
  }
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]);
508
+ }, [overlays, onSave, onClose, returnOnSave, cut.aiDraft]);
381
509
 
382
510
  const handleExport = useCallback(async () => {
383
511
  // Block export when the cut plan contained overlays that could not be placed
@@ -399,10 +527,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
399
527
  const { exportCut, ensureFontsReady } = await import("./export-cut");
400
528
 
401
529
  const usesSfx = overlays.some((o) => o.type === "sfx");
402
- const fontsToCheck = [bodyFont.family, ...(usesSfx ? [displayFont.family] : [])];
530
+ const fontsToCheck = [
531
+ bodyFont.family,
532
+ ...(usesSfx ? [displayFont.family] : []),
533
+ ];
403
534
  const { ready, missing } = await ensureFontsReady(fontsToCheck);
404
535
  if (!ready) {
405
- setExportError(`Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`);
536
+ setExportError(
537
+ `Fonts not loaded: ${missing.join(", ")}. Check your connection and retry.`,
538
+ );
406
539
  setExporting(false);
407
540
  return;
408
541
  }
@@ -427,7 +560,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
427
560
  { narration: cut.narration, dialogue: cut.dialogue },
428
561
  // Text panels have no clean image — render the final on a styled
429
562
  // background canvas sized by the panel's aspect ratio (#351).
430
- cut.kind === "text" ? { background: cut.background, aspectRatio: cut.aspectRatio } : undefined,
563
+ cut.kind === "text"
564
+ ? { background: cut.background, aspectRatio: cut.aspectRatio }
565
+ : undefined,
431
566
  );
432
567
 
433
568
  const fd = new FormData();
@@ -452,7 +587,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
452
587
  } finally {
453
588
  setExporting(false);
454
589
  }
455
- }, [cut, cleanAsset, overlays, storyName, plotFile, bodyFont, displayFont, bodyFontFamily, displayFontFamily, authFetch, onSave, onExported, invalidOverlayCount, acknowledgedInvalid]);
590
+ }, [
591
+ cut,
592
+ cleanAsset,
593
+ overlays,
594
+ storyName,
595
+ plotFile,
596
+ bodyFont,
597
+ displayFont,
598
+ bodyFontFamily,
599
+ displayFontFamily,
600
+ authFetch,
601
+ onSave,
602
+ onExported,
603
+ invalidOverlayCount,
604
+ acknowledgedInvalid,
605
+ ]);
456
606
 
457
607
  const selectedOverlay = overlays.find((o) => o.id === selectedId);
458
608
 
@@ -460,7 +610,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
460
610
  // the writer gets a readability warning before export/publish (#318). Computed
461
611
  // from the live overlay positions, so it clears as soon as bubbles are moved
462
612
  // apart. Non-blocking: overlap can be intentional, so it never blocks export.
463
- const overlapPairs = useMemo(() => detectOverlappingOverlays(overlays), [overlays]);
613
+ const overlapPairs = useMemo(
614
+ () => detectOverlappingOverlays(overlays),
615
+ [overlays],
616
+ );
464
617
 
465
618
  // Re-baseline when a different cut opens without a remount (rare — the parent
466
619
  // normally unmounts the editor between cuts).
@@ -468,7 +621,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
468
621
  useEffect(() => {
469
622
  if (baselineCutIdRef.current !== cut.id) {
470
623
  baselineCutIdRef.current = cut.id;
471
- setExportBaselineSig(overlaysSignature(overlayNormalization.overlays as Overlay[]));
624
+ setExportBaselineSig(
625
+ overlaysSignature(overlayNormalization.overlays as Overlay[]),
626
+ );
472
627
  }
473
628
  }, [cut.id, overlayNormalization.overlays]);
474
629
 
@@ -502,7 +657,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
502
657
  const outOfBounds = isOverlayOutOfBounds(o);
503
658
  let overflow = false;
504
659
  if (fontsReady && imageBounds.width > 0 && o.text) {
505
- const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
660
+ const fontFamily =
661
+ o.type === "sfx" ? displayFontFamily : bodyFontFamily;
506
662
  const w = toPixel(o.width, imageBounds.width);
507
663
  const h = toPixel(o.height, imageBounds.height);
508
664
  const layout = layoutBubbleText(
@@ -517,8 +673,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
517
673
  if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
518
674
  }
519
675
  return out;
520
- }, [overlays, fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
676
+ }, [
677
+ overlays,
678
+ fontsReady,
679
+ imageBounds,
680
+ measureWidth,
681
+ bodyFontFamily,
682
+ displayFontFamily,
683
+ ]);
521
684
  const warningCount = Object.keys(overlayWarnings).length;
685
+ const checklistChips: Array<{
686
+ key: string;
687
+ label: string;
688
+ done: boolean;
689
+ }> = [
690
+ { key: "clean-image", label: "Clean", done: checklist.hasCleanImage },
691
+ { key: "script-text", label: "Script", done: checklist.hasScriptText },
692
+ {
693
+ key: "bubbles",
694
+ label: checklist.bubblesPlaced
695
+ ? `Bubbles ${checklist.bubblesPlaced}`
696
+ : "Bubbles",
697
+ done: checklist.bubblesPlaced > 0,
698
+ },
699
+ { key: "exported", label: "Exported", done: checklist.exported },
700
+ { key: "uploaded", label: "Uploaded", done: checklist.uploaded },
701
+ ];
522
702
 
523
703
  const isTextPanel = cut.kind === "text";
524
704
  const isNarrationCut = !cut.cleanImagePath;
@@ -526,7 +706,13 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
526
706
  // A text/interstitial panel (#351) is editable on a styled background canvas
527
707
  // even when empty, so it skips the "no clean image" guard that applies to a
528
708
  // would-be image cut with nothing placed yet.
529
- if (!isTextPanel && isNarrationCut && overlays.length === 0 && !cut.narration && !cut.dialogue?.length) {
709
+ if (
710
+ !isTextPanel &&
711
+ isNarrationCut &&
712
+ overlays.length === 0 &&
713
+ !cut.narration &&
714
+ !cut.dialogue?.length
715
+ ) {
530
716
  return (
531
717
  <div className="h-full flex items-center justify-center text-sm text-muted">
532
718
  No clean image — upload one first, or add overlays for a narration cut.
@@ -535,55 +721,192 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
535
721
  }
536
722
 
537
723
  return (
538
- <div className="h-full flex flex-col" data-testid="focused-lettering-editor">
724
+ <div
725
+ className="h-full flex flex-col"
726
+ data-testid="focused-lettering-editor"
727
+ >
539
728
  {/* Toolbar */}
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>
549
- <span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
729
+ <div
730
+ className="px-3 py-2 border-b border-border bg-surface/40 flex items-center justify-between gap-2 flex-wrap"
731
+ data-testid="lettering-toolbar"
732
+ >
733
+ <div className="flex items-center gap-1.5 flex-wrap min-w-0">
734
+ <button
735
+ onClick={onClose}
736
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
737
+ data-testid="return-to-cut-review-btn"
738
+ >
739
+ Back to cut review
740
+ </button>
741
+ <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.16em] text-accent">
742
+ Focused lettering editor
743
+ </span>
744
+ <span className="text-[11px] font-mono text-muted">
745
+ {targetLabel ?? `Cut #${cut.id}`}
746
+ </span>
747
+ <span
748
+ className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] text-muted"
749
+ data-testid="overlay-count"
750
+ >
751
+ {overlays.length} overlays
752
+ </span>
753
+ {checklistChips.map((chip) => (
754
+ <span
755
+ key={chip.key}
756
+ data-testid={`lettering-check-${chip.key}`}
757
+ data-done={chip.done ? "true" : "false"}
758
+ className={`rounded-full border px-2 py-0.5 text-[10px] ${
759
+ chip.done
760
+ ? "border-green-700/30 bg-green-700/10 text-green-700"
761
+ : "border-border bg-background text-muted"
762
+ }`}
763
+ >
764
+ {chip.done ? "✓ " : "○ "}
765
+ {chip.label}
766
+ </span>
767
+ ))}
768
+ {cut.aiDraft?.status === "generated" && (
769
+ <span
770
+ className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
771
+ data-testid="ai-draft-current-target"
772
+ >
773
+ AI draft ready
774
+ </span>
775
+ )}
776
+ {staleExport && (
777
+ <span
778
+ className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-700"
779
+ data-testid="lettering-stale-chip"
780
+ >
781
+ Re-export needed
782
+ </span>
783
+ )}
550
784
  </div>
551
- <div className="flex items-center gap-2 flex-wrap justify-end">
552
- <div className="flex items-center gap-1 ml-2">
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>
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>
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>
785
+ <div className="flex items-center gap-1.5 flex-wrap justify-end">
786
+ {onToggleWorkspaceVisible && (
787
+ <button
788
+ onClick={onToggleWorkspaceVisible}
789
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
790
+ data-testid="toggle-work-area-btn"
791
+ >
792
+ {workspaceVisible ? "Hide work area" : "Show work area"}
793
+ </button>
794
+ )}
795
+ <button
796
+ type="button"
797
+ onClick={() => setShowHelp((prev) => !prev)}
798
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
799
+ data-testid="lettering-help-toggle"
800
+ >
801
+ {showHelp ? "Hide help" : "Help"}
802
+ </button>
803
+ <div className="flex items-center gap-1">
804
+ <button
805
+ onClick={() => addOverlay("speech")}
806
+ className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
807
+ data-testid="add-speech"
808
+ >
809
+ Speech
810
+ </button>
811
+ <button
812
+ onClick={() => addOverlay("narration")}
813
+ className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
814
+ data-testid="add-narration"
815
+ >
816
+ Narration
817
+ </button>
818
+ <button
819
+ onClick={() => addOverlay("sfx")}
820
+ className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
821
+ data-testid="add-sfx"
822
+ >
823
+ SFX
824
+ </button>
556
825
  </div>
557
- {exportError && <span className="text-[10px] text-error">{exportError}</span>}
558
- {saveError && <span className="text-[10px] text-error">{saveError}</span>}
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">
826
+ {exportError && (
827
+ <span className="text-[10px] text-error max-w-[18rem]">
828
+ {exportError}
829
+ </span>
830
+ )}
831
+ {saveError && (
832
+ <span className="text-[10px] text-error max-w-[18rem]">
833
+ {saveError}
834
+ </span>
835
+ )}
836
+ <button
837
+ onClick={handleExport}
838
+ disabled={exporting}
839
+ className="px-2.5 py-1 text-[11px] border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
840
+ data-testid="export-btn"
841
+ >
560
842
  {exporting ? "Exporting..." : "Export"}
561
843
  </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>
844
+ <button
845
+ onClick={() => {
846
+ void handleSave();
847
+ }}
848
+ className="px-2.5 py-1 text-[11px] bg-accent text-white rounded hover:bg-accent-dim"
849
+ data-testid="save-lettering-btn"
850
+ >
851
+ Save
852
+ </button>
853
+ <button
854
+ onClick={onClose}
855
+ className="px-2.5 py-1 text-[11px] text-muted hover:text-foreground border border-border rounded"
856
+ data-testid="cancel-lettering-btn"
857
+ >
858
+ Cancel
859
+ </button>
564
860
  </div>
565
861
  </div>
566
862
 
863
+ {showHelp && (
864
+ <div
865
+ className="px-3 py-1.5 border-b border-border bg-background text-[10px] text-muted"
866
+ data-testid="lettering-help-panel"
867
+ >
868
+ Add or select a bubble, edit it in the inspector, then Save to return
869
+ to cut review. Use Export after the overlay layout is ready. Text cards
870
+ use narration overlays on the canvas.
871
+ </div>
872
+ )}
873
+
567
874
  {invalidOverlayCount > 0 && !acknowledgedInvalid ? (
568
- <div className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap" data-testid="overlay-repair-note">
875
+ <div
876
+ className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
877
+ data-testid="overlay-repair-note"
878
+ >
569
879
  <span>
570
- {invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"} from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no usable position and cannot be exported. Re-place {invalidOverlayCount === 1 ? "it" : "them"}, or
880
+ {invalidOverlayCount} overlay{invalidOverlayCount === 1 ? "" : "s"}{" "}
881
+ from the cut plan {invalidOverlayCount === 1 ? "has" : "have"} no
882
+ usable position and cannot be exported. Re-place{" "}
883
+ {invalidOverlayCount === 1 ? "it" : "them"}, or
571
884
  </span>
572
885
  <button
573
886
  onClick={() => setAcknowledgedInvalid(true)}
574
887
  data-testid="discard-invalid-overlays"
575
888
  className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
576
889
  >
577
- discard {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"}
890
+ discard {invalidOverlayCount} unplaceable overlay
891
+ {invalidOverlayCount === 1 ? "" : "s"}
578
892
  </button>
579
893
  </div>
580
894
  ) : invalidOverlayCount > 0 ? (
581
- <div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
582
- Discarded {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"} — the export will not include {invalidOverlayCount === 1 ? "it" : "them"}.
895
+ <div
896
+ className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
897
+ data-testid="overlay-repair-note"
898
+ >
899
+ Discarded {invalidOverlayCount} unplaceable overlay
900
+ {invalidOverlayCount === 1 ? "" : "s"} — the export will not include{" "}
901
+ {invalidOverlayCount === 1 ? "it" : "them"}.
583
902
  </div>
584
903
  ) : autoPlacedOverlays ? (
585
- <div className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700" data-testid="overlay-repair-note">
586
- Auto-placed overlays from the cut plan — review their positions before exporting.
904
+ <div
905
+ className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
906
+ data-testid="overlay-repair-note"
907
+ >
908
+ Auto-placed overlays from the cut plan — review their positions before
909
+ exporting.
587
910
  </div>
588
911
  ) : null}
589
912
 
@@ -592,48 +915,29 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
592
915
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
593
916
  data-testid="overlay-overlap-warning"
594
917
  >
595
- Cut #{cut.id}: {overlapPairs.length} bubble {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and may be hard to read —{" "}
918
+ Cut #{cut.id}: {overlapPairs.length} bubble{" "}
919
+ {overlapPairs.length === 1 ? "pair overlaps" : "pairs overlap"} and
920
+ may be hard to read —{" "}
596
921
  {overlapPairs
597
- .map((p) => `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`)
922
+ .map(
923
+ (p) =>
924
+ `#${p.indexA + 1} ${overlapLabel(overlays[p.indexA])} ↔ #${p.indexB + 1} ${overlapLabel(overlays[p.indexB])}`,
925
+ )
598
926
  .join("; ")}
599
927
  . Move them apart, or export as-is if the overlap is intended.
600
928
  </div>
601
929
  )}
602
930
 
603
- {/* Per-cut lettering checklist (#336): shows how far this cut has come so
604
- the writer can finish it from the editor without inspecting cuts.json. */}
605
- <div
606
- className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
607
- data-testid="lettering-checklist"
608
- >
609
- {([
610
- ["clean-image", "Clean image", checklist.hasCleanImage],
611
- ["script-text", "Script text", checklist.hasScriptText],
612
- ["bubbles", `Bubbles placed${checklist.bubblesPlaced ? ` (${checklist.bubblesPlaced})` : ""}`, checklist.bubblesPlaced > 0],
613
- ["exported", "Final exported", checklist.exported],
614
- ["uploaded", "Uploaded", checklist.uploaded],
615
- ] as [string, string, boolean][]).map(([key, label, done]) => (
616
- <span
617
- key={key}
618
- data-testid={`lettering-check-${key}`}
619
- data-done={done ? "true" : "false"}
620
- className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
621
- >
622
- <span aria-hidden>{done ? "✓" : "○"}</span>
623
- {label}
624
- </span>
625
- ))}
626
- </div>
627
-
628
931
  {/* Stale-export warning (#336, re1): bubbles changed since the recorded
629
932
  export/upload, so the final image/uploaded URL are out of date. The
630
- checklist already marks export/upload incomplete; this says why. */}
933
+ compact toolbar chips already mark export/upload incomplete; this says why. */}
631
934
  {staleExport && (
632
935
  <div
633
936
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
634
937
  data-testid="lettering-stale-export-warning"
635
938
  >
636
- Bubbles changed since the last export — re-export this cut and upload the new final image before publishing.
939
+ Bubbles changed since the last export — re-export this cut and upload
940
+ the new final image before publishing.
637
941
  </div>
638
942
  )}
639
943
 
@@ -644,11 +948,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
644
948
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
645
949
  data-testid="lettering-export-warning"
646
950
  >
647
- {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export cleanly:{" "}
951
+ {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
952
+ cleanly:{" "}
648
953
  {Object.entries(overlayWarnings)
649
954
  .map(([id, w]) => {
650
955
  const idx = overlays.findIndex((o) => o.id === id);
651
- const problems = [w.outOfBounds ? "outside image" : null, w.overflow ? "text overflow" : null]
956
+ const problems = [
957
+ w.outOfBounds ? "outside image" : null,
958
+ w.overflow ? "text overflow" : null,
959
+ ]
652
960
  .filter(Boolean)
653
961
  .join(", ");
654
962
  return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
@@ -667,11 +975,17 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
667
975
  data-testid="editor-surface"
668
976
  >
669
977
  {cut.cleanImagePath && cleanAsset.error ? (
670
- <div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-error">
978
+ <div
979
+ className="w-full h-full flex items-center justify-center text-muted text-xs"
980
+ data-testid="clean-image-error"
981
+ >
671
982
  Clean image not available
672
983
  </div>
673
984
  ) : cut.cleanImagePath && !cleanAsset.url ? (
674
- <div className="w-full h-full flex items-center justify-center text-muted text-xs" data-testid="clean-image-loading">
985
+ <div
986
+ className="w-full h-full flex items-center justify-center text-muted text-xs"
987
+ data-testid="clean-image-loading"
988
+ >
675
989
  Loading clean image…
676
990
  </div>
677
991
  ) : cut.cleanImagePath ? (
@@ -708,7 +1022,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
708
1022
  if (el && imageBounds.width === 0) {
709
1023
  const rect = el.getBoundingClientRect();
710
1024
  if (rect.width > 0) {
711
- setImageBounds({ x: 0, y: 0, width: rect.width, height: rect.height });
1025
+ setImageBounds({
1026
+ x: 0,
1027
+ y: 0,
1028
+ width: rect.width,
1029
+ height: rect.height,
1030
+ });
712
1031
  }
713
1032
  }
714
1033
  }}
@@ -725,15 +1044,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
725
1044
  Tailless speech (no tailAnchor, or a tip inside the bubble) traces
726
1045
  a plain rounded rectangle. Tail-anchor edits update the path live. */}
727
1046
  {imageBounds.width > 0 && (
728
- <svg className="absolute inset-0 w-full h-full pointer-events-none" data-testid="balloon-layer">
1047
+ <svg
1048
+ className="absolute inset-0 w-full h-full pointer-events-none"
1049
+ data-testid="balloon-layer"
1050
+ >
729
1051
  {overlays.map((overlay) => {
730
1052
  if (overlay.type !== "speech") return null;
731
- const ox = imageBounds.x + toPixel(overlay.x, imageBounds.width);
732
- const oy = imageBounds.y + toPixel(overlay.y, imageBounds.height);
1053
+ const ox =
1054
+ imageBounds.x + toPixel(overlay.x, imageBounds.width);
1055
+ const oy =
1056
+ imageBounds.y + toPixel(overlay.y, imageBounds.height);
733
1057
  const ow = toPixel(overlay.width, imageBounds.width);
734
1058
  const oh = toPixel(overlay.height, imageBounds.height);
735
1059
  const radius = balloonRadiusForOverlay(overlay, ow, oh);
736
- const tail = overlay.tailAnchor ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius) : null;
1060
+ const tail = overlay.tailAnchor
1061
+ ? speechTailPoints(ox, oy, ow, oh, overlay.tailAnchor, radius)
1062
+ : null;
737
1063
  // Strong, clean near-black outline scaled to the preview size so
738
1064
  // the bubble reads as a webtoon balloon (matching the export's
739
1065
  // proportional stroke), not a faint UI box (#363).
@@ -753,159 +1079,166 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
753
1079
  </svg>
754
1080
  )}
755
1081
 
756
- {imageBounds.width > 0 && overlays.map((overlay) => {
757
- const left = imageBounds.x + toPixel(overlay.x, imageBounds.width);
758
- const top = imageBounds.y + toPixel(overlay.y, imageBounds.height);
759
- const width = toPixel(overlay.width, imageBounds.width);
760
- const height = toPixel(overlay.height, imageBounds.height);
761
- const isSelected = overlay.id === selectedId;
762
- // Speech bubbles draw no body border here — the integrated balloon
763
- // <path> in the layer below is their outline, so a box border would
764
- // re-introduce the body/tail seam (#327). Their selection cue is the
765
- // path's accent stroke (plus the resize handle). Narration/SFX keep
766
- // their bordered box + selection ring as before.
767
- const isSpeech = overlay.type === "speech";
768
- // Narration reads as an intentional parchment caption card (rounded,
769
- // filled), mirroring the export, instead of an empty bordered box (#363).
770
- const isNarration = overlay.type === "narration";
771
- const warned = !!overlayWarnings[overlay.id];
772
-
773
- return (
774
- <div
775
- key={overlay.id}
776
- data-testid={`overlay-${overlay.id}`}
777
- data-warning={warned ? "true" : "false"}
778
- onClick={(e) => handleOverlayClick(e, overlay.id)}
779
- onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
780
- className={`absolute rounded cursor-move select-none ${
781
- isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
782
- } ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
783
- isSelected && !isSpeech ? "ring-2 ring-accent" : ""
784
- } ${warned ? "ring-2 ring-amber-500" : ""}`}
785
- style={{ left, top, width, height }}
786
- >
787
- {(() => {
788
- const fontFamily = overlay.type === "sfx" ? displayFontFamily : bodyFontFamily;
789
- if (!overlay.text) {
790
- return (
791
- <span className="text-[9px] px-1 text-muted truncate block pointer-events-none" style={{ fontFamily }}>
792
- {TYPE_LABEL[overlay.type]}
793
- </span>
1082
+ {imageBounds.width > 0 &&
1083
+ overlays.map((overlay) => {
1084
+ const left =
1085
+ imageBounds.x + toPixel(overlay.x, imageBounds.width);
1086
+ const top =
1087
+ imageBounds.y + toPixel(overlay.y, imageBounds.height);
1088
+ const width = toPixel(overlay.width, imageBounds.width);
1089
+ const height = toPixel(overlay.height, imageBounds.height);
1090
+ const isSelected = overlay.id === selectedId;
1091
+ // Speech bubbles draw no body border here the integrated balloon
1092
+ // <path> in the layer below is their outline, so a box border would
1093
+ // re-introduce the body/tail seam (#327). Their selection cue is the
1094
+ // path's accent stroke (plus the resize handle). Narration/SFX keep
1095
+ // their bordered box + selection ring as before.
1096
+ const isSpeech = overlay.type === "speech";
1097
+ // Narration reads as an intentional parchment caption card (rounded,
1098
+ // filled), mirroring the export, instead of an empty bordered box (#363).
1099
+ const isNarration = overlay.type === "narration";
1100
+ const warned = !!overlayWarnings[overlay.id];
1101
+
1102
+ return (
1103
+ <div
1104
+ key={overlay.id}
1105
+ data-testid={`overlay-${overlay.id}`}
1106
+ data-warning={warned ? "true" : "false"}
1107
+ onClick={(e) => handleOverlayClick(e, overlay.id)}
1108
+ onMouseDown={(e) => handleMouseDown(e, overlay.id, "move")}
1109
+ className={`absolute rounded cursor-move select-none ${
1110
+ isSpeech ? "" : `border-2 ${TYPE_BORDER[overlay.type]}`
1111
+ } ${isNarration ? "bg-[#f4efe6]/85 rounded-md" : ""} ${
1112
+ isSelected && !isSpeech ? "ring-2 ring-accent" : ""
1113
+ } ${warned ? "ring-2 ring-amber-500" : ""}`}
1114
+ style={{ left, top, width, height }}
1115
+ >
1116
+ {(() => {
1117
+ const fontFamily =
1118
+ overlay.type === "sfx"
1119
+ ? displayFontFamily
1120
+ : bodyFontFamily;
1121
+ if (!overlay.text) {
1122
+ return (
1123
+ <span
1124
+ className="text-[9px] px-1 text-muted truncate block pointer-events-none"
1125
+ style={{ fontFamily }}
1126
+ >
1127
+ {TYPE_LABEL[overlay.type]}
1128
+ </span>
1129
+ );
1130
+ }
1131
+ const hasSpeaker =
1132
+ overlay.type !== "sfx" && !!overlay.speaker;
1133
+ if (!fontsReady) {
1134
+ // Until the web font's metrics are available, don't freeze
1135
+ // canvas-measured line breaks from fallback metrics (they
1136
+ // would diverge from export). Show a CSS-wrapped transient;
1137
+ // the exact layout computes once fonts are ready (#310, re1).
1138
+ return (
1139
+ <div
1140
+ className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
1141
+ style={{
1142
+ fontFamily,
1143
+ fontSize: Math.max(9, Math.min(height * 0.05, 16)),
1144
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
1145
+ }}
1146
+ data-testid={`overlay-text-${overlay.id}`}
1147
+ data-fonts-ready="false"
1148
+ >
1149
+ {hasSpeaker
1150
+ ? `${overlay.speaker}: ${overlay.text}`
1151
+ : overlay.text}
1152
+ </div>
1153
+ );
1154
+ }
1155
+ const layout = layoutBubbleText(
1156
+ measureWidth(fontFamily),
1157
+ overlay.text,
1158
+ width,
1159
+ height,
1160
+ bubbleLayoutOptionsForOverlay(
1161
+ overlay,
1162
+ imageBounds.height,
1163
+ width,
1164
+ height,
1165
+ ),
794
1166
  );
795
- }
796
- const hasSpeaker = overlay.type !== "sfx" && !!overlay.speaker;
797
- if (!fontsReady) {
798
- // Until the web font's metrics are available, don't freeze
799
- // canvas-measured line breaks from fallback metrics (they
800
- // would diverge from export). Show a CSS-wrapped transient;
801
- // the exact layout computes once fonts are ready (#310, re1).
802
1167
  return (
803
1168
  <div
804
- className="absolute inset-0 flex items-center justify-center px-1 overflow-hidden pointer-events-none text-center break-words"
805
- style={{
806
- fontFamily,
807
- fontSize: Math.max(9, Math.min(height * 0.05, 16)),
808
- fontWeight: overlay.textStyle?.fontWeight ?? 400,
809
- }}
1169
+ className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
1170
+ style={{ fontFamily }}
810
1171
  data-testid={`overlay-text-${overlay.id}`}
811
- data-fonts-ready="false"
1172
+ data-fonts-ready="true"
812
1173
  >
813
- {hasSpeaker ? `${overlay.speaker}: ${overlay.text}` : overlay.text}
1174
+ {hasSpeaker && (
1175
+ <span
1176
+ className="font-bold text-[#3a3a3a] block"
1177
+ style={{
1178
+ fontSize: layout.speakerFontSize,
1179
+ lineHeight: 1.2,
1180
+ }}
1181
+ >
1182
+ {overlay.speaker}
1183
+ </span>
1184
+ )}
1185
+ <span
1186
+ className="text-[#1a1a1a]"
1187
+ style={{
1188
+ fontSize: layout.fontSize,
1189
+ lineHeight: `${layout.lineHeight}px`,
1190
+ fontWeight: overlay.textStyle?.fontWeight ?? 400,
1191
+ }}
1192
+ >
1193
+ {layout.lines.map((line, i) => (
1194
+ <span key={i} className="block">
1195
+ {line}
1196
+ </span>
1197
+ ))}
1198
+ </span>
814
1199
  </div>
815
1200
  );
816
- }
817
- const layout = layoutBubbleText(
818
- measureWidth(fontFamily),
819
- overlay.text,
820
- width,
821
- height,
822
- bubbleLayoutOptionsForOverlay(overlay, imageBounds.height, width, height),
823
- );
824
- return (
1201
+ })()}
1202
+ {isSelected && (
825
1203
  <div
826
- className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
827
- style={{ fontFamily }}
828
- data-testid={`overlay-text-${overlay.id}`}
829
- data-fonts-ready="true"
830
- >
831
- {hasSpeaker && (
832
- <span className="font-bold text-[#3a3a3a] block" style={{ fontSize: layout.speakerFontSize, lineHeight: 1.2 }}>
833
- {overlay.speaker}
834
- </span>
835
- )}
836
- <span
837
- className="text-[#1a1a1a]"
838
- style={{ fontSize: layout.fontSize, lineHeight: `${layout.lineHeight}px`, fontWeight: overlay.textStyle?.fontWeight ?? 400 }}
839
- >
840
- {layout.lines.map((line, i) => (
841
- <span key={i} className="block">{line}</span>
842
- ))}
843
- </span>
844
- </div>
845
- );
846
- })()}
847
- {isSelected && (
848
- <div
849
- onMouseDown={(e) => { e.stopPropagation(); handleMouseDown(e, overlay.id, "resize"); }}
850
- className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
851
- data-testid={`resize-${overlay.id}`}
852
- />
853
- )}
854
- </div>
855
- );
856
- })}
1204
+ onMouseDown={(e) => {
1205
+ e.stopPropagation();
1206
+ handleMouseDown(e, overlay.id, "resize");
1207
+ }}
1208
+ className="absolute bottom-0 right-0 w-2 h-2 bg-accent cursor-se-resize"
1209
+ data-testid={`resize-${overlay.id}`}
1210
+ />
1211
+ )}
1212
+ </div>
1213
+ );
1214
+ })}
857
1215
  </div>
858
1216
 
859
1217
  {/* Inspector panel */}
860
1218
  <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>
875
- {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
876
- SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
877
- {scriptLines.length > 0 && (
878
- <div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
879
- <span className="text-[10px] font-medium text-muted">From script</span>
880
- <div className="flex flex-col gap-1">
881
- {scriptLines.map((line) => (
882
- <button
883
- key={line.key}
884
- onClick={() => addScriptLine(line)}
885
- data-testid={`script-insert-${line.key}`}
886
- title={`Add ${line.type} overlay with this text`}
887
- className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
888
- >
889
- <span className="font-medium text-accent">+ {TYPE_LABEL[line.type]}</span>{" "}
890
- <span className="text-muted">
891
- {line.speaker ? `${line.speaker}: ` : ""}
892
- {line.text.length > 32 ? `${line.text.slice(0, 32)}…` : line.text}
893
- </span>
894
- </button>
895
- ))}
896
- </div>
897
- </div>
898
- )}
899
1219
  {selectedOverlay ? (
900
1220
  <div className="space-y-3">
901
- <p className="text-xs font-medium text-foreground">{TYPE_LABEL[selectedOverlay.type]}</p>
1221
+ <div className="flex items-center justify-between gap-2">
1222
+ <p className="text-xs font-medium text-foreground">
1223
+ {TYPE_LABEL[selectedOverlay.type]}
1224
+ </p>
1225
+ <span className="text-[10px] text-muted">
1226
+ #{overlays.findIndex((o) => o.id === selectedOverlay.id) + 1}
1227
+ </span>
1228
+ </div>
902
1229
 
903
1230
  {selectedOverlay.speaker !== undefined && (
904
1231
  <label className="block space-y-1">
905
- <span className="text-[10px] font-medium text-muted">Speaker</span>
1232
+ <span className="text-[10px] font-medium text-muted">
1233
+ Speaker
1234
+ </span>
906
1235
  <input
907
1236
  value={selectedOverlay.speaker || ""}
908
- onChange={(e) => updateOverlay(selectedOverlay.id, { speaker: e.target.value })}
1237
+ onChange={(e) =>
1238
+ updateOverlay(selectedOverlay.id, {
1239
+ speaker: e.target.value,
1240
+ })
1241
+ }
909
1242
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
910
1243
  placeholder="Character name"
911
1244
  data-testid="inspector-speaker"
@@ -917,7 +1250,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
917
1250
  <span className="text-[10px] font-medium text-muted">Text</span>
918
1251
  <textarea
919
1252
  value={selectedOverlay.text}
920
- onChange={(e) => updateOverlay(selectedOverlay.id, { text: e.target.value })}
1253
+ onChange={(e) =>
1254
+ updateOverlay(selectedOverlay.id, { text: e.target.value })
1255
+ }
921
1256
  rows={3}
922
1257
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
923
1258
  placeholder="Overlay text"
@@ -928,7 +1263,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
928
1263
  {/* One-click resize so a long line that overflows can be fitted
929
1264
  without hand-dragging the box (#452). */}
930
1265
  <button
931
- onClick={() => updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))}
1266
+ onClick={() =>
1267
+ updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))
1268
+ }
932
1269
  data-testid="inspector-fit-text"
933
1270
  className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
934
1271
  title="Resize this overlay so its text fits without overflowing"
@@ -936,13 +1273,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
936
1273
  Fit box to text
937
1274
  </button>
938
1275
 
939
- <div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-typography">
1276
+ <div
1277
+ className="space-y-1.5 rounded border border-border/70 p-2"
1278
+ data-testid="inspector-typography"
1279
+ >
940
1280
  <div className="flex items-center justify-between gap-2">
941
- <span className="text-[10px] font-medium text-muted">Typography</span>
1281
+ <span className="text-[10px] font-medium text-muted">
1282
+ Typography
1283
+ </span>
942
1284
  {selectedOverlay.textStyle?.mode === "manual" ? (
943
1285
  <button
944
1286
  type="button"
945
- onClick={() => updateOverlay(selectedOverlay.id, { textStyle: undefined })}
1287
+ onClick={() =>
1288
+ updateOverlay(selectedOverlay.id, {
1289
+ textStyle: undefined,
1290
+ })
1291
+ }
946
1292
  className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
947
1293
  data-testid="inspector-text-auto"
948
1294
  >
@@ -962,20 +1308,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
962
1308
  {selectedOverlay.textStyle?.mode === "manual" ? (
963
1309
  <div className="space-y-1.5">
964
1310
  <label className="block space-y-1">
965
- <span className="text-[10px] text-muted">Font size (% panel height)</span>
1311
+ <span className="text-[10px] text-muted">
1312
+ Font size (% panel height)
1313
+ </span>
966
1314
  <input
967
1315
  type="number"
968
1316
  step="0.1"
969
1317
  min="1.5"
970
1318
  max="12"
971
- value={(((selectedOverlay.textStyle.fontScale ?? 0.032) * 100)).toFixed(1)}
972
- onChange={(e) => updateOverlay(selectedOverlay.id, {
973
- textStyle: {
974
- ...selectedOverlay.textStyle,
975
- mode: "manual",
976
- fontScale: Math.max(0.015, Math.min(0.12, (parseFloat(e.target.value) || 3.2) / 100)),
977
- },
978
- })}
1319
+ value={(
1320
+ (selectedOverlay.textStyle.fontScale ?? 0.032) * 100
1321
+ ).toFixed(1)}
1322
+ onChange={(e) =>
1323
+ updateOverlay(selectedOverlay.id, {
1324
+ textStyle: {
1325
+ ...selectedOverlay.textStyle,
1326
+ mode: "manual",
1327
+ fontScale: Math.max(
1328
+ 0.015,
1329
+ Math.min(
1330
+ 0.12,
1331
+ (parseFloat(e.target.value) || 3.2) / 100,
1332
+ ),
1333
+ ),
1334
+ },
1335
+ })
1336
+ }
979
1337
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
980
1338
  data-testid="inspector-font-scale"
981
1339
  />
@@ -983,14 +1341,18 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
983
1341
  <label className="block space-y-1">
984
1342
  <span className="text-[10px] text-muted">Weight</span>
985
1343
  <select
986
- value={String(selectedOverlay.textStyle.fontWeight ?? 400)}
987
- onChange={(e) => updateOverlay(selectedOverlay.id, {
988
- textStyle: {
989
- ...selectedOverlay.textStyle,
990
- mode: "manual",
991
- fontWeight: e.target.value === "700" ? 700 : 400,
992
- },
993
- })}
1344
+ value={String(
1345
+ selectedOverlay.textStyle.fontWeight ?? 400,
1346
+ )}
1347
+ onChange={(e) =>
1348
+ updateOverlay(selectedOverlay.id, {
1349
+ textStyle: {
1350
+ ...selectedOverlay.textStyle,
1351
+ mode: "manual",
1352
+ fontWeight: e.target.value === "700" ? 700 : 400,
1353
+ },
1354
+ })
1355
+ }
994
1356
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
995
1357
  data-testid="inspector-font-weight"
996
1358
  >
@@ -999,40 +1361,61 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
999
1361
  </select>
1000
1362
  </label>
1001
1363
  <label className="block space-y-1">
1002
- <span className="text-[10px] text-muted">Line height</span>
1364
+ <span className="text-[10px] text-muted">
1365
+ Line height
1366
+ </span>
1003
1367
  <input
1004
1368
  type="number"
1005
1369
  step="0.05"
1006
1370
  min="0.9"
1007
1371
  max="2"
1008
- value={(selectedOverlay.textStyle.lineHeightFactor ?? 1.2).toFixed(2)}
1009
- onChange={(e) => updateOverlay(selectedOverlay.id, {
1010
- textStyle: {
1011
- ...selectedOverlay.textStyle,
1012
- mode: "manual",
1013
- lineHeightFactor: Math.max(0.9, Math.min(2, parseFloat(e.target.value) || 1.2)),
1014
- },
1015
- })}
1372
+ value={(
1373
+ selectedOverlay.textStyle.lineHeightFactor ?? 1.2
1374
+ ).toFixed(2)}
1375
+ onChange={(e) =>
1376
+ updateOverlay(selectedOverlay.id, {
1377
+ textStyle: {
1378
+ ...selectedOverlay.textStyle,
1379
+ mode: "manual",
1380
+ lineHeightFactor: Math.max(
1381
+ 0.9,
1382
+ Math.min(2, parseFloat(e.target.value) || 1.2),
1383
+ ),
1384
+ },
1385
+ })
1386
+ }
1016
1387
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1017
1388
  data-testid="inspector-line-height"
1018
1389
  />
1019
1390
  </label>
1020
1391
  {selectedOverlay.type !== "sfx" && (
1021
1392
  <label className="block space-y-1">
1022
- <span className="text-[10px] text-muted">Speaker scale</span>
1393
+ <span className="text-[10px] text-muted">
1394
+ Speaker scale
1395
+ </span>
1023
1396
  <input
1024
1397
  type="number"
1025
1398
  step="0.05"
1026
1399
  min="0.5"
1027
1400
  max="1.5"
1028
- value={(selectedOverlay.textStyle.speakerScale ?? 0.8).toFixed(2)}
1029
- onChange={(e) => updateOverlay(selectedOverlay.id, {
1030
- textStyle: {
1031
- ...selectedOverlay.textStyle,
1032
- mode: "manual",
1033
- speakerScale: Math.max(0.5, Math.min(1.5, parseFloat(e.target.value) || 0.8)),
1034
- },
1035
- })}
1401
+ value={(
1402
+ selectedOverlay.textStyle.speakerScale ?? 0.8
1403
+ ).toFixed(2)}
1404
+ onChange={(e) =>
1405
+ updateOverlay(selectedOverlay.id, {
1406
+ textStyle: {
1407
+ ...selectedOverlay.textStyle,
1408
+ mode: "manual",
1409
+ speakerScale: Math.max(
1410
+ 0.5,
1411
+ Math.min(
1412
+ 1.5,
1413
+ parseFloat(e.target.value) || 0.8,
1414
+ ),
1415
+ ),
1416
+ },
1417
+ })
1418
+ }
1036
1419
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1037
1420
  data-testid="inspector-speaker-scale"
1038
1421
  />
@@ -1040,109 +1423,176 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
1040
1423
  )}
1041
1424
  </div>
1042
1425
  ) : (
1043
- <p className="text-[10px] text-muted">Auto-fit stays on by default and resizes text to the box.</p>
1426
+ <p className="text-[10px] text-muted">
1427
+ Auto-fit stays on by default and resizes text to the box.
1428
+ </p>
1044
1429
  )}
1045
1430
  </div>
1046
1431
 
1047
- {selectedOverlay.type === "speech" && (() => {
1048
- const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
1049
- return (
1050
- <div className="space-y-1">
1051
- <span className="text-[10px] font-medium text-muted">Tail anchor</span>
1052
- <div className="flex flex-wrap gap-1" data-testid="inspector-tail-presets">
1053
- {TAIL_PRESETS.map((preset) => (
1054
- <button
1055
- key={preset.key}
1056
- type="button"
1057
- onClick={() => updateOverlay(selectedOverlay.id, { tailAnchor: preset.anchor })}
1058
- className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1059
- data-testid={`inspector-tail-${preset.key}`}
1060
- >
1061
- {preset.label}
1062
- </button>
1063
- ))}
1064
- </div>
1065
- <div className="flex gap-2">
1066
- <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1067
- x
1068
- <input
1069
- type="number"
1070
- step="0.1"
1071
- value={tail.x}
1072
- onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, x: parseFloat(e.target.value) || 0 } })}
1073
- className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1074
- data-testid="inspector-tail-x"
1075
- />
1076
- </label>
1077
- <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1078
- y
1079
- <input
1080
- type="number"
1081
- step="0.1"
1082
- value={tail.y}
1083
- onChange={(e) => updateOverlay(selectedOverlay.id, { tailAnchor: { ...tail, y: parseFloat(e.target.value) || 0 } })}
1084
- className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1085
- data-testid="inspector-tail-y"
1086
- />
1087
- </label>
1432
+ {selectedOverlay.type === "speech" &&
1433
+ (() => {
1434
+ const tail = selectedOverlay.tailAnchor || { x: 0.5, y: 1.2 };
1435
+ return (
1436
+ <div className="space-y-1">
1437
+ <span className="text-[10px] font-medium text-muted">
1438
+ Tail anchor
1439
+ </span>
1440
+ <div
1441
+ className="flex flex-wrap gap-1"
1442
+ data-testid="inspector-tail-presets"
1443
+ >
1444
+ {TAIL_PRESETS.map((preset) => (
1445
+ <button
1446
+ key={preset.key}
1447
+ type="button"
1448
+ onClick={() =>
1449
+ updateOverlay(selectedOverlay.id, {
1450
+ tailAnchor: preset.anchor,
1451
+ })
1452
+ }
1453
+ className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1454
+ data-testid={`inspector-tail-${preset.key}`}
1455
+ >
1456
+ {preset.label}
1457
+ </button>
1458
+ ))}
1459
+ </div>
1460
+ <div className="flex gap-2">
1461
+ <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1462
+ x
1463
+ <input
1464
+ type="number"
1465
+ step="0.1"
1466
+ value={tail.x}
1467
+ onChange={(e) =>
1468
+ updateOverlay(selectedOverlay.id, {
1469
+ tailAnchor: {
1470
+ ...tail,
1471
+ x: parseFloat(e.target.value) || 0,
1472
+ },
1473
+ })
1474
+ }
1475
+ className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1476
+ data-testid="inspector-tail-x"
1477
+ />
1478
+ </label>
1479
+ <label className="flex items-center gap-1 text-[10px] font-mono text-muted">
1480
+ y
1481
+ <input
1482
+ type="number"
1483
+ step="0.1"
1484
+ value={tail.y}
1485
+ onChange={(e) =>
1486
+ updateOverlay(selectedOverlay.id, {
1487
+ tailAnchor: {
1488
+ ...tail,
1489
+ y: parseFloat(e.target.value) || 0,
1490
+ },
1491
+ })
1492
+ }
1493
+ className="w-14 px-1 py-0.5 text-[10px] border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1494
+ data-testid="inspector-tail-y"
1495
+ />
1496
+ </label>
1497
+ </div>
1088
1498
  </div>
1089
- </div>
1090
- );
1091
- })()}
1499
+ );
1500
+ })()}
1092
1501
 
1093
1502
  {selectedOverlay.type !== "sfx" && (
1094
- <div className="space-y-1.5 rounded border border-border/70 p-2" data-testid="inspector-bubble-style">
1095
- <span className="text-[10px] font-medium text-muted">Bubble controls</span>
1503
+ <div
1504
+ className="space-y-1.5 rounded border border-border/70 p-2"
1505
+ data-testid="inspector-bubble-style"
1506
+ >
1507
+ <span className="text-[10px] font-medium text-muted">
1508
+ Bubble controls
1509
+ </span>
1096
1510
  <label className="block space-y-1">
1097
- <span className="text-[10px] text-muted">Padding X (% width)</span>
1511
+ <span className="text-[10px] text-muted">
1512
+ Padding X (% width)
1513
+ </span>
1098
1514
  <input
1099
1515
  type="number"
1100
1516
  step="1"
1101
1517
  min="0"
1102
1518
  max="25"
1103
- value={(((selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100)).toFixed(0)}
1104
- onChange={(e) => updateOverlay(selectedOverlay.id, {
1105
- bubbleStyle: {
1106
- ...selectedOverlay.bubbleStyle,
1107
- paddingX: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 6) / 100)),
1108
- },
1109
- })}
1519
+ value={(
1520
+ (selectedOverlay.bubbleStyle?.paddingX ?? 0.06) * 100
1521
+ ).toFixed(0)}
1522
+ onChange={(e) =>
1523
+ updateOverlay(selectedOverlay.id, {
1524
+ bubbleStyle: {
1525
+ ...selectedOverlay.bubbleStyle,
1526
+ paddingX: Math.max(
1527
+ 0,
1528
+ Math.min(
1529
+ 0.25,
1530
+ (parseFloat(e.target.value) || 6) / 100,
1531
+ ),
1532
+ ),
1533
+ },
1534
+ })
1535
+ }
1110
1536
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1111
1537
  data-testid="inspector-padding-x"
1112
1538
  />
1113
1539
  </label>
1114
1540
  <label className="block space-y-1">
1115
- <span className="text-[10px] text-muted">Padding Y (% height)</span>
1541
+ <span className="text-[10px] text-muted">
1542
+ Padding Y (% height)
1543
+ </span>
1116
1544
  <input
1117
1545
  type="number"
1118
1546
  step="1"
1119
1547
  min="0"
1120
1548
  max="25"
1121
- value={(((selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100)).toFixed(0)}
1122
- onChange={(e) => updateOverlay(selectedOverlay.id, {
1123
- bubbleStyle: {
1124
- ...selectedOverlay.bubbleStyle,
1125
- paddingY: Math.max(0, Math.min(0.25, (parseFloat(e.target.value) || 8) / 100)),
1126
- },
1127
- })}
1549
+ value={(
1550
+ (selectedOverlay.bubbleStyle?.paddingY ?? 0.08) * 100
1551
+ ).toFixed(0)}
1552
+ onChange={(e) =>
1553
+ updateOverlay(selectedOverlay.id, {
1554
+ bubbleStyle: {
1555
+ ...selectedOverlay.bubbleStyle,
1556
+ paddingY: Math.max(
1557
+ 0,
1558
+ Math.min(
1559
+ 0.25,
1560
+ (parseFloat(e.target.value) || 8) / 100,
1561
+ ),
1562
+ ),
1563
+ },
1564
+ })
1565
+ }
1128
1566
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1129
1567
  data-testid="inspector-padding-y"
1130
1568
  />
1131
1569
  </label>
1132
1570
  <label className="block space-y-1">
1133
- <span className="text-[10px] text-muted">Corner roundness (% short side)</span>
1571
+ <span className="text-[10px] text-muted">
1572
+ Corner roundness (% short side)
1573
+ </span>
1134
1574
  <input
1135
1575
  type="number"
1136
1576
  step="1"
1137
1577
  min="0"
1138
1578
  max="49"
1139
- value={(((selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100)).toFixed(0)}
1140
- onChange={(e) => updateOverlay(selectedOverlay.id, {
1141
- bubbleStyle: {
1142
- ...selectedOverlay.bubbleStyle,
1143
- cornerRadius: Math.max(0, Math.min(0.49, (parseFloat(e.target.value) || 40) / 100)),
1144
- },
1145
- })}
1579
+ value={(
1580
+ (selectedOverlay.bubbleStyle?.cornerRadius ?? 0.4) * 100
1581
+ ).toFixed(0)}
1582
+ onChange={(e) =>
1583
+ updateOverlay(selectedOverlay.id, {
1584
+ bubbleStyle: {
1585
+ ...selectedOverlay.bubbleStyle,
1586
+ cornerRadius: Math.max(
1587
+ 0,
1588
+ Math.min(
1589
+ 0.49,
1590
+ (parseFloat(e.target.value) || 40) / 100,
1591
+ ),
1592
+ ),
1593
+ },
1594
+ })
1595
+ }
1146
1596
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1147
1597
  data-testid="inspector-corner-radius"
1148
1598
  />
@@ -1150,13 +1600,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
1150
1600
  </div>
1151
1601
  )}
1152
1602
 
1153
- <div className="text-[10px] text-muted" data-testid="inspector-font">
1154
- Font: {selectedOverlay.type === "sfx" ? displayFont.family : bodyFont.family}
1603
+ <div
1604
+ className="text-[10px] text-muted"
1605
+ data-testid="inspector-font"
1606
+ >
1607
+ Font:{" "}
1608
+ {selectedOverlay.type === "sfx"
1609
+ ? displayFont.family
1610
+ : bodyFont.family}
1155
1611
  </div>
1156
1612
 
1157
1613
  <div className="text-[10px] font-mono text-muted space-y-0.5">
1158
- <p>x: {selectedOverlay.x.toFixed(3)}, y: {selectedOverlay.y.toFixed(3)}</p>
1159
- <p>w: {selectedOverlay.width.toFixed(3)}, h: {selectedOverlay.height.toFixed(3)}</p>
1614
+ <p>
1615
+ x: {selectedOverlay.x.toFixed(3)}, y:{" "}
1616
+ {selectedOverlay.y.toFixed(3)}
1617
+ </p>
1618
+ <p>
1619
+ w: {selectedOverlay.width.toFixed(3)}, h:{" "}
1620
+ {selectedOverlay.height.toFixed(3)}
1621
+ </p>
1160
1622
  </div>
1161
1623
 
1162
1624
  <button
@@ -1169,11 +1631,80 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
1169
1631
  >
1170
1632
  {confirmDelete ? "Click again to delete" : "Delete"}
1171
1633
  </button>
1634
+
1635
+ {scriptLines.length > 0 && (
1636
+ <div
1637
+ className="space-y-1.5 border-t border-border pt-3"
1638
+ data-testid="script-insert-panel"
1639
+ >
1640
+ <span className="text-[10px] font-medium text-muted">
1641
+ Add from script
1642
+ </span>
1643
+ <div className="flex flex-col gap-1">
1644
+ {scriptLines.map((line) => (
1645
+ <button
1646
+ key={line.key}
1647
+ onClick={() => addScriptLine(line)}
1648
+ data-testid={`script-insert-${line.key}`}
1649
+ title={`Add ${line.type} overlay with this text`}
1650
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1651
+ >
1652
+ <span className="font-medium text-accent">
1653
+ + {TYPE_LABEL[line.type]}
1654
+ </span>{" "}
1655
+ <span className="text-muted">
1656
+ {line.speaker ? `${line.speaker}: ` : ""}
1657
+ {line.text.length > 32
1658
+ ? `${line.text.slice(0, 32)}…`
1659
+ : line.text}
1660
+ </span>
1661
+ </button>
1662
+ ))}
1663
+ </div>
1664
+ </div>
1665
+ )}
1172
1666
  </div>
1173
1667
  ) : (
1174
- <p className="text-xs text-muted" data-testid="inspector-empty">
1175
- Select an overlay to inspect.
1176
- </p>
1668
+ <div className="space-y-3">
1669
+ <p className="text-xs text-muted" data-testid="inspector-empty">
1670
+ Select or add an overlay to inspect it.
1671
+ </p>
1672
+ {cut.aiDraft?.status === "generated" && (
1673
+ <div className="rounded border border-accent/30 bg-accent/5 p-2 text-[10px] text-muted">
1674
+ AI drafted overlays are editable here before export.
1675
+ </div>
1676
+ )}
1677
+ {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
1678
+ SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
1679
+ {scriptLines.length > 0 && (
1680
+ <div className="space-y-1.5" data-testid="script-insert-panel">
1681
+ <span className="text-[10px] font-medium text-muted">
1682
+ Add from script
1683
+ </span>
1684
+ <div className="flex flex-col gap-1">
1685
+ {scriptLines.map((line) => (
1686
+ <button
1687
+ key={line.key}
1688
+ onClick={() => addScriptLine(line)}
1689
+ data-testid={`script-insert-${line.key}`}
1690
+ title={`Add ${line.type} overlay with this text`}
1691
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1692
+ >
1693
+ <span className="font-medium text-accent">
1694
+ + {TYPE_LABEL[line.type]}
1695
+ </span>{" "}
1696
+ <span className="text-muted">
1697
+ {line.speaker ? `${line.speaker}: ` : ""}
1698
+ {line.text.length > 32
1699
+ ? `${line.text.slice(0, 32)}…`
1700
+ : line.text}
1701
+ </span>
1702
+ </button>
1703
+ ))}
1704
+ </div>
1705
+ </div>
1706
+ )}
1707
+ </div>
1177
1708
  )}
1178
1709
  </div>
1179
1710
  </div>