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.
@@ -90,6 +90,11 @@ interface LetteringEditorProps {
90
90
  workspaceVisible?: boolean;
91
91
  /** Toggle the surrounding app work area while staying in the editor. */
92
92
  onToggleWorkspaceVisible?: () => void;
93
+ /** Move to adjacent cuts while staying in the focused editor. */
94
+ onPreviousCut?: () => void;
95
+ onNextCut?: () => void;
96
+ hasPreviousCut?: boolean;
97
+ hasNextCut?: boolean;
93
98
  }
94
99
 
95
100
  const TYPE_LABEL: Record<OverlayType, string> = {
@@ -138,6 +143,10 @@ export function LetteringEditor({
138
143
  returnOnSave = false,
139
144
  workspaceVisible = false,
140
145
  onToggleWorkspaceVisible,
146
+ onPreviousCut,
147
+ onNextCut,
148
+ hasPreviousCut = false,
149
+ hasNextCut = false,
141
150
  }: LetteringEditorProps) {
142
151
  const bodyFont = getDefaultFont(language);
143
152
  const displayFont = getDisplayFont();
@@ -244,6 +253,17 @@ export function LetteringEditor({
244
253
  origH: number;
245
254
  } | null>(null);
246
255
 
256
+ useEffect(() => {
257
+ const nextOverlays = overlayNormalization.overlays as Overlay[];
258
+ setOverlays(nextOverlays);
259
+ setSelectedId(null);
260
+ setAcknowledgedInvalid(false);
261
+ setConfirmDelete(false);
262
+ setExportError(null);
263
+ setSaveError(null);
264
+ setExportBaselineSig(overlaysSignature(nextOverlays));
265
+ }, [cut.id, overlayNormalization]);
266
+
247
267
  const updateImageBounds = useCallback(() => {
248
268
  const container = containerRef.current;
249
269
  if (!container) return;
@@ -727,19 +747,19 @@ export function LetteringEditor({
727
747
  >
728
748
  {/* Toolbar */}
729
749
  <div
730
- className="px-3 py-2 border-b border-border bg-surface/40 flex items-center justify-between gap-2 flex-wrap"
750
+ className="px-3 py-1.5 border-b border-border bg-surface/55 grid grid-cols-[minmax(14rem,1fr)_auto_minmax(12rem,1fr)] items-center gap-2"
731
751
  data-testid="lettering-toolbar"
732
752
  >
733
- <div className="flex items-center gap-1.5 flex-wrap min-w-0">
753
+ <div className="flex items-center gap-1.5 min-w-0 overflow-hidden">
734
754
  <button
735
755
  onClick={onClose}
736
756
  className="px-2.5 py-1 text-[11px] border border-border rounded text-muted hover:text-foreground"
737
757
  data-testid="return-to-cut-review-btn"
738
758
  >
739
- Back to cut review
759
+ Cut review
740
760
  </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
761
+ <span className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent whitespace-nowrap">
762
+ Lettering
743
763
  </span>
744
764
  <span className="text-[11px] font-mono text-muted">
745
765
  {targetLabel ?? `Cut #${cut.id}`}
@@ -756,6 +776,10 @@ export function LetteringEditor({
756
776
  data-testid={`lettering-check-${chip.key}`}
757
777
  data-done={chip.done ? "true" : "false"}
758
778
  className={`rounded-full border px-2 py-0.5 text-[10px] ${
779
+ chip.key === "exported" || chip.key === "uploaded"
780
+ ? "hidden xl:inline-flex"
781
+ : ""
782
+ } ${
759
783
  chip.done
760
784
  ? "border-green-700/30 bg-green-700/10 text-green-700"
761
785
  : "border-border bg-background text-muted"
@@ -765,6 +789,7 @@ export function LetteringEditor({
765
789
  {chip.label}
766
790
  </span>
767
791
  ))}
792
+ <span className="sr-only">Focused lettering editor</span>
768
793
  {cut.aiDraft?.status === "generated" && (
769
794
  <span
770
795
  className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 text-[10px] text-accent"
@@ -773,16 +798,31 @@ export function LetteringEditor({
773
798
  AI draft ready
774
799
  </span>
775
800
  )}
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
801
  </div>
785
- <div className="flex items-center gap-1.5 flex-wrap justify-end">
802
+ <div className="flex items-center justify-center gap-1 rounded border border-border bg-background px-1 py-0.5">
803
+ <button
804
+ onClick={() => addOverlay("speech")}
805
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
806
+ data-testid="add-speech"
807
+ >
808
+ Speech
809
+ </button>
810
+ <button
811
+ onClick={() => addOverlay("narration")}
812
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
813
+ data-testid="add-narration"
814
+ >
815
+ Narration
816
+ </button>
817
+ <button
818
+ onClick={() => addOverlay("sfx")}
819
+ className="px-2.5 py-1 text-[11px] rounded hover:bg-accent/10 hover:text-accent"
820
+ data-testid="add-sfx"
821
+ >
822
+ SFX
823
+ </button>
824
+ </div>
825
+ <div className="flex items-center gap-1.5 justify-end min-w-0">
786
826
  {onToggleWorkspaceVisible && (
787
827
  <button
788
828
  onClick={onToggleWorkspaceVisible}
@@ -800,29 +840,6 @@ export function LetteringEditor({
800
840
  >
801
841
  {showHelp ? "Hide help" : "Help"}
802
842
  </button>
803
- <div className="flex items-center gap-1">
804
- <button
805
- onClick={() => addOverlay("speech")}
806
- className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
807
- data-testid="add-speech"
808
- >
809
- Speech
810
- </button>
811
- <button
812
- onClick={() => addOverlay("narration")}
813
- className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
814
- data-testid="add-narration"
815
- >
816
- Narration
817
- </button>
818
- <button
819
- onClick={() => addOverlay("sfx")}
820
- className="px-2 py-0.5 text-[10px] border border-border rounded hover:border-accent hover:bg-accent/5"
821
- data-testid="add-sfx"
822
- >
823
- SFX
824
- </button>
825
- </div>
826
843
  {exportError && (
827
844
  <span className="text-[10px] text-error max-w-[18rem]">
828
845
  {exportError}
@@ -1036,6 +1053,33 @@ export function LetteringEditor({
1036
1053
  </div>
1037
1054
  )}
1038
1055
 
1056
+ {(onPreviousCut || onNextCut) && (
1057
+ <>
1058
+ <button
1059
+ type="button"
1060
+ onClick={onPreviousCut}
1061
+ disabled={!hasPreviousCut}
1062
+ className="absolute left-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
1063
+ data-testid="previous-cut-btn"
1064
+ aria-label="Previous cut"
1065
+ >
1066
+ <span aria-hidden>‹</span>
1067
+ <span className="sr-only">Previous cut</span>
1068
+ </button>
1069
+ <button
1070
+ type="button"
1071
+ onClick={onNextCut}
1072
+ disabled={!hasNextCut}
1073
+ className="absolute right-3 top-1/2 z-20 flex h-12 w-8 -translate-y-1/2 items-center justify-center rounded border border-border bg-background/85 text-2xl text-accent shadow-sm hover:bg-background disabled:opacity-30 disabled:hover:bg-background/85"
1074
+ data-testid="next-cut-btn"
1075
+ aria-label="Next cut"
1076
+ >
1077
+ <span aria-hidden>›</span>
1078
+ <span className="sr-only">Next cut</span>
1079
+ </button>
1080
+ </>
1081
+ )}
1082
+
1039
1083
  {/* Speech balloons, drawn under the overlay boxes (which carry the
1040
1084
  text + drag/resize handles) so the box sits on top of the fill.
1041
1085
  Body + tail are ONE integrated <path> per bubble (#327), mirroring
@@ -5,8 +5,6 @@ import remarkGfm from "remark-gfm";
5
5
  import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
6
6
  import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
7
7
  import type { CoachUiAction } from "@app-lib/cartoon-coach";
8
- import { CartoonPreview } from "./CartoonPreview";
9
- import { CartoonPublishPreview } from "./CartoonPublishPreview";
10
8
  import { CutListPanel } from "./CutListPanel";
11
9
  import {
12
10
  classifyCartoonReadiness,
@@ -147,6 +145,37 @@ function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): bool
147
145
  || action === "refresh-assets";
148
146
  }
149
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
+
150
179
  export function PreviewPanel({
151
180
  storyName,
152
181
  fileName,
@@ -172,21 +201,6 @@ export function PreviewPanel({
172
201
  const [fileData, setFileData] = useState<FileData | null>(null);
173
202
  const [loading, setLoading] = useState(false);
174
203
  const [activeTab, setActiveTab] = useState<Tab>("preview");
175
- // Cartoon preview sub-mode: "publish" = exact PlotLink-bound markdown;
176
- // "inspect" = cuts.json planning inspector. Kept distinct so planning prose
177
- // does not masquerade as publish content (#289).
178
- const [cartoonPreviewMode, setCartoonPreviewMode] = useState<
179
- "publish" | "inspect"
180
- >("publish");
181
- // Cartoon Genesis is a hybrid (a prose opening + its own genesis.cuts.json image
182
- // cuts), so its Edit tab offers two sub-views: the opening-text editor and the
183
- // cut workspace (#429). Plots use the cut workspace directly; fiction never sees
184
- // this. Defaults to "text" so opening Edit on Genesis is unchanged; the workflow
185
- // coach's cut actions switch it to "cuts" so lettering/upload/refresh land on a
186
- // real, actionable workspace instead of the markdown editor.
187
- const [genesisEditMode, setGenesisEditMode] = useState<"text" | "cuts">(
188
- "text",
189
- );
190
204
  // #371: a deep-link request from the Cut Inspector's per-cut CTA into the Edit
191
205
  // tab for that exact cut. `seq` makes repeated clicks (even on the same cut)
192
206
  // re-trigger the focus/expand effect in CutListPanel; it is cleared once
@@ -196,10 +210,6 @@ export function PreviewPanel({
196
210
  openEditor: boolean;
197
211
  seq: number;
198
212
  } | null>(null);
199
- const handleEditCut = useCallback((cutId: number, openEditor: boolean) => {
200
- setActiveTab("edit");
201
- setCutFocus((prev) => ({ cutId, openEditor, seq: (prev?.seq ?? 0) + 1 }));
202
- }, []);
203
213
  const [editContent, setEditContent] = useState("");
204
214
  const [saving, setSaving] = useState(false);
205
215
  const [dirty, setDirty] = useState(false);
@@ -402,9 +412,9 @@ export function PreviewPanel({
402
412
  setCartoonTotalCuts(result.totalCuts);
403
413
  setCartoonCutProgress(summarizeCutProgress(cuts));
404
414
  // Cut plan's episode title for the publish-title display (#358).
405
- setCartoonEpisodeTitle(
406
- typeof cutsData.title === "string" ? cutsData.title : null,
407
- );
415
+ const cutsTitle =
416
+ typeof cutsData.title === "string" ? cutsData.title.trim() : "";
417
+ setCartoonEpisodeTitle(cutsTitle || extractMarkdownTitle(content));
408
418
  }
409
419
  } catch {
410
420
  if (!cancelled) {
@@ -565,7 +575,6 @@ export function PreviewPanel({
565
575
  case "upload":
566
576
  case "refresh-assets":
567
577
  setActiveTab("edit");
568
- setGenesisEditMode("cuts");
569
578
  break;
570
579
  case "generate-markdown":
571
580
  handleGenerateMarkdown();
@@ -817,7 +826,7 @@ export function PreviewPanel({
817
826
  setDetectedCoverWarning(null);
818
827
  setCoverStatus("unknown");
819
828
  coverUserTouchedRef.current = false;
820
- setGenesisEditMode(pendingWorkflowCutsRef.current ? "cuts" : "text");
829
+ if (pendingWorkflowCutsRef.current) setActiveTab("edit");
821
830
  }, [storyName, fileName]);
822
831
 
823
832
  // Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
@@ -1000,6 +1009,13 @@ export function PreviewPanel({
1000
1009
  // readiness block — those move to the Publish tab — and show only the opening
1001
1010
  // content, production next-step guidance, and a compact "Review publish" CTA.
1002
1011
  const isCartoonEpisode = isCartoonGenesis || isCartoonPlot;
1012
+ const cartoonEpisodeDisplayTitle =
1013
+ isCartoonEpisode
1014
+ ? cartoonEpisodeHeaderTitle(
1015
+ fileName,
1016
+ cartoonEpisodeTitle || extractMarkdownTitle(fileData?.content),
1017
+ )
1018
+ : null;
1003
1019
  const isPublished =
1004
1020
  fileData?.status === "published" ||
1005
1021
  fileData?.status === "published-not-indexed";
@@ -1263,20 +1279,34 @@ export function PreviewPanel({
1263
1279
  {/* Header with file path + tabs */}
1264
1280
  {!hideFocusedEditorChrome && (
1265
1281
  <div className="border-b border-border">
1266
- <div className="px-3 py-1.5 flex items-center justify-between">
1267
- <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">
1268
1290
  {onViewProgress && (
1269
1291
  <button
1270
1292
  onClick={onViewProgress}
1271
1293
  data-testid="view-progress-btn"
1272
- className="text-accent hover:underline font-sans"
1294
+ className="text-accent hover:underline"
1273
1295
  title="Story progress overview"
1274
1296
  >
1275
1297
  ← Progress
1276
1298
  </button>
1277
1299
  )}
1278
- <span>
1279
- {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}`}
1280
1310
  </span>
1281
1311
  {fileData?.status === "published" && (
1282
1312
  <span className="text-green-700 font-medium">Published</span>
@@ -1293,89 +1323,95 @@ export function PreviewPanel({
1293
1323
  <span className="text-amber-700 font-medium">Pending</span>
1294
1324
  )}
1295
1325
  </div>
1296
- <div className="flex items-center gap-2">
1297
- <span
1298
- className={`text-xs font-mono ${overLimit ? "text-error font-medium" : "text-muted"}`}
1299
- >
1300
- {charCount.toLocaleString()}
1301
- {charLimit !== null
1302
- ? `/${charLimit.toLocaleString()}`
1303
- : " chars"}
1304
- </span>
1305
- {overLimit && (
1306
- <span className="text-error text-xs font-medium">
1307
- {(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"}
1308
1359
  </span>
1309
- )}
1310
- </div>
1311
- </div>
1312
-
1313
- {/* Tabs */}
1314
- <div className="flex px-3 gap-1">
1315
- <button
1316
- onClick={() => setActiveTab("preview")}
1317
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1318
- activeTab === "preview"
1319
- ? "border-accent text-accent"
1320
- : "border-transparent text-muted hover:text-foreground"
1321
- }`}
1322
- >
1323
- Preview
1324
- </button>
1325
- <button
1326
- onClick={() => setActiveTab("edit")}
1327
- className={`px-3 py-1 text-xs font-medium border-b-2 transition-colors ${
1328
- activeTab === "edit"
1329
- ? "border-accent text-accent"
1330
- : "border-transparent text-muted hover:text-foreground"
1331
- }`}
1332
- >
1333
- Edit
1334
- {dirty && <span className="ml-1 text-amber-600">*</span>}
1335
- </button>
1360
+ {overLimit && (
1361
+ <span className="text-error text-xs font-medium">
1362
+ {(charCount - charLimit).toLocaleString()} over limit
1363
+ </span>
1364
+ )}
1365
+ </div>
1366
+ )}
1336
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
+ )}
1337
1393
  </div>
1338
1394
  )}
1339
1395
 
1340
1396
  {/* Content area */}
1341
1397
  {activeTab === "preview" ? (
1342
- isCartoonPlot ? (
1398
+ isCartoonEpisode ? (
1343
1399
  <div
1344
1400
  className="flex-1 min-h-0 flex flex-col"
1345
1401
  style={{ background: "var(--paper-bg)" }}
1346
1402
  >
1347
- {/* Two explicit modes: Publish Preview (exact PlotLink markdown) vs
1348
- Cut Inspector (cuts.json planning metadata) — see #289. */}
1349
- <div className="flex gap-1 px-3 py-1 border-b border-border">
1350
- <button
1351
- data-testid="cartoon-mode-publish"
1352
- onClick={() => setCartoonPreviewMode("publish")}
1353
- className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "publish" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1354
- >
1355
- Publish Preview
1356
- </button>
1357
- <button
1358
- data-testid="cartoon-mode-inspect"
1359
- onClick={() => setCartoonPreviewMode("inspect")}
1360
- className={`px-2 py-0.5 text-[11px] rounded ${cartoonPreviewMode === "inspect" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1361
- >
1362
- Cut Inspector
1363
- </button>
1364
- </div>
1365
1403
  <div className="flex-1 min-h-0">
1366
- {cartoonPreviewMode === "publish" ? (
1367
- <CartoonPublishPreview
1368
- content={fileData?.content ?? ""}
1369
- stage={cartoonStage}
1370
- />
1371
- ) : (
1372
- <CartoonPreview
1373
- storyName={storyName!}
1374
- fileName={fileName!}
1375
- authFetch={authFetch}
1376
- onEditCut={handleEditCut}
1377
- />
1378
- )}
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
+ />
1379
1415
  </div>
1380
1416
  </div>
1381
1417
  ) : (
@@ -1397,7 +1433,7 @@ export function PreviewPanel({
1397
1433
  )}
1398
1434
  </div>
1399
1435
  )
1400
- ) : isCartoonPlot ? (
1436
+ ) : isCartoonEpisode ? (
1401
1437
  <div
1402
1438
  className="flex-1 min-h-[22rem] overflow-hidden"
1403
1439
  style={{ background: "var(--paper-bg)" }}
@@ -1407,66 +1443,23 @@ export function PreviewPanel({
1407
1443
  fileName={fileName!}
1408
1444
  authFetch={authFetch}
1409
1445
  language={language}
1446
+ mode="edit"
1410
1447
  onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1411
1448
  focusRequest={cutFocus}
1412
1449
  onFocusHandled={() => setCutFocus(null)}
1413
1450
  onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1414
1451
  workspaceVisible={focusedLetteringWorkspaceVisible}
1415
1452
  onWorkspaceVisibleChange={onFocusedLetteringWorkspaceVisibleChange}
1453
+ onExitFocusedEditor={() => setActiveTab("preview")}
1454
+ compactEpisodeChrome
1416
1455
  />
1417
1456
  </div>
1418
- ) : isCartoonGenesis ? (
1419
- // Genesis Edit tab: opening-text editor vs. its cut workspace (#429), so
1420
- // the coach's lettering/upload/refresh actions for Episode 1 are actionable
1421
- // and Genesis cuts get the same workspace as plots — without losing the
1422
- // hand-written opening prose editor.
1423
- <div
1424
- className="flex-1 min-h-0 flex flex-col"
1425
- style={{ background: "var(--paper-bg)" }}
1426
- >
1427
- <div className="flex gap-1 px-3 py-1 border-b border-border">
1428
- <button
1429
- data-testid="genesis-edit-mode-text"
1430
- onClick={() => setGenesisEditMode("text")}
1431
- className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "text" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1432
- >
1433
- Opening text
1434
- </button>
1435
- <button
1436
- data-testid="genesis-edit-mode-cuts"
1437
- onClick={() => setGenesisEditMode("cuts")}
1438
- className={`px-2 py-0.5 text-[11px] rounded ${genesisEditMode === "cuts" ? "bg-accent text-white" : "text-muted hover:text-foreground"}`}
1439
- >
1440
- Cuts
1441
- </button>
1442
- </div>
1443
- <div className="flex-1 min-h-0 flex flex-col">
1444
- {genesisEditMode === "cuts" ? (
1445
- <CutListPanel
1446
- storyName={storyName!}
1447
- fileName={fileName!}
1448
- authFetch={authFetch}
1449
- language={language}
1450
- onCutsChanged={() => setCutsRefreshKey((k) => k + 1)}
1451
- focusRequest={cutFocus}
1452
- onFocusHandled={() => setCutFocus(null)}
1453
- onFocusedLetteringModeChange={onFocusedLetteringModeChange}
1454
- workspaceVisible={focusedLetteringWorkspaceVisible}
1455
- onWorkspaceVisibleChange={
1456
- onFocusedLetteringWorkspaceVisibleChange
1457
- }
1458
- />
1459
- ) : (
1460
- proseEditor
1461
- )}
1462
- </div>
1463
- </div>
1464
1457
  ) : (
1465
1458
  proseEditor
1466
1459
  )}
1467
1460
 
1468
1461
  {/* Action bar */}
1469
- {!hideFocusedEditorChrome && (
1462
+ {!hideFocusedEditorChrome && !isCartoonEpisode && (
1470
1463
  <div
1471
1464
  className="shrink-0 px-3 py-2 border-t border-border flex items-center justify-between bg-surface/95"
1472
1465
  data-testid="preview-panel-footer"
@@ -1939,9 +1932,7 @@ export function PreviewPanel({
1939
1932
  cut/lettering editor gets the height — the cover stays available
1940
1933
  in the Opening-text/Preview view, Story Info, and the Publish page,
1941
1934
  and the auto-detect effect still loads it for publish. */}
1942
- {isGenesis &&
1943
- contentType !== "cartoon" &&
1944
- !(activeTab === "edit" && genesisEditMode === "cuts") && (
1935
+ {isGenesis && contentType !== "cartoon" && (
1945
1936
  <div
1946
1937
  className="flex flex-col gap-1.5"
1947
1938
  data-testid="prepublish-cover"
@@ -2077,7 +2068,7 @@ export function PreviewPanel({
2077
2068
  </div>
2078
2069
  </div>
2079
2070
  </div>
2080
- )}
2071
+ )}
2081
2072
  {/* Public title shown + validated before publish (#358). #461: moved
2082
2073
  to the Publish tab for cartoon — fiction keeps it inline. */}
2083
2074
  {!isCartoonEpisode && renderPublishTitle()}
@@ -1011,9 +1011,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1011
1011
  selectedStory,
1012
1012
  );
1013
1013
 
1014
- // Cartoon-only right-panel workflow nav (#439). The active tab follows the
1015
- // open non-file view (Story Info / Episodes) or the closest file: structure.md
1016
- // ⇒ Whitepaper, genesis.md ⇒ Genesis / Ep 1, plot-NN ⇒ Episodes, else Progress.
1014
+ // Cartoon-only right-panel workflow nav. Cartoon users work from episodes in
1015
+ // the left browser; the top nav stays at story/workflow level only.
1017
1016
  const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
1018
1017
  const activeCartoonTab: CartoonWorkflowTab =
1019
1018
  cartoonView === "story-info"
@@ -1022,13 +1021,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1022
1021
  ? "episodes"
1023
1022
  : cartoonView === "publish"
1024
1023
  ? "publish"
1025
- : selectedFile === "structure.md"
1026
- ? "whitepaper"
1027
- : selectedFile === "genesis.md"
1028
- ? "genesis"
1029
- : selectedFile && /^plot-\d+\.md$/.test(selectedFile)
1030
- ? "episodes"
1031
- : "progress";
1024
+ : selectedFile &&
1025
+ (selectedFile === "genesis.md" || /^plot-\d+\.md$/.test(selectedFile))
1026
+ ? "episodes"
1027
+ : "progress";
1032
1028
 
1033
1029
  const handleCartoonNav = useCallback(
1034
1030
  (tab: CartoonWorkflowTab) => {
@@ -1049,12 +1045,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1049
1045
  case "episodes":
1050
1046
  setCartoonView("episodes");
1051
1047
  break;
1052
- case "whitepaper":
1053
- handleSelectFile(story, "structure.md");
1054
- break;
1055
- case "genesis":
1056
- handleSelectFile(story, "genesis.md");
1057
- break;
1058
1048
  // Publish opens its own readiness page and stays on the Publish tab (#449),
1059
1049
  // instead of visually routing to the Genesis file view.
1060
1050
  case "publish":
@@ -1298,7 +1288,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1298
1288
  />
1299
1289
  )}
1300
1290
  </div>
1301
- {!focusedLetteringMode && isCartoonStory && selectedStory && (
1291
+ {!focusedLetteringMode &&
1292
+ isCartoonStory &&
1293
+ selectedStory &&
1294
+ !selectedFile && (
1302
1295
  <div
1303
1296
  className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(22rem,calc(100%-2rem))]"
1304
1297
  data-testid="workflow-persistent-next-action"