plotlink-ows 1.2.97 → 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.
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A normal webtoon creator should not need the file tree: this compact tab bar
5
5
  * sits above the right-panel content whenever a CARTOON story is selected and
6
- * routes between the workflow pages — Progress, Story Info, Whitepaper, Genesis /
7
- * Episode 1, Episodes, Publish. The left file tree stays for power users; opening
8
- * a file directly just reflects the closest workflow tab here.
6
+ * routes between the workflow pages — Progress, Story Info, Episodes, Publish.
7
+ * Episode files are selected from the left story browser; opening any episode
8
+ * keeps the workflow tab on Episodes.
9
9
  *
10
10
  * Fiction renders no nav (the caller only mounts this for cartoon stories), so
11
11
  * the fiction UX is unchanged.
@@ -14,16 +14,12 @@
14
14
  export type CartoonWorkflowTab =
15
15
  | "progress"
16
16
  | "story-info"
17
- | "whitepaper"
18
- | "genesis"
19
17
  | "episodes"
20
18
  | "publish";
21
19
 
22
20
  const TABS: { key: CartoonWorkflowTab; label: string }[] = [
23
21
  { key: "progress", label: "Progress" },
24
22
  { key: "story-info", label: "Story Info" },
25
- { key: "whitepaper", label: "Whitepaper" },
26
- { key: "genesis", label: "Genesis / Ep 1" },
27
23
  { key: "episodes", label: "Episodes" },
28
24
  { key: "publish", label: "Publish" },
29
25
  ];
@@ -111,6 +111,12 @@ interface CutListPanelProps {
111
111
  workspaceVisible?: boolean;
112
112
  /** Restore/fold the wider app work area while staying in the editor. */
113
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;
114
120
  }
115
121
 
116
122
  type CutStatus = "missing" | "clean" | "lettered" | "uploaded" | "text";
@@ -804,6 +810,9 @@ export function CutListPanel({
804
810
  onFocusedLetteringModeChange,
805
811
  workspaceVisible = false,
806
812
  onWorkspaceVisibleChange,
813
+ mode = "preview",
814
+ onExitFocusedEditor,
815
+ compactEpisodeChrome = false,
807
816
  }: CutListPanelProps) {
808
817
  const [cutsFile, setCutsFile] = useState<CutsFile | null>(null);
809
818
  // Latest onCutsChanged in a ref so loadCuts can notify the parent without
@@ -816,6 +825,7 @@ export function CutListPanel({
816
825
  const [error, setError] = useState<string | null>(null);
817
826
  const [expandedCut, setExpandedCut] = useState<number | null>(null);
818
827
  const [editingCutId, setEditingCutId] = useState<number | null>(null);
828
+ const autoOpenedEditRef = useRef(false);
819
829
  const [generating, setGenerating] = useState(false);
820
830
  const [genWarnings, setGenWarnings] = useState<string[]>([]);
821
831
  const [uploading, setUploading] = useState(false);
@@ -888,6 +898,30 @@ export function CutListPanel({
888
898
  return () => onFocusedLetteringModeChange?.(false);
889
899
  }, [editingCutId, onFocusedLetteringModeChange]);
890
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
+
891
925
  // Scroll a deep-linked, expanded cut into view once its row is on screen. Runs
892
926
  // when the target is set and again after the cut plan loads (rows mount). Best
893
927
  // effort: `scrollIntoView` is a no-op/undefined under jsdom.
@@ -912,7 +946,14 @@ export function CutListPanel({
912
946
  return;
913
947
  }
914
948
  const parsed = await res.json();
915
- 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);
916
957
  setError(null);
917
958
  // Read the episode's publish markdown + on-chain status so the Finish panel
918
959
  // can show "Episode sequence prepared" / "Ready to publish" / "Published"
@@ -926,7 +967,7 @@ export function CutListPanel({
926
967
  const fd = await fileRes.json();
927
968
  const content: string =
928
969
  typeof fd?.content === "string" ? fd.content : "";
929
- const cuts = Array.isArray(parsed?.cuts) ? parsed.cuts : [];
970
+ const cuts = normalized.cuts;
930
971
  const markdownReady =
931
972
  content.length > 0 && checkMarkdownReadiness(content, cuts).ready;
932
973
  const published =
@@ -1480,6 +1521,10 @@ export function CutListPanel({
1480
1521
  editingCutId !== null
1481
1522
  ? cutsFile.cuts.find((c) => c.id === editingCutId)
1482
1523
  : null;
1524
+ const editingCutIndex =
1525
+ editingCutId !== null
1526
+ ? cutsFile.cuts.findIndex((c) => c.id === editingCutId)
1527
+ : -1;
1483
1528
 
1484
1529
  if (editingCut) {
1485
1530
  return (
@@ -1516,8 +1561,19 @@ export function CutListPanel({
1516
1561
  }
1517
1562
  }}
1518
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
+ }}
1519
1574
  onClose={() => {
1520
1575
  setEditingCutId(null);
1576
+ onExitFocusedEditor?.();
1521
1577
  loadCuts();
1522
1578
  }}
1523
1579
  />
@@ -1612,88 +1668,55 @@ export function CutListPanel({
1612
1668
  ).length;
1613
1669
  const assetSummary = workspaceAssetSummary(assetDiagnostics);
1614
1670
 
1615
- return (
1671
+ const compactEndSummary = (
1616
1672
  <div
1617
- className="h-full min-h-[22rem] flex flex-col overflow-hidden"
1618
- 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"
1619
1675
  >
1620
- {/* Episode header + creator-facing progress summary (#440). */}
1621
- <div
1622
- className="px-3 py-2 border-b border-border flex-shrink-0"
1623
- data-testid="cut-board-header"
1624
- >
1625
- <div className="flex items-start gap-3 justify-between">
1626
- <div className="min-w-0">
1627
- <div className="flex items-center gap-2 text-xs">
1628
- <span className="font-serif text-foreground truncate">
1629
- {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}
1630
1704
  </span>
1631
- {episodeTitle && (
1632
- <span className="text-muted truncate">· {episodeTitle}</span>
1633
- )}
1634
- </div>
1635
- <div
1636
- className="mt-0.5 text-[10px] text-muted"
1637
- data-testid="cut-board-summary"
1638
- >
1639
- {boardSummary.cuts} cuts · {boardSummary.artwork} artwork found ·{" "}
1640
- {boardSummary.converted} converted · {boardSummary.lettered}{" "}
1641
- lettered · {boardSummary.uploaded} uploaded
1642
- </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
+ )}
1643
1712
  </div>
1644
- {aiDraftEligibleCount > 0 && (
1645
- <button
1646
- onClick={draftAllUnletteredCuts}
1647
- disabled={aiDraftingAll}
1648
- data-testid="ai-draft-all-btn"
1649
- className="px-2.5 py-1 text-[11px] rounded border border-accent/40 text-accent hover:bg-accent/5 disabled:opacity-50"
1650
- >
1651
- {aiDraftingAll
1652
- ? "Drafting…"
1653
- : `AI draft all unlettered (${aiDraftEligibleCount})`}
1654
- </button>
1655
- )}
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>
1656
1716
  </div>
1657
- </div>
1658
- <details
1659
- className="border-b border-border bg-surface/35 flex-shrink-0"
1660
- data-testid="cut-workspace-tools"
1661
- >
1662
- <summary className="list-none cursor-pointer px-3 py-2 hover:bg-surface/50">
1663
- <div className="flex items-start gap-3 justify-between">
1664
- <div className="min-w-0 space-y-1">
1665
- <div className="flex flex-wrap items-center gap-1.5 text-[10px]">
1666
- <span className="rounded-full border border-accent/30 bg-background px-2 py-0.5 font-medium text-accent">
1667
- Workflow: {workflowLabel}
1668
- </span>
1669
- {assetSummary && (
1670
- <span
1671
- className="rounded-full border border-border bg-background px-2 py-0.5 text-muted"
1672
- data-testid="workspace-asset-summary"
1673
- >
1674
- Assets: {assetSummary}
1675
- </span>
1676
- )}
1677
- {conversionJobs.length > 0 && (
1678
- <span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-amber-700">
1679
- Convert {conversionJobs.length} PNG
1680
- {conversionJobs.length === 1 ? "" : "s"}
1681
- </span>
1682
- )}
1683
- </div>
1684
- <p className="text-[10px] text-muted">
1685
- Per-cut actions stay on each card. Open workspace tools for
1686
- publish prep, asset recovery, narration cards, and blocked-step
1687
- details.
1688
- </p>
1689
- </div>
1690
- <span className="flex-shrink-0 rounded border border-border bg-background px-2 py-1 text-[10px] text-muted">
1691
- Workspace tools
1692
- </span>
1693
- </div>
1694
- </summary>
1695
- <div className="border-t border-border px-3 py-2 space-y-3">
1696
- <div className="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]">
1697
1720
  <span className="font-mono text-muted">
1698
1721
  {cutsFile.cuts.length} cuts
1699
1722
  </span>
@@ -1777,111 +1800,156 @@ export function CutListPanel({
1777
1800
  >
1778
1801
  {uploadProgress || "Upload & Prepare for Publish"}
1779
1802
  </button>
1803
+ </div>
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
+ )}
1780
1858
  </div>
1781
- <div className="mt-1 text-[10px] text-muted">
1782
- Use <span className="text-accent">Add narration/text panel</span>{" "}
1783
- for a narration or title card. It becomes a solid card exported as a
1784
- final image.
1785
- </div>
1786
- {conversionJobs.length > 0 && (
1787
- <div
1788
- className="rounded border border-amber-500/40 bg-amber-500/10 p-2 text-[11px]"
1789
- data-testid="convert-artwork"
1790
- >
1791
- <div className="flex items-center gap-2 flex-wrap">
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
+ >
1792
1872
  <span
1793
- className="font-medium text-amber-700"
1794
- data-testid="convert-artwork-count"
1873
+ className="text-muted"
1874
+ data-testid="asset-diag-summary"
1795
1875
  >
1796
- {conversionJobs.length} PNG image
1797
- {conversionJobs.length === 1 ? "" : "s"} found
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` : ""}
1798
1882
  </span>
1799
- <button
1800
- onClick={() => convertAll(conversionJobs)}
1801
- disabled={converting}
1802
- data-testid="convert-all-btn"
1803
- 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"
1804
- >
1805
- {converting ? "Converting…" : "Convert all to WebP"}
1806
- </button>
1807
- </div>
1808
- <p className="mt-1 text-[10px] text-muted">
1809
- PNG artwork is fine while drafting. Convert it before
1810
- lettering/export so PlotLink can publish it safely.
1811
- </p>
1812
- {convertResult && (
1813
- <p
1814
- className="mt-1 text-[10px] text-muted"
1815
- data-testid="convert-result"
1816
- >
1817
- {convertResult}
1818
- </p>
1819
- )}
1820
- {conversionIssues.length > 0 && (
1821
- <details
1822
- className="mt-1"
1823
- data-testid="convert-technical-details"
1824
- >
1825
- <summary className="text-[10px] text-muted cursor-pointer">
1826
- Conversion notes
1827
- </summary>
1828
- <ul className="mt-1 ml-3 list-disc text-[10px] text-muted">
1829
- {conversionIssues.map((m, i) => (
1830
- <li key={i}>{m}</li>
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>
1831
1890
  ))}
1832
1891
  </ul>
1833
- </details>
1834
- )}
1835
- </div>
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}
1925
+ </span>
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
1935
+ </span>
1936
+ </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>
1836
1948
  )}
1837
- {assetDiagnostics &&
1838
- assetDiagnostics.length > 0 &&
1839
- (() => {
1840
- const s = summarizeAssetDiagnostics(assetDiagnostics);
1841
- const missing = assetDiagnostics.filter(
1842
- (d) => d.state === "missing",
1843
- );
1844
- return (
1845
- <div
1846
- className="rounded border border-border bg-background p-2 text-[10px]"
1847
- data-testid="asset-diagnostics"
1848
- >
1849
- <span
1850
- className="text-muted"
1851
- data-testid="asset-diag-summary"
1852
- >
1853
- Assets: {s.uploaded} uploaded · {s.finalReady} final ·{" "}
1854
- {s.cleanReady} clean · {s.planned} planned
1855
- {s.needsConversion > 0
1856
- ? ` · ${s.needsConversion} needs conversion`
1857
- : ""}
1858
- {s.missing > 0 ? ` · ${s.missing} missing` : ""}
1859
- </span>
1860
- {missing.length > 0 && (
1861
- <ul
1862
- className="mt-1 ml-3 list-disc text-error"
1863
- data-testid="asset-diag-issues"
1864
- >
1865
- {missing.map((d) => (
1866
- <li key={d.cutId}>{d.issue}</li>
1867
- ))}
1868
- </ul>
1869
- )}
1870
- </div>
1871
- );
1872
- })()}
1873
- <FinishEpisodePanel
1874
- checklist={finishChecklist}
1875
- issues={genWarnings}
1876
- onFinish={finishEpisode}
1877
- finishing={uploading}
1878
- progressText={uploadProgress}
1879
- canFinish={canFinish}
1880
- markdownReady={episodeState.markdownReady}
1881
- published={episodeState.published}
1882
- />
1883
1949
  </div>
1884
- </details>
1950
+ </div>
1951
+ )}
1952
+ {!compactEpisodeChrome && workspaceTools}
1885
1953
  {/* Stale bubble-renderer warning (#381): a final image lettered before the
1886
1954
  current seamless-tail renderer may show the old separate-tail seam.
1887
1955
  Mark those cuts so the writer re-exports (open lettering → Export) and
@@ -1903,11 +1971,12 @@ export function CutListPanel({
1903
1971
  Codex generation is complete even if the terminal session is still
1904
1972
  connected — no more guessing whether it is still Working. */}
1905
1973
  {detectConfirmed &&
1974
+ !compactEpisodeChrome &&
1906
1975
  imageCutCount > 0 &&
1907
1976
  stats.missing === 0 &&
1908
1977
  staleByCut.size === 0 && (
1909
1978
  <div
1910
- 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"
1911
1980
  data-testid="clean-assets-ready"
1912
1981
  >
1913
1982
  <span aria-hidden>✓</span>
@@ -1989,6 +2058,12 @@ export function CutListPanel({
1989
2058
  disabled={addingPanel}
1990
2059
  onAdd={() => addTextPanelAt(cutsFile.cuts.length)}
1991
2060
  />
2061
+ {compactEpisodeChrome && (
2062
+ <>
2063
+ {compactEndSummary}
2064
+ {workspaceTools}
2065
+ </>
2066
+ )}
1992
2067
  </div>
1993
2068
  </div>
1994
2069
  );
@@ -103,11 +103,19 @@ export function CutOverlayPreview({
103
103
  useEffect(() => {
104
104
  const el = stageRef.current;
105
105
  if (!el) return;
106
- const observer = new ResizeObserver(() => {
106
+ const update = () => {
107
107
  setStageSize({
108
108
  width: el.clientWidth,
109
109
  height: el.clientHeight,
110
110
  });
111
+ };
112
+ if (typeof ResizeObserver === "undefined") {
113
+ update();
114
+ window.addEventListener("resize", update);
115
+ return () => window.removeEventListener("resize", update);
116
+ }
117
+ const observer = new ResizeObserver(() => {
118
+ update();
111
119
  });
112
120
  observer.observe(el);
113
121
  return () => observer.disconnect();
@@ -49,11 +49,18 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
49
49
  const activeCount = episodes.filter((ep) => !ep.published).length;
50
50
  const blockedCount = episodes.filter((ep) => ep.state === "blocked").length;
51
51
  const readyCount = episodes.filter((ep) => ep.state === "ready").length;
52
+ const displayLabel = (ep: EpisodeProgress) => {
53
+ if (ep.file === "genesis.md") return "epi-01 (Genesis)";
54
+ const m = ep.file.match(/^plot-(\d+)\.md$/);
55
+ if (!m) return ep.label;
56
+ const episodeNumber = parseInt(m[1], 10) + 1;
57
+ return `epi-${String(episodeNumber).padStart(2, "0")}`;
58
+ };
52
59
 
53
60
  return (
54
61
  <div className="h-full overflow-y-auto px-4 py-4" data-testid="episodes-page">
55
62
  <h2 className="text-base font-serif text-foreground">Episodes</h2>
56
- <p className="mt-0.5 text-[11px] text-muted">Genesis is Episode 1; each plot file is the next episode.</p>
63
+ <p className="mt-0.5 text-[11px] text-muted">Open an episode to preview its cuts or edit lettering.</p>
57
64
  <div className="mt-3 flex flex-wrap gap-1.5 text-[10px]" data-testid="episodes-summary">
58
65
  <span className="rounded-full border border-border bg-background px-2 py-0.5 text-foreground">
59
66
  {episodes.length} total
@@ -75,7 +82,7 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
75
82
  </div>
76
83
 
77
84
  {episodes.length === 0 ? (
78
- <p className="mt-4 text-xs text-muted italic" data-testid="episodes-empty">No episodes yet — write the Genesis to start Episode 1.</p>
85
+ <p className="mt-4 text-xs text-muted italic" data-testid="episodes-empty">No episodes yet.</p>
79
86
  ) : (
80
87
  <ol className="mt-3 flex flex-col gap-1">
81
88
  {episodes.map((ep) => (
@@ -88,9 +95,8 @@ export function EpisodesPage({ storyName, authFetch, onOpenFile }: EpisodesPageP
88
95
  >
89
96
  <span className="min-w-0 flex-1">
90
97
  <span className="flex items-center gap-1.5">
91
- <span className="text-xs font-medium text-foreground">{ep.label}</span>
98
+ <span className="text-xs font-medium text-foreground">{displayLabel(ep)}</span>
92
99
  {ep.title && <span className="text-[11px] text-muted truncate">· {ep.title}</span>}
93
- <span className="ml-auto text-[10px] text-muted">{ep.file}</span>
94
100
  </span>
95
101
  <span className="block text-[11px] text-muted">{ep.summary}</span>
96
102
  </span>