From 4f443f7c08a9e6454c0d987dc929d1f02682c255 Mon Sep 17 00:00:00 2001 From: Project7 Date: Mon, 8 Jun 2026 02:47:29 +0000 Subject: [PATCH] feat: unify cartoon production status ui --- app/lib/cartoon-production-status.ts | 77 ++++++++++ .../components/CartoonProductionStatus.tsx | 142 ++++++++++++++++++ .../components/CartoonPublishPage.test.tsx | 55 +++++-- app/web/components/CartoonPublishPage.tsx | 59 +++++--- app/web/components/CutListPanel.tsx | 36 ++--- app/web/components/FinishEpisodePanel.tsx | 129 +++------------- .../components/StoryProgressPanel.test.tsx | 1 + app/web/components/StoryProgressPanel.tsx | 13 ++ 8 files changed, 344 insertions(+), 168 deletions(-) create mode 100644 app/lib/cartoon-production-status.ts create mode 100644 app/web/components/CartoonProductionStatus.tsx 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) => ( +
  1. + {STATUS_MARK[step.status]} + {step.label} + {step.detail && · {step.detail}} +
  2. + ))} +
+ + {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) => ( -
  1. - {STATUS_MARK[s.status]} - {s.label} - {s.detail && · {s.detail}} -
  2. - ))} -
- - {/* 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) => )}