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.
@@ -4,11 +4,8 @@ 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 { CartoonPreview } from "./CartoonPreview";
8
- import { CartoonPublishPreview } from "./CartoonPublishPreview";
9
- import { CutListPanel } from "./CutListPanel";
10
- import { WorkflowCoach } from "./WorkflowCoach";
11
7
  import type { CoachUiAction } from "@app-lib/cartoon-coach";
8
+ import { CutListPanel } from "./CutListPanel";
12
9
  import {
13
10
  classifyCartoonReadiness,
14
11
  cartoonGenesisReadiness,
@@ -120,6 +117,11 @@ interface PreviewPanelProps {
120
117
  onFocusedLetteringModeChange?: (active: boolean) => void;
121
118
  /** Restore/fold the wider app work area while staying in the editor. */
122
119
  onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
120
+ workflowActionRequest?: {
121
+ action: CoachUiAction;
122
+ seq: number;
123
+ } | null;
124
+ onWorkflowActionHandled?: (seq: number) => void;
123
125
  }
124
126
 
125
127
  interface FileData {
@@ -136,6 +138,44 @@ interface FileData {
136
138
 
137
139
  type Tab = "preview" | "edit";
138
140
 
141
+ function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): boolean {
142
+ return action === "open-cuts"
143
+ || action === "open-lettering"
144
+ || action === "upload"
145
+ || action === "refresh-assets";
146
+ }
147
+
148
+ function cartoonEpisodeLabel(fileName: string | null): string {
149
+ if (fileName === "genesis.md") return "epi-01 (Genesis)";
150
+ const m = fileName?.match(/^plot-(\d+)\.md$/);
151
+ if (!m) return fileName ?? "";
152
+ const episodeNumber = parseInt(m[1], 10) + 1;
153
+ return `epi-${String(episodeNumber).padStart(2, "0")}`;
154
+ }
155
+
156
+ function extractMarkdownTitle(content: string | null | undefined): string | null {
157
+ const title = content?.match(/^#\s+(.+?)\s*$/m)?.[1]?.trim();
158
+ return title || null;
159
+ }
160
+
161
+ function cartoonEpisodeNumberLabel(fileName: string | null): string | null {
162
+ if (fileName === "genesis.md") return "Episode 1";
163
+ const m = fileName?.match(/^plot-(\d+)\.md$/);
164
+ if (!m) return null;
165
+ return `Episode ${parseInt(m[1], 10) + 1}`;
166
+ }
167
+
168
+ function cartoonEpisodeHeaderTitle(
169
+ fileName: string | null,
170
+ title: string | null,
171
+ ): string | null {
172
+ const trimmedTitle = title?.trim();
173
+ if (!trimmedTitle) return null;
174
+ if (/^Episode\s+\d+\s*[—-]\s*/i.test(trimmedTitle)) return trimmedTitle;
175
+ const episodeLabel = cartoonEpisodeNumberLabel(fileName);
176
+ return episodeLabel ? `${episodeLabel} — ${trimmedTitle}` : trimmedTitle;
177
+ }
178
+
139
179
  export function PreviewPanel({
140
180
  storyName,
141
181
  fileName,
@@ -155,25 +195,12 @@ export function PreviewPanel({
155
195
  focusedLetteringWorkspaceVisible = false,
156
196
  onFocusedLetteringModeChange,
157
197
  onFocusedLetteringWorkspaceVisibleChange,
198
+ workflowActionRequest = null,
199
+ onWorkflowActionHandled,
158
200
  }: PreviewPanelProps) {
159
201
  const [fileData, setFileData] = useState<FileData | null>(null);
160
202
  const [loading, setLoading] = useState(false);
161
203
  const [activeTab, setActiveTab] = useState<Tab>("preview");
162
- // Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
163
- // "inspect" = cuts.json planning inspector. Kept distinct so planning prose
164
- // does not masquerade as publish content (#289).
165
- const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
166
- "publish" | "inspect"
167
- >("publish");
168
- // Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
169
- // cuts), so its Edit tab offers two sub-views: the opening-text editor and the
170
- // cut workspace (#429). Plots use the cut workspace directly; fiction never sees
171
- // this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
172
- // coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
173
- // real, actionable workspace instead of the markdown editor.
174
- const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
175
- "text",
176
- );
177
204
  // #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
178
205
  // tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
179
206
  // re-trigger the focus/expand effect in CutListPanel; it is cleared once
@@ -183,10 +210,6 @@ export function PreviewPanel({
183
210
  openEditor: boolean;
184
211
  seq: number;
185
212
  } | null>(null);
186
- const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
187
- setActiveTab("edit");
188
- setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
189
- }, []);
190
213
  const [editContent, setEditContent] = useState("");
191
214
  const [saving, setSaving] = useState(false);
192
215
  const [dirty, setDirty] = useState(false);
@@ -269,6 +292,11 @@ export function PreviewPanel({
269
292
  const illustrationInputRef = useRef<HTMLInputElement>(null);
270
293
 
271
294
  const prevFileRef = useRef<string | null>(null);
295
+ const appliedWorkflowSeqRef = useRef(0);
296
+ const pendingWorkflowCutsRef = useRef(false);
297
+ pendingWorkflowCutsRef.current = workflowActionNeedsCuts(
298
+ workflowActionRequest?.action,
299
+ );
272
300
 
273
301
  const loadFile = useCallback(async () => {
274
302
  if (!storyName || !fileName) {
@@ -384,9 +412,9 @@ export function PreviewPanel({
384
412
  setCartoonTotalCuts(result.totalCuts);
385
413
  setCartoonCutProgress(summarizeCutProgress(cuts));
386
414
  // Cut plan's episode title for the publish-title display (#358).
387
- setCartoonEpisodeTitle(
388
- typeof cutsData.title === "string" ? cutsData.title : null,
389
- );
415
+ const cutsTitle =
416
+ typeof cutsData.title === "string" ? cutsData.title.trim() : "";
417
+ setCartoonEpisodeTitle(cutsTitle || extractMarkdownTitle(content));
390
418
  }
391
419
  } catch {
392
420
  if (!cancelled) {
@@ -533,43 +561,30 @@ export function PreviewPanel({
533
561
  }
534
562
  }, [storyName, fileName, authFetch, loadFile]);
535
563
 
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") {
564
+ useEffect(() => {
565
+ if (!workflowActionRequest) return;
566
+ if (workflowActionRequest.seq === appliedWorkflowSeqRef.current) return;
567
+ appliedWorkflowSeqRef.current = workflowActionRequest.seq;
568
+
569
+ switch (workflowActionRequest.action) {
570
+ case "view-progress":
545
571
  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
- );
572
+ break;
573
+ case "open-cuts":
574
+ case "open-lettering":
575
+ case "upload":
576
+ case "refresh-assets":
577
+ setActiveTab("edit");
578
+ break;
579
+ case "generate-markdown":
580
+ handleGenerateMarkdown();
581
+ break;
582
+ case "publish":
583
+ setActiveTab("preview");
584
+ break;
585
+ }
586
+ onWorkflowActionHandled?.(workflowActionRequest.seq);
587
+ }, [workflowActionRequest, onViewProgress, handleGenerateMarkdown, onWorkflowActionHandled]);
573
588
 
574
589
  // Handle cover image selection
575
590
  const handleCoverSelect = useCallback(
@@ -811,7 +826,7 @@ export function PreviewPanel({
811
826
  setDetectedCoverWarning(null);
812
827
  setCoverStatus("unknown");
813
828
  coverUserTouchedRef.current = false;
814
- setGenesisEditMode("text");
829
+ if (pendingWorkflowCutsRef.current) setActiveTab("edit");
815
830
  }, [storyName, fileName]);
816
831
 
817
832
  // Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
@@ -994,6 +1009,13 @@ export function PreviewPanel({
994
1009
  // readiness block — those move to the Publish tab — and show only the opening
995
1010
  // content, production next-step guidance, and a compact "Review publish" CTA.
996
1011
  const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
1012
+ const cartoonEpisodeDisplayTitle =
1013
+ isCartoonEpisode
1014
+ ? cartoonEpisodeHeaderTitle(
1015
+ fileName,
1016
+ cartoonEpisodeTitle || extractMarkdownTitle(fileData?.content),
1017
+ )
1018
+ : null;
997
1019
  const isPublished =
998
1020
  fileData?.status === "published" ||
999
1021
  fileData?.status === "published-not-indexed";
@@ -1213,7 +1235,8 @@ export function PreviewPanel({
1213
1235
  // Plain prose editor (fiction files + the Genesis "Opening text" sub-view).
1214
1236
  const proseEditor = (
1215
1237
  <div
1216
- className="flex-1 min-h-0 flex flex-col"
1238
+ className="flex-1 min-h-0 flex flex-col overflow-hidden"
1239
+ data-testid="prose-editor-shell"
1217
1240
  style={{ background: "var(--paper-bg)" }}
1218
1241
  >
1219
1242
  <textarea
@@ -1231,8 +1254,12 @@ export function PreviewPanel({
1231
1254
  color: "var(--text)",
1232
1255
  }}
1233
1256
  spellCheck={false}
1257
+ data-testid="prose-editor-textarea"
1234
1258
  />
1235
- <div className="px-3 py-1.5 border-t border-border flex items-center justify-between">
1259
+ <div
1260
+ className="shrink-0 px-3 py-1.5 border-t border-border flex items-center justify-between bg-surface/95"
1261
+ data-testid="prose-editor-savebar"
1262
+ >
1236
1263
  <span className="text-xs text-muted">
1237
1264
  {dirty ? "Unsaved changes" : "No changes"}
1238
1265
  </span>
@@ -1248,24 +1275,38 @@ export function PreviewPanel({
1248
1275
  );
1249
1276
 
1250
1277
  return (
1251
- <div className="h-full flex flex-col">
1278
+ <div className="h-full min-h-0 flex flex-col">
1252
1279
  {/* Header with file path + tabs */}
1253
1280
  {!hideFocusedEditorChrome && (
1254
1281
  <div className="border-b border-border">
1255
- <div className="px-3 py-1.5 flex items-center justify-between">
1256
- <div className="flex items-center gap-2 text-xs font-mono text-muted">
1282
+ <div
1283
+ className={
1284
+ isCartoonEpisode
1285
+ ? "px-3 py-1.5 flex items-center justify-between gap-3"
1286
+ : "px-3 py-1.5 flex items-center justify-between"
1287
+ }
1288
+ >
1289
+ <div className="flex items-center gap-2 text-xs text-muted min-w-0">
1257
1290
  {onViewProgress && (
1258
1291
  <button
1259
1292
  onClick={onViewProgress}
1260
1293
  data-testid="view-progress-btn"
1261
- className="text-accent hover:underline font-sans"
1294
+ className="text-accent hover:underline"
1262
1295
  title="Story progress overview"
1263
1296
  >
1264
1297
  ← Progress
1265
1298
  </button>
1266
1299
  )}
1267
- <span>
1268
- {storyName}/{fileName}
1300
+ <span
1301
+ className={
1302
+ isCartoonEpisode
1303
+ ? "font-medium text-foreground truncate"
1304
+ : "font-mono"
1305
+ }
1306
+ >
1307
+ {isCartoonEpisode
1308
+ ? `${cartoonEpisodeLabel(fileName)}${cartoonEpisodeDisplayTitle ? ` · ${cartoonEpisodeDisplayTitle}` : ""}`
1309
+ : `${storyName}/${fileName}`}
1269
1310
  </span>
1270
1311
  {fileData?.status === "published" && (
1271
1312
  <span className="text-green-700 font-medium">Published</span>
@@ -1282,108 +1323,95 @@ export function PreviewPanel({
1282
1323
  <span className="text-amber-700 font-medium">Pending</span>
1283
1324
  )}
1284
1325
  </div>
1285
- <div className="flex items-center gap-2">
1286
- <span
1287
- className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
1288
- >
1289
- {charCount.toLocaleString()}
1290
- {charLimit !== null
1291
- ? `/${charLimit.toLocaleString()}`
1292
- : " chars"}
1293
- </span>
1294
- {overLimit && (
1295
- <span className="text-error text-xs font-medium">
1296
- {(charCount - charLimit).toLocaleString()} over limit
1326
+ {isCartoonEpisode ? (
1327
+ <div className="flex items-center gap-1 rounded border border-border bg-surface/40 p-0.5">
1328
+ <button
1329
+ onClick={() => setActiveTab("preview")}
1330
+ className={`px-2.5 py-0.5 text-[11px] font-medium rounded transition-colors ${
1331
+ activeTab === "preview"
1332
+ ? "bg-accent text-white"
1333
+ : "text-muted hover:text-foreground"
1334
+ }`}
1335
+ >
1336
+ Preview
1337
+ </button>
1338
+ <button
1339
+ onClick={() => setActiveTab("edit")}
1340
+ className={`px-2.5 py-0.5 text-[11px] font-medium rounded transition-colors ${
1341
+ activeTab === "edit"
1342
+ ? "bg-accent text-white"
1343
+ : "text-muted hover:text-foreground"
1344
+ }`}
1345
+ >
1346
+ Edit
1347
+ {dirty && <span className="ml-1 text-amber-200">*</span>}
1348
+ </button>
1349
+ </div>
1350
+ ) : (
1351
+ <div className="flex items-center gap-2">
1352
+ <span
1353
+ className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
1354
+ >
1355
+ {charCount.toLocaleString()}
1356
+ {charLimit !== null
1357
+ ? `/${charLimit.toLocaleString()}`
1358
+ : " chars"}
1297
1359
  </span>
1298
- )}
1299
- </div>
1300
- </div>
1301
-
1302
- {/* Tabs */}
1303
- <div className="flex px-3 gap-1">
1304
- <button
1305
- onClick={() => setActiveTab("preview")}
1306
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1307
- activeTab === "preview"
1308
- ? "border-accent text-accent"
1309
- : "border-transparent text-muted hover:text-foreground"
1310
- }`}
1311
- >
1312
- Preview
1313
- </button>
1314
- <button
1315
- onClick={() => setActiveTab("edit")}
1316
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1317
- activeTab === "edit"
1318
- ? "border-accent text-accent"
1319
- : "border-transparent text-muted hover:text-foreground"
1320
- }`}
1321
- >
1322
- Edit
1323
- {dirty && <span className="ml-1 text-amber-600">*</span>}
1324
- </button>
1360
+ {overLimit && (
1361
+ <span className="text-error text-xs font-medium">
1362
+ {(charCount - charLimit).toLocaleString()} over limit
1363
+ </span>
1364
+ )}
1365
+ </div>
1366
+ )}
1325
1367
  </div>
1368
+ {!isCartoonEpisode && (
1369
+ <div className="flex px-3 gap-1">
1370
+ <button
1371
+ onClick={() => setActiveTab("preview")}
1372
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1373
+ activeTab === "preview"
1374
+ ? "border-accent text-accent"
1375
+ : "border-transparent text-muted hover:text-foreground"
1376
+ }`}
1377
+ >
1378
+ Preview
1379
+ </button>
1380
+ <button
1381
+ onClick={() => setActiveTab("edit")}
1382
+ className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1383
+ activeTab === "edit"
1384
+ ? "border-accent text-accent"
1385
+ : "border-transparent text-muted hover:text-foreground"
1386
+ }`}
1387
+ >
1388
+ Edit
1389
+ {dirty && <span className="ml-1 text-amber-600">*</span>}
1390
+ </button>
1391
+ </div>
1392
+ )}
1326
1393
  </div>
1327
1394
  )}
1328
1395
 
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
1396
  {/* Content area */}
1349
1397
  {activeTab === "preview" ? (
1350
- isCartoonPlot ? (
1398
+ isCartoonEpisode ? (
1351
1399
  <div
1352
1400
  className="flex-1 min-h-0 flex flex-col"
1353
1401
  style={{ background: "var(--paper-bg)" }}
1354
1402
  >
1355
- {/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
1356
- Cut Inspector (cuts.json planning metadata) — see #289. */}
1357
- <div className="flex gap-1 px-3 py-1 border-b border-border">
1358
- <button
1359
- data-testid="cartoon-mode-publish"
1360
- onClick={() => setCartoonPreviewMode("publish")}
1361
- className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1362
- >
1363
- Publish Preview
1364
- </button>
1365
- <button
1366
- data-testid="cartoon-mode-inspect"
1367
- onClick={() => setCartoonPreviewMode("inspect")}
1368
- className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1369
- >
1370
- Cut Inspector
1371
- </button>
1372
- </div>
1373
1403
  <div className="flex-1 min-h-0">
1374
- {cartoonPreviewMode === "publish" ? (
1375
- <CartoonPublishPreview
1376
- content={fileData?.content ?? ""}
1377
- stage={cartoonStage}
1378
- />
1379
- ) : (
1380
- <CartoonPreview
1381
- storyName={storyName!}
1382
- fileName={fileName!}
1383
- authFetch={authFetch}
1384
- onEditCut={handleEditCut}
1385
- />
1386
- )}
1404
+ <CutListPanel
1405
+ storyName={storyName!}
1406
+ fileName={fileName!}
1407
+ authFetch={authFetch}
1408
+ language={language}
1409
+ mode="preview"
1410
+ onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1411
+ focusRequest={cutFocus}
1412
+ onFocusHandled={() => setCutFocus(null)}
1413
+ compactEpisodeChrome
1414
+ />
1387
1415
  </div>
1388
1416
  </div>
1389
1417
  ) : (
@@ -1405,7 +1433,7 @@ export function PreviewPanel({
1405
1433
  )}
1406
1434
  </div>
1407
1435
  )
1408
- ) : isCartoonPlot ? (
1436
+ ) : isCartoonEpisode ? (
1409
1437
  <div
1410
1438
  className="flex-1 min-h-[22rem] overflow-hidden"
1411
1439
  style={{ background: "var(--paper-bg)" }}
@@ -1415,67 +1443,27 @@ export function PreviewPanel({
1415
1443
  fileName={fileName!}
1416
1444
  authFetch={authFetch}
1417
1445
  language={language}
1446
+ mode="edit"
1418
1447
  onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1419
1448
  focusRequest={cutFocus}
1420
1449
  onFocusHandled={() => setCutFocus(null)}
1421
1450
  onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1422
1451
  workspaceVisible={focusedLetteringWorkspaceVisible}
1423
1452
  onWorkspaceVisibleChange={onFocusedLetteringWorkspaceVisibleChange}
1453
+ onExitFocusedEditor={() => setActiveTab("preview")}
1454
+ compactEpisodeChrome
1424
1455
  />
1425
1456
  </div>
1426
- ) : isCartoonGenesis ? (
1427
- // Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
1428
- // the coach's lettering/upload/refresh actions for Episode 1 are actionable
1429
- // and Genesis cuts get the same workspace as plots — without losing the
1430
- // hand-written opening prose editor.
1431
- <div
1432
- className="flex-1 min-h-0 flex flex-col"
1433
- style={{ background: "var(--paper-bg)" }}
1434
- >
1435
- <div className="flex gap-1 px-3 py-1 border-b border-border">
1436
- <button
1437
- data-testid="genesis-edit-mode-text"
1438
- onClick={() => setGenesisEditMode("text")}
1439
- className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1440
- >
1441
- Opening text
1442
- </button>
1443
- <button
1444
- data-testid="genesis-edit-mode-cuts"
1445
- onClick={() => setGenesisEditMode("cuts")}
1446
- className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1447
- >
1448
- Cuts
1449
- </button>
1450
- </div>
1451
- <div className="flex-1 min-h-0">
1452
- {genesisEditMode === "cuts" ? (
1453
- <CutListPanel
1454
- storyName={storyName!}
1455
- fileName={fileName!}
1456
- authFetch={authFetch}
1457
- language={language}
1458
- onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1459
- focusRequest={cutFocus}
1460
- onFocusHandled={() => setCutFocus(null)}
1461
- onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1462
- workspaceVisible={focusedLetteringWorkspaceVisible}
1463
- onWorkspaceVisibleChange={
1464
- onFocusedLetteringWorkspaceVisibleChange
1465
- }
1466
- />
1467
- ) : (
1468
- proseEditor
1469
- )}
1470
- </div>
1471
- </div>
1472
1457
  ) : (
1473
1458
  proseEditor
1474
1459
  )}
1475
1460
 
1476
1461
  {/* Action bar */}
1477
- {!hideFocusedEditorChrome && (
1478
- <div className="px-3 py-2 border-t border-border flex items-center justify-between">
1462
+ {!hideFocusedEditorChrome && !isCartoonEpisode && (
1463
+ <div
1464
+ className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
1465
+ data-testid="preview-panel-footer"
1466
+ >
1479
1467
  {fileName === "structure.md" ? (
1480
1468
  <p
1481
1469
  className="text-muted text-xs italic"
@@ -1944,9 +1932,7 @@ export function PreviewPanel({
1944
1932
  cut/lettering editor gets the height — the cover stays available
1945
1933
  in the Opening-text/Preview view, Story Info, and the Publish page,
1946
1934
  and the auto-detect effect still loads it for publish. */}
1947
- {isGenesis &&
1948
- contentType !== "cartoon" &&
1949
- !(activeTab === "edit" && genesisEditMode === "cuts") && (
1935
+ {isGenesis && contentType !== "cartoon" && (
1950
1936
  <div
1951
1937
  className="flex flex-col gap-1.5"
1952
1938
  data-testid="prepublish-cover"
@@ -2082,7 +2068,7 @@ export function PreviewPanel({
2082
2068
  </div>
2083
2069
  </div>
2084
2070
  </div>
2085
- )}
2071
+ )}
2086
2072
  {/* Public title shown + validated before publish (#358). #461: moved
2087
2073
  to the Publish tab for cartoon — fiction keeps it inline. */}
2088
2074
  {!isCartoonEpisode && renderPublishTitle()}