plotlink-ows 1.2.96 → 1.2.98

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { Fragment, useState, useEffect, useCallback, useRef } from "react";
2
2
  import { LetteringEditor } from "./LetteringEditor";
3
- import { AssetImage, assetUrl } from "./asset-image";
3
+ import { assetUrl } from "./asset-image";
4
+ import { CutOverlayPreview } from "./CutOverlayPreview";
4
5
  import { buildCodexTaskPrompt } from "@app-lib/cartoon-prompt";
5
6
  import type { Cut as LibCut } from "@app-lib/cuts";
6
7
  import { isTextPanel, isStaleTailedExport } from "@app-lib/cuts";
@@ -15,6 +16,7 @@ import {
15
16
  } from "../lib/import-image";
16
17
  import { CodexImportPicker } from "./CodexImportPicker";
17
18
  import { FinishEpisodePanel } from "./FinishEpisodePanel";
19
+ import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
18
20
  import {
19
21
  cartoonChecklist,
20
22
  checkMarkdownReadiness,
@@ -109,6 +111,12 @@ interface CutListPanelProps {
109
111
  workspaceVisible?: boolean;
110
112
  /** Restore/fold the wider app work area while staying in the editor. */
111
113
  onWorkspaceVisibleChange?: (visible: boolean) => void;
114
+ /** Episode-centric cartoon screen mode: preview board or direct lettering edit. */
115
+ mode?: "preview" | "edit";
116
+ /** Called when the focused editor closes back to the episode cut board. */
117
+ onExitFocusedEditor?: () => void;
118
+ /** Let the parent episode header own title/status chrome; render tools at the scroll end. */
119
+ compactEpisodeChrome?: boolean;
112
120
  }
113
121
 
114
122
  type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
@@ -265,6 +273,7 @@ function CutRow({
265
273
  cut,
266
274
  storyName,
267
275
  plotFile,
276
+ language,
268
277
  expanded,
269
278
  onToggle,
270
279
  authFetch,
@@ -282,10 +291,12 @@ function CutRow({
282
291
  onConvert,
283
292
  converting,
284
293
  rowRef,
294
+ featured = false,
285
295
  }: {
286
296
  cut: Cut;
287
297
  storyName: string;
288
298
  plotFile: string;
299
+ language?: string;
289
300
  expanded: boolean;
290
301
  onToggle: () => void;
291
302
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
@@ -304,6 +315,7 @@ function CutRow({
304
315
  onConvert: (cutId: number, pngPath: string) => Promise<boolean>;
305
316
  converting: boolean;
306
317
  rowRef?: (el: HTMLDivElement | null) => void;
318
+ featured?: boolean;
307
319
  }) {
308
320
  const fileInputRef = useRef<HTMLInputElement>(null);
309
321
  const [uploading, setUploading] = useState(false);
@@ -407,7 +419,9 @@ function CutRow({
407
419
  !cut.uploadedUrl &&
408
420
  canDraftLettering(cut);
409
421
  const aiDraftLabel =
410
- (cut.overlays?.length ?? 0) > 0 ? "Re-draft with AI" : "AI draft lettering";
422
+ (cut.overlays?.length ?? 0) > 0
423
+ ? "Re-draft bubbles with AI"
424
+ : "AI draft bubbles";
411
425
 
412
426
  const primary: { label: string; onClick: () => void; testid: string } | null =
413
427
  board.key === "convert"
@@ -436,6 +450,65 @@ function CutRow({
436
450
  }
437
451
  : null; // exported / uploaded — the next action is the episode-level upload/publish
438
452
  const reviewState = letteringReviewState(cut);
453
+ const canOpenPreviewEditor =
454
+ !!cut.cleanImagePath ||
455
+ !!cut.narration ||
456
+ cut.dialogue.length > 0 ||
457
+ isTextPanel(cut);
458
+ const previewClassName = featured
459
+ ? "w-full rounded border border-border bg-white min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]"
460
+ : "w-full rounded border border-border bg-white min-h-[11rem] md:min-h-[13rem]";
461
+ const actionControls = (
462
+ <div className="flex items-center gap-2 flex-wrap">
463
+ {atLetteringStage ? (
464
+ <>
465
+ <button
466
+ onClick={onOpenEditor}
467
+ data-testid={`add-bubbles-${cut.id}`}
468
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
469
+ >
470
+ {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
471
+ </button>
472
+ {canAiDraft && (
473
+ <button
474
+ onClick={onAiDraft}
475
+ disabled={aiDrafting}
476
+ data-testid={`ai-draft-${cut.id}`}
477
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
478
+ >
479
+ {aiDrafting ? "Drafting…" : aiDraftLabel}
480
+ </button>
481
+ )}
482
+ </>
483
+ ) : primary ? (
484
+ <button
485
+ onClick={primary.onClick}
486
+ disabled={board.key === "convert" && (convertingThis || converting)}
487
+ data-testid={primary.testid}
488
+ className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
489
+ >
490
+ {primary.label}
491
+ </button>
492
+ ) : null}
493
+ {!atLetteringStage && !primary && canAiDraft && (
494
+ <button
495
+ onClick={onAiDraft}
496
+ disabled={aiDrafting}
497
+ data-testid={`ai-draft-${cut.id}`}
498
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
499
+ >
500
+ {aiDrafting ? "Drafting…" : aiDraftLabel}
501
+ </button>
502
+ )}
503
+ <button
504
+ onClick={onToggle}
505
+ data-testid={`cut-details-${cut.id}`}
506
+ className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
507
+ >
508
+ {expanded ? "Hide details" : "Open details"}
509
+ </button>
510
+ </div>
511
+ );
439
512
 
440
513
  return (
441
514
  <div
@@ -463,17 +536,24 @@ function CutRow({
463
536
  {board.label}
464
537
  </span>
465
538
  </div>
466
- {thumbPath ? (
467
- <AssetImage
539
+ {actionControls}
540
+ {thumbPath || isTextPanel(cut) ? (
541
+ <CutOverlayPreview
468
542
  storyName={storyName}
469
543
  assetPath={thumbPath}
470
544
  authFetch={authFetch}
471
545
  alt={`Cut ${cut.id} artwork`}
472
- className="w-full max-h-[32rem] object-contain rounded border border-border bg-white"
546
+ overlays={cut.overlays}
547
+ language={language}
548
+ background={cut.background}
549
+ aspectRatio={cut.aspectRatio}
550
+ onClick={canOpenPreviewEditor ? onOpenEditor : undefined}
551
+ className={previewClassName}
552
+ testId={`cut-preview-${cut.id}`}
473
553
  />
474
554
  ) : (
475
555
  <div
476
- className="w-full min-h-28 rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted"
556
+ className={`${featured ? "min-h-[14rem] md:min-h-[18rem] xl:min-h-[21rem]" : "min-h-28 md:min-h-40"} w-full rounded border border-dashed border-border bg-surface/40 flex items-center justify-center text-[10px] text-muted`}
477
557
  data-testid={`cut-card-noart-${cut.id}`}
478
558
  >
479
559
  {isTextPanel(cut)
@@ -495,57 +575,6 @@ function CutRow({
495
575
  >
496
576
  {cut.description || "No description"}
497
577
  </button>
498
- <div className="flex items-center gap-2 flex-wrap">
499
- {atLetteringStage ? (
500
- <>
501
- <button
502
- onClick={onOpenEditor}
503
- data-testid={`add-bubbles-${cut.id}`}
504
- className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim"
505
- >
506
- {bubblesPlaced > 0 ? "Review lettering" : "Open focused editor"}
507
- </button>
508
- {canAiDraft && (
509
- <button
510
- onClick={onAiDraft}
511
- disabled={aiDrafting}
512
- data-testid={`ai-draft-${cut.id}`}
513
- className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
514
- >
515
- {aiDrafting ? "Drafting…" : aiDraftLabel}
516
- </button>
517
- )}
518
- </>
519
- ) : primary ? (
520
- <button
521
- onClick={primary.onClick}
522
- disabled={
523
- board.key === "convert" && (convertingThis || converting)
524
- }
525
- data-testid={primary.testid}
526
- className="px-2.5 py-1 text-[11px] font-medium rounded bg-accent text-white hover:bg-accent-dim disabled:opacity-50"
527
- >
528
- {primary.label}
529
- </button>
530
- ) : null}
531
- {!atLetteringStage && !primary && canAiDraft && (
532
- <button
533
- onClick={onAiDraft}
534
- disabled={aiDrafting}
535
- data-testid={`ai-draft-${cut.id}`}
536
- className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
537
- >
538
- {aiDrafting ? "Drafting…" : aiDraftLabel}
539
- </button>
540
- )}
541
- <button
542
- onClick={onToggle}
543
- data-testid={`cut-details-${cut.id}`}
544
- className="px-2.5 py-1 text-[11px] rounded border border-border text-muted hover:border-accent hover:text-accent"
545
- >
546
- {expanded ? "Hide details" : "Open details"}
547
- </button>
548
- </div>
549
578
  </div>
550
579
 
551
580
  {expanded && (
@@ -781,6 +810,9 @@ export function CutListPanel({
781
810
  onFocusedLetteringModeChange,
782
811
  workspaceVisible = false,
783
812
  onWorkspaceVisibleChange,
813
+ mode = "preview",
814
+ onExitFocusedEditor,
815
+ compactEpisodeChrome = false,
784
816
  }: CutListPanelProps) {
785
817
  const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
786
818
  // Latest onCutsChanged in a ref so loadCuts can notify the parent without
@@ -793,6 +825,7 @@ export function CutListPanel({
793
825
  const [error, setError] = useState<string | null>(null);
794
826
  const [expandedCut, setExpandedCut] = useState<number | null>(null);
795
827
  const [editingCutId, setEditingCutId] = useState<number | null>(null);
828
+ const autoOpenedEditRef = useRef(false);
796
829
  const [generating, setGenerating] = useState(false);
797
830
  const [genWarnings, setGenWarnings] = useState<string[]>([]);
798
831
  const [uploading, setUploading] = useState(false);
@@ -865,6 +898,30 @@ export function CutListPanel({
865
898
  return () => onFocusedLetteringModeChange?.(false);
866
899
  }, [editingCutId, onFocusedLetteringModeChange]);
867
900
 
901
+ useEffect(() => {
902
+ if (mode !== "edit") {
903
+ autoOpenedEditRef.current = false;
904
+ return;
905
+ }
906
+ if (
907
+ autoOpenedEditRef.current ||
908
+ editingCutId !== null ||
909
+ !cutsFile?.cuts.length
910
+ ) {
911
+ return;
912
+ }
913
+ const firstEditable =
914
+ cutsFile.cuts.find(
915
+ (cut) =>
916
+ cut.cleanImagePath ||
917
+ cut.narration ||
918
+ cut.dialogue.length > 0 ||
919
+ isTextPanel(cut),
920
+ ) ?? cutsFile.cuts[0];
921
+ autoOpenedEditRef.current = true;
922
+ setEditingCutId(firstEditable.id);
923
+ }, [mode, editingCutId, cutsFile]);
924
+
868
925
  // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
869
926
  // when the target is set and again after the cut plan loads (rows mount). Best
870
927
  // effort: `scrollIntoView` is a no-op/undefined under jsdom.
@@ -889,7 +946,14 @@ export function CutListPanel({
889
946
  return;
890
947
  }
891
948
  const parsed = await res.json();
892
- setCutsFile(parsed);
949
+ const normalized: CutsFile = {
950
+ ...parsed,
951
+ version: typeof parsed?.version === "number" ? parsed.version : 1,
952
+ plotFile:
953
+ typeof parsed?.plotFile === "string" ? parsed.plotFile : plotFile,
954
+ cuts: Array.isArray(parsed?.cuts) ? parsed.cuts : [],
955
+ };
956
+ setCutsFile(normalized);
893
957
  setError(null);
894
958
  // Read the episode's publish markdown + on-chain status so the Finish panel
895
959
  // can show "Episode sequence prepared" / "Ready to publish" / "Published"
@@ -903,7 +967,7 @@ export function CutListPanel({
903
967
  const fd = await fileRes.json();
904
968
  const content: string =
905
969
  typeof fd?.content === "string" ? fd.content : "";
906
- const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
970
+ const cuts = normalized.cuts;
907
971
  const markdownReady =
908
972
  content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
909
973
  const published =
@@ -1457,6 +1521,10 @@ export function CutListPanel({
1457
1521
  editingCutId !== null
1458
1522
  ? cutsFile.cuts.find((c) => c.id === editingCutId)
1459
1523
  : null;
1524
+ const editingCutIndex =
1525
+ editingCutId !== null
1526
+ ? cutsFile.cuts.findIndex((c) => c.id === editingCutId)
1527
+ : -1;
1460
1528
 
1461
1529
  if (editingCut) {
1462
1530
  return (
@@ -1493,8 +1561,19 @@ export function CutListPanel({
1493
1561
  }
1494
1562
  }}
1495
1563
  onExported={() => loadCuts()}
1564
+ hasPreviousCut={editingCutIndex > 0}
1565
+ hasNextCut={editingCutIndex >= 0 && editingCutIndex < cutsFile.cuts.length - 1}
1566
+ onPreviousCut={() => {
1567
+ const previous = cutsFile.cuts[editingCutIndex - 1];
1568
+ if (previous) setEditingCutId(previous.id);
1569
+ }}
1570
+ onNextCut={() => {
1571
+ const next = cutsFile.cuts[editingCutIndex + 1];
1572
+ if (next) setEditingCutId(next.id);
1573
+ }}
1496
1574
  onClose={() => {
1497
1575
  setEditingCutId(null);
1576
+ onExitFocusedEditor?.();
1498
1577
  loadCuts();
1499
1578
  }}
1500
1579
  />
@@ -1534,6 +1613,11 @@ export function CutListPanel({
1534
1613
  const canFinish =
1535
1614
  cutsFile.cuts.some((ct) => ct.finalImagePath && !ct.uploadedCid) ||
1536
1615
  (uploadStepDone && !episodeState.markdownReady);
1616
+ const workflowLabel = currentWorkflowLabel(
1617
+ finishChecklist,
1618
+ episodeState.markdownReady,
1619
+ episodeState.published,
1620
+ );
1537
1621
 
1538
1622
  // PNG clean images awaiting conversion (#441): a friendly, batch-able step, not
1539
1623
  // a red unsupported-extension dump. Built from the disk-validated diagnostics.
@@ -1582,61 +1666,57 @@ export function CutListPanel({
1582
1666
  !cut.uploadedCid &&
1583
1667
  !cut.uploadedUrl,
1584
1668
  ).length;
1669
+ const assetSummary = workspaceAssetSummary(assetDiagnostics);
1585
1670
 
1586
- return (
1671
+ const compactEndSummary = (
1587
1672
  <div
1588
- className="h-full min-h-[22rem] flex flex-col overflow-hidden"
1589
- data-testid="cut-list-panel"
1673
+ className="rounded border border-border bg-surface/45 px-3 py-2 text-xs text-muted"
1674
+ data-testid="cut-board-end-summary"
1590
1675
  >
1591
- {/* Episode header + creator-facing progress summary (#440). */}
1592
- <div
1593
- className="px-3 py-2 border-b border-border flex-shrink-0"
1594
- data-testid="cut-board-header"
1595
- >
1596
- <div className="flex items-start gap-3 justify-between">
1597
- <div className="min-w-0">
1598
- <div className="flex items-center gap-2 text-xs">
1599
- <span className="font-serif text-foreground truncate">
1600
- {episodeLabel}
1676
+ <span className="font-medium text-foreground">
1677
+ {episodeLabel}
1678
+ {episodeTitle ? ` · ${episodeTitle}` : ""}
1679
+ </span>
1680
+ <span className="ml-2">
1681
+ {boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
1682
+ {boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
1683
+ </span>
1684
+ </div>
1685
+ );
1686
+
1687
+ const workspaceTools = (
1688
+ <details
1689
+ className="border-b border-border bg-surface/35 flex-shrink-0"
1690
+ data-testid="cut-workspace-tools"
1691
+ >
1692
+ <summary className="list-none cursor-pointer px-3 py-1 hover:bg-surface/50">
1693
+ <div className="flex items-center gap-2 justify-between">
1694
+ <div className="flex min-w-0 flex-wrap items-center gap-1.5 text-[10px]">
1695
+ <span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
1696
+ Workflow: {workflowLabel}
1697
+ </span>
1698
+ {assetSummary && (
1699
+ <span
1700
+ className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
1701
+ data-testid="workspace-asset-summary"
1702
+ >
1703
+ Assets: {assetSummary}
1601
1704
  </span>
1602
- {episodeTitle && (
1603
- <span className="text-muted truncate">· {episodeTitle}</span>
1604
- )}
1605
- </div>
1606
- <div
1607
- className="mt-0.5 text-[10px] text-muted"
1608
- data-testid="cut-board-summary"
1609
- >
1610
- {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "}
1611
- {boardSummary.converted} converted · {boardSummary.lettered}{" "}
1612
- lettered · {boardSummary.uploaded} uploaded
1613
- </div>
1705
+ )}
1706
+ {conversionJobs.length > 0 && (
1707
+ <span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
1708
+ Convert {conversionJobs.length} PNG
1709
+ {conversionJobs.length === 1 ? "" : "s"}
1710
+ </span>
1711
+ )}
1614
1712
  </div>
1615
- {aiDraftEligibleCount > 0 && (
1616
- <button
1617
- onClick={draftAllUnletteredCuts}
1618
- disabled={aiDraftingAll}
1619
- data-testid="ai-draft-all-btn"
1620
- className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
1621
- >
1622
- {aiDraftingAll
1623
- ? "Drafting…"
1624
- : `AI draft all unlettered (${aiDraftEligibleCount})`}
1625
- </button>
1626
- )}
1713
+ <span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
1714
+ Workspace tools
1715
+ </span>
1627
1716
  </div>
1628
- </div>
1629
- {/* Lower-level / manual controls, collapsed by default so the board stays
1630
- focused on per-cut actions (#440). The guided Finish flow + per-cut
1631
- primary actions are the main path; these stay for power users. */}
1632
- <details
1633
- className="border-b border-border flex-shrink-0"
1634
- data-testid="cut-advanced"
1635
- >
1636
- <summary className="px-3 py-1.5 text-[10px] text-muted cursor-pointer hover:text-foreground">
1637
- Technical details
1638
- </summary>
1639
- <div className="px-3 py-2 flex flex-wrap items-center gap-2 text-[10px]">
1717
+ </summary>
1718
+ <div className="border-t border-border px-3 py-2 space-y-3">
1719
+ <div className="flex flex-wrap items-center gap-2 text-[10px]">
1640
1720
  <span className="font-mono text-muted">
1641
1721
  {cutsFile.cuts.length} cuts
1642
1722
  </span>
@@ -1721,41 +1801,155 @@ export function CutListPanel({
1721
1801
  {uploadProgress || "Upload & Prepare for Publish"}
1722
1802
  </button>
1723
1803
  </div>
1724
- </details>
1725
- {/* Plain-language workflow + text-panel explainer (#360) so a non-technical
1726
- writer understands the order of operations and what a text panel is. */}
1727
- <details
1728
- className="px-3 py-1.5 border-b border-border bg-surface/40 flex-shrink-0"
1729
- data-testid="cartoon-workflow-help"
1730
- >
1731
- <summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
1732
- Cut workflow help
1733
- </summary>
1734
- <div className="mt-1.5">
1735
- <div className="flex flex-wrap items-center gap-1.5 text-[10px] text-muted">
1736
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1737
- 1. Letter
1738
- </span>
1739
- <span aria-hidden>→</span>
1740
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1741
- 2. Export
1742
- </span>
1743
- <span aria-hidden>→</span>
1744
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1745
- 3. Upload
1804
+ <div className="mt-1 text-[10px] text-muted">
1805
+ Use <span className="text-accent">Add narration/text panel</span>{" "}
1806
+ for a narration or title card. It becomes a solid card exported as a
1807
+ final image.
1808
+ </div>
1809
+ {conversionJobs.length > 0 && (
1810
+ <div
1811
+ className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px]"
1812
+ data-testid="convert-artwork"
1813
+ >
1814
+ <div className="flex items-center gap-2 flex-wrap">
1815
+ <span
1816
+ className="font-medium text-amber-700"
1817
+ data-testid="convert-artwork-count"
1818
+ >
1819
+ {conversionJobs.length} PNG image
1820
+ {conversionJobs.length === 1 ? "" : "s"} found
1821
+ </span>
1822
+ <button
1823
+ onClick={() => convertAll(conversionJobs)}
1824
+ disabled={converting}
1825
+ data-testid="convert-all-btn"
1826
+ className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
1827
+ >
1828
+ {converting ? "Converting…" : "Convert all to WebP"}
1829
+ </button>
1830
+ </div>
1831
+ <p className="mt-1 text-[10px] text-muted">
1832
+ PNG artwork is fine while drafting. Convert it before
1833
+ lettering/export so PlotLink can publish it safely.
1834
+ </p>
1835
+ {convertResult && (
1836
+ <p
1837
+ className="mt-1 text-[10px] text-muted"
1838
+ data-testid="convert-result"
1839
+ >
1840
+ {convertResult}
1841
+ </p>
1842
+ )}
1843
+ {conversionIssues.length > 0 && (
1844
+ <details
1845
+ className="mt-1"
1846
+ data-testid="convert-technical-details"
1847
+ >
1848
+ <summary className="text-[10px] text-muted cursor-pointer">
1849
+ Conversion notes
1850
+ </summary>
1851
+ <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1852
+ {conversionIssues.map((m, i) => (
1853
+ <li key={i}>{m}</li>
1854
+ ))}
1855
+ </ul>
1856
+ </details>
1857
+ )}
1858
+ </div>
1859
+ )}
1860
+ {assetDiagnostics &&
1861
+ assetDiagnostics.length > 0 &&
1862
+ (() => {
1863
+ const s = summarizeAssetDiagnostics(assetDiagnostics);
1864
+ const missing = assetDiagnostics.filter(
1865
+ (d) => d.state === "missing",
1866
+ );
1867
+ return (
1868
+ <div
1869
+ className="rounded border border-border bg-background p-2 text-[10px]"
1870
+ data-testid="asset-diagnostics"
1871
+ >
1872
+ <span
1873
+ className="text-muted"
1874
+ data-testid="asset-diag-summary"
1875
+ >
1876
+ Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
1877
+ {s.cleanReady} clean · {s.planned} planned
1878
+ {s.needsConversion > 0
1879
+ ? ` · ${s.needsConversion} needs conversion`
1880
+ : ""}
1881
+ {s.missing > 0 ? ` · ${s.missing} missing` : ""}
1882
+ </span>
1883
+ {missing.length > 0 && (
1884
+ <ul
1885
+ className="mt-1 ml-3 list-disc text-error"
1886
+ data-testid="asset-diag-issues"
1887
+ >
1888
+ {missing.map((d) => (
1889
+ <li key={d.cutId}>{d.issue}</li>
1890
+ ))}
1891
+ </ul>
1892
+ )}
1893
+ </div>
1894
+ );
1895
+ })()}
1896
+ <FinishEpisodePanel
1897
+ checklist={finishChecklist}
1898
+ issues={genWarnings}
1899
+ onFinish={finishEpisode}
1900
+ finishing={uploading}
1901
+ progressText={uploadProgress}
1902
+ canFinish={canFinish}
1903
+ markdownReady={episodeState.markdownReady}
1904
+ published={episodeState.published}
1905
+ />
1906
+ </div>
1907
+ </details>
1908
+ );
1909
+
1910
+ return (
1911
+ <div
1912
+ className="h-full min-h-[22rem] flex flex-col overflow-hidden"
1913
+ data-testid="cut-list-panel"
1914
+ >
1915
+ {/* Episode header + creator-facing progress summary (#440). */}
1916
+ {!compactEpisodeChrome && (
1917
+ <div
1918
+ className="px-3 py-1 border-b border-border flex-shrink-0"
1919
+ data-testid="cut-board-header"
1920
+ >
1921
+ <div className="flex items-center gap-3 justify-between">
1922
+ <div className="flex items-center gap-2 min-w-0 text-xs">
1923
+ <span className="font-serif text-foreground truncate">
1924
+ {episodeLabel}
1746
1925
  </span>
1747
- <span aria-hidden>→</span>
1748
- <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
1749
- 4. Prepare episode for publish
1926
+ {episodeTitle && (
1927
+ <span className="text-muted truncate">· {episodeTitle}</span>
1928
+ )}
1929
+ <span
1930
+ className="text-[10px] text-muted whitespace-nowrap"
1931
+ data-testid="cut-board-summary"
1932
+ >
1933
+ {boardSummary.cuts} cuts · {boardSummary.converted} clean ·{" "}
1934
+ {boardSummary.lettered} lettered · {boardSummary.uploaded} uploaded
1750
1935
  </span>
1751
1936
  </div>
1752
- <div className="mt-1 text-[10px] text-muted">
1753
- Use <span className="text-accent">Add narration/text panel</span>{" "}
1754
- for a narration or title card. It becomes a solid card exported as a
1755
- final image.
1756
- </div>
1937
+ {aiDraftEligibleCount > 0 && (
1938
+ <button
1939
+ onClick={draftAllUnletteredCuts}
1940
+ disabled={aiDraftingAll}
1941
+ data-testid="ai-draft-all-btn"
1942
+ className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
1943
+ >
1944
+ {aiDraftingAll
1945
+ ? "Drafting…"
1946
+ : `AI draft all unlettered (${aiDraftEligibleCount})`}
1947
+ </button>
1948
+ )}
1757
1949
  </div>
1758
- </details>
1950
+ </div>
1951
+ )}
1952
+ {!compactEpisodeChrome && workspaceTools}
1759
1953
  {/* Stale bubble-renderer warning (#381): a final image lettered before the
1760
1954
  current seamless-tail renderer may show the old separate-tail seam.
1761
1955
  Mark those cuts so the writer re-exports (open lettering → Export) and
@@ -1777,11 +1971,12 @@ export function CutListPanel({
1777
1971
  Codex generation is complete even if the terminal session is still
1778
1972
  connected — no more guessing whether it is still Working. */}
1779
1973
  {detectConfirmed &&
1974
+ !compactEpisodeChrome &&
1780
1975
  imageCutCount > 0 &&
1781
1976
  stats.missing === 0 &&
1782
1977
  staleByCut.size === 0 && (
1783
1978
  <div
1784
- className="px-3 py-1 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0"
1979
+ className="px-3 py-0.5 border-b border-border bg-green-600/10 text-[10px] text-green-700 flex items-center gap-1 flex-shrink-0"
1785
1980
  data-testid="clean-assets-ready"
1786
1981
  >
1787
1982
  <span aria-hidden>✓</span>
@@ -1800,109 +1995,6 @@ export function CutListPanel({
1800
1995
  {syncResult}
1801
1996
  </div>
1802
1997
  )}
1803
- {/* Convert artwork step (#441, spec §8): PNG clean images are a normal
1804
- drafting intermediate, surfaced as a friendly batch conversion rather
1805
- than red "Unsupported extension" errors. The raw reasons stay available
1806
- under a collapsed "Technical details" disclosure. */}
1807
- {conversionJobs.length > 0 && (
1808
- <div
1809
- className="px-3 py-2 border-b border-amber-500/40 bg-amber-500/10 text-[11px] flex-shrink-0"
1810
- data-testid="convert-artwork"
1811
- >
1812
- <div className="flex items-center gap-2 flex-wrap">
1813
- <span
1814
- className="font-medium text-amber-700"
1815
- data-testid="convert-artwork-count"
1816
- >
1817
- {conversionJobs.length} PNG image
1818
- {conversionJobs.length === 1 ? "" : "s"} found
1819
- </span>
1820
- <button
1821
- onClick={() => convertAll(conversionJobs)}
1822
- disabled={converting}
1823
- data-testid="convert-all-btn"
1824
- className="ml-auto px-2 py-0.5 border border-amber-500/50 text-amber-800 rounded hover:bg-amber-500/20 disabled:opacity-50"
1825
- >
1826
- {converting ? "Converting…" : "Convert all to WebP"}
1827
- </button>
1828
- </div>
1829
- <p className="mt-1 text-[10px] text-muted">
1830
- PNG artwork is fine while drafting. Convert it before
1831
- lettering/export so PlotLink can publish it safely.
1832
- </p>
1833
- {convertResult && (
1834
- <p
1835
- className="mt-1 text-[10px] text-muted"
1836
- data-testid="convert-result"
1837
- >
1838
- {convertResult}
1839
- </p>
1840
- )}
1841
- {conversionIssues.length > 0 && (
1842
- <details className="mt-1" data-testid="convert-technical-details">
1843
- <summary className="text-[10px] text-muted cursor-pointer">
1844
- Technical details
1845
- </summary>
1846
- <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1847
- {conversionIssues.map((m, i) => (
1848
- <li key={i}>{m}</li>
1849
- ))}
1850
- </ul>
1851
- </details>
1852
- )}
1853
- </div>
1854
- )}
1855
- {/* Read-only per-cut asset state validated against disk (#427): a compact
1856
- state tally + a precise per-cut reason when a recorded path is broken,
1857
- so "files exist but aren't shown" / a typoed path is a clear diagnostic
1858
- rather than a generic publish warning. */}
1859
- {assetDiagnostics &&
1860
- assetDiagnostics.length > 0 &&
1861
- (() => {
1862
- const s = summarizeAssetDiagnostics(assetDiagnostics);
1863
- const missing = assetDiagnostics.filter((d) => d.state === "missing");
1864
- return (
1865
- <div
1866
- className="px-3 py-1.5 border-b border-border bg-surface/40 text-[10px] flex-shrink-0"
1867
- data-testid="asset-diagnostics"
1868
- >
1869
- <span className="text-muted" data-testid="asset-diag-summary">
1870
- Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
1871
- {s.cleanReady} clean · {s.planned} planned
1872
- {s.needsConversion > 0
1873
- ? ` · ${s.needsConversion} needs conversion`
1874
- : ""}
1875
- {s.missing > 0 ? ` · ${s.missing} missing` : ""}
1876
- </span>
1877
- {missing.length > 0 && (
1878
- <ul
1879
- className="mt-1 ml-3 list-disc text-error"
1880
- data-testid="asset-diag-issues"
1881
- >
1882
- {missing.map((d) => (
1883
- <li key={d.cutId}>{d.issue}</li>
1884
- ))}
1885
- </ul>
1886
- )}
1887
- </div>
1888
- );
1889
- })()}
1890
- {/* Guided Finish-episode flow (#414): writer-language step status, one primary
1891
- "Finish episode" action that uploads finals then prepares the publish
1892
- markdown in order, and any blockers grouped by the step that fixes them —
1893
- replacing the old flat amber warning list. The lower-level controls in the
1894
- header above stay available for manual recovery. */}
1895
- <FinishEpisodePanel
1896
- checklist={finishChecklist}
1897
- issues={genWarnings}
1898
- onFinish={finishEpisode}
1899
- finishing={uploading}
1900
- progressText={uploadProgress}
1901
- canFinish={canFinish}
1902
- markdownReady={episodeState.markdownReady}
1903
- published={episodeState.published}
1904
- />
1905
-
1906
1998
  {/* Full cut review (#488): all clean cuts are shown vertically first, with
1907
1999
  explicit between-scene slots for narration/title cards. */}
1908
2000
  <div
@@ -1926,6 +2018,7 @@ export function CutListPanel({
1926
2018
  cut={cut}
1927
2019
  storyName={storyName}
1928
2020
  plotFile={plotFile}
2021
+ language={language}
1929
2022
  expanded={expandedCut === cut.id}
1930
2023
  onToggle={() =>
1931
2024
  setExpandedCut(expandedCut === cut.id ? null : cut.id)
@@ -1950,6 +2043,7 @@ export function CutListPanel({
1950
2043
  conversionPng={conversionByCut.get(cut.id) ?? null}
1951
2044
  onConvert={convertCut}
1952
2045
  converting={converting}
2046
+ featured={index === 0}
1953
2047
  rowRef={(el) => {
1954
2048
  if (el) rowRefs.current.set(cut.id, el);
1955
2049
  else rowRefs.current.delete(cut.id);
@@ -1964,6 +2058,12 @@ export function CutListPanel({
1964
2058
  disabled={addingPanel}
1965
2059
  onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
1966
2060
  />
2061
+ {compactEpisodeChrome && (
2062
+ <>
2063
+ {compactEndSummary}
2064
+ {workspaceTools}
2065
+ </>
2066
+ )}
1967
2067
  </div>
1968
2068
  </div>
1969
2069
  );
@@ -2007,3 +2107,32 @@ function BetweenSceneSlot({
2007
2107
  </div>
2008
2108
  );
2009
2109
  }
2110
+
2111
+ function workspaceAssetSummary(
2112
+ assetDiagnostics: CutAssetDiagnostic[] | null,
2113
+ ): string | null {
2114
+ if (!assetDiagnostics || assetDiagnostics.length === 0) return null;
2115
+ const s = summarizeAssetDiagnostics(assetDiagnostics);
2116
+ const parts = [
2117
+ `${s.cleanReady} clean`,
2118
+ `${s.finalReady} final`,
2119
+ `${s.uploaded} uploaded`,
2120
+ ];
2121
+ if (s.needsConversion > 0) parts.push(`${s.needsConversion} PNG`);
2122
+ if (s.missing > 0) parts.push(`${s.missing} missing`);
2123
+ return parts.join(" · ");
2124
+ }
2125
+
2126
+ function currentWorkflowLabel(
2127
+ checklist: ReturnType<typeof cartoonChecklist>,
2128
+ markdownReady: boolean,
2129
+ published: boolean,
2130
+ ): string {
2131
+ return (
2132
+ buildCartoonProductionStatus({
2133
+ checklist,
2134
+ markdownReady,
2135
+ published,
2136
+ })?.statusLabel ?? "Review cuts"
2137
+ );
2138
+ }