diff --git a/app/lib/cartoon-production-status.ts b/app/lib/cartoon-production-status.ts
new file mode 100644
index 0000000..44122ac
--- /dev/null
+++ b/app/lib/cartoon-production-status.ts
@@ -0,0 +1,77 @@
+import type { CartoonChecklist, CartoonChecklistStep } from "./cartoon-readiness";
+
+export type CartoonProductionStepKey =
+ | CartoonChecklistStep["key"]
+ | "assemble"
+ | "ready";
+
+export interface CartoonProductionStep {
+ key: CartoonProductionStepKey;
+ label: string;
+ status: "done" | "current" | "todo";
+ detail: string | null;
+}
+
+export interface CartoonProductionStatus {
+ steps: CartoonProductionStep[];
+ activeStep: CartoonProductionStep | null;
+ statusLabel: string | null;
+ completedCount: number;
+ totalCount: number;
+ outstandingCount: number;
+}
+
+export function buildCartoonProductionStatus(input: {
+ checklist: CartoonChecklist | null;
+ markdownReady?: boolean;
+ published?: boolean;
+}): CartoonProductionStatus | null {
+ const { checklist, markdownReady = false, published = false } = input;
+ if (!checklist || checklist.steps.length === 0) return null;
+
+ const uploadDone =
+ checklist.steps.find((step) => step.key === "upload")?.status === "done";
+ const ready = uploadDone && markdownReady && !published;
+ const assembleStatus: CartoonProductionStep["status"] = published || markdownReady
+ ? "done"
+ : uploadDone
+ ? "current"
+ : "todo";
+ const readyStatus: CartoonProductionStep["status"] = published
+ ? "done"
+ : ready
+ ? "current"
+ : "todo";
+
+ const steps: CartoonProductionStep[] = [
+ ...checklist.steps.filter((step) => step.key !== "publish"),
+ {
+ key: "assemble",
+ label: "Episode sequence prepared",
+ status: assembleStatus,
+ detail: null,
+ },
+ {
+ key: "ready",
+ label: published ? "Published to PlotLink" : "Ready to publish",
+ status: readyStatus,
+ detail: null,
+ },
+ ];
+
+ const activeStep =
+ steps.find((step) => step.status === "current")
+ ?? steps.find((step) => step.status === "todo")
+ ?? steps[steps.length - 1]
+ ?? null;
+ const completedCount = steps.filter((step) => step.status === "done").length;
+
+ return {
+ steps,
+ activeStep,
+ statusLabel: activeStep?.label ?? null,
+ completedCount,
+ totalCount: steps.length,
+ outstandingCount: steps.filter((step) => step.status !== "done").length,
+ };
+}
diff --git a/app/web/components/CartoonProductionStatus.tsx b/app/web/components/CartoonProductionStatus.tsx
new file mode 100644
index 0000000..1726200
--- /dev/null
+++ b/app/web/components/CartoonProductionStatus.tsx
@@ -0,0 +1,142 @@
+import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
+import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
+import type { ReactNode } from "react";
+
+const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
+ done: "✓",
+ current: "▸",
+ todo: "○",
+};
+
+interface CartoonProductionStatusProps {
+ checklist: CartoonChecklist | null;
+ markdownReady?: boolean;
+ published?: boolean;
+ issues?: string[];
+ title?: string;
+ subtitle?: ReactNode;
+ action?: ReactNode;
+ rootTestId?: string;
+ detailsTestId?: string;
+ stepTestIdPrefix?: string;
+ issuesTestId?: string;
+ issueGroupTestIdPrefix?: string;
+ detailsLabel?: string;
+ summaryTestId?: string;
+}
+
+export function CartoonProductionStatus({
+ checklist,
+ markdownReady = false,
+ published = false,
+ issues = [],
+ title = "Episode production",
+ subtitle,
+ action,
+ rootTestId = "cartoon-production-status",
+ detailsTestId = "cartoon-production-details",
+ stepTestIdPrefix = "cartoon-production-step",
+ issuesTestId,
+ issueGroupTestIdPrefix,
+ detailsLabel,
+ summaryTestId,
+}: CartoonProductionStatusProps) {
+ const production = buildCartoonProductionStatus({
+ checklist,
+ markdownReady,
+ published,
+ });
+ if (!production) return null;
+
+ const groups = groupCartoonIssues(issues);
+ const issuesCount = groups.reduce((sum, group) => sum + group.lines.length, 0);
+ const statusLabel = production.statusLabel ?? "Review cuts";
+ const progressLabel = `${production.completedCount} / ${production.totalCount} steps done`;
+ const detailsText = detailsLabel
+ ?? (production.outstandingCount === 0
+ ? "Production details"
+ : `${production.outstandingCount} step${production.outstandingCount === 1 ? "" : "s"} left`);
+
+ return (
+
+
+
+
+ {title}
+
+ Active: {statusLabel}
+
+
+ {progressLabel}
+
+ {production.activeStep?.detail && (
+ {production.activeStep.detail}
+ )}
+
+ {subtitle ? (
+
{subtitle}
+ ) : null}
+
+ {action ?
{action}
: null}
+
+
+
+
+ {detailsText}
+ {issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
+
+
+
+
+ {production.steps.map((step) => (
+ -
+ {STATUS_MARK[step.status]}
+ {step.label}
+ {step.detail && · {step.detail}}
+
+ ))}
+
+
+ {groups.length > 0 && (
+
+ {groups.map((group) => (
+
+
{group.title}
+
+ {group.lines.map((line, i) => (
+ - {line}
+ ))}
+
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/web/components/CartoonPublishPage.test.tsx b/app/web/components/CartoonPublishPage.test.tsx
index 85a1463..21260a2 100644
--- a/app/web/components/CartoonPublishPage.test.tsx
+++ b/app/web/components/CartoonPublishPage.test.tsx
@@ -1,8 +1,9 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, afterEach } from "vitest";
-import { render, screen, cleanup, fireEvent, waitFor, within } from "@testing-library/react";
+import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
import { CartoonPublishPage } from "./CartoonPublishPage";
import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
+import type { Cut } from "@app-lib/cuts";
afterEach(cleanup);
@@ -25,6 +26,46 @@ function progress(o: Partial & { episodes: EpisodeProgress[] }):
};
}
+function makeCut(id: number, overrides: Partial = {}): Cut {
+ return {
+ id,
+ shotType: "medium",
+ description: "scene",
+ characters: [],
+ dialogue: [],
+ narration: "",
+ sfx: "",
+ cleanImagePath: null,
+ finalImagePath: null,
+ exportedAt: null,
+ uploadedCid: null,
+ uploadedUrl: null,
+ overlays: [],
+ ...overrides,
+ };
+}
+
+function cleanCuts(count: number): Cut[] {
+ return Array.from({ length: count }, (_, i) =>
+ makeCut(i + 1, {
+ cleanImagePath: `assets/genesis/cut-${i + 1}-clean.webp`,
+ }),
+ );
+}
+
+function uploadedCuts(count: number): Cut[] {
+ return Array.from({ length: count }, (_, i) =>
+ makeCut(i + 1, {
+ cleanImagePath: `assets/genesis/cut-${i + 1}-clean.webp`,
+ finalImagePath: `assets/genesis/cut-${i + 1}-final.webp`,
+ exportedAt: "2026-06-08T00:00:00Z",
+ uploadedCid: `Qm${i + 1}`,
+ uploadedUrl: `https://ipfs.example/${i + 1}`,
+ overlays: [{ id: `o-${i + 1}`, type: "speech", x: 0.1, y: 0.1, width: 0.2, height: 0.1, text: "hi" }],
+ }),
+ );
+}
+
// A publishable Genesis opening (real H1 + multi-paragraph prose) so the migrated
// title (#358) and prologue-readiness (#359) diagnostics don't block publish in
// the ready-state tests. Override per test via the `files` map.
@@ -37,7 +78,7 @@ By dawn, nothing in the building — or the city beyond it — will be the same.
function makeAuthFetch(p: StoryProgress | null, files?: Record) {
const map: Record = {
content: { content: GOOD_GENESIS },
- cuts: { cuts: [], title: null },
+ cuts: { cuts: uploadedCuts(2), title: null },
structure: { content: "# The Awakening\n" },
...files,
};
@@ -56,12 +97,10 @@ const MIDWAY_CUTS = { total: 10, needClean: 10, withClean: 10, withText: 0, expo
describe("CartoonPublishPage (#449)", () => {
it("summarizes readiness for the active episode and disables Publish until ready", async () => {
const p = progress({ cover: "present", episodes: [ep({ file: "genesis.md", state: "in-progress", summary: "3 / 10 cuts have uploaded images", cuts: MIDWAY_CUTS })] });
- render();
+ render();
expect(await screen.findByTestId("cartoon-publish-page")).toHaveTextContent("Publish Episode 1 / Genesis");
- const checklist = screen.getByTestId("publish-checklist");
- expect(checklist).toHaveTextContent("Cuts lettered");
- expect(checklist).toHaveTextContent("Final images uploaded");
+ expect(await screen.findByTestId("publish-production-status")).toHaveTextContent("Active: Add speech bubbles & captions");
// Not ready → the publish CTA is disabled and a reason is shown.
expect(screen.getByTestId("publish-cta")).toBeDisabled();
expect(screen.getByTestId("publish-blocked-reason")).toBeInTheDocument();
@@ -140,9 +179,7 @@ describe("CartoonPublishPage (#449)", () => {
fireEvent.click(await screen.findByTestId("publish-add-cover"));
expect(onOpenStoryInfo).toHaveBeenCalled();
- // The cover check reads as not-done.
- const coverRow = within(screen.getByTestId("publish-checklist")).getByText("Cover image");
- expect(coverRow.closest("[data-testid='publish-check']")).toHaveAttribute("data-status", "todo");
+ expect(screen.getByTestId("publish-cover-status")).toHaveTextContent("Cover image: Missing");
});
it("shows an all-published state when every episode is published", async () => {
diff --git a/app/web/components/CartoonPublishPage.tsx b/app/web/components/CartoonPublishPage.tsx
index 0ce8f43..bee3035 100644
--- a/app/web/components/CartoonPublishPage.tsx
+++ b/app/web/components/CartoonPublishPage.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
-import { cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
+import { cartoonChecklist, cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
import type { Cut } from "@app-lib/cuts";
+import { CartoonProductionStatus } from "./CartoonProductionStatus";
import { derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
interface CartoonPublishPageProps {
@@ -23,9 +24,6 @@ interface CartoonPublishPageProps {
refreshKey?: number;
}
-type CheckState = "done" | "todo";
-interface PublishCheck { label: string; status: CheckState; detail?: string | null }
-
/**
* Dedicated cartoon "Publish" workflow page (#449, spec §10).
*
@@ -172,18 +170,8 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
);
}
- const c = active.cuts;
const coverDone = progress.cover === "present";
- const checks: PublishCheck[] = [
- { label: "Opening text ready", status: "done" }, // the episode exists once it appears here
- { label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
- { label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
- { label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
- { label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
- { label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
- { label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
- { label: "Publish to PlotLink", status: active.published ? "done" : "todo" },
- ];
+ const checklist = cartoonChecklist({ cuts: activeCuts ?? [], published: active.published });
const ready = active.state === "ready";
const blocked = active.state === "blocked";
@@ -247,17 +235,38 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
return (
Publish {active.label}
-
Finalize this episode: convert, letter, export, upload, then publish to PlotLink.
+
Publish stays focused on readiness and blockers. Open production details only if you need the full step map.
-
- {checks.map((ck, i) => (
- -
- {ck.status === "done" ? "✓" : "○"}
- {ck.label}
- {ck.detail && · {ck.detail}}
-
- ))}
-
+
+
+
+
+ Cover image: {coverDone ? "Ready" : "Missing"}
+
+ {isGenesisActive && (
+
+ Story info: {metaReady ? "Ready" : "Set genre & language"}
+
+ )}
+
+
{/* Migrated episode diagnostics (#461): the publish title (#358), Genesis
prologue readiness (#359), and grouped publish issues (#360) that used
diff --git a/app/web/components/CutListPanel.tsx b/app/web/components/CutListPanel.tsx
index 5b60456..8f08490 100644
--- a/app/web/components/CutListPanel.tsx
+++ b/app/web/components/CutListPanel.tsx
@@ -16,6 +16,7 @@ import {
} from "../lib/import-image";
import { CodexImportPicker } from "./CodexImportPicker";
import { FinishEpisodePanel } from "./FinishEpisodePanel";
+import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
import {
cartoonChecklist,
checkMarkdownReadiness,
@@ -1679,8 +1680,8 @@ export function CutListPanel({
Per-cut actions stay on each card. Open workspace tools for
- publish prep, asset recovery, narration cards, and workflow
- guidance.
+ publish prep, asset recovery, narration cards, and blocked-step
+ details.
@@ -1774,23 +1775,6 @@ export function CutListPanel({
{uploadProgress || "Upload & Prepare for Publish"}
-
-
- 1. Letter
-
- →
-
- 2. Export
-
- →
-
- 3. Upload
-
- →
-
- 4. Prepare episode for publish
-
-
Use
Add narration/text panel{" "}
for a narration or title card. It becomes a solid card exported as a
@@ -2066,11 +2050,11 @@ function currentWorkflowLabel(
markdownReady: boolean,
published: boolean,
): string {
- if (published) return "Published to PlotLink";
- if (markdownReady) return "Ready to publish";
- const current =
- checklist.steps.find((step) => step.status === "current")
- ?? checklist.steps.find((step) => step.status === "todo")
- ?? checklist.steps[checklist.steps.length - 1];
- return current?.label ?? "Review cuts";
+ return (
+ buildCartoonProductionStatus({
+ checklist,
+ markdownReady,
+ published,
+ })?.statusLabel ?? "Review cuts"
+ );
}
diff --git a/app/web/components/FinishEpisodePanel.tsx b/app/web/components/FinishEpisodePanel.tsx
index 721e7be..129f66d 100644
--- a/app/web/components/FinishEpisodePanel.tsx
+++ b/app/web/components/FinishEpisodePanel.tsx
@@ -1,8 +1,5 @@
-import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
-
-type StepStatus = "done" | "current" | "todo";
-
-const STATUS_MARK: Record
= { done: "✓", current: "▸", todo: "○" };
+import type { CartoonChecklist } from "@app-lib/cartoon-readiness";
+import { CartoonProductionStatus } from "./CartoonProductionStatus";
interface FinishEpisodePanelProps {
/** Writer-language production checklist for this episode (null ⇒ not a cartoon plot). */
@@ -23,28 +20,11 @@ interface FinishEpisodePanelProps {
published?: boolean;
}
-interface DisplayStep {
- key: string;
- label: string;
- status: StepStatus;
- detail: string | null;
-}
-
/**
* Guided "Finish episode" flow for a cartoon plot (#414).
*
- * The end-to-end pilot showed the production tail (export → upload → prepare
- * markdown → publish) was technically complete but fragmented: a writer had to know
- * which low-level button to click and read a flat wall of "Cut N: …" errors. This
- * panel makes the tail one guided surface in writer language: it shows the six
- * production steps with live status, offers ONE primary "Finish episode" action
- * that runs the remaining automatable steps in order (resumable — already-uploaded
- * cuts are skipped by the caller), and groups any blockers under the actionable
- * step heading instead of a long red list. The lower-level controls stay available
- * elsewhere in the workspace for manual recovery.
- *
- * Renders nothing when there is no checklist (e.g. a fiction plot or an unparsed
- * cut plan), so it never appears outside the cartoon flow.
+ * This now reuses the shared production-status surface so the Cuts workspace and
+ * Publish tab speak the same workflow language and active-step text.
*/
export function FinishEpisodePanel({
checklist,
@@ -58,105 +38,38 @@ export function FinishEpisodePanel({
}: FinishEpisodePanelProps) {
if (!checklist || checklist.steps.length === 0) return null;
- const groups = groupCartoonIssues(issues);
-
- // The base checklist (plan → upload) models per-cut art/lettering/export/upload
- // progress; it has no notion of the publish markdown being assembled. #414 needs
- // the post-upload tail modelled explicitly, so replace its single "publish" step
- // with two real states: "Episode sequence prepared" (markdown built + ready) and
- // "Ready to publish" (which becomes "Published" once it's on-chain).
- const uploadDone = checklist.steps.find((s) => s.key === "upload")?.status === "done";
- const ready = uploadDone && markdownReady && !published; // ready to publish, not yet published
-
- const assembleStatus: StepStatus = published || markdownReady ? "done" : uploadDone ? "current" : "todo";
- const readyStatus: StepStatus = published ? "done" : ready ? "current" : "todo";
-
- const steps: DisplayStep[] = [
- ...checklist.steps.filter((s) => s.key !== "publish"),
- { key: "assemble", label: "Episode sequence prepared", status: assembleStatus, detail: null },
- { key: "ready", label: published ? "Published to PlotLink" : "Ready to publish", status: readyStatus, detail: null },
- ];
-
const buttonLabel = finishing
? progressText || "Finishing…"
: published
? "Published ✓"
- : ready
+ : markdownReady
? "Episode ready to publish"
: "Finish episode";
- const outstandingCount = steps.filter((s) => s.status !== "done").length;
- const issuesCount = groups.reduce((sum, g) => sum + g.lines.length, 0);
-
return (
-
-
- Finish episode
- {checklist.nextStep && (
-
- Next: {checklist.nextStep}
-
- )}
+
{buttonLabel}
-
-
-
-
- {outstandingCount === 0 ? "Progress details" : `${outstandingCount} step${outstandingCount === 1 ? "" : "s"} left`}
- {issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
-
-
-
- {/* Writer-language step status — the exact webtoon production sequence. */}
-
- {steps.map((s) => (
- -
- {STATUS_MARK[s.status]}
- {s.label}
- {s.detail && · {s.detail}}
-
- ))}
-
-
- {/* Blockers grouped by the step that fixes them, not a flat red list. */}
- {groups.length > 0 && (
-
- {groups.map((g) => (
-
-
{g.title}
-
- {g.lines.map((line, i) => (
- - {line}
- ))}
-
-
- ))}
-
- )}
-
-
-
+ )}
+ />
);
}
diff --git a/app/web/components/StoryProgressPanel.test.tsx b/app/web/components/StoryProgressPanel.test.tsx
index 5cace1d..2c06487 100644
--- a/app/web/components/StoryProgressPanel.test.tsx
+++ b/app/web/components/StoryProgressPanel.test.tsx
@@ -133,6 +133,7 @@ describe("StoryProgressPanel — cartoon workflow map (#438)", () => {
render();
await screen.findByTestId("story-progress-panel");
const genesis = screen.getByTestId("workflow-section-3");
+ expect(genesis).toHaveTextContent("Active step: Ready to publish");
expect(genesis).toHaveTextContent("Opening text");
expect(genesis).toHaveTextContent("Create clean images");
expect(genesis).toHaveTextContent("Upload final images");
diff --git a/app/web/components/StoryProgressPanel.tsx b/app/web/components/StoryProgressPanel.tsx
index d4c6e21..17caad0 100644
--- a/app/web/components/StoryProgressPanel.tsx
+++ b/app/web/components/StoryProgressPanel.tsx
@@ -1,6 +1,7 @@
import { useEffect, useState } from "react";
import type { StoryProgress, EpisodeProgress, EpisodeState } from "@app-lib/story-progress";
import type { CartoonChecklistStep } from "@app-lib/cartoon-readiness";
+import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
import { cartoonWorkflowActiveKey } from "./CartoonNextAction";
interface StoryProgressPanelProps {
@@ -296,6 +297,11 @@ function EpisodeSection({
}) {
const status = episodeStatus(ep, isActive);
const items = episodeItems(ep, openingDone);
+ const production = buildCartoonProductionStatus({
+ checklist: ep.checklist ? { steps: ep.checklist, nextStep: null } : null,
+ markdownReady: ep.state === "ready" || ep.published,
+ published: ep.published,
+ });
const title = ep.title ? `${ep.label} · ${ep.title}` : ep.label;
const heading = (
@@ -319,6 +325,13 @@ function EpisodeSection({
) : (
{heading}
)}
+ {production?.statusLabel && (
+
+ Active step:{" "}
+ {production.statusLabel}
+ {production.activeStep?.detail ? ` · ${production.activeStep.detail}` : ""}
+
+ )}
{items.map((it, i) => )}