plotlink-ows 1.2.94 → 1.2.96

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.
@@ -1,13 +1,15 @@
1
- import { useEffect, useState, type ReactNode } from "react";
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 { WorkflowCoachView } from "./WorkflowCoach";
4
+ import { cartoonWorkflowActiveKey, CartoonNextActionView } from "./CartoonNextAction";
5
5
 
6
6
  interface StoryProgressPanelProps {
7
7
  storyName: string;
8
8
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
9
9
  /** Open a file from the map (the workflow steps link to their file). */
10
10
  onOpenFile: (storyName: string, file: string) => void;
11
+ /** Open the Story Info workflow page when metadata/cover is the next gate. */
12
+ onOpenStoryInfo?: () => void;
11
13
  /** Bumped by the parent to force a refresh (e.g. after a publish). */
12
14
  refreshKey?: number;
13
15
  }
@@ -18,15 +20,13 @@ interface StoryProgressPanelProps {
18
20
  * For CARTOON stories this is the writer's main production dashboard: a vertical
19
21
  * workflow map of numbered sections (Define Story Info → Story Whitepaper →
20
22
  * Genesis / Episode 1 → Episode 2 …), each with a checkbox checklist and a clear
21
- * status. The single next-action CTA is rendered inside the section it belongs to
22
- * (never a duplicated global bar), so a first-time creator understands the whole
23
- * webtoon workflow and where they are in it without reading terminal output or
24
- * raw file names.
23
+ * status. The single next-action CTA stays persistent above the map, while the
24
+ * current section still marks where that action belongs.
25
25
  *
26
26
  * FICTION keeps the simpler original layout — metadata, setup steps, a chapter
27
27
  * list — and is completely unaffected by the cartoon redesign.
28
28
  */
29
- export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKey = 0 }: StoryProgressPanelProps) {
29
+ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, onOpenStoryInfo, refreshKey = 0 }: StoryProgressPanelProps) {
30
30
  const [progress, setProgress] = useState<StoryProgress | null>(null);
31
31
  const [loading, setLoading] = useState(true);
32
32
 
@@ -56,7 +56,7 @@ export function StoryProgressPanel({ storyName, authFetch, onOpenFile, refreshKe
56
56
  }
57
57
 
58
58
  return progress.contentType === "cartoon"
59
- ? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} />
59
+ ? <CartoonWorkflowMap progress={progress} storyName={storyName} onOpenFile={onOpenFile} onOpenStoryInfo={onOpenStoryInfo} />
60
60
  : <FictionProgressView progress={progress} storyName={storyName} onOpenFile={onOpenFile} />;
61
61
  }
62
62
 
@@ -128,13 +128,11 @@ function ChecklistRow({ item }: { item: ChecklistItem }) {
128
128
  }
129
129
 
130
130
  /**
131
- * One numbered workflow section: a status bullet + title + status badge, a nested
132
- * checklist, and only for the active section the single next-action CTA. The
133
- * header navigates to `openFile` when one is provided (sections whose dedicated
134
- * page does not exist yet, e.g. Story Info, render a plain header).
131
+ * One numbered workflow section: a status bullet + title + status badge, plus a
132
+ * nested checklist. The header navigates to `openFile` when one is provided.
135
133
  */
136
134
  function Section({
137
- index, title, status, items, fileName, openFile, cta,
135
+ index, title, status, items, fileName, openFile,
138
136
  }: {
139
137
  index: number;
140
138
  title: string;
@@ -144,8 +142,6 @@ function Section({
144
142
  fileName?: string | null;
145
143
  /** Called to open the section's underlying file, or undefined for no navigation. */
146
144
  openFile?: () => void;
147
- /** The single CTA node, rendered under this section when it is the active one. */
148
- cta?: ReactNode;
149
145
  }) {
150
146
  const heading = (
151
147
  <div className="flex items-center gap-2 min-w-0">
@@ -163,7 +159,6 @@ function Section({
163
159
  <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
164
160
  {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
165
161
  </div>
166
- {cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
167
162
  </div>
168
163
  );
169
164
  }
@@ -207,89 +202,32 @@ const GENESIS_STUB: EpisodeProgress = {
207
202
  state: "placeholder", summary: "", published: false, checklist: [], cuts: null,
208
203
  };
209
204
 
210
- /** The single Story-Info next step, when cover/metadata is the active gate. */
211
- function storyInfoNextStep(progress: StoryProgress): string {
212
- if (progress.cover !== "present") {
213
- return progress.cover === "invalid"
214
- ? "Replace the cover image — it must be a valid WebP or JPEG."
215
- : "Add a cover image before publishing.";
216
- }
217
- const missing: string[] = [];
218
- if (!progress.metadata.language) missing.push("language");
219
- if (!progress.metadata.genre) missing.push("genre");
220
- if (!progress.metadata.title) missing.push("title");
221
- return `Add the story ${missing.join(" and ") || "details"} before publishing.`;
222
- }
223
-
224
205
  function CartoonWorkflowMap({
225
- progress, storyName, onOpenFile,
226
- }: { progress: StoryProgress; storyName: string; onOpenFile: (storyName: string, file: string) => void }) {
227
- const coach = progress.coach ?? null;
206
+ progress, storyName, onOpenFile, onOpenStoryInfo,
207
+ }: {
208
+ progress: StoryProgress;
209
+ storyName: string;
210
+ onOpenFile: (storyName: string, file: string) => void;
211
+ onOpenStoryInfo?: () => void;
212
+ }) {
228
213
  const m = progress.metadata;
229
214
  const hasStructure = progress.setup.hasStructure;
230
- const hasGenesis = progress.setup.hasGenesis;
231
215
  const coverDone = progress.cover === "present";
232
- // Required publish metadata (title/language/genre) still hard-gates the active
233
- // step. A missing COVER is a publish-readiness recommendation, NOT the primary
234
- // step (#462) — it's kept out of the active-gate decision while an episode is
235
- // mid-production, so the cut/lettering production CTA leads instead.
236
216
  const metadataIncomplete = !m.title || !m.language || !m.genre;
237
217
  const storyInfoIncomplete = metadataIncomplete || !coverDone;
238
- // The active (first unpublished) episode and whether it still has production
239
- // work to do (anything short of publish-ready).
240
- const activeEp = progress.episodes.find((e) => !e.published) ?? null;
241
- const productionPending = !!activeEp && activeEp.state !== "ready";
242
-
243
- // The SINGLE active gate, chosen in the same order buildStoryProgress derives
244
- // its next step (structure genesis → story info/cover → active episode), so
245
- // the one CTA always matches the story-level next action and lands in its own
246
- // section. `deriveCartoonCoach` agrees on every gate EXCEPT story info (it
247
- // skips cover/metadata), so we own that gate here; the coach drives the rest.
248
- // Crucially, every gate maps to a section that is ALWAYS rendered (Whitepaper,
249
- // the always-present Genesis section, an episode, or the trailing block), so
250
- // the CTA can never fall through the cracks (#444 review: it vanished when the
251
- // bible was written but Genesis wasn't).
252
- let activeKey: string | null;
253
- if (!hasStructure) activeKey = "whitepaper";
254
- else if (!hasGenesis) activeKey = "genesis.md";
255
- else if (metadataIncomplete) activeKey = "story-info";
256
- // #462: a mid-production episode leads over a missing cover — the cut/lettering
257
- // production CTA is the primary step. A missing cover only becomes the active
258
- // step once the active episode's production is complete (no work pending),
259
- // where it reads as the publish-readiness recommendation.
260
- else if (productionPending && coach?.episodeFile) activeKey = coach.episodeFile;
261
- else if (!coverDone) activeKey = "story-info";
262
- else activeKey = coach?.episodeFile ?? null;
263
-
264
- // The coach-driven CTA (setup prompts + episode actions), reused from the
265
- // tested coach view so routing stays byte-identical to the old overview.
266
- const coachCta = coach ? (
267
- <WorkflowCoachView
268
- coach={coach}
269
- onAction={(action, episodeFile) => {
218
+ const activeKey = cartoonWorkflowActiveKey(progress);
219
+
220
+ const topNextAction = (
221
+ <CartoonNextActionView
222
+ progress={progress}
223
+ onOpenStoryInfo={onOpenStoryInfo}
224
+ onCoachAction={(action, episodeFile) => {
270
225
  if (action === "view-progress") return; // already here
271
226
  if (episodeFile) onOpenFile(storyName, episodeFile);
272
227
  }}
273
228
  />
274
- ) : null;
275
-
276
- // Story Info owns the CTA when metadata/cover is the gate. There is no
277
- // dedicated Story Info page yet (#439/§4) and the coach carries no cover
278
- // action, so this is an informational next-step line (not a route) — still the
279
- // one and only CTA, placed in the relevant section.
280
- const storyInfoCta = (
281
- <div className="flex items-center gap-2 px-3 py-2 bg-accent/5 border border-accent/30 rounded text-xs" data-testid="story-info-cta">
282
- <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0">Story info</span>
283
- <span className="min-w-0 flex-1 text-foreground"><span className="text-muted">Next: </span><span className="font-medium">{storyInfoNextStep(progress)}</span></span>
284
- </div>
285
229
  );
286
230
 
287
- // Return the one CTA for a section, or null — guarantees a single visible CTA.
288
- const ctaFor = (key: string): ReactNode => {
289
- if (key !== activeKey) return null;
290
- return activeKey === "story-info" ? storyInfoCta : coachCta;
291
- };
292
-
293
231
  const infoItems: ChecklistItem[] = [
294
232
  { label: "Public title", status: m.title ? "done" : "todo", detail: m.title ?? null },
295
233
  { label: "Language", status: m.language ? "done" : "todo", detail: m.language ?? null },
@@ -307,6 +245,9 @@ function CartoonWorkflowMap({
307
245
  return (
308
246
  <div className="h-full overflow-y-auto" data-testid="story-progress-panel">
309
247
  <ProgressHeader progress={progress} />
248
+ <div className="border-b border-border" data-testid="persistent-next-action">
249
+ {topNextAction}
250
+ </div>
310
251
  <p className="px-4 pt-3 pb-1 text-[11px] font-medium text-muted uppercase tracking-wider">Production Progress</p>
311
252
 
312
253
  <Section
@@ -314,7 +255,6 @@ function CartoonWorkflowMap({
314
255
  title="Define Story Info"
315
256
  status={infoStatus}
316
257
  items={infoItems}
317
- cta={ctaFor("story-info") ?? undefined}
318
258
  />
319
259
 
320
260
  <Section
@@ -324,7 +264,6 @@ function CartoonWorkflowMap({
324
264
  fileName="structure.md"
325
265
  openFile={hasStructure ? () => onOpenFile(storyName, "structure.md") : undefined}
326
266
  items={[{ label: "Planning document", status: hasStructure ? "done" : "todo", detail: hasStructure ? null : "Not written yet" }]}
327
- cta={ctaFor("whitepaper") ?? undefined}
328
267
  />
329
268
 
330
269
  {/* Genesis / Episode 1 — always shown (a not-started stub before it's
@@ -332,29 +271,22 @@ function CartoonWorkflowMap({
332
271
  {genesisEp ? (
333
272
  <EpisodeSection
334
273
  index={++idx} ep={genesisEp} isActive={activeKey === genesisEp.file}
335
- storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(genesisEp.file) ?? undefined}
274
+ storyName={storyName} onOpenFile={onOpenFile}
336
275
  />
337
276
  ) : (
338
277
  <EpisodeSection
339
278
  index={++idx} ep={GENESIS_STUB} isActive={activeKey === "genesis.md"} openingDone={false} canOpen={false}
340
- storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor("genesis.md") ?? undefined}
279
+ storyName={storyName} onOpenFile={onOpenFile}
341
280
  />
342
281
  )}
343
282
 
344
283
  {plotEps.map((ep) => (
345
284
  <EpisodeSection
346
285
  key={ep.file} index={++idx} ep={ep} isActive={activeKey === ep.file}
347
- storyName={storyName} onOpenFile={onOpenFile} cta={ctaFor(ep.file) ?? undefined}
286
+ storyName={storyName} onOpenFile={onOpenFile}
348
287
  />
349
288
  ))}
350
289
 
351
- {/* All episodes published with nothing queued → a trailing "start next" CTA. */}
352
- {activeKey === null && coachCta && (
353
- <div className="px-4 py-2.5 border-b border-border" data-testid="workflow-next-episode">
354
- <div className="ml-1" data-testid="section-cta">{coachCta}</div>
355
- </div>
356
- )}
357
-
358
290
  <div className="px-4 py-2 text-[11px] text-muted flex flex-wrap gap-x-3" data-testid="progress-summary">
359
291
  <span>{progress.summary.published} published</span>
360
292
  <span>{progress.summary.readyToPublish} ready</span>
@@ -367,14 +299,13 @@ function CartoonWorkflowMap({
367
299
 
368
300
  /** A `progress-episode-<file>` section, kept testid-stable so clicking it opens the file. */
369
301
  function EpisodeSection({
370
- index, ep, isActive, storyName, onOpenFile, cta, openingDone = true, canOpen = true,
302
+ index, ep, isActive, storyName, onOpenFile, openingDone = true, canOpen = true,
371
303
  }: {
372
304
  index: number;
373
305
  ep: EpisodeProgress;
374
306
  isActive: boolean;
375
307
  storyName: string;
376
308
  onOpenFile: (storyName: string, file: string) => void;
377
- cta?: ReactNode;
378
309
  /** Whether the genesis opening text is already written (false for the stub). */
379
310
  openingDone?: boolean;
380
311
  /** Whether the header navigates to the file (false for the not-yet-written stub). */
@@ -408,7 +339,6 @@ function EpisodeSection({
408
339
  <div className="mt-1.5 ml-1 flex flex-col gap-1 border-l border-border pl-3">
409
340
  {items.map((it, i) => <ChecklistRow key={i} item={it} />)}
410
341
  </div>
411
- {cta && <div className="mt-2 ml-1" data-testid="section-cta">{cta}</div>}
412
342
  </div>
413
343
  );
414
344
  }
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect } from "react";
1
+ import React, { useCallback, useState, useEffect } from "react";
2
2
 
3
3
  const API_BASE = "http://localhost:7777";
4
4
 
@@ -7,29 +7,45 @@ interface WalletInfo {
7
7
  walletId?: string;
8
8
  name?: string;
9
9
  address?: string;
10
+ activeWallet?: WalletChoice;
11
+ wallets?: WalletChoice[];
12
+ selectionRequired?: boolean;
10
13
  ethBalance?: string;
11
14
  usdcBalance?: string;
12
15
  plotBalance?: string;
13
16
  error?: string;
14
17
  }
15
18
 
19
+ interface WalletChoice {
20
+ walletId?: string;
21
+ name: string;
22
+ address?: string;
23
+ normalizedAddress?: string;
24
+ source: "ows";
25
+ label: string;
26
+ recognized: boolean;
27
+ active: boolean;
28
+ }
29
+
16
30
  export function WalletCard({ token }: { token: string }) {
17
31
  const [wallet, setWallet] = useState<WalletInfo | null>(null);
18
32
  const [creating, setCreating] = useState(false);
33
+ const [switching, setSwitching] = useState<string | null>(null);
19
34
  const [copied, setCopied] = useState(false);
20
35
  const [error, setError] = useState<string | null>(null);
21
36
 
22
- const authFetch = (url: string, opts?: RequestInit) =>
23
- fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } });
37
+ const authFetch = useCallback((url: string, opts?: RequestInit) =>
38
+ fetch(url, { ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${token}`, "Content-Type": "application/json" } }),
39
+ [token]);
24
40
 
25
- const loadWallet = () => {
41
+ const loadWallet = useCallback(() => {
26
42
  authFetch(`${API_BASE}/api/wallet`)
27
43
  .then((r) => r.json())
28
44
  .then((data) => setWallet(data))
29
45
  .catch(() => setWallet({ exists: false, error: "Failed to load wallet" }));
30
- };
46
+ }, [authFetch]);
31
47
 
32
- useEffect(() => { loadWallet(); }, []);
48
+ useEffect(() => { loadWallet(); }, [loadWallet]);
33
49
 
34
50
  const handleCreate = async () => {
35
51
  setCreating(true);
@@ -45,6 +61,27 @@ export function WalletCard({ token }: { token: string }) {
45
61
  setCreating(false);
46
62
  };
47
63
 
64
+ const handleSwitch = async (choice: WalletChoice) => {
65
+ setSwitching(choice.walletId || choice.name);
66
+ setError(null);
67
+ try {
68
+ const res = await authFetch(`${API_BASE}/api/wallet/active`, {
69
+ method: "POST",
70
+ body: JSON.stringify({
71
+ walletId: choice.walletId,
72
+ name: choice.name,
73
+ address: choice.normalizedAddress || choice.address,
74
+ }),
75
+ });
76
+ const data = await res.json();
77
+ if (!res.ok) throw new Error(data.error || "Wallet switch failed");
78
+ loadWallet();
79
+ } catch (err: unknown) {
80
+ setError(err instanceof Error ? err.message : "Failed to switch wallet");
81
+ }
82
+ setSwitching(null);
83
+ };
84
+
48
85
  const copyAddress = () => {
49
86
  if (wallet?.address) {
50
87
  navigator.clipboard.writeText(wallet.address);
@@ -63,7 +100,7 @@ export function WalletCard({ token }: { token: string }) {
63
100
 
64
101
  {wallet && !wallet.exists && (
65
102
  <div className="space-y-3">
66
- <p className="text-muted text-xs">No wallet created yet. Create one to enable autonomous transactions.</p>
103
+ <p className="text-muted text-xs">{wallet.error || "No wallet created yet. Create one to enable autonomous transactions."}</p>
67
104
  {error && <p className="text-error text-xs">{error}</p>}
68
105
  <button
69
106
  onClick={handleCreate}
@@ -75,15 +112,44 @@ export function WalletCard({ token }: { token: string }) {
75
112
  </div>
76
113
  )}
77
114
 
115
+ {wallet?.selectionRequired && wallet.wallets && wallet.wallets.length > 0 && (
116
+ <div className="mb-4 space-y-3 rounded border border-amber-600/30 bg-amber-950/10 p-3">
117
+ <p className="text-xs text-amber-700">Multiple OWS wallets found. Select the wallet OWS should use for publishing and signing.</p>
118
+ {wallet.wallets.map((choice) => (
119
+ <div key={choice.walletId || choice.name} className="border-border flex items-center justify-between gap-3 rounded border p-2">
120
+ <div className="min-w-0">
121
+ <p className="text-foreground truncate text-xs font-medium">{choice.name}</p>
122
+ <p className="text-muted truncate text-[10px] font-mono">{choice.address || "No EVM address"}</p>
123
+ </div>
124
+ <button
125
+ onClick={() => handleSwitch(choice)}
126
+ disabled={!choice.address || switching === (choice.walletId || choice.name)}
127
+ className="border-accent text-accent hover:bg-accent/10 disabled:opacity-40 rounded border px-2 py-1 text-[10px] font-medium transition-colors"
128
+ >
129
+ {switching === (choice.walletId || choice.name) ? "switching..." : "use"}
130
+ </button>
131
+ </div>
132
+ ))}
133
+ {error && <p className="text-error text-xs">{error}</p>}
134
+ </div>
135
+ )}
136
+
78
137
  {wallet && wallet.exists && wallet.address && (
79
138
  <div className="space-y-3">
80
139
  <div className="flex items-center justify-between">
81
- <span className="text-muted text-[10px] uppercase tracking-wider">Address (Base)</span>
140
+ <span className="text-muted text-[10px] uppercase tracking-wider">Active Wallet (Base)</span>
82
141
  <span className={`rounded border px-1.5 py-0.5 text-[9px] ${wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "border-accent/30 text-accent" : "border-accent-dim/30 text-accent-dim"}`}>
83
142
  {wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "active" : "no balance"}
84
143
  </span>
85
144
  </div>
86
145
 
146
+ {wallet.name && (
147
+ <div className="flex justify-between text-xs">
148
+ <span className="text-muted">Name</span>
149
+ <span className="text-foreground truncate pl-3 font-mono text-[10px]">{wallet.name}</span>
150
+ </div>
151
+ )}
152
+
87
153
  <div className="flex items-center gap-2">
88
154
  <code className="text-foreground bg-surface rounded px-2 py-1 text-xs font-mono">{truncate(wallet.address)}</code>
89
155
  <button onClick={copyAddress} className="text-muted hover:text-accent text-xs transition-colors">
@@ -110,6 +176,32 @@ export function WalletCard({ token }: { token: string }) {
110
176
  </div>
111
177
  </div>
112
178
 
179
+ {wallet.wallets && wallet.wallets.length > 1 && (
180
+ <div className="border-border space-y-2 border-t pt-3">
181
+ <p className="text-muted text-[10px] font-medium uppercase tracking-wider">Switch Wallet</p>
182
+ {wallet.wallets.map((choice) => (
183
+ <div key={choice.walletId || choice.name} className="flex items-center justify-between gap-3 text-xs">
184
+ <div className="min-w-0">
185
+ <p className={choice.active ? "text-accent truncate font-medium" : "text-foreground truncate"}>
186
+ {choice.name}{choice.active ? " (active)" : ""}
187
+ </p>
188
+ <p className="text-muted truncate text-[10px] font-mono">{choice.address || "No EVM address"}</p>
189
+ </div>
190
+ {!choice.active && (
191
+ <button
192
+ onClick={() => handleSwitch(choice)}
193
+ disabled={!choice.address || switching === (choice.walletId || choice.name)}
194
+ className="border-border text-muted hover:border-accent hover:text-accent disabled:opacity-40 rounded border px-2 py-1 text-[10px] transition-colors"
195
+ >
196
+ {switching === (choice.walletId || choice.name) ? "..." : "use"}
197
+ </button>
198
+ )}
199
+ </div>
200
+ ))}
201
+ {error && <p className="text-error text-xs">{error}</p>}
202
+ </div>
203
+ )}
204
+
113
205
  {/* Fund wallet */}
114
206
  <div className="border-border border-t pt-3">
115
207
  <p className="text-muted mb-2 text-[10px] font-medium uppercase tracking-wider">Fund Wallet</p>
@@ -118,6 +210,16 @@ export function WalletCard({ token }: { token: string }) {
118
210
  </div>
119
211
  </div>
120
212
  )}
213
+
214
+ {wallet?.exists && (
215
+ <button
216
+ onClick={handleCreate}
217
+ disabled={creating}
218
+ className="border-border text-muted hover:border-accent hover:text-accent disabled:opacity-40 mt-4 rounded border px-3 py-1.5 text-[10px] font-medium transition-colors"
219
+ >
220
+ {creating ? "creating..." : "create another wallet"}
221
+ </button>
222
+ )}
121
223
  </div>
122
224
  );
123
225
  }
@@ -24,53 +24,80 @@ interface WorkflowCoachViewProps {
24
24
  /** Run an app-driven step (the agent steps copy a prompt instead). */
25
25
  onAction: (action: CoachUiAction, episodeFile: string | null) => void;
26
26
  className?: string;
27
+ /** Show a clear completed state instead of disappearing when no coach exists. */
28
+ showEmptyState?: boolean;
27
29
  }
28
30
 
29
- export function WorkflowCoachView({ coach, onAction, className = "" }: WorkflowCoachViewProps) {
31
+ export function WorkflowCoachView({ coach, onAction, className = "", showEmptyState = false }: WorkflowCoachViewProps) {
30
32
  // Track the prompt that was copied rather than a bare boolean, so the "Copied!"
31
33
  // confirmation derives to false the moment the coach (and its prompt) changes —
32
34
  // no reset effect, no stale confirmation under a new stage.
33
35
  const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
34
36
  const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
35
37
 
36
- if (!coach) return null;
38
+ if (coach === undefined) return null;
39
+ if (!coach) {
40
+ if (!showEmptyState) return null;
41
+ return (
42
+ <div
43
+ className={`m-3 rounded-lg border border-green-700/25 bg-green-950/5 px-4 py-3 ${className}`}
44
+ data-testid="workflow-coach"
45
+ data-state="complete"
46
+ >
47
+ <div className="flex items-start gap-3">
48
+ <span className="rounded-full bg-green-700/10 px-2 py-1 text-[10px] font-bold uppercase tracking-[0.16em] text-green-700">
49
+ Complete
50
+ </span>
51
+ <div className="min-w-0 flex-1">
52
+ <p className="text-sm font-semibold text-foreground">No next action available</p>
53
+ <p className="mt-0.5 text-xs text-muted">This workflow has no queued next step right now.</p>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
37
59
 
38
60
  return (
39
61
  <div
40
- className={`flex items-center gap-2 px-3 py-2 bg-accent/5 border-b border-accent/30 text-xs ${className}`}
62
+ className={`m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm ${className}`}
41
63
  data-testid="workflow-coach"
42
64
  data-stage={coach.stageLabel}
43
65
  data-action-kind={coach.actionKind}
44
66
  data-ui-action={coach.uiAction ?? ""}
45
67
  >
46
- <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0" data-testid="workflow-coach-stage">
47
- {coach.stageLabel}
48
- </span>
49
- <span className="min-w-0 flex-1 text-foreground" data-testid="workflow-coach-action">
50
- <span className="text-muted">Next: </span>
51
- <span className="font-medium">{coach.action}</span>
52
- </span>
53
- {coach.actionKind === "agent" && coach.prompt ? (
54
- <button
55
- onClick={() => {
56
- if (!coach.prompt) return;
57
- const prompt = coach.prompt;
58
- navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
59
- }}
60
- data-testid="workflow-coach-copy"
61
- className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
62
- >
63
- {copied ? "Copied!" : "Copy prompt"}
64
- </button>
65
- ) : coach.actionKind === "ui" && coach.uiAction ? (
66
- <button
67
- onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
68
- data-testid="workflow-coach-do"
69
- className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
70
- >
71
- {coach.action}
72
- </button>
73
- ) : null}
68
+ <div className="flex items-center gap-3">
69
+ <div className="min-w-0 flex-1">
70
+ <span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent" data-testid="workflow-coach-stage">
71
+ {coach.stageLabel}
72
+ </span>
73
+ <p className="mt-1 text-sm text-foreground" data-testid="workflow-coach-action">
74
+ <span className="font-semibold">Next: </span>
75
+ <span>{coach.action}</span>
76
+ </p>
77
+ {copied && <p className="mt-1 text-[11px] font-medium text-accent">Prompt copied.</p>}
78
+ </div>
79
+ {coach.actionKind === "agent" && coach.prompt ? (
80
+ <button
81
+ onClick={() => {
82
+ if (!coach.prompt) return;
83
+ const prompt = coach.prompt;
84
+ navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
85
+ }}
86
+ data-testid="workflow-coach-copy"
87
+ 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"
88
+ >
89
+ Next Action
90
+ </button>
91
+ ) : coach.actionKind === "ui" && coach.uiAction ? (
92
+ <button
93
+ onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
94
+ data-testid="workflow-coach-do"
95
+ 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"
96
+ >
97
+ Next Action
98
+ </button>
99
+ ) : null}
100
+ </div>
74
101
  </div>
75
102
  );
76
103
  }
@@ -83,6 +110,7 @@ interface WorkflowCoachProps {
83
110
  /** Bumped by the parent to reload after a state change (cut edit / publish). */
84
111
  refreshKey?: number;
85
112
  onAction: (action: CoachUiAction, episodeFile: string | null) => void;
113
+ showEmptyState?: boolean;
86
114
  }
87
115
 
88
116
  /**
@@ -92,8 +120,8 @@ interface WorkflowCoachProps {
92
120
  * previous file's coach can never linger under a different file when the new
93
121
  * request fails or 404s (the stale-state-on-error class flagged on #420/#427).
94
122
  */
95
- export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction }: WorkflowCoachProps) {
96
- const [coach, setCoach] = useState<CartoonCoach | null>(null);
123
+ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction, showEmptyState = false }: WorkflowCoachProps) {
124
+ const [coach, setCoach] = useState<CartoonCoach | null | undefined>(undefined);
97
125
 
98
126
  // Reset the coach the instant the target changes (file switch / refresh),
99
127
  // during render — React's recommended way to reset state on a changing input.
@@ -108,7 +136,7 @@ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0,
108
136
  const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
109
137
  const [loadedKey, setLoadedKey] = useState<string | null>(null);
110
138
  if (loadedKey !== targetKey) {
111
- setCoach(null);
139
+ setCoach(undefined);
112
140
  setLoadedKey(targetKey);
113
141
  }
114
142
 
@@ -124,5 +152,5 @@ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0,
124
152
  return () => { cancelled = true; };
125
153
  }, [storyName, fileName, authFetch, refreshKey]);
126
154
 
127
- return <WorkflowCoachView coach={coach} onAction={onAction} />;
155
+ return <WorkflowCoachView coach={coach} onAction={onAction} showEmptyState={showEmptyState} />;
128
156
  }
@@ -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-BAZGwVwj.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-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};