plotlink-ows 1.2.95 → 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,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,24 @@ 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 [imageBounds, setImageBounds] = useState({
228
+ x: 0,
229
+ y: 0,
230
+ width: 0,
231
+ height: 0,
232
+ });
188
233
  const containerRef = useRef<HTMLDivElement>(null);
189
234
  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);
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);
191
245
 
192
246
  const updateImageBounds = useCallback(() => {
193
247
  const container = containerRef.current;
@@ -199,7 +253,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
199
253
  if (cut.kind === "text") {
200
254
  // A text panel has no image — size the editor canvas from the SAME aspect
201
255
  // ratio the export uses, so lettering and the exported final agree (#351).
202
- const dims = textPanelDimensions(cut.aspectRatio) ?? { width: 800, height: 600 };
256
+ const dims = textPanelDimensions(cut.aspectRatio) ?? {
257
+ width: 800,
258
+ height: 600,
259
+ };
203
260
  iw = dims.width;
204
261
  ih = dims.height;
205
262
  } else {
@@ -212,7 +269,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
212
269
  const scale = Math.min(cw / iw, ch / ih);
213
270
  const rw = iw * scale;
214
271
  const rh = ih * scale;
215
- 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
+ });
216
278
  }, [cut.kind, cut.aspectRatio]);
217
279
 
218
280
  useEffect(() => {
@@ -229,76 +291,130 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
229
291
  // default font; without measurement it falls back to a generous default that
230
292
  // fits ordinary lines, instead of the tiny create-default. The writer can still
231
293
  // 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]);
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
+ );
254
327
 
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]);
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
+ );
261
341
 
262
342
  // Insert a line from the cut's cuts.json script (#336) as a prefilled overlay,
263
343
  // 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]);
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
+ );
275
364
 
276
365
  const updateOverlay = useCallback((id: string, changes: Partial<Overlay>) => {
277
- 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
+ );
278
369
  }, []);
279
370
 
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, {
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, {
293
395
  textStyle: {
294
396
  mode: "manual",
295
397
  fontScale: autoLayout.fontSize / Math.max(1, renderHeight),
296
398
  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,
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,
299
407
  },
300
- });
301
- }, [imageBounds, displayFontFamily, bodyFontFamily, measureWidth, updateOverlay]);
408
+ });
409
+ },
410
+ [
411
+ imageBounds,
412
+ displayFontFamily,
413
+ bodyFontFamily,
414
+ measureWidth,
415
+ updateOverlay,
416
+ ],
417
+ );
302
418
 
303
419
  const deleteOverlay = useCallback((id: string) => {
304
420
  setOverlays((prev) => prev.filter((o) => o.id !== id));
@@ -317,23 +433,26 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
317
433
  setConfirmDelete(false);
318
434
  }, []);
319
435
 
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]);
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
+ );
337
456
 
338
457
  useEffect(() => {
339
458
  const onMouseMove = (e: MouseEvent) => {
@@ -353,7 +472,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
353
472
  }
354
473
  };
355
474
 
356
- const onMouseUp = () => { dragRef.current = null; };
475
+ const onMouseUp = () => {
476
+ dragRef.current = null;
477
+ };
357
478
 
358
479
  window.addEventListener("mousemove", onMouseMove);
359
480
  window.addEventListener("mouseup", onMouseUp);
@@ -366,18 +487,24 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
366
487
  const handleSave = useCallback(async () => {
367
488
  setSaveError(null);
368
489
  try {
369
- await onSave(overlays);
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);
370
501
  if (returnOnSave) onClose();
371
502
  } catch (err) {
372
- setSaveError(err instanceof Error ? err.message : "Failed to save overlays");
503
+ setSaveError(
504
+ err instanceof Error ? err.message : "Failed to save overlays",
505
+ );
373
506
  }
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]);
507
+ }, [overlays, onSave, onClose, returnOnSave, cut.aiDraft]);
381
508
 
382
509
  const handleExport = useCallback(async () => {
383
510
  // Block export when the cut plan contained overlays that could not be placed
@@ -399,10 +526,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
399
526
  const { exportCut, ensureFontsReady } = await import("./export-cut");
400
527
 
401
528
  const usesSfx = overlays.some((o) => o.type === "sfx");
402
- const fontsToCheck = [bodyFont.family, ...(usesSfx ? [displayFont.family] : [])];
529
+ const fontsToCheck = [
530
+ bodyFont.family,
531
+ ...(usesSfx ? [displayFont.family] : []),
532
+ ];
403
533
  const { ready, missing } = await ensureFontsReady(fontsToCheck);
404
534
  if (!ready) {
405
- 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
+ );
406
538
  setExporting(false);
407
539
  return;
408
540
  }
@@ -427,7 +559,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
427
559
  { narration: cut.narration, dialogue: cut.dialogue },
428
560
  // Text panels have no clean image — render the final on a styled
429
561
  // background canvas sized by the panel's aspect ratio (#351).
430
- cut.kind === "text" ? { background: cut.background, aspectRatio: cut.aspectRatio } : undefined,
562
+ cut.kind === "text"
563
+ ? { background: cut.background, aspectRatio: cut.aspectRatio }
564
+ : undefined,
431
565
  );
432
566
 
433
567
  const fd = new FormData();
@@ -452,7 +586,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
452
586
  } finally {
453
587
  setExporting(false);
454
588
  }
455
- }, [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
+ ]);
456
605
 
457
606
  const selectedOverlay = overlays.find((o) => o.id === selectedId);
458
607
 
@@ -460,7 +609,10 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
460
609
  // the writer gets a readability warning before export/publish (#318). Computed
461
610
  // from the live overlay positions, so it clears as soon as bubbles are moved
462
611
  // apart. Non-blocking: overlap can be intentional, so it never blocks export.
463
- const overlapPairs = useMemo(() => detectOverlappingOverlays(overlays), [overlays]);
612
+ const overlapPairs = useMemo(
613
+ () => detectOverlappingOverlays(overlays),
614
+ [overlays],
615
+ );
464
616
 
465
617
  // Re-baseline when a different cut opens without a remount (rare — the parent
466
618
  // normally unmounts the editor between cuts).
@@ -468,7 +620,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
468
620
  useEffect(() => {
469
621
  if (baselineCutIdRef.current !== cut.id) {
470
622
  baselineCutIdRef.current = cut.id;
471
- setExportBaselineSig(overlaysSignature(overlayNormalization.overlays as Overlay[]));
623
+ setExportBaselineSig(
624
+ overlaysSignature(overlayNormalization.overlays as Overlay[]),
625
+ );
472
626
  }
473
627
  }, [cut.id, overlayNormalization.overlays]);
474
628
 
@@ -502,7 +656,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
502
656
  const outOfBounds = isOverlayOutOfBounds(o);
503
657
  let overflow = false;
504
658
  if (fontsReady && imageBounds.width > 0 && o.text) {
505
- const fontFamily = o.type === "sfx" ? displayFontFamily : bodyFontFamily;
659
+ const fontFamily =
660
+ o.type === "sfx" ? displayFontFamily : bodyFontFamily;
506
661
  const w = toPixel(o.width, imageBounds.width);
507
662
  const h = toPixel(o.height, imageBounds.height);
508
663
  const layout = layoutBubbleText(
@@ -517,7 +672,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
517
672
  if (outOfBounds || overflow) out[o.id] = { outOfBounds, overflow };
518
673
  }
519
674
  return out;
520
- }, [overlays, fontsReady, imageBounds, measureWidth, bodyFontFamily, displayFontFamily]);
675
+ }, [
676
+ overlays,
677
+ fontsReady,
678
+ imageBounds,
679
+ measureWidth,
680
+ bodyFontFamily,
681
+ displayFontFamily,
682
+ ]);
521
683
  const warningCount = Object.keys(overlayWarnings).length;
522
684
 
523
685
  const isTextPanel = cut.kind === "text";
@@ -526,7 +688,13 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
526
688
  // A text/interstitial panel (#351) is editable on a styled background canvas
527
689
  // even when empty, so it skips the "no clean image" guard that applies to a
528
690
  // would-be image cut with nothing placed yet.
529
- 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
+ ) {
530
698
  return (
531
699
  <div className="h-full flex items-center justify-center text-sm text-muted">
532
700
  No clean image — upload one first, or add overlays for a narration cut.
@@ -535,55 +703,138 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
535
703
  }
536
704
 
537
705
  return (
538
- <div className="h-full flex flex-col" data-testid="focused-lettering-editor">
706
+ <div
707
+ className="h-full flex flex-col"
708
+ data-testid="focused-lettering-editor"
709
+ >
539
710
  {/* Toolbar */}
540
711
  <div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
541
712
  <div className="min-w-0">
542
713
  <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>
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>
545
720
  </div>
546
721
  <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.
722
+ Place bubbles, captions, SFX, or between-scene card text, then save
723
+ back to the full cut review.
548
724
  </p>
549
- <span className="text-[10px] text-muted" data-testid="overlay-count">{overlays.length} overlays</span>
725
+ <span className="text-[10px] text-muted" data-testid="overlay-count">
726
+ {overlays.length} overlays
727
+ </span>
550
728
  </div>
551
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
+ )}
552
746
  <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>
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>
556
768
  </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">
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
+ >
560
781
  {exporting ? "Exporting..." : "Export"}
561
782
  </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>
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>
564
799
  </div>
565
800
  </div>
566
801
 
567
802
  {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">
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
+ >
569
807
  <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
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
571
812
  </span>
572
813
  <button
573
814
  onClick={() => setAcknowledgedInvalid(true)}
574
815
  data-testid="discard-invalid-overlays"
575
816
  className="px-1.5 py-0.5 border border-error/40 rounded hover:bg-error/10"
576
817
  >
577
- discard {invalidOverlayCount} unplaceable overlay{invalidOverlayCount === 1 ? "" : "s"}
818
+ discard {invalidOverlayCount} unplaceable overlay
819
+ {invalidOverlayCount === 1 ? "" : "s"}
578
820
  </button>
579
821
  </div>
580
822
  ) : 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"}.
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"}.
583
830
  </div>
584
831
  ) : 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.
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.
587
838
  </div>
588
839
  ) : null}
589
840
 
@@ -592,9 +843,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
592
843
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
593
844
  data-testid="overlay-overlap-warning"
594
845
  >
595
- 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 —{" "}
596
849
  {overlapPairs
597
- .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
+ )
598
854
  .join("; ")}
599
855
  . Move them apart, or export as-is if the overlap is intended.
600
856
  </div>
@@ -606,13 +862,19 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
606
862
  className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
607
863
  data-testid="lettering-checklist"
608
864
  >
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]) => (
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]) => (
616
878
  <span
617
879
  key={key}
618
880
  data-testid={`lettering-check-${key}`}
@@ -633,7 +895,8 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
633
895
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
634
896
  data-testid="lettering-stale-export-warning"
635
897
  >
636
- 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.
637
900
  </div>
638
901
  )}
639
902
 
@@ -644,11 +907,15 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
644
907
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
645
908
  data-testid="lettering-export-warning"
646
909
  >
647
- {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export cleanly:{" "}
910
+ {warningCount} bubble{warningCount === 1 ? "" : "s"} may not export
911
+ cleanly:{" "}
648
912
  {Object.entries(overlayWarnings)
649
913
  .map(([id, w]) => {
650
914
  const idx = overlays.findIndex((o) => o.id === id);
651
- 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
+ ]
652
919
  .filter(Boolean)
653
920
  .join(", ");
654
921
  return `#${idx + 1} ${overlapLabel(overlays[idx])} (${problems})`;
@@ -667,11 +934,17 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
667
934
  data-testid="editor-surface"
668
935
  >
669
936
  {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">
937
+ <div
938
+ className="w-full h-full flex items-center justify-center text-muted text-xs"
939
+ data-testid="clean-image-error"
940
+ >
671
941
  Clean image not available
672
942
  </div>
673
943
  ) : 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">
944
+ <div
945
+ className="w-full h-full flex items-center justify-center text-muted text-xs"
946
+ data-testid="clean-image-loading"
947
+ >
675
948
  Loading clean image…
676
949
  </div>
677
950
  ) : cut.cleanImagePath ? (
@@ -708,7 +981,12 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
708
981
  if (el && imageBounds.width === 0) {
709
982
  const rect = el.getBoundingClientRect();
710
983
  if (rect.width > 0) {
711
- 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
+ });
712
990
  }
713
991
  }
714
992
  }}
@@ -725,15 +1003,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
725
1003
  Tailless speech (no tailAnchor, or a tip inside the bubble) traces
726
1004
  a plain rounded rectangle. Tail-anchor edits update the path live. */}
727
1005
  {imageBounds.width > 0 && (
728
- <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
+ >
729
1010
  {overlays.map((overlay) => {
730
1011
  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);
1012
+ const ox =
1013
+ imageBounds.x + toPixel(overlay.x, imageBounds.width);
1014
+ const oy =
1015
+ imageBounds.y + toPixel(overlay.y, imageBounds.height);
733
1016
  const ow = toPixel(overlay.width, imageBounds.width);
734
1017
  const oh = toPixel(overlay.height, imageBounds.height);
735
1018
  const radius = balloonRadiusForOverlay(overlay, ow, oh);
736
- 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;
737
1022
  // Strong, clean near-black outline scaled to the preview size so
738
1023
  // the bubble reads as a webtoon balloon (matching the export's
739
1024
  // proportional stroke), not a faint UI box (#363).
@@ -753,130 +1038,164 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
753
1038
  </svg>
754
1039
  )}
755
1040
 
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>
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
+ ),
794
1125
  );
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
1126
  return (
803
1127
  <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
- }}
1128
+ className="absolute inset-0 flex flex-col items-center justify-center px-1 overflow-hidden pointer-events-none text-center"
1129
+ style={{ fontFamily }}
810
1130
  data-testid={`overlay-text-${overlay.id}`}
811
- data-fonts-ready="false"
1131
+ data-fonts-ready="true"
812
1132
  >
813
- {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>
814
1158
  </div>
815
1159
  );
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 (
1160
+ })()}
1161
+ {isSelected && (
825
1162
  <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
- })}
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
+ })}
857
1174
  </div>
858
1175
 
859
1176
  {/* Inspector panel */}
860
1177
  <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"
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"
871
1182
  >
872
- {aiCopied ? "Copied!" : "Copy AI draft prompt"}
873
- </button>
874
- </div>
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
+ )}
875
1192
  {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
876
1193
  SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
877
1194
  {scriptLines.length > 0 && (
878
1195
  <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>
1196
+ <span className="text-[10px] font-medium text-muted">
1197
+ From script
1198
+ </span>
880
1199
  <div className="flex flex-col gap-1">
881
1200
  {scriptLines.map((line) => (
882
1201
  <button
@@ -886,10 +1205,14 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
886
1205
  title={`Add ${line.type} overlay with this text`}
887
1206
  className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
888
1207
  >
889
- <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>{" "}
890
1211
  <span className="text-muted">
891
1212
  {line.speaker ? `${line.speaker}: ` : ""}
892
- {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}
893
1216
  </span>
894
1217
  </button>
895
1218
  ))}
@@ -898,14 +1221,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
898
1221
  )}
899
1222
  {selectedOverlay ? (
900
1223
  <div className="space-y-3">
901
- <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>
902
1227
 
903
1228
  {selectedOverlay.speaker !== undefined && (
904
1229
  <label className="block space-y-1">
905
- <span className="text-[10px] font-medium text-muted">Speaker</span>
1230
+ <span className="text-[10px] font-medium text-muted">
1231
+ Speaker
1232
+ </span>
906
1233
  <input
907
1234
  value={selectedOverlay.speaker || ""}
908
- onChange={(e) => updateOverlay(selectedOverlay.id, { speaker: e.target.value })}
1235
+ onChange={(e) =>
1236
+ updateOverlay(selectedOverlay.id, {
1237
+ speaker: e.target.value,
1238
+ })
1239
+ }
909
1240
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
910
1241
  placeholder="Character name"
911
1242
  data-testid="inspector-speaker"
@@ -917,7 +1248,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
917
1248
  <span className="text-[10px] font-medium text-muted">Text</span>
918
1249
  <textarea
919
1250
  value={selectedOverlay.text}
920
- onChange={(e) => updateOverlay(selectedOverlay.id, { text: e.target.value })}
1251
+ onChange={(e) =>
1252
+ updateOverlay(selectedOverlay.id, { text: e.target.value })
1253
+ }
921
1254
  rows={3}
922
1255
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent resize-none focus:border-accent focus:outline-none"
923
1256
  placeholder="Overlay text"
@@ -928,7 +1261,9 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
928
1261
  {/* One-click resize so a long line that overflows can be fitted
929
1262
  without hand-dragging the box (#452). */}
930
1263
  <button
931
- onClick={() => updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))}
1264
+ onClick={() =>
1265
+ updateOverlay(selectedOverlay.id, fittedSize(selectedOverlay))
1266
+ }
932
1267
  data-testid="inspector-fit-text"
933
1268
  className="w-full px-2 py-1 text-[11px] border border-border rounded hover:border-accent hover:text-accent"
934
1269
  title="Resize this overlay so its text fits without overflowing"
@@ -936,13 +1271,22 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
936
1271
  Fit box to text
937
1272
  </button>
938
1273
 
939
- <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
+ >
940
1278
  <div className="flex items-center justify-between gap-2">
941
- <span className="text-[10px] font-medium text-muted">Typography</span>
1279
+ <span className="text-[10px] font-medium text-muted">
1280
+ Typography
1281
+ </span>
942
1282
  {selectedOverlay.textStyle?.mode === "manual" ? (
943
1283
  <button
944
1284
  type="button"
945
- onClick={() => updateOverlay(selectedOverlay.id, { textStyle: undefined })}
1285
+ onClick={() =>
1286
+ updateOverlay(selectedOverlay.id, {
1287
+ textStyle: undefined,
1288
+ })
1289
+ }
946
1290
  className="px-1.5 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
947
1291
  data-testid="inspector-text-auto"
948
1292
  >
@@ -962,20 +1306,32 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
962
1306
  {selectedOverlay.textStyle?.mode === "manual" ? (
963
1307
  <div className="space-y-1.5">
964
1308
  <label className="block space-y-1">
965
- <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>
966
1312
  <input
967
1313
  type="number"
968
1314
  step="0.1"
969
1315
  min="1.5"
970
1316
  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
- })}
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
+ }
979
1335
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
980
1336
  data-testid="inspector-font-scale"
981
1337
  />
@@ -983,14 +1339,18 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
983
1339
  <label className="block space-y-1">
984
1340
  <span className="text-[10px] text-muted">Weight</span>
985
1341
  <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
- })}
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
+ }
994
1354
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
995
1355
  data-testid="inspector-font-weight"
996
1356
  >
@@ -999,40 +1359,61 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
999
1359
  </select>
1000
1360
  </label>
1001
1361
  <label className="block space-y-1">
1002
- <span className="text-[10px] text-muted">Line height</span>
1362
+ <span className="text-[10px] text-muted">
1363
+ Line height
1364
+ </span>
1003
1365
  <input
1004
1366
  type="number"
1005
1367
  step="0.05"
1006
1368
  min="0.9"
1007
1369
  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
- })}
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
+ }
1016
1385
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1017
1386
  data-testid="inspector-line-height"
1018
1387
  />
1019
1388
  </label>
1020
1389
  {selectedOverlay.type !== "sfx" && (
1021
1390
  <label className="block space-y-1">
1022
- <span className="text-[10px] text-muted">Speaker scale</span>
1391
+ <span className="text-[10px] text-muted">
1392
+ Speaker scale
1393
+ </span>
1023
1394
  <input
1024
1395
  type="number"
1025
1396
  step="0.05"
1026
1397
  min="0.5"
1027
1398
  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
- })}
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
+ }
1036
1417
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1037
1418
  data-testid="inspector-speaker-scale"
1038
1419
  />
@@ -1040,109 +1421,176 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
1040
1421
  )}
1041
1422
  </div>
1042
1423
  ) : (
1043
- <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>
1044
1427
  )}
1045
1428
  </div>
1046
1429
 
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>
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>
1088
1496
  </div>
1089
- </div>
1090
- );
1091
- })()}
1497
+ );
1498
+ })()}
1092
1499
 
1093
1500
  {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>
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>
1096
1508
  <label className="block space-y-1">
1097
- <span className="text-[10px] text-muted">Padding X (% width)</span>
1509
+ <span className="text-[10px] text-muted">
1510
+ Padding X (% width)
1511
+ </span>
1098
1512
  <input
1099
1513
  type="number"
1100
1514
  step="1"
1101
1515
  min="0"
1102
1516
  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
- })}
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
+ }
1110
1534
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1111
1535
  data-testid="inspector-padding-x"
1112
1536
  />
1113
1537
  </label>
1114
1538
  <label className="block space-y-1">
1115
- <span className="text-[10px] text-muted">Padding Y (% height)</span>
1539
+ <span className="text-[10px] text-muted">
1540
+ Padding Y (% height)
1541
+ </span>
1116
1542
  <input
1117
1543
  type="number"
1118
1544
  step="1"
1119
1545
  min="0"
1120
1546
  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
- })}
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
+ }
1128
1564
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1129
1565
  data-testid="inspector-padding-y"
1130
1566
  />
1131
1567
  </label>
1132
1568
  <label className="block space-y-1">
1133
- <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>
1134
1572
  <input
1135
1573
  type="number"
1136
1574
  step="1"
1137
1575
  min="0"
1138
1576
  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
- })}
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
+ }
1146
1594
  className="w-full px-2 py-1 text-xs border border-border rounded bg-transparent focus:border-accent focus:outline-none"
1147
1595
  data-testid="inspector-corner-radius"
1148
1596
  />
@@ -1150,13 +1598,25 @@ export function LetteringEditor({ storyName, cut, plotFile, onSave, onClose, onE
1150
1598
  </div>
1151
1599
  )}
1152
1600
 
1153
- <div className="text-[10px] text-muted" data-testid="inspector-font">
1154
- 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}
1155
1609
  </div>
1156
1610
 
1157
1611
  <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>
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>
1160
1620
  </div>
1161
1621
 
1162
1622
  <button