plotlink-ows 1.2.96 → 1.2.97

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.
@@ -0,0 +1,77 @@
1
+ import type { CartoonChecklist, CartoonChecklistStep } from "./cartoon-readiness";
2
+
3
+ export type CartoonProductionStepKey =
4
+ | CartoonChecklistStep["key"]
5
+ | "assemble"
6
+ | "ready";
7
+
8
+ export interface CartoonProductionStep {
9
+ key: CartoonProductionStepKey;
10
+ label: string;
11
+ status: "done" | "current" | "todo";
12
+ detail: string | null;
13
+ }
14
+
15
+ export interface CartoonProductionStatus {
16
+ steps: CartoonProductionStep[];
17
+ activeStep: CartoonProductionStep | null;
18
+ statusLabel: string | null;
19
+ completedCount: number;
20
+ totalCount: number;
21
+ outstandingCount: number;
22
+ }
23
+
24
+ export function buildCartoonProductionStatus(input: {
25
+ checklist: CartoonChecklist | null;
26
+ markdownReady?: boolean;
27
+ published?: boolean;
28
+ }): CartoonProductionStatus | null {
29
+ const { checklist, markdownReady = false, published = false } = input;
30
+ if (!checklist || checklist.steps.length === 0) return null;
31
+
32
+ const uploadDone =
33
+ checklist.steps.find((step) => step.key === "upload")?.status === "done";
34
+ const ready = uploadDone && markdownReady && !published;
35
+ const assembleStatus: CartoonProductionStep["status"] = published || markdownReady
36
+ ? "done"
37
+ : uploadDone
38
+ ? "current"
39
+ : "todo";
40
+ const readyStatus: CartoonProductionStep["status"] = published
41
+ ? "done"
42
+ : ready
43
+ ? "current"
44
+ : "todo";
45
+
46
+ const steps: CartoonProductionStep[] = [
47
+ ...checklist.steps.filter((step) => step.key !== "publish"),
48
+ {
49
+ key: "assemble",
50
+ label: "Episode sequence prepared",
51
+ status: assembleStatus,
52
+ detail: null,
53
+ },
54
+ {
55
+ key: "ready",
56
+ label: published ? "Published to PlotLink" : "Ready to publish",
57
+ status: readyStatus,
58
+ detail: null,
59
+ },
60
+ ];
61
+
62
+ const activeStep =
63
+ steps.find((step) => step.status === "current")
64
+ ?? steps.find((step) => step.status === "todo")
65
+ ?? steps[steps.length - 1]
66
+ ?? null;
67
+ const completedCount = steps.filter((step) => step.status === "done").length;
68
+
69
+ return {
70
+ steps,
71
+ activeStep,
72
+ statusLabel: activeStep?.label ?? null,
73
+ completedCount,
74
+ totalCount: steps.length,
75
+ outstandingCount: steps.filter((step) => step.status !== "done").length,
76
+ };
77
+ }
@@ -1,7 +1,6 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useState, type ReactNode } from "react";
2
2
  import type { StoryProgress } from "@app-lib/story-progress";
3
- import type { CoachUiAction } from "@app-lib/cartoon-coach";
4
- import { WorkflowCoachView } from "./WorkflowCoach";
3
+ import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";
5
4
 
6
5
  export function storyInfoNextStep(progress: StoryProgress): string {
7
6
  if (progress.cover !== "present") {
@@ -34,39 +33,161 @@ export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null
34
33
  return coach?.episodeFile ?? null;
35
34
  }
36
35
 
37
- export function StoryInfoNextActionCard({
38
- progress,
39
- onOpenStoryInfo,
36
+ function CompactNextActionShell({
37
+ badge,
38
+ tone = "accent",
39
+ summary,
40
+ children,
41
+ note,
42
+ testId,
40
43
  }: {
41
- progress: StoryProgress;
42
- onOpenStoryInfo?: () => void;
44
+ badge: string;
45
+ tone?: "accent" | "complete";
46
+ summary: ReactNode;
47
+ children?: ReactNode;
48
+ note?: ReactNode;
49
+ testId: string;
43
50
  }) {
51
+ const shellTone =
52
+ tone === "complete"
53
+ ? "border-green-700/20 bg-green-950/5"
54
+ : "border-accent/30 bg-background/95";
55
+ const badgeTone =
56
+ tone === "complete"
57
+ ? "bg-green-700/10 text-green-700"
58
+ : "bg-accent/10 text-accent";
44
59
  return (
45
- <div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
46
- <div className="flex items-center gap-3">
60
+ <div
61
+ className={`border px-3 py-3 sm:px-4 ${shellTone}`}
62
+ data-testid={testId}
63
+ data-state={tone === "complete" ? "complete" : "active"}
64
+ >
65
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
47
66
  <div className="min-w-0 flex-1">
48
- <span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
49
- Story info
67
+ <span
68
+ className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] ${badgeTone}`}
69
+ >
70
+ {badge}
50
71
  </span>
51
- <p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
52
- <span className="font-semibold">Next: </span>
53
- <span>{storyInfoNextStep(progress)}</span>
54
- </p>
72
+ <p className="mt-1 text-sm text-foreground">{summary}</p>
73
+ {note ? <p className="mt-1 text-[11px] font-medium text-accent">{note}</p> : null}
55
74
  </div>
56
- <button
57
- type="button"
58
- onClick={onOpenStoryInfo}
59
- disabled={!onOpenStoryInfo}
60
- className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
61
- data-testid="story-info-next-action-btn"
62
- >
63
- Next Action
64
- </button>
75
+ {children ? (
76
+ <div className="flex w-full justify-end sm:w-auto sm:flex-shrink-0">
77
+ {children}
78
+ </div>
79
+ ) : null}
65
80
  </div>
66
81
  </div>
67
82
  );
68
83
  }
69
84
 
85
+ function NextActionButton({
86
+ onClick,
87
+ disabled,
88
+ testId,
89
+ }: {
90
+ onClick: () => void;
91
+ disabled?: boolean;
92
+ testId: string;
93
+ }) {
94
+ return (
95
+ <button
96
+ type="button"
97
+ onClick={onClick}
98
+ disabled={disabled}
99
+ className="w-full rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
100
+ data-testid={testId}
101
+ >
102
+ Next Action
103
+ </button>
104
+ );
105
+ }
106
+
107
+ function WorkflowCoachCompact({
108
+ coach,
109
+ onAction,
110
+ }: {
111
+ coach: CartoonCoach | null | undefined;
112
+ onAction: (action: CoachUiAction, episodeFile: string | null) => void;
113
+ }) {
114
+ const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
115
+ const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
116
+
117
+ if (coach === undefined) return null;
118
+ if (!coach) {
119
+ return (
120
+ <CompactNextActionShell
121
+ badge="Complete"
122
+ tone="complete"
123
+ summary="No next action available."
124
+ note="This workflow has no queued next step right now."
125
+ testId="cartoon-next-action"
126
+ />
127
+ );
128
+ }
129
+
130
+ const button =
131
+ coach.actionKind === "agent" && coach.prompt ? (
132
+ <NextActionButton
133
+ testId="workflow-coach-copy"
134
+ onClick={() => {
135
+ if (!coach.prompt) return;
136
+ const prompt = coach.prompt;
137
+ navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
138
+ }}
139
+ />
140
+ ) : coach.actionKind === "ui" && coach.uiAction ? (
141
+ <NextActionButton
142
+ testId="workflow-coach-do"
143
+ onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
144
+ />
145
+ ) : null;
146
+
147
+ return (
148
+ <CompactNextActionShell
149
+ badge={coach.stageLabel}
150
+ summary={(
151
+ <span data-testid="workflow-coach-action">
152
+ <span className="font-semibold">Next: </span>
153
+ <span>{coach.action}</span>
154
+ </span>
155
+ )}
156
+ note={copied ? "Prompt copied." : undefined}
157
+ testId="cartoon-next-action"
158
+ >
159
+ {button}
160
+ </CompactNextActionShell>
161
+ );
162
+ }
163
+
164
+ export function StoryInfoNextActionCard({
165
+ progress,
166
+ onOpenStoryInfo,
167
+ }: {
168
+ progress: StoryProgress;
169
+ onOpenStoryInfo?: () => void;
170
+ }) {
171
+ return (
172
+ <CompactNextActionShell
173
+ badge="Story info"
174
+ summary={(
175
+ <span data-testid="story-info-next-action">
176
+ <span className="font-semibold">Next: </span>
177
+ <span>{storyInfoNextStep(progress)}</span>
178
+ </span>
179
+ )}
180
+ testId="story-info-cta"
181
+ >
182
+ <NextActionButton
183
+ testId="story-info-next-action-btn"
184
+ onClick={() => onOpenStoryInfo?.()}
185
+ disabled={!onOpenStoryInfo}
186
+ />
187
+ </CompactNextActionShell>
188
+ );
189
+ }
190
+
70
191
  export function CartoonNextActionView({
71
192
  progress,
72
193
  onCoachAction,
@@ -80,31 +201,27 @@ export function CartoonNextActionView({
80
201
  if (activeKey === "story-info") {
81
202
  return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
82
203
  }
83
- return (
84
- <WorkflowCoachView
85
- coach={progress.coach ?? null}
86
- showEmptyState
87
- onAction={onCoachAction}
88
- />
89
- );
204
+ return <WorkflowCoachCompact coach={progress.coach ?? null} onAction={onCoachAction} />;
90
205
  }
91
206
 
92
207
  export function CartoonNextAction({
93
208
  storyName,
94
209
  authFetch,
210
+ fileName,
95
211
  refreshKey = 0,
96
212
  onCoachAction,
97
213
  onOpenStoryInfo,
98
214
  }: {
99
215
  storyName: string;
100
216
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
217
+ fileName?: string | null;
101
218
  refreshKey?: number;
102
219
  onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
103
220
  onOpenStoryInfo?: () => void;
104
221
  }) {
105
222
  const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);
106
223
 
107
- const targetKey = JSON.stringify([storyName, refreshKey]);
224
+ const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
108
225
  const [loadedKey, setLoadedKey] = useState<string | null>(null);
109
226
  if (loadedKey !== targetKey) {
110
227
  setProgress(undefined);
@@ -113,7 +230,8 @@ export function CartoonNextAction({
113
230
 
114
231
  useEffect(() => {
115
232
  let cancelled = false;
116
- authFetch(`/api/stories/${storyName}/progress`)
233
+ const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
234
+ authFetch(`/api/stories/${storyName}/progress${focus}`)
117
235
  .then((res) => (res.ok ? res.json() : null))
118
236
  .then((data: StoryProgress | null) => {
119
237
  if (!cancelled) setProgress(isValidProgress(data) ? data : null);
@@ -122,11 +240,11 @@ export function CartoonNextAction({
122
240
  if (!cancelled) setProgress(null);
123
241
  });
124
242
  return () => { cancelled = true; };
125
- }, [storyName, authFetch, refreshKey]);
243
+ }, [storyName, fileName, authFetch, refreshKey]);
126
244
 
127
245
  if (progress === undefined) return null;
128
246
  if (!progress) {
129
- return <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
247
+ return <WorkflowCoachCompact coach={null} onAction={onCoachAction} />;
130
248
  }
131
249
  return (
132
250
  <CartoonNextActionView
@@ -0,0 +1,142 @@
1
+ import { groupCartoonIssues, type CartoonChecklist } from "@app-lib/cartoon-readiness";
2
+ import { buildCartoonProductionStatus } from "@app-lib/cartoon-production-status";
3
+ import type { ReactNode } from "react";
4
+
5
+ const STATUS_MARK: Record<"done" | "current" | "todo", string> = {
6
+ done: "✓",
7
+ current: "▸",
8
+ todo: "○",
9
+ };
10
+
11
+ interface CartoonProductionStatusProps {
12
+ checklist: CartoonChecklist | null;
13
+ markdownReady?: boolean;
14
+ published?: boolean;
15
+ issues?: string[];
16
+ title?: string;
17
+ subtitle?: ReactNode;
18
+ action?: ReactNode;
19
+ rootTestId?: string;
20
+ detailsTestId?: string;
21
+ stepTestIdPrefix?: string;
22
+ issuesTestId?: string;
23
+ issueGroupTestIdPrefix?: string;
24
+ detailsLabel?: string;
25
+ summaryTestId?: string;
26
+ }
27
+
28
+ export function CartoonProductionStatus({
29
+ checklist,
30
+ markdownReady = false,
31
+ published = false,
32
+ issues = [],
33
+ title = "Episode production",
34
+ subtitle,
35
+ action,
36
+ rootTestId = "cartoon-production-status",
37
+ detailsTestId = "cartoon-production-details",
38
+ stepTestIdPrefix = "cartoon-production-step",
39
+ issuesTestId,
40
+ issueGroupTestIdPrefix,
41
+ detailsLabel,
42
+ summaryTestId,
43
+ }: CartoonProductionStatusProps) {
44
+ const production = buildCartoonProductionStatus({
45
+ checklist,
46
+ markdownReady,
47
+ published,
48
+ });
49
+ if (!production) return null;
50
+
51
+ const groups = groupCartoonIssues(issues);
52
+ const issuesCount = groups.reduce((sum, group) => sum + group.lines.length, 0);
53
+ const statusLabel = production.statusLabel ?? "Review cuts";
54
+ const progressLabel = `${production.completedCount} / ${production.totalCount} steps done`;
55
+ const detailsText = detailsLabel
56
+ ?? (production.outstandingCount === 0
57
+ ? "Production details"
58
+ : `${production.outstandingCount} step${production.outstandingCount === 1 ? "" : "s"} left`);
59
+
60
+ return (
61
+ <div
62
+ className="rounded-lg border border-border bg-background/80 px-3 py-2"
63
+ data-testid={rootTestId}
64
+ >
65
+ <div className="flex flex-wrap items-start gap-2 justify-between">
66
+ <div className="min-w-0 space-y-1">
67
+ <div className="flex flex-wrap items-center gap-1.5 text-[10px]">
68
+ <span className="font-medium text-foreground">{title}</span>
69
+ <span
70
+ className="rounded-full border border-accent/30 bg-accent/10 px-2 py-0.5 font-medium text-accent"
71
+ data-testid={summaryTestId}
72
+ >
73
+ Active: {statusLabel}
74
+ </span>
75
+ <span className="rounded-full border border-border bg-background px-2 py-0.5 text-muted">
76
+ {progressLabel}
77
+ </span>
78
+ {production.activeStep?.detail && (
79
+ <span className="text-muted">{production.activeStep.detail}</span>
80
+ )}
81
+ </div>
82
+ {subtitle ? (
83
+ <div className="text-[10px] text-muted">{subtitle}</div>
84
+ ) : null}
85
+ </div>
86
+ {action ? <div className="flex-shrink-0">{action}</div> : null}
87
+ </div>
88
+
89
+ <details className="mt-2" data-testid={detailsTestId}>
90
+ <summary className="cursor-pointer select-none text-[10px] text-muted hover:text-foreground">
91
+ {detailsText}
92
+ {issuesCount > 0 ? ` · ${issuesCount} blocker${issuesCount === 1 ? "" : "s"}` : ""}
93
+ </summary>
94
+
95
+ <div className="mt-1.5 space-y-1.5">
96
+ <ol className="flex flex-wrap gap-1.5">
97
+ {production.steps.map((step) => (
98
+ <li
99
+ key={step.key}
100
+ data-testid={`${stepTestIdPrefix}-${step.key}`}
101
+ data-status={step.status}
102
+ className={`flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] ${
103
+ step.status === "current"
104
+ ? "border-accent/40 bg-accent/10 text-accent"
105
+ : step.status === "done"
106
+ ? "border-border bg-background/70 text-foreground"
107
+ : "border-border/70 bg-background/40 text-muted"
108
+ }`}
109
+ >
110
+ <span aria-hidden>{STATUS_MARK[step.status]}</span>
111
+ <span>{step.label}</span>
112
+ {step.detail && <span className="text-muted">· {step.detail}</span>}
113
+ </li>
114
+ ))}
115
+ </ol>
116
+
117
+ {groups.length > 0 && (
118
+ <div
119
+ className="space-y-1.5"
120
+ data-testid={issuesTestId ?? `${stepTestIdPrefix}-issues`}
121
+ >
122
+ {groups.map((group) => (
123
+ <div
124
+ key={group.key}
125
+ data-testid={`${issueGroupTestIdPrefix ?? `${stepTestIdPrefix}-issue-group`}-${group.key}`}
126
+ className="text-[10px]"
127
+ >
128
+ <p className="font-medium text-amber-700">{group.title}</p>
129
+ <ul className="ml-3 list-disc text-muted">
130
+ {group.lines.map((line, i) => (
131
+ <li key={i}>{line}</li>
132
+ ))}
133
+ </ul>
134
+ </div>
135
+ ))}
136
+ </div>
137
+ )}
138
+ </div>
139
+ </details>
140
+ </div>
141
+ );
142
+ }
@@ -1,7 +1,8 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import type { StoryProgress, EpisodeProgress } from "@app-lib/story-progress";
3
- import { cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
3
+ import { cartoonChecklist, cartoonGenesisReadiness, classifyCartoonReadiness, groupCartoonIssues } from "@app-lib/cartoon-readiness";
4
4
  import type { Cut } from "@app-lib/cuts";
5
+ import { CartoonProductionStatus } from "./CartoonProductionStatus";
5
6
  import { derivePublishTitle, isRawFilenameTitle, hasExplicitEpisodeTitle } from "../lib/publish-helpers";
6
7
 
7
8
  interface CartoonPublishPageProps {
@@ -23,9 +24,6 @@ interface CartoonPublishPageProps {
23
24
  refreshKey?: number;
24
25
  }
25
26
 
26
- type CheckState = "done" | "todo";
27
- interface PublishCheck { label: string; status: CheckState; detail?: string | null }
28
-
29
27
  /**
30
28
  * Dedicated cartoon "Publish" workflow page (#449, spec §10).
31
29
  *
@@ -172,18 +170,8 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
172
170
  );
173
171
  }
174
172
 
175
- const c = active.cuts;
176
173
  const coverDone = progress.cover === "present";
177
- const checks: PublishCheck[] = [
178
- { label: "Opening text ready", status: "done" }, // the episode exists once it appears here
179
- { label: "Cut plan", status: c && c.total > 0 ? "done" : "todo", detail: c ? `${c.total} cut${c.total === 1 ? "" : "s"} planned` : "not started" },
180
- { label: "Clean images converted", status: c && c.needClean > 0 && c.withClean === c.needClean ? "done" : "todo", detail: c ? `${c.withClean} / ${c.needClean}` : null },
181
- { label: "Cuts lettered", status: c && c.total > 0 && c.withText === c.total ? "done" : "todo", detail: c ? `${c.withText} / ${c.total}` : null },
182
- { label: "Final images exported", status: c && c.total > 0 && c.exported === c.total ? "done" : "todo", detail: c ? `${c.exported} / ${c.total}` : null },
183
- { label: "Final images uploaded", status: c && c.total > 0 && c.uploaded === c.total ? "done" : "todo", detail: c ? `${c.uploaded} / ${c.total}` : null },
184
- { label: "Cover image", status: coverDone ? "done" : "todo", detail: coverDone ? null : "recommended before publishing" },
185
- { label: "Publish to PlotLink", status: active.published ? "done" : "todo" },
186
- ];
174
+ const checklist = cartoonChecklist({ cuts: activeCuts ?? [], published: active.published });
187
175
 
188
176
  const ready = active.state === "ready";
189
177
  const blocked = active.state === "blocked";
@@ -247,17 +235,38 @@ export function CartoonPublishPage({ storyName, authFetch, onOpenFile, onOpenSto
247
235
  return (
248
236
  <div className="h-full overflow-y-auto px-4 py-4" data-testid="cartoon-publish-page">
249
237
  <h2 className="text-base font-serif text-foreground">Publish {active.label}</h2>
250
- <p className="mt-0.5 text-[11px] text-muted">Finalize this episode: convert, letter, export, upload, then publish to PlotLink.</p>
238
+ <p className="mt-0.5 text-[11px] text-muted">Publish stays focused on readiness and blockers. Open production details only if you need the full step map.</p>
251
239
 
252
- <ul className="mt-3 flex flex-col gap-1.5 max-w-xl" data-testid="publish-checklist">
253
- {checks.map((ck, i) => (
254
- <li key={i} className="flex items-baseline gap-2 text-xs" data-testid="publish-check" data-status={ck.status}>
255
- <span className={`flex-shrink-0 ${ck.status === "done" ? "text-green-700" : "text-muted"}`} aria-hidden>{ck.status === "done" ? "✓" : "○"}</span>
256
- <span className={ck.status === "done" ? "text-foreground" : "text-muted"}>{ck.label}</span>
257
- {ck.detail && <span className="text-muted">· {ck.detail}</span>}
258
- </li>
259
- ))}
260
- </ul>
240
+ <div className="mt-3 max-w-xl" data-testid="publish-checklist">
241
+ <CartoonProductionStatus
242
+ checklist={checklist}
243
+ markdownReady={ready}
244
+ published={active.published}
245
+ title="Episode production"
246
+ subtitle={coverDone
247
+ ? "Cover and publish readiness are checked below."
248
+ : "Episode production is tracked here; cover readiness is checked below."}
249
+ rootTestId="publish-production-status"
250
+ detailsTestId="publish-production-details"
251
+ stepTestIdPrefix="publish-step"
252
+ />
253
+ <div className="mt-2 flex flex-wrap gap-1.5 text-[10px]">
254
+ <span
255
+ className={`rounded-full border px-2 py-0.5 ${coverDone ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}
256
+ data-testid="publish-cover-status"
257
+ >
258
+ Cover image: {coverDone ? "Ready" : "Missing"}
259
+ </span>
260
+ {isGenesisActive && (
261
+ <span
262
+ className={`rounded-full border px-2 py-0.5 ${metaReady ? "border-green-700/30 bg-green-700/10 text-green-700" : "border-border bg-background text-muted"}`}
263
+ data-testid="publish-metadata-status"
264
+ >
265
+ Story info: {metaReady ? "Ready" : "Set genre & language"}
266
+ </span>
267
+ )}
268
+ </div>
269
+ </div>
261
270
 
262
271
  {/* Migrated episode diagnostics (#461): the publish title (#358), Genesis
263
272
  prologue readiness (#359), and grouped publish issues (#360) that used