plotlink-ows 1.2.96 → 1.2.97

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -224,6 +224,7 @@ export function LetteringEditor({
224
224
  const [exporting, setExporting] = useState(false);
225
225
  const [exportError, setExportError] = useState<string | null>(null);
226
226
  const [saveError, setSaveError] = useState<string | null>(null);
227
+ const [showHelp, setShowHelp] = useState(false);
227
228
  const [imageBounds, setImageBounds] = useState({
228
229
  x: 0,
229
230
  y: 0,
@@ -681,6 +682,23 @@ export function LetteringEditor({
681
682
  displayFontFamily,
682
683
  ]);
683
684
  const warningCount = Object.keys(overlayWarnings).length;
685
+ const checklistChips: Array<{
686
+ key: string;
687
+ label: string;
688
+ done: boolean;
689
+ }> = [
690
+ { key: "clean-image", label: "Clean", done: checklist.hasCleanImage },
691
+ { key: "script-text", label: "Script", done: checklist.hasScriptText },
692
+ {
693
+ key: "bubbles",
694
+ label: checklist.bubblesPlaced
695
+ ? `Bubbles ${checklist.bubblesPlaced}`
696
+ : "Bubbles",
697
+ done: checklist.bubblesPlaced > 0,
698
+ },
699
+ { key: "exported", label: "Exported", done: checklist.exported },
700
+ { key: "uploaded", label: "Uploaded", done: checklist.uploaded },
701
+ ];
684
702
 
685
703
  const isTextPanel = cut.kind === "text";
686
704
  const isNarrationCut = !cut.cleanImagePath;
@@ -708,42 +726,81 @@ export function LetteringEditor({
708
726
  data-testid="focused-lettering-editor"
709
727
  >
710
728
  {/* Toolbar */}
711
- <div className="px-4 py-3 border-b border-border bg-surface/40 flex items-center justify-between gap-3">
712
- <div className="min-w-0">
713
- <div className="flex items-center gap-2">
714
- <span className="text-[10px] font-bold uppercase tracking-[0.16em] text-accent">
715
- Focused lettering editor
716
- </span>
717
- <span className="text-xs font-mono text-muted">
718
- {targetLabel ?? `Cut #${cut.id}`}
719
- </span>
720
- </div>
721
- <p className="mt-0.5 text-[11px] text-muted">
722
- Place bubbles, captions, SFX, or between-scene card text, then save
723
- back to the full cut review.
724
- </p>
725
- <span className="text-[10px] text-muted" data-testid="overlay-count">
726
- {overlays.length} overlays
727
- </span>
728
- </div>
729
- <div className="flex items-center gap-2 flex-wrap justify-end">
729
+ <div
730
+ className="px-3 py-2 border-b border-border bg-surface/40 flex items-center justify-between gap-2 flex-wrap"
731
+ data-testid="lettering-toolbar"
732
+ >
733
+ <div className="flex items-center gap-1.5 flex-wrap min-w-0">
730
734
  <button
731
735
  onClick={onClose}
732
- className="px-3 py-1 text-xs border border-border rounded text-muted hover:text-foreground"
736
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
733
737
  data-testid="return-to-cut-review-btn"
734
738
  >
735
739
  Back to cut review
736
740
  </button>
741
+ <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.16em] text-accent">
742
+ Focused lettering editor
743
+ </span>
744
+ <span className="text-[11px] font-mono text-muted">
745
+ {targetLabel ?? `Cut #${cut.id}`}
746
+ </span>
747
+ <span
748
+ className="rounded-full border border-border bg-background px-2 py-0.5 text-[10px] text-muted"
749
+ data-testid="overlay-count"
750
+ >
751
+ {overlays.length} overlays
752
+ </span>
753
+ {checklistChips.map((chip) => (
754
+ <span
755
+ key={chip.key}
756
+ data-testid={`lettering-check-${chip.key}`}
757
+ data-done={chip.done ? "true" : "false"}
758
+ className={`rounded-full border px-2 py-0.5 text-[10px] ${
759
+ chip.done
760
+ ? "border-green-700/30 bg-green-700/10 text-green-700"
761
+ : "border-border bg-background text-muted"
762
+ }`}
763
+ >
764
+ {chip.done ? "✓ " : "○ "}
765
+ {chip.label}
766
+ </span>
767
+ ))}
768
+ {cut.aiDraft?.status === "generated" && (
769
+ <span
770
+ className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
771
+ data-testid="ai-draft-current-target"
772
+ >
773
+ AI draft ready
774
+ </span>
775
+ )}
776
+ {staleExport && (
777
+ <span
778
+ className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-700"
779
+ data-testid="lettering-stale-chip"
780
+ >
781
+ Re-export needed
782
+ </span>
783
+ )}
784
+ </div>
785
+ <div className="flex items-center gap-1.5 flex-wrap justify-end">
737
786
  {onToggleWorkspaceVisible && (
738
787
  <button
739
788
  onClick={onToggleWorkspaceVisible}
740
- className="px-3 py-1 text-xs border border-border rounded text-muted hover:border-accent hover:text-accent"
789
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
741
790
  data-testid="toggle-work-area-btn"
742
791
  >
743
792
  {workspaceVisible ? "Hide work area" : "Show work area"}
744
793
  </button>
745
794
  )}
746
- <div className="flex items-center gap-1 ml-2">
795
+ <button
796
+ type="button"
797
+ onClick={() => setShowHelp((prev) => !prev)}
798
+ className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:border-accent hover:text-accent"
799
+ data-testid="lettering-help-toggle"
800
+ >
801
+ {showHelp ? "Hide help" : "Help"}
802
+ </button>
803
+ <div className="flex items-center gap-1">
747
804
  <button
748
805
  onClick={() => addOverlay("speech")}
749
806
  className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
@@ -767,15 +824,19 @@ export function LetteringEditor({
767
824
  </button>
768
825
  </div>
769
826
  {exportError && (
770
- <span className="text-[10px] text-error">{exportError}</span>
827
+ <span className="text-[10px] text-error max-w-[18rem]">
828
+ {exportError}
829
+ </span>
771
830
  )}
772
831
  {saveError && (
773
- <span className="text-[10px] text-error">{saveError}</span>
832
+ <span className="text-[10px] text-error max-w-[18rem]">
833
+ {saveError}
834
+ </span>
774
835
  )}
775
836
  <button
776
837
  onClick={handleExport}
777
838
  disabled={exporting}
778
- className="px-3 py-1 text-xs border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
839
+ className="px-2.5 py-1 text-[11px] border border-accent text-accent rounded hover:bg-accent/5 disabled:opacity-50"
779
840
  data-testid="export-btn"
780
841
  >
781
842
  {exporting ? "Exporting..." : "Export"}
@@ -784,14 +845,14 @@ export function LetteringEditor({
784
845
  onClick={() => {
785
846
  void handleSave();
786
847
  }}
787
- className="px-3 py-1 text-xs bg-accent text-white rounded hover:bg-accent-dim"
848
+ className="px-2.5 py-1 text-[11px] bg-accent text-white rounded hover:bg-accent-dim"
788
849
  data-testid="save-lettering-btn"
789
850
  >
790
851
  Save
791
852
  </button>
792
853
  <button
793
854
  onClick={onClose}
794
- className="px-3 py-1 text-xs text-muted hover:text-foreground border border-border rounded"
855
+ className="px-2.5 py-1 text-[11px] text-muted hover:text-foreground border border-border rounded"
795
856
  data-testid="cancel-lettering-btn"
796
857
  >
797
858
  Cancel
@@ -799,6 +860,17 @@ export function LetteringEditor({
799
860
  </div>
800
861
  </div>
801
862
 
863
+ {showHelp && (
864
+ <div
865
+ className="px-3 py-1.5 border-b border-border bg-background text-[10px] text-muted"
866
+ data-testid="lettering-help-panel"
867
+ >
868
+ Add or select a bubble, edit it in the inspector, then Save to return
869
+ to cut review. Use Export after the overlay layout is ready. Text cards
870
+ use narration overlays on the canvas.
871
+ </div>
872
+ )}
873
+
802
874
  {invalidOverlayCount > 0 && !acknowledgedInvalid ? (
803
875
  <div
804
876
  className="px-3 py-1 border-b border-border bg-error/10 text-[10px] text-error flex items-center gap-2 flex-wrap"
@@ -856,40 +928,9 @@ export function LetteringEditor({
856
928
  </div>
857
929
  )}
858
930
 
859
- {/* Per-cut lettering checklist (#336): shows how far this cut has come so
860
- the writer can finish it from the editor without inspecting cuts.json. */}
861
- <div
862
- className="px-3 py-1 border-b border-border flex items-center gap-3 flex-wrap text-[10px] text-muted"
863
- data-testid="lettering-checklist"
864
- >
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]) => (
878
- <span
879
- key={key}
880
- data-testid={`lettering-check-${key}`}
881
- data-done={done ? "true" : "false"}
882
- className={`flex items-center gap-1 ${done ? "text-green-700" : "text-muted/70"}`}
883
- >
884
- <span aria-hidden>{done ? "✓" : "○"}</span>
885
- {label}
886
- </span>
887
- ))}
888
- </div>
889
-
890
931
  {/* Stale-export warning (#336, re1): bubbles changed since the recorded
891
932
  export/upload, so the final image/uploaded URL are out of date. The
892
- checklist already marks export/upload incomplete; this says why. */}
933
+ compact toolbar chips already mark export/upload incomplete; this says why. */}
893
934
  {staleExport && (
894
935
  <div
895
936
  className="px-3 py-1 border-b border-border bg-amber-500/10 text-[10px] text-amber-700"
@@ -1175,55 +1216,16 @@ export function LetteringEditor({
1175
1216
 
1176
1217
  {/* Inspector panel */}
1177
1218
  <div className="w-64 border-l border-border p-3 overflow-y-auto flex-shrink-0">
1178
- {cut.aiDraft?.status === "generated" && (
1179
- <div
1180
- className="mb-3 rounded border border-accent/30 bg-accent/5 p-2 space-y-1"
1181
- data-testid="ai-draft-current-target"
1182
- >
1183
- <p className="text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
1184
- AI draft ready
1185
- </p>
1186
- <p className="text-[11px] text-muted">
1187
- These first-pass overlays came from the cut script. Review and
1188
- tune them here before exporting the final panel.
1189
- </p>
1190
- </div>
1191
- )}
1192
- {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
1193
- SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
1194
- {scriptLines.length > 0 && (
1195
- <div className="mb-3 space-y-1.5" data-testid="script-insert-panel">
1196
- <span className="text-[10px] font-medium text-muted">
1197
- From script
1198
- </span>
1199
- <div className="flex flex-col gap-1">
1200
- {scriptLines.map((line) => (
1201
- <button
1202
- key={line.key}
1203
- onClick={() => addScriptLine(line)}
1204
- data-testid={`script-insert-${line.key}`}
1205
- title={`Add ${line.type} overlay with this text`}
1206
- className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1207
- >
1208
- <span className="font-medium text-accent">
1209
- + {TYPE_LABEL[line.type]}
1210
- </span>{" "}
1211
- <span className="text-muted">
1212
- {line.speaker ? `${line.speaker}: ` : ""}
1213
- {line.text.length > 32
1214
- ? `${line.text.slice(0, 32)}…`
1215
- : line.text}
1216
- </span>
1217
- </button>
1218
- ))}
1219
- </div>
1220
- </div>
1221
- )}
1222
1219
  {selectedOverlay ? (
1223
1220
  <div className="space-y-3">
1224
- <p className="text-xs font-medium text-foreground">
1225
- {TYPE_LABEL[selectedOverlay.type]}
1226
- </p>
1221
+ <div className="flex items-center justify-between gap-2">
1222
+ <p className="text-xs font-medium text-foreground">
1223
+ {TYPE_LABEL[selectedOverlay.type]}
1224
+ </p>
1225
+ <span className="text-[10px] text-muted">
1226
+ #{overlays.findIndex((o) => o.id === selectedOverlay.id) + 1}
1227
+ </span>
1228
+ </div>
1227
1229
 
1228
1230
  {selectedOverlay.speaker !== undefined && (
1229
1231
  <label className="block space-y-1">
@@ -1629,11 +1631,80 @@ export function LetteringEditor({
1629
1631
  >
1630
1632
  {confirmDelete ? "Click again to delete" : "Delete"}
1631
1633
  </button>
1634
+
1635
+ {scriptLines.length > 0 && (
1636
+ <div
1637
+ className="space-y-1.5 border-t border-border pt-3"
1638
+ data-testid="script-insert-panel"
1639
+ >
1640
+ <span className="text-[10px] font-medium text-muted">
1641
+ Add from script
1642
+ </span>
1643
+ <div className="flex flex-col gap-1">
1644
+ {scriptLines.map((line) => (
1645
+ <button
1646
+ key={line.key}
1647
+ onClick={() => addScriptLine(line)}
1648
+ data-testid={`script-insert-${line.key}`}
1649
+ title={`Add ${line.type} overlay with this text`}
1650
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1651
+ >
1652
+ <span className="font-medium text-accent">
1653
+ + {TYPE_LABEL[line.type]}
1654
+ </span>{" "}
1655
+ <span className="text-muted">
1656
+ {line.speaker ? `${line.speaker}: ` : ""}
1657
+ {line.text.length > 32
1658
+ ? `${line.text.slice(0, 32)}…`
1659
+ : line.text}
1660
+ </span>
1661
+ </button>
1662
+ ))}
1663
+ </div>
1664
+ </div>
1665
+ )}
1632
1666
  </div>
1633
1667
  ) : (
1634
- <p className="text-xs text-muted" data-testid="inspector-empty">
1635
- Select an overlay to inspect.
1636
- </p>
1668
+ <div className="space-y-3">
1669
+ <p className="text-xs text-muted" data-testid="inspector-empty">
1670
+ Select or add an overlay to inspect it.
1671
+ </p>
1672
+ {cut.aiDraft?.status === "generated" && (
1673
+ <div className="rounded border border-accent/30 bg-accent/5 p-2 text-[10px] text-muted">
1674
+ AI drafted overlays are editable here before export.
1675
+ </div>
1676
+ )}
1677
+ {/* Insert-from-script (#336): drop the cut's planned dialogue/narration/
1678
+ SFX straight into a prefilled overlay — no copy/paste out of JSON. */}
1679
+ {scriptLines.length > 0 && (
1680
+ <div className="space-y-1.5" data-testid="script-insert-panel">
1681
+ <span className="text-[10px] font-medium text-muted">
1682
+ Add from script
1683
+ </span>
1684
+ <div className="flex flex-col gap-1">
1685
+ {scriptLines.map((line) => (
1686
+ <button
1687
+ key={line.key}
1688
+ onClick={() => addScriptLine(line)}
1689
+ data-testid={`script-insert-${line.key}`}
1690
+ title={`Add ${line.type} overlay with this text`}
1691
+ className="text-left px-2 py-1 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
1692
+ >
1693
+ <span className="font-medium text-accent">
1694
+ + {TYPE_LABEL[line.type]}
1695
+ </span>{" "}
1696
+ <span className="text-muted">
1697
+ {line.speaker ? `${line.speaker}: ` : ""}
1698
+ {line.text.length > 32
1699
+ ? `${line.text.slice(0, 32)}…`
1700
+ : line.text}
1701
+ </span>
1702
+ </button>
1703
+ ))}
1704
+ </div>
1705
+ </div>
1706
+ )}
1707
+ </div>
1637
1708
  )}
1638
1709
  </div>
1639
1710
  </div>
@@ -4,11 +4,10 @@ import remarkBreaks from "remark-breaks";
4
4
  import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6
6
  import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
7
+ import type { CoachUiAction } from "@app-lib/cartoon-coach";
7
8
  import { CartoonPreview } from "./CartoonPreview";
8
9
  import { CartoonPublishPreview } from "./CartoonPublishPreview";
9
10
  import { CutListPanel } from "./CutListPanel";
10
- import { WorkflowCoach } from "./WorkflowCoach";
11
- import type { CoachUiAction } from "@app-lib/cartoon-coach";
12
11
  import {
13
12
  classifyCartoonReadiness,
14
13
  cartoonGenesisReadiness,
@@ -120,6 +119,11 @@ interface PreviewPanelProps {
120
119
  onFocusedLetteringModeChange?: (active: boolean) => void;
121
120
  /** Restore/fold the wider app work area while staying in the editor. */
122
121
  onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
122
+ workflowActionRequest?: {
123
+ action: CoachUiAction;
124
+ seq: number;
125
+ } | null;
126
+ onWorkflowActionHandled?: (seq: number) => void;
123
127
  }
124
128
 
125
129
  interface FileData {
@@ -136,6 +140,13 @@ interface FileData {
136
140
 
137
141
  type Tab = "preview" | "edit";
138
142
 
143
+ function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): boolean {
144
+ return action === "open-cuts"
145
+ || action === "open-lettering"
146
+ || action === "upload"
147
+ || action === "refresh-assets";
148
+ }
149
+
139
150
  export function PreviewPanel({
140
151
  storyName,
141
152
  fileName,
@@ -155,6 +166,8 @@ export function PreviewPanel({
155
166
  focusedLetteringWorkspaceVisible = false,
156
167
  onFocusedLetteringModeChange,
157
168
  onFocusedLetteringWorkspaceVisibleChange,
169
+ workflowActionRequest = null,
170
+ onWorkflowActionHandled,
158
171
  }: PreviewPanelProps) {
159
172
  const [fileData, setFileData] = useState<FileData | null>(null);
160
173
  const [loading, setLoading] = useState(false);
@@ -269,6 +282,11 @@ export function PreviewPanel({
269
282
  const illustrationInputRef = useRef<HTMLInputElement>(null);
270
283
 
271
284
  const prevFileRef = useRef<string | null>(null);
285
+ const appliedWorkflowSeqRef = useRef(0);
286
+ const pendingWorkflowCutsRef = useRef(false);
287
+ pendingWorkflowCutsRef.current = workflowActionNeedsCuts(
288
+ workflowActionRequest?.action,
289
+ );
272
290
 
273
291
  const loadFile = useCallback(async () => {
274
292
  if (!storyName || !fileName) {
@@ -533,43 +551,31 @@ export function PreviewPanel({
533
551
  }
534
552
  }, [storyName, fileName, authFetch, loadFile]);
535
553
 
536
- // Route a workflow-coach UI action to the right control (#429). When the
537
- // action concerns a different episode than the open file (e.g. the coach on
538
- // structure.md points at the active episode), open that file first — the coach
539
- // there offers the same action in place. Otherwise reveal the control: the cut
540
- // workspace for letter/export/upload/refresh, the Preview tab for publish (the
541
- // writer still confirms the irreversible publish), or run Prepare directly.
542
- const handleCoachAction = useCallback(
543
- (action: CoachUiAction, episodeFile: string | null) => {
544
- if (action === "view-progress") {
554
+ useEffect(() => {
555
+ if (!workflowActionRequest) return;
556
+ if (workflowActionRequest.seq === appliedWorkflowSeqRef.current) return;
557
+ appliedWorkflowSeqRef.current = workflowActionRequest.seq;
558
+
559
+ switch (workflowActionRequest.action) {
560
+ case "view-progress":
545
561
  onViewProgress?.();
546
- return;
547
- }
548
- if (episodeFile && episodeFile !== fileName) {
549
- onOpenFile?.(episodeFile);
550
- return;
551
- }
552
- switch (action) {
553
- case "open-cuts":
554
- case "open-lettering":
555
- case "upload":
556
- case "refresh-assets":
557
- setActiveTab("edit");
558
- // For Genesis the Edit tab defaults to the opening-text editor; switch to
559
- // the cut workspace so the lettering/upload/refresh action is actionable.
560
- // No-op for plots (the cut workspace is the only Edit view).
561
- setGenesisEditMode("cuts");
562
- break;
563
- case "generate-markdown":
564
- handleGenerateMarkdown();
565
- break;
566
- case "publish":
567
- setActiveTab("preview");
568
- break;
569
- }
570
- },
571
- [fileName, onViewProgress, onOpenFile, handleGenerateMarkdown],
572
- );
562
+ break;
563
+ case "open-cuts":
564
+ case "open-lettering":
565
+ case "upload":
566
+ case "refresh-assets":
567
+ setActiveTab("edit");
568
+ setGenesisEditMode("cuts");
569
+ break;
570
+ case "generate-markdown":
571
+ handleGenerateMarkdown();
572
+ break;
573
+ case "publish":
574
+ setActiveTab("preview");
575
+ break;
576
+ }
577
+ onWorkflowActionHandled?.(workflowActionRequest.seq);
578
+ }, [workflowActionRequest, onViewProgress, handleGenerateMarkdown, onWorkflowActionHandled]);
573
579
 
574
580
  // Handle cover image selection
575
581
  const handleCoverSelect = useCallback(
@@ -811,7 +817,7 @@ export function PreviewPanel({
811
817
  setDetectedCoverWarning(null);
812
818
  setCoverStatus("unknown");
813
819
  coverUserTouchedRef.current = false;
814
- setGenesisEditMode("text");
820
+ setGenesisEditMode(pendingWorkflowCutsRef.current ? "cuts" : "text");
815
821
  }, [storyName, fileName]);
816
822
 
817
823
  // Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
@@ -1213,7 +1219,8 @@ export function PreviewPanel({
1213
1219
  // Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
1214
1220
  const proseEditor = (
1215
1221
  <div
1216
- className="flex-1 min-h-0 flex flex-col"
1222
+ className="flex-1 min-h-0 flex flex-col overflow-hidden"
1223
+ data-testid="prose-editor-shell"
1217
1224
  style={{ background: "var(--paper-bg)" }}
1218
1225
  >
1219
1226
  <textarea
@@ -1231,8 +1238,12 @@ export function PreviewPanel({
1231
1238
  color: "var(--text)",
1232
1239
  }}
1233
1240
  spellCheck={false}
1241
+ data-testid="prose-editor-textarea"
1234
1242
  />
1235
- <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
1243
+ <div
1244
+ className="shrink-0 px-3 py-1.5 border-t border-border flex items-center justify-between bg-surface/95"
1245
+ data-testid="prose-editor-savebar"
1246
+ >
1236
1247
  <span className="text-xs text-muted">
1237
1248
  {dirty ? "Unsaved changes" : "No changes"}
1238
1249
  </span>
@@ -1248,7 +1259,7 @@ export function PreviewPanel({
1248
1259
  );
1249
1260
 
1250
1261
  return (
1251
- <div className="h-full flex flex-col">
1262
+ <div className="h-full min-h-0 flex flex-col">
1252
1263
  {/* Header with file path + tabs */}
1253
1264
  {!hideFocusedEditorChrome && (
1254
1265
  <div className="border-b border-border">
@@ -1326,25 +1337,6 @@ export function PreviewPanel({
1326
1337
  </div>
1327
1338
  )}
1328
1339
 
1329
- {/* Persistent cartoon workflow coach (#429): one clear next action across
1330
- every cartoon file view, derived from real story/episode state. Sits
1331
- above the content so it stays visible on both the Preview and Edit
1332
- tabs. Fiction renders nothing (the coach is null), so fiction views are
1333
- unchanged. */}
1334
- {!hideFocusedEditorChrome &&
1335
- contentType === "cartoon" &&
1336
- storyName &&
1337
- fileName && (
1338
- <WorkflowCoach
1339
- storyName={storyName}
1340
- fileName={fileName}
1341
- authFetch={authFetch}
1342
- refreshKey={cutsRefreshKey}
1343
- onAction={handleCoachAction}
1344
- showEmptyState
1345
- />
1346
- )}
1347
-
1348
1340
  {/* Content area */}
1349
1341
  {activeTab === "preview" ? (
1350
1342
  isCartoonPlot ? (
@@ -1448,7 +1440,7 @@ export function PreviewPanel({
1448
1440
  Cuts
1449
1441
  </button>
1450
1442
  </div>
1451
- <div className="flex-1 min-h-0">
1443
+ <div className="flex-1 min-h-0 flex flex-col">
1452
1444
  {genesisEditMode === "cuts" ? (
1453
1445
  <CutListPanel
1454
1446
  storyName={storyName!}
@@ -1475,7 +1467,10 @@ export function PreviewPanel({
1475
1467
 
1476
1468
  {/* Action bar */}
1477
1469
  {!hideFocusedEditorChrome && (
1478
- <div className="px-3 py-2 border-t border-border flex items-center justify-between">
1470
+ <div
1471
+ className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
1472
+ data-testid="preview-panel-footer"
1473
+ >
1479
1474
  {fileName === "structure.md" ? (
1480
1475
  <p
1481
1476
  className="text-muted text-xs italic"