plotlink-ows 1.2.94 → 1.2.96

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