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.
@@ -143,11 +143,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
143
143
  focusedLetteringWorkspaceVisible,
144
144
  setFocusedLetteringWorkspaceVisible,
145
145
  ] = useState(true);
146
+ const [workflowActionRequest, setWorkflowActionRequest] = useState<{
147
+ action: CoachUiAction;
148
+ seq: number;
149
+ } | null>(null);
146
150
  const contentTypeMap = useRef<Map<string, "fiction" | "cartoon">>(new Map());
147
151
  const languageMap = useRef<Map<string, string>>(new Map());
148
152
  const agentModeMap = useRef<Map<string, "normal" | "bypass">>(new Map());
149
153
  const agentProviderMap = useRef<Map<string, "claude" | "codex">>(new Map());
150
154
  const knownStoriesRef = useRef<Set<string>>(new Set());
155
+ const workflowActionSeqRef = useRef(0);
151
156
  const renameRef = useRef<
152
157
  | ((
153
158
  oldName: string,
@@ -1006,9 +1011,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1006
1011
  selectedStory,
1007
1012
  );
1008
1013
 
1009
- // Cartoon-only right-panel workflow nav (#439). The active tab follows the
1010
- // open non-file view (Story Info / Episodes) or the closest file: structure.md
1011
- // ⇒ 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.
1012
1016
  const isCartoonStory = !!selectedStory && selectedContentType === "cartoon";
1013
1017
  const activeCartoonTab: CartoonWorkflowTab =
1014
1018
  cartoonView === "story-info"
@@ -1017,13 +1021,10 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1017
1021
  ? "episodes"
1018
1022
  : cartoonView === "publish"
1019
1023
  ? "publish"
1020
- : selectedFile === "structure.md"
1021
- ? "whitepaper"
1022
- : selectedFile === "genesis.md"
1023
- ? "genesis"
1024
- : selectedFile && /^plot-\d+\.md$/.test(selectedFile)
1025
- ? "episodes"
1026
- : "progress";
1024
+ : selectedFile &&
1025
+ (selectedFile === "genesis.md" || /^plot-\d+\.md$/.test(selectedFile))
1026
+ ? "episodes"
1027
+ : "progress";
1027
1028
 
1028
1029
  const handleCartoonNav = useCallback(
1029
1030
  (tab: CartoonWorkflowTab) => {
@@ -1044,12 +1045,6 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1044
1045
  case "episodes":
1045
1046
  setCartoonView("episodes");
1046
1047
  break;
1047
- case "whitepaper":
1048
- handleSelectFile(story, "structure.md");
1049
- break;
1050
- case "genesis":
1051
- handleSelectFile(story, "genesis.md");
1052
- break;
1053
1048
  // Publish opens its own readiness page and stays on the Publish tab (#449),
1054
1049
  // instead of visually routing to the Genesis file view.
1055
1050
  case "publish":
@@ -1064,6 +1059,13 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1064
1059
  (action: CoachUiAction, episodeFile: string | null) => {
1065
1060
  const story = selectedStory;
1066
1061
  if (!story) return;
1062
+ const queueWorkflowAction = (nextAction: CoachUiAction) => {
1063
+ workflowActionSeqRef.current += 1;
1064
+ setWorkflowActionRequest({
1065
+ action: nextAction,
1066
+ seq: workflowActionSeqRef.current,
1067
+ });
1068
+ };
1067
1069
  switch (action) {
1068
1070
  case "view-progress":
1069
1071
  setCartoonView(null);
@@ -1077,7 +1079,9 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1077
1079
  case "upload":
1078
1080
  case "refresh-assets":
1079
1081
  case "generate-markdown":
1080
- if (episodeFile) handleSelectFile(story, episodeFile);
1082
+ if (!episodeFile) return;
1083
+ handleSelectFile(story, episodeFile);
1084
+ queueWorkflowAction(action);
1081
1085
  break;
1082
1086
  }
1083
1087
  },
@@ -1196,7 +1200,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1196
1200
  {/* Preview — takes remaining space. With a story but no file selected, show
1197
1201
  the story-level progress overview (#418) instead of the empty state. */}
1198
1202
  <div
1199
- className="min-w-0 flex flex-col"
1203
+ className="min-w-0 min-h-0 flex flex-col"
1200
1204
  style={
1201
1205
  hideFocusedLetteringWorkspace
1202
1206
  ? { flex: "1 0 0" }
@@ -1211,91 +1215,101 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1211
1215
  onSelect={handleCartoonNav}
1212
1216
  />
1213
1217
  )}
1218
+ <div className="flex-1 min-h-0 flex flex-col">
1219
+ {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
1220
+ <StoryInfoPage
1221
+ storyName={selectedStory}
1222
+ authFetch={authFetch}
1223
+ onSaved={handleStoryInfoSaved}
1224
+ />
1225
+ ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
1226
+ <EpisodesPage
1227
+ storyName={selectedStory}
1228
+ authFetch={authFetch}
1229
+ onOpenFile={handleSelectFile}
1230
+ />
1231
+ ) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
1232
+ <CartoonPublishPage
1233
+ storyName={selectedStory}
1234
+ authFetch={authFetch}
1235
+ onOpenFile={handleSelectFile}
1236
+ onOpenStoryInfo={() => setCartoonView("story-info")}
1237
+ onPublish={handlePublish}
1238
+ publishingFile={publishingFile}
1239
+ genre={storyGenres[selectedStory]}
1240
+ language={storyLanguages[selectedStory]}
1241
+ isNsfw={storyNsfw[selectedStory]}
1242
+ refreshKey={cartoonPublishRefresh}
1243
+ />
1244
+ ) : selectedStory && !selectedFile ? (
1245
+ <StoryProgressPanel
1246
+ storyName={selectedStory}
1247
+ authFetch={authFetch}
1248
+ onOpenFile={handleSelectFile}
1249
+ />
1250
+ ) : (
1251
+ <PreviewPanel
1252
+ storyName={selectedStory}
1253
+ fileName={selectedFile}
1254
+ authFetch={authFetch}
1255
+ onPublish={handlePublish}
1256
+ publishingFile={publishingFile}
1257
+ walletAddress={walletAddress}
1258
+ contentType={
1259
+ resolveSelectedContentType(
1260
+ selectedStory,
1261
+ storyContentTypes,
1262
+ contentTypeMap.current,
1263
+ ) || "fiction"
1264
+ }
1265
+ language={selectedStory ? storyLanguages[selectedStory] : undefined}
1266
+ genre={selectedStory ? storyGenres[selectedStory] : undefined}
1267
+ isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
1268
+ hasGenesis={
1269
+ selectedStory ? genesisStories.has(selectedStory) : false
1270
+ }
1271
+ onViewProgress={() => setSelectedFile(null)}
1272
+ onOpenFile={(file) =>
1273
+ selectedStory && handleSelectFile(selectedStory, file)
1274
+ }
1275
+ onViewPublish={() => setCartoonView("publish")}
1276
+ focusedLetteringMode={focusedLetteringMode}
1277
+ focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
1278
+ onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
1279
+ onFocusedLetteringWorkspaceVisibleChange={
1280
+ setFocusedLetteringWorkspaceVisible
1281
+ }
1282
+ workflowActionRequest={workflowActionRequest}
1283
+ onWorkflowActionHandled={(seq) =>
1284
+ setWorkflowActionRequest((prev) =>
1285
+ prev?.seq === seq ? null : prev,
1286
+ )
1287
+ }
1288
+ />
1289
+ )}
1290
+ </div>
1214
1291
  {!focusedLetteringMode &&
1215
1292
  isCartoonStory &&
1216
1293
  selectedStory &&
1217
- cartoonView !== null && (
1218
- <div
1219
- className="flex-shrink-0 border-b border-border"
1220
- data-testid="workflow-context-next-action"
1221
- >
1294
+ !selectedFile && (
1295
+ <div
1296
+ className="pointer-events-none absolute bottom-4 right-4 z-10 w-[min(22rem,calc(100%-2rem))]"
1297
+ data-testid="workflow-persistent-next-action"
1298
+ >
1299
+ <div className="pointer-events-auto rounded-xl border border-border/80 bg-background/95 shadow-lg backdrop-blur">
1222
1300
  <CartoonNextAction
1223
1301
  storyName={selectedStory}
1302
+ fileName={cartoonView === null ? selectedFile : null}
1224
1303
  authFetch={authFetch}
1225
1304
  refreshKey={cartoonPublishRefresh}
1226
1305
  onCoachAction={handleWorkflowNextAction}
1227
1306
  onOpenStoryInfo={() => setCartoonView("story-info")}
1228
1307
  />
1229
1308
  </div>
1230
- )}
1231
- {isCartoonStory && cartoonView === "story-info" && selectedStory ? (
1232
- <StoryInfoPage
1233
- storyName={selectedStory}
1234
- authFetch={authFetch}
1235
- onSaved={handleStoryInfoSaved}
1236
- />
1237
- ) : isCartoonStory && cartoonView === "episodes" && selectedStory ? (
1238
- <EpisodesPage
1239
- storyName={selectedStory}
1240
- authFetch={authFetch}
1241
- onOpenFile={handleSelectFile}
1242
- />
1243
- ) : isCartoonStory && cartoonView === "publish" && selectedStory ? (
1244
- <CartoonPublishPage
1245
- storyName={selectedStory}
1246
- authFetch={authFetch}
1247
- onOpenFile={handleSelectFile}
1248
- onOpenStoryInfo={() => setCartoonView("story-info")}
1249
- onPublish={handlePublish}
1250
- publishingFile={publishingFile}
1251
- genre={storyGenres[selectedStory]}
1252
- language={storyLanguages[selectedStory]}
1253
- isNsfw={storyNsfw[selectedStory]}
1254
- refreshKey={cartoonPublishRefresh}
1255
- />
1256
- ) : selectedStory && !selectedFile ? (
1257
- <StoryProgressPanel
1258
- storyName={selectedStory}
1259
- authFetch={authFetch}
1260
- onOpenFile={handleSelectFile}
1261
- onOpenStoryInfo={() => setCartoonView("story-info")}
1262
- />
1263
- ) : (
1264
- <PreviewPanel
1265
- storyName={selectedStory}
1266
- fileName={selectedFile}
1267
- authFetch={authFetch}
1268
- onPublish={handlePublish}
1269
- publishingFile={publishingFile}
1270
- walletAddress={walletAddress}
1271
- contentType={
1272
- resolveSelectedContentType(
1273
- selectedStory,
1274
- storyContentTypes,
1275
- contentTypeMap.current,
1276
- ) || "fiction"
1277
- }
1278
- language={selectedStory ? storyLanguages[selectedStory] : undefined}
1279
- genre={selectedStory ? storyGenres[selectedStory] : undefined}
1280
- isNsfw={selectedStory ? storyNsfw[selectedStory] : undefined}
1281
- hasGenesis={
1282
- selectedStory ? genesisStories.has(selectedStory) : false
1283
- }
1284
- onViewProgress={() => setSelectedFile(null)}
1285
- onOpenFile={(file) =>
1286
- selectedStory && handleSelectFile(selectedStory, file)
1287
- }
1288
- onViewPublish={() => setCartoonView("publish")}
1289
- focusedLetteringMode={focusedLetteringMode}
1290
- focusedLetteringWorkspaceVisible={focusedLetteringWorkspaceVisible}
1291
- onFocusedLetteringModeChange={handleFocusedLetteringModeChange}
1292
- onFocusedLetteringWorkspaceVisibleChange={
1293
- setFocusedLetteringWorkspaceVisible
1294
- }
1295
- />
1309
+ </div>
1296
1310
  )}
1297
1311
  {publishProgress && (
1298
- <div className="px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
1312
+ <div className="shrink-0 px-3 py-1.5 bg-surface border-t border-border text-xs text-muted">
1299
1313
  {publishProgress}
1300
1314
  </div>
1301
1315
  )}
@@ -1304,7 +1318,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
1304
1318
  doesn't disappear on a timer. */}
1305
1319
  {publishError && (
1306
1320
  <div
1307
- className="px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
1321
+ className="shrink-0 px-3 py-2 bg-error/10 border-t border-error/40 text-xs text-error flex items-start justify-between gap-3"
1308
1322
  data-testid="publish-block-error"
1309
1323
  role="alert"
1310
1324
  >
@@ -124,6 +124,22 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
124
124
  return files[0]?.file ?? null;
125
125
  };
126
126
 
127
+ const isEpisodeFile = (file: string) =>
128
+ file === "genesis.md" || /^plot-\d+\.md$/.test(file);
129
+
130
+ const cartoonEpisodeMeta = (file: string): { label: string; sort: number } | null => {
131
+ if (file === "genesis.md") {
132
+ return { label: "epi-01 (Genesis)", sort: 1 };
133
+ }
134
+ const m = file.match(/^plot-(\d+)\.md$/);
135
+ if (!m) return null;
136
+ const n = parseInt(m[1], 10) + 1;
137
+ return {
138
+ label: `epi-${String(n).padStart(2, "0")}`,
139
+ sort: n,
140
+ };
141
+ };
142
+
127
143
  const handleStoryClick = (story: StoryInfo) => {
128
144
  toggleExpand(story.name);
129
145
  // Cartoon: a root-row click opens the story-level progress overview (#418) on
@@ -139,8 +155,18 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
139
155
  }
140
156
  };
141
157
 
142
- // Sort files: structure first, genesis, then plots in order
143
- const sortFiles = (files: FileStatus[]) => {
158
+ // Sort files: structure first, genesis, then plots in order. Cartoon stories
159
+ // show only episode files to avoid leaking the markdown implementation model.
160
+ const sortFiles = (files: FileStatus[], contentType?: "fiction" | "cartoon") => {
161
+ if (contentType === "cartoon") {
162
+ return [...files]
163
+ .filter((f) => isEpisodeFile(f.file))
164
+ .sort((a, b) => {
165
+ const aa = cartoonEpisodeMeta(a.file)?.sort ?? 999;
166
+ const bb = cartoonEpisodeMeta(b.file)?.sort ?? 999;
167
+ return aa - bb;
168
+ });
169
+ }
144
170
  const order = (f: string) => {
145
171
  if (f === "structure.md") return 0;
146
172
  if (f === "genesis.md") return 1;
@@ -238,27 +264,35 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
238
264
  {story.contentType === "cartoon" && (
239
265
  <span className="bg-accent/10 text-accent rounded px-1.5 py-0.5 text-[10px] font-medium flex-shrink-0">Cartoon</span>
240
266
  )}
241
- <span className="ml-auto flex-shrink-0 text-xs text-muted">
242
- {story.publishedCount}/{story.files.length}
243
- </span>
244
- </button>
245
- {expanded.has(story.name) && (
246
- <div className="pl-4">
247
- {sortFiles(story.files).map((f) => {
248
- const isSelected = selectedStory === story.name && selectedFile === f.file;
249
- return (
250
- <button
251
- key={f.file}
267
+ <span className="ml-auto flex-shrink-0 text-xs text-muted">
268
+ {story.contentType === "cartoon"
269
+ ? `${sortFiles(story.files, "cartoon").length} ep`
270
+ : `${story.publishedCount}/${story.files.length}`}
271
+ </span>
272
+ </button>
273
+ {expanded.has(story.name) && (
274
+ <div className="pl-4">
275
+ {sortFiles(story.files, story.contentType).map((f) => {
276
+ const isSelected = selectedStory === story.name && selectedFile === f.file;
277
+ const cartoonMeta =
278
+ story.contentType === "cartoon"
279
+ ? cartoonEpisodeMeta(f.file)
280
+ : null;
281
+ return (
282
+ <button
283
+ key={f.file}
252
284
  onClick={() => onSelectFile(story.name, f.file)}
253
285
  className={`w-full px-3 py-1.5 text-left flex items-center gap-2 text-xs hover:bg-surface ${
254
286
  isSelected ? "bg-surface font-medium" : ""
255
287
  }`}
256
- >
257
- <span className={STATUS_COLOR[f.status]}>{STATUS_ICON[f.status]}</span>
258
- <span className="truncate font-mono">{f.file}</span>
259
- </button>
260
- );
261
- })}
288
+ >
289
+ <span className={STATUS_COLOR[f.status]}>{STATUS_ICON[f.status]}</span>
290
+ <span className={story.contentType === "cartoon" ? "truncate" : "truncate font-mono"}>
291
+ {cartoonMeta?.label ?? f.file}
292
+ </span>
293
+ </button>
294
+ );
295
+ })}
262
296
  </div>
263
297
  )}
264
298
  </div>
@@ -152,8 +152,37 @@ export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPagePr
152
152
 
153
153
  return (
154
154
  <div className="h-full overflow-y-auto px-4 py-4" data-testid="story-info-page">
155
- <h2 className="text-base font-serif text-foreground">Story Info</h2>
156
- <p className="mt-0.5 text-[11px] text-muted">These details appear on PlotLink when the story is published.</p>
155
+ <div className="flex flex-wrap items-start justify-between gap-3">
156
+ <div className="min-w-0">
157
+ <h2 className="text-base font-serif text-foreground">Story Info</h2>
158
+ <p className="mt-0.5 text-[11px] text-muted">These details appear on PlotLink when the story is published.</p>
159
+ <div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
160
+ <span className={`rounded-full border px-2 py-0.5 ${title.trim() ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
161
+ Title {title.trim() ? "ready" : "missing"}
162
+ </span>
163
+ <span className={`rounded-full border px-2 py-0.5 ${genre ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
164
+ Genre {genre ? "set" : "needed"}
165
+ </span>
166
+ <span className={`rounded-full border px-2 py-0.5 ${language ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
167
+ Language {language ? "set" : "needed"}
168
+ </span>
169
+ <span className={`rounded-full border px-2 py-0.5 ${cover === "present" ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}>
170
+ {cover === "present" ? "Cover ready" : "Cover missing"}
171
+ </span>
172
+ </div>
173
+ </div>
174
+ <div className="flex flex-col items-start gap-1.5">
175
+ <button
176
+ type="button" onClick={handleSave} disabled={saving}
177
+ data-testid="story-info-save"
178
+ className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
179
+ >
180
+ {saving ? "Saving…" : "Save Story Info"}
181
+ </button>
182
+ {saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
183
+ {saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
184
+ </div>
185
+ </div>
157
186
 
158
187
  <div className="mt-4 flex flex-col gap-4 max-w-xl">
159
188
  <label className="flex flex-col gap-1">
@@ -248,18 +277,6 @@ export function StoryInfoPage({ storyName, authFetch, onSaved }: StoryInfoPagePr
248
277
  />
249
278
  <span className="text-xs text-foreground">This story contains adult content (18+)</span>
250
279
  </label>
251
-
252
- <div className="flex items-center gap-3">
253
- <button
254
- type="button" onClick={handleSave} disabled={saving}
255
- data-testid="story-info-save"
256
- className="rounded bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-accent-dim transition-colors disabled:opacity-50"
257
- >
258
- {saving ? "Saving…" : "Save Story Info"}
259
- </button>
260
- {saved && <span className="text-[11px] text-green-700" data-testid="story-info-saved">Saved</span>}
261
- {saveError && <span className="text-[11px] text-error" data-testid="story-info-error">{saveError}</span>}
262
- </div>
263
280
  </div>
264
281
  </div>
265
282
  );
@@ -1,15 +1,14 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
3
3
  import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
4
- import { cartoonWorkflowActiveKey, CartoonNextActionView } from "./CartoonNextAction";
4
+ import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
5
+ import { cartoonWorkflowActiveKey } from "./CartoonNextAction";
5
6
 
6
7
  interface StoryProgressPanelProps {
7
8
  storyName: string;
8
9
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
9
10
  /** Open a file from the map (the workflow steps link to their file). */
10
11
  onOpenFile: (storyName: string, file: string) => void;
11
- /** Open the Story Info workflow page when metadata/cover is the next gate. */
12
- onOpenStoryInfo?: () => void;
13
12
  /** Bumped by the parent to force a refresh (e.g. after a publish). */
14
13
  refreshKey?: number;
15
14
  }
@@ -20,13 +19,13 @@ interface StoryProgressPanelProps {
20
19
  * For CARTOON stories this is the writer's main production dashboard: a vertical
21
20
  * workflow map of numbered sections (Define Story Info → Story Whitepaper →
22
21
  * Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
23
- * status. The single next-action CTA stays persistent above the map, while the
24
- * current section still marks where that action belongs.
22
+ * status. The shell owns the single persistent next-action CTA, while the current
23
+ * section still marks where that action belongs.
25
24
  *
26
25
  * FICTION keeps the simpler original layout — metadata, setup steps, a chapter
27
26
  * list — and is completely unaffected by the cartoon redesign.
28
27
  */
29
- export function StoryProgressPanel({ storyName, authFetch, onOpenFile, onOpenStoryInfo, refreshKey = 0 }: StoryProgressPanelProps) {
28
+ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
30
29
  const [progress, setProgress] = useState<StoryProgress | null>(null);
31
30
  const [loading, setLoading] = useState(true);
32
31
 
@@ -56,7 +55,7 @@ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, onOpenSto
56
55
  }
57
56
 
58
57
  return progress.contentType === "cartoon"
59
- ? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} onOpenStoryInfo={onOpenStoryInfo} />
58
+ ? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
60
59
  : <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
61
60
  }
62
61
 
@@ -138,7 +137,7 @@ function Section({
138
137
  title: string;
139
138
  status: SectionStatus;
140
139
  items: ChecklistItem[];
141
- /** Power-user secondary text (real file name), shown small. */
140
+ /** Secondary text, shown small. */
142
141
  fileName?: string | null;
143
142
  /** Called to open the section's underlying file, or undefined for no navigation. */
144
143
  openFile?: () => void;
@@ -172,6 +171,14 @@ function episodeStatus(ep: EpisodeProgress, isActive: boolean): SectionStatus {
172
171
  return "needs-action";
173
172
  }
174
173
 
174
+ function episodeDisplayLabel(ep: EpisodeProgress): string {
175
+ if (ep.file === "genesis.md") return "epi-01 (Genesis)";
176
+ const m = ep.file.match(/^plot-(\d+)\.md$/);
177
+ if (!m) return ep.label;
178
+ const episodeNumber = parseInt(m[1], 10) + 1;
179
+ return `epi-${String(episodeNumber).padStart(2, "0")}`;
180
+ }
181
+
175
182
  /** Build the rendered checklist for a cartoon episode from its production checklist. */
176
183
  function episodeItems(ep: EpisodeProgress, openingDone = true): ChecklistItem[] {
177
184
  const steps = ep.checklist ?? [];
@@ -203,12 +210,11 @@ const GENESIS_STUB: EpisodeProgress = {
203
210
  };
204
211
 
205
212
  function CartoonWorkflowMap({
206
- progress, storyName, onOpenFile, onOpenStoryInfo,
213
+ progress, storyName, onOpenFile,
207
214
  }: {
208
215
  progress: StoryProgress;
209
216
  storyName: string;
210
217
  onOpenFile: (storyName: string, file: string) => void;
211
- onOpenStoryInfo?: () => void;
212
218
  }) {
213
219
  const m = progress.metadata;
214
220
  const hasStructure = progress.setup.hasStructure;
@@ -217,17 +223,6 @@ function CartoonWorkflowMap({
217
223
  const storyInfoIncomplete = metadataIncomplete || !coverDone;
218
224
  const activeKey = cartoonWorkflowActiveKey(progress);
219
225
 
220
- const topNextAction = (
221
- <CartoonNextActionView
222
- progress={progress}
223
- onOpenStoryInfo={onOpenStoryInfo}
224
- onCoachAction={(action, episodeFile) => {
225
- if (action === "view-progress") return; // already here
226
- if (episodeFile) onOpenFile(storyName, episodeFile);
227
- }}
228
- />
229
- );
230
-
231
226
  const infoItems: ChecklistItem[] = [
232
227
  { label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
233
228
  { label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
@@ -245,9 +240,6 @@ function CartoonWorkflowMap({
245
240
  return (
246
241
  <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
247
242
  <ProgressHeader progress={progress} />
248
- <div className="border-b border-border" data-testid="persistent-next-action">
249
- {topNextAction}
250
- </div>
251
243
  <p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
252
244
 
253
245
  <Section
@@ -259,9 +251,8 @@ function CartoonWorkflowMap({
259
251
 
260
252
  <Section
261
253
  index={++idx}
262
- title="Story Whitepaper"
254
+ title="Story Bible"
263
255
  status={whitepaperStatus}
264
- fileName="structure.md"
265
256
  openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
266
257
  items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
267
258
  />
@@ -313,12 +304,17 @@ function EpisodeSection({
313
304
  }) {
314
305
  const status = episodeStatus(ep, isActive);
315
306
  const items = episodeItems(ep, openingDone);
316
- const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
307
+ const production = buildCartoonProductionStatus({
308
+ checklist: ep.checklist ? { steps: ep.checklist, nextStep: null } : null,
309
+ markdownReady: ep.state === "ready" || ep.published,
310
+ published: ep.published,
311
+ });
312
+ const label = episodeDisplayLabel(ep);
313
+ const title = ep.title ? `${label} · ${ep.title}` : label;
317
314
  const heading = (
318
315
  <div className="flex items-center gap-2 min-w-0">
319
316
  <span className={`flex-shrink-0 ${SECTION_TONE[status]}`} aria-hidden>{SECTION_ICON[status]}</span>
320
317
  <span className="text-xs font-medium text-foreground truncate">{index}. {title}</span>
321
- <span className="text-[10px] text-muted truncate">{ep.file}</span>
322
318
  <span className={`ml-auto text-[10px] font-medium ${SECTION_TONE[status]} flex-shrink-0`}>{SECTION_LABEL[status]}</span>
323
319
  </div>
324
320
  );
@@ -336,6 +332,13 @@ function EpisodeSection({
336
332
  ) : (
337
333
  <div data-state={ep.state}>{heading}</div>
338
334
  )}
335
+ {production?.statusLabel && (
336
+ <div className="mt-1 ml-1 text-[10px] text-muted">
337
+ <span className="font-medium text-foreground">Active step:</span>{" "}
338
+ {production.statusLabel}
339
+ {production.activeStep?.detail ? ` · ${production.activeStep.detail}` : ""}
340
+ </div>
341
+ )}
339
342
  <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
340
343
  {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
341
344
  </div>
@@ -1 +1 @@
1
- import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-C43toXVm.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};
1
+ import{M as w,v as O,t as A,c as M,b as R,s as H,l as I,a as B,d as F}from"./index-CoG6WKyb.js";const z=w;async function G(e){if(typeof document>"u"||!document.fonts||typeof document.fonts.load!="function")return{ready:!0,missing:[]};const n=[];for(const s of e)try{const t=await document.fonts.load(`16px "${s}"`);!t||t.length===0?n.push(s):document.fonts.check(`16px "${s}"`)||n.push(s)}catch{n.push(s)}return{ready:n.length===0,missing:n}}function C(e){return new Promise((n,s)=>{const t=new Image;t.crossOrigin="anonymous",t.onload=()=>n(t),t.onerror=()=>s(new Error("Failed to load image")),t.src=e})}const W="rgba(255, 255, 255, 0.95)",_="#1a1a1a",L="rgba(244, 239, 230, 0.94)",N="rgba(26, 26, 26, 0.55)";function Y(e){return Math.max(2,e*.004)}function K(e,n,s,t,c,d,f){e.beginPath();for(const o of F(n,s,t,c,d,f))o.k==="M"?e.moveTo(o.x,o.y):o.k==="L"?e.lineTo(o.x,o.y):e.arcTo(o.cornerX,o.cornerY,o.x,o.y,o.r);e.closePath()}function P(e,n,s,t,c,d){var f;for(const o of n){const l=o.x*s,i=o.y*t,r=o.width*s,a=o.height*t,y=Y(t);if(o.type==="speech"){const u=R(o,r,a),k=o.tailAnchor?H(l,i,r,a,o.tailAnchor,u):null;K(e,l,i,r,a,k,u),e.fillStyle=W,e.fill(),e.strokeStyle=_,e.lineWidth=y,e.lineJoin="round",e.stroke()}else if(o.type==="narration"){const u=Math.min(r,a)*.12;e.beginPath(),e.roundRect(l,i,r,a,u),e.fillStyle=L,e.fill(),e.strokeStyle=N,e.lineWidth=Math.max(1.5,y*.75),e.lineJoin="round",e.stroke()}const g=o.type==="sfx"?d:c,T=o.type!=="sfx"&&!!o.speaker,h=I((u,k,E=400)=>(e.font=`${E} ${k}px ${g}`,e.measureText(u).width),o.text,r,a,B(o,t,r,a));e.textAlign="center",e.textBaseline="middle";const p=l+r/2,S=T?h.speakerFontSize*1.2:0;T&&(e.fillStyle="#3a3a3a",e.font=`700 ${h.speakerFontSize}px ${g}`,e.fillText(o.speaker,p,i+S/2+a*.04,r-6));const v=i+S,b=a-S,$=h.lines.length*h.lineHeight;let m=v+b/2-$/2+h.lineHeight/2;e.font=`${((f=o.textStyle)==null?void 0:f.fontWeight)??400} ${h.fontSize}px ${g}`;for(const u of h.lines)o.type==="sfx"?(e.fillStyle="#000",e.strokeStyle="#fff",e.lineWidth=3,e.strokeText(u,p,m),e.fillText(u,p,m)):(e.fillStyle="#1a1a1a",e.fillText(u,p,m)),m+=h.lineHeight}}function X(e,n,s,t,c){const d=Math.max(14,Math.min(t*.05,28));e.font=`${d}px ${c}`,e.fillStyle="#1a1a1a",e.textAlign="center",e.textBaseline="middle";const f=[];if(n.dialogue)for(const i of n.dialogue)f.push(`${i.speaker}: ${i.text}`);n.narration&&f.push(n.narration);const o=d*1.6,l=t/2-(f.length-1)*o/2;for(let i=0;i<f.length;i++)e.fillText(f[i],s/2,l+i*o,s-40)}async function Z(e,n,s,t,c,d){const f=O(n);if(!f.valid)throw new Error(f.error??"Overlay geometry is invalid");let o=800,l=600,i=null;if(e)i=await C(e),o=i.naturalWidth,l=i.naturalHeight;else if(d){const y=A(d.aspectRatio);y&&(o=y.width,l=y.height)}const r=document.createElement("canvas");r.width=o,r.height=l;const a=r.getContext("2d");return i?a.drawImage(i,0,0,o,l):(a.fillStyle=(d==null?void 0:d.background)||"#ffffff",a.fillRect(0,0,o,l)),P(a,n,o,l,s,t),c&&n.length===0&&!i&&X(a,c,o,l,s),M(r)}function j(e){return e.size>z?{valid:!1,error:`Image is ${(e.size/1024).toFixed(0)}KB, exceeds 1MB limit`}:{valid:!0}}export{z as MAX_SIZE,G as ensureFontsReady,Z as exportCut,P as renderOverlays,A as textPanelDimensions,j as validateExportSize};