plotlink-ows 1.0.33 → 1.2.94

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.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
package/README.md CHANGED
@@ -256,9 +256,13 @@ PlotLink supports both human writers and AI agent writers via [ERC-8004](https:/
256
256
 
257
257
  ## Development
258
258
 
259
+ Use Node 20 with npm 10. The CI pipeline runs this combination, and newer npm
260
+ majors can rewrite optional peer dependency entries in `package-lock.json`.
261
+
259
262
  ```bash
260
263
  git clone https://github.com/realproject7/plotlink-ows.git
261
264
  cd plotlink-ows
265
+ nvm use
262
266
  npm install
263
267
  npm run app:dev # Start local writer app (Hono + Vite dev)
264
268
  npm run app:build # Build frontend for production
@@ -0,0 +1,85 @@
1
+ import type { AgentProvider } from "../routes/stories";
2
+
3
+ /**
4
+ * Pure construction of a terminal agent's CLI invocation as argv.
5
+ *
6
+ * This module performs NO fs/pty/env/network access so command building is
7
+ * fully unit-testable. It returns `{ command, args }` (the binary + argv).
8
+ *
9
+ * Claude (KEEP BYTE-IDENTICAL with the legacy inline behavior):
10
+ * - fresh: `claude --session-id <newSessionId>`
11
+ * - resume: `claude --resume <sessionId>`
12
+ * - bypass: append `--dangerously-skip-permissions`
13
+ *
14
+ * Codex (net-new). Both fresh AND resume carry the story cwd (`--cd`) and the
15
+ * `image_generation` capability — a resumed cartoon session needs the same
16
+ * working directory and image-gen feature as a fresh one (see #265):
17
+ * - fresh: `codex --enable image_generation --cd <storyDir>`
18
+ * - resume: `codex resume <sessionId> --enable image_generation --cd <storyDir>`
19
+ * (subcommand style) when an id is stored, otherwise
20
+ * `codex resume --last --enable image_generation --cd <storyDir>`.
21
+ * NEVER `--resume <id>`.
22
+ * - bypass: append `--dangerously-bypass-approvals-and-sandbox`
23
+ *
24
+ * Claude-only and Codex-only flags are never mixed across providers.
25
+ */
26
+ export type AgentMode = "normal" | "bypass";
27
+
28
+ export interface BuildAgentCommandOptions {
29
+ provider: AgentProvider;
30
+ mode: AgentMode;
31
+ resume: boolean;
32
+ /** Stored resume id (Claude UUID / Codex session id), or null. */
33
+ sessionId: string | null;
34
+ /** Freshly generated UUID used for a brand-new Claude session. */
35
+ newSessionId: string;
36
+ /** Absolute story working directory (used by Codex `--cd`). */
37
+ storyDir: string;
38
+ }
39
+
40
+ export interface AgentCommand {
41
+ command: string;
42
+ args: string[];
43
+ }
44
+
45
+ export function buildAgentCommand(opts: BuildAgentCommandOptions): AgentCommand {
46
+ if (opts.provider === "codex") {
47
+ return buildCodexCommand(opts);
48
+ }
49
+ return buildClaudeArgs(opts);
50
+ }
51
+
52
+ function buildClaudeArgs(opts: BuildAgentCommandOptions): AgentCommand {
53
+ const args: string[] = [];
54
+ // Resume only when requested AND a stored id exists; else fresh session.
55
+ if (opts.resume && opts.sessionId) {
56
+ args.push("--resume", opts.sessionId);
57
+ } else {
58
+ args.push("--session-id", opts.newSessionId);
59
+ }
60
+ if (opts.mode === "bypass") {
61
+ args.push("--dangerously-skip-permissions");
62
+ }
63
+ return { command: "claude", args };
64
+ }
65
+
66
+ function buildCodexCommand(opts: BuildAgentCommandOptions): AgentCommand {
67
+ const args: string[] = [];
68
+ if (opts.resume) {
69
+ // Codex resume is a subcommand (never `--resume <id>`). The subcommand and
70
+ // its target come first, then the capability/cwd flags.
71
+ if (opts.sessionId) {
72
+ args.push("resume", opts.sessionId);
73
+ } else {
74
+ args.push("resume", "--last");
75
+ }
76
+ }
77
+ // Both fresh and resume need the image-generation capability and the story
78
+ // cwd so a resumed cartoon session lands in the right directory with image
79
+ // generation enabled (not just whatever global session `--last` would pick).
80
+ args.push("--enable", "image_generation", "--cd", opts.storyDir);
81
+ if (opts.mode === "bypass") {
82
+ args.push("--dangerously-bypass-approvals-and-sandbox");
83
+ }
84
+ return { command: "codex", args };
85
+ }
@@ -0,0 +1,133 @@
1
+ // Agent (CLI) readiness detection.
2
+ //
3
+ // This module is pure: every shell interaction goes through an injected `run`
4
+ // function so it can be unit-tested without spawning processes. The route layer
5
+ // supplies a real `run` that shells out via the user's login shell, and stamps
6
+ // the `checkedAt` timestamp (kept OUT of this pure function so it stays
7
+ // deterministic/testable — no clocks here).
8
+ //
9
+ // Codex image-generation detection parses the structured `codex features list`
10
+ // output rather than guessing from generic `--help` text. See
11
+ // `probeAgentReadiness` for the exact parsing rules.
12
+
13
+ export type ImageGenStatus = "enabled" | "disabled" | "unknown";
14
+
15
+ // Codex auth/login hint. "ok" when `codex features list` could actually be read
16
+ // (so we trust the imageGeneration verdict); "unknown" when Codex is installed
17
+ // but its capabilities couldn't be read — commonly a logged-out / unclear-auth
18
+ // state. Best-effort and conservative: default "unknown", never blocks fiction.
19
+ export type AuthStatus = "ok" | "unknown";
20
+
21
+ export interface AgentReadiness {
22
+ claude: { installed: boolean };
23
+ codex: { installed: boolean; version: string | null; imageGeneration: ImageGenStatus; auth: AuthStatus };
24
+ checkedAt: number; // epoch ms — added by the route, NOT by the pure probe.
25
+ }
26
+
27
+ /**
28
+ * Distinct "you may not be logged in to Codex" signal (#263): Codex is installed
29
+ * but `codex features list` couldn't be read, so the actionable next step is a
30
+ * Codex login (outside OWS), NOT enabling a feature. Pure + shared so the New
31
+ * Story flow, the terminal launch-blocked panel, and Settings stay consistent.
32
+ */
33
+ export function isCodexAuthUnclear(
34
+ readiness: Pick<AgentReadiness, "codex"> | null | undefined,
35
+ ): boolean {
36
+ return !!readiness && readiness.codex.installed && readiness.codex.auth === "unknown";
37
+ }
38
+
39
+ /** Operator-facing copy for the auth-unclear case (#263). Shared across surfaces. */
40
+ export const CODEX_AUTH_UNCLEAR_MESSAGE =
41
+ "Codex is installed but its capabilities couldn't be read — you may need to log in to Codex (resolve outside OWS), then re-check.";
42
+
43
+ /** First non-empty, trimmed line of a command's stdout (or null). */
44
+ function firstNonEmptyTrimmedLine(stdout: string): string | null {
45
+ for (const raw of stdout.split("\n")) {
46
+ const line = raw.trim();
47
+ if (line.length > 0) return line;
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /**
53
+ * Determine the effective image_generation state from a single matched line of
54
+ * `codex features list` output.
55
+ *
56
+ * Rules:
57
+ * - "enabled" when the line shows a truthy state: `true`, `enabled`, `on`, or
58
+ * a trailing check mark (✓).
59
+ * - "disabled" when the line shows a falsy state: `false`, `disabled`, `off`.
60
+ * - "unknown" when image_generation is present but the state is unparseable.
61
+ *
62
+ * Truthy is checked before falsy is irrelevant because a single line never
63
+ * carries both; we test falsy first to avoid `disabled` matching a substring of
64
+ * something truthy (there is none, but order keeps intent explicit).
65
+ */
66
+ function parseImageGenLine(line: string): ImageGenStatus {
67
+ const l = line.toLowerCase();
68
+ // Falsy markers.
69
+ if (/\b(false|disabled|off)\b/.test(l)) return "disabled";
70
+ // Truthy markers (word states or a trailing check mark).
71
+ if (/\b(true|enabled|on)\b/.test(l) || /✓/.test(line)) return "enabled";
72
+ return "unknown";
73
+ }
74
+
75
+ /**
76
+ * Probe local agent CLIs. Pure: all shelling-out is injected via `run`.
77
+ * Returns everything except `checkedAt` (the route stamps that with Date.now()).
78
+ *
79
+ * Checks performed:
80
+ * - claude.installed: `claude --version` succeeds.
81
+ * - codex.installed: `codex --version` succeeds.
82
+ * - codex.version: first non-empty line of `codex --version` stdout (or null).
83
+ * - codex.imageGeneration: parsed from `codex features list`:
84
+ * * codex not installed -> "unknown"
85
+ * * `codex features list` fails / empty -> "unknown"
86
+ * * line mentions image_generation: -> parseImageGenLine(...)
87
+ * * successful listing WITHOUT the line -> "disabled"
88
+ * (a real `features list` that omits image_generation means the feature
89
+ * isn't available)
90
+ */
91
+ export async function probeAgentReadiness(
92
+ run: (cmd: string) => Promise<{ ok: boolean; stdout: string }>,
93
+ ): Promise<Omit<AgentReadiness, "checkedAt">> {
94
+ const claudeInstalled = (await run("claude --version")).ok;
95
+
96
+ const codexVersionResult = await run("codex --version");
97
+ const codexInstalled = codexVersionResult.ok;
98
+ const codexVersion = codexInstalled
99
+ ? firstNonEmptyTrimmedLine(codexVersionResult.stdout) || null
100
+ : null;
101
+
102
+ let imageGeneration: ImageGenStatus = "unknown";
103
+ // Conservative default: until we can actually read `codex features list`, treat
104
+ // auth as unclear (covers not-installed and logged-out states alike).
105
+ let auth: AuthStatus = "unknown";
106
+
107
+ if (codexInstalled) {
108
+ const features = await run("codex features list");
109
+ if (features.ok && features.stdout.trim().length > 0) {
110
+ // A readable feature listing means Codex auth/login is working.
111
+ auth = "ok";
112
+ // Accept either `image_generation` or `image-generation` naming.
113
+ const matchLine = features.stdout
114
+ .split("\n")
115
+ .find((line) => {
116
+ const l = line.toLowerCase();
117
+ return l.includes("image_generation") || l.includes("image-generation");
118
+ });
119
+ if (matchLine) {
120
+ imageGeneration = parseImageGenLine(matchLine);
121
+ } else {
122
+ // Successful listing that never mentions image_generation => not available.
123
+ imageGeneration = "disabled";
124
+ }
125
+ }
126
+ // else: command failed or empty -> stays "unknown".
127
+ }
128
+
129
+ return {
130
+ claude: { installed: claudeInstalled },
131
+ codex: { installed: codexInstalled, version: codexVersion, imageGeneration, auth },
132
+ };
133
+ }
@@ -0,0 +1,55 @@
1
+ import fs from "fs";
2
+
3
+ /**
4
+ * Local SQLite schema setup WITHOUT the native Prisma schema-engine (#484).
5
+ *
6
+ * The installed `plotlink-ows` package must bring its SQLite schema up at
7
+ * startup, but `prisma db push` spawns a platform-specific schema-engine binary
8
+ * that fails to start in some packed prod-only installs (an empty
9
+ * "Schema engine error:" on macOS arm64). The Prisma *client* the app already
10
+ * uses runs on the library query engine — a different, reliably-present engine —
11
+ * and can execute the DDL directly via `$executeRawUnsafe`.
12
+ *
13
+ * So we ship the canonical DDL as `app/prisma/schema.sql` (generated from
14
+ * `schema.prisma` with `npm run prisma:sql`) and apply it through the client.
15
+ */
16
+
17
+ /**
18
+ * Split a committed `.sql` file into individual executable statements, dropping
19
+ * `-- ...` comment lines and blanks. Our DDL is a small, controlled grammar
20
+ * (CREATE TABLE / CREATE [UNIQUE] INDEX) with no semicolons inside values, so a
21
+ * `;`-split is safe here.
22
+ */
23
+ export function parseSqlStatements(sql: string): string[] {
24
+ return sql
25
+ .split(";")
26
+ .map((chunk) =>
27
+ chunk
28
+ .split("\n")
29
+ .filter((line) => !line.trim().startsWith("--"))
30
+ .join("\n")
31
+ .trim(),
32
+ )
33
+ .filter((stmt) => stmt.length > 0);
34
+ }
35
+
36
+ /**
37
+ * Rewrite a CREATE statement to be idempotent so applying the schema on an
38
+ * already-initialized database is a no-op (the app applies it on every startup).
39
+ * Only the CREATE TABLE / CREATE [UNIQUE] INDEX forms our schema emits are
40
+ * rewritten; anything else is returned unchanged.
41
+ */
42
+ export function makeIdempotent(statement: string): string {
43
+ return statement
44
+ .replace(/^CREATE TABLE\s+(?!IF NOT EXISTS)/i, "CREATE TABLE IF NOT EXISTS ")
45
+ .replace(
46
+ /^CREATE\s+(UNIQUE\s+)?INDEX\s+(?!IF NOT EXISTS)/i,
47
+ (_match, unique) => `CREATE ${unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS `,
48
+ );
49
+ }
50
+
51
+ /** Read the committed schema DDL and return idempotent, ready-to-execute statements. */
52
+ export function loadSchemaStatements(schemaSqlPath: string): string[] {
53
+ const sql = fs.readFileSync(schemaSqlPath, "utf8");
54
+ return parseSqlStatements(sql).map(makeIdempotent);
55
+ }
@@ -0,0 +1,160 @@
1
+ // Shared text layout for cartoon lettering bubbles (#310).
2
+ //
3
+ // Both the export canvas (export-cut.ts) and the editor preview (LetteringEditor)
4
+ // run THIS function with a canvas `measureText`-based width measurer, so dialogue
5
+ // wraps by words and the font is sized to fit the bubble identically in the
6
+ // preview and the exported final image (WYSIWYG). Previously each drew a single
7
+ // maxWidth-compressed line, so long dialogue overflowed/clipped and the preview
8
+ // did not match the export.
9
+
10
+ export interface BubbleTextLayout {
11
+ /** Wrapped lines of body text (never empty; [""] for empty text). */
12
+ lines: string[];
13
+ /** Chosen body font size in the caller's pixel space. */
14
+ fontSize: number;
15
+ /** Line advance (fontSize * lineHeightFactor). */
16
+ lineHeight: number;
17
+ /** Speaker label font size, or 0 when there is no speaker. */
18
+ speakerFontSize: number;
19
+ /**
20
+ * True when the text did not fit even at the minimum font (the lines are a
21
+ * best-effort wrap that may clip/overflow the box). Drives the editor's
22
+ * text-overflow warning (#336). Export rendering ignores it (unchanged).
23
+ */
24
+ overflow: boolean;
25
+ }
26
+
27
+ export interface BubbleLayoutOptions {
28
+ /** Largest body font to try, in the caller's pixel space. */
29
+ maxFontSize: number;
30
+ /** Smallest body font (used even if text still overflows). */
31
+ minFontSize: number;
32
+ /** Fixed body font size; when present, skip auto-fit and use this size. */
33
+ fontSize?: number;
34
+ /** Line advance as a multiple of font size. Default 1.2. */
35
+ lineHeightFactor?: number;
36
+ /** Speaker-label size as a multiple of body font size. Default 0.8. */
37
+ speakerScale?: number;
38
+ /** Body text weight, for consistent bold/regular measurement and layout. */
39
+ fontWeight?: 400 | 700;
40
+ /** Horizontal padding inside the box (each side). Default 6% of width. */
41
+ paddingX?: number;
42
+ /** Vertical padding inside the box (each side). Default 8% of height. */
43
+ paddingY?: number;
44
+ /** Present a speaker label strip above the body. Default false. */
45
+ hasSpeaker?: boolean;
46
+ }
47
+
48
+ /** Measure rendered width of `text` at `fontSize` (canvas measureText-backed). */
49
+ export type MeasureWidth = (text: string, fontSize: number, fontWeight?: 400 | 700) => number;
50
+
51
+ /** Greedy word-wrap of `text` to lines no wider than `maxWidth` at `fontSize`. */
52
+ export function wrapText(
53
+ measure: MeasureWidth,
54
+ text: string,
55
+ maxWidth: number,
56
+ fontSize: number,
57
+ ): string[] {
58
+ const words = text.split(/\s+/).filter(Boolean);
59
+ if (words.length === 0) return [""];
60
+ const lines: string[] = [];
61
+ let current = "";
62
+ for (const word of words) {
63
+ const candidate = current ? `${current} ${word}` : word;
64
+ // Keep a word on the current line if it fits, or if the line is empty (a
65
+ // single over-long word still occupies its own line — the fit loop shrinks
66
+ // the font until it fits the box).
67
+ if (!current || measure(candidate, fontSize) <= maxWidth) {
68
+ current = candidate;
69
+ } else {
70
+ lines.push(current);
71
+ current = word;
72
+ }
73
+ }
74
+ if (current) lines.push(current);
75
+ return lines;
76
+ }
77
+
78
+ /**
79
+ * Lay out bubble text: pick the largest font (between min and max) at which the
80
+ * word-wrapped lines fit the box width AND total height, reserving a strip for a
81
+ * speaker label when present. Deterministic given the same `measure`, so the
82
+ * editor preview and the export canvas produce identical wrapping/sizing.
83
+ */
84
+ export function layoutBubbleText(
85
+ measure: MeasureWidth,
86
+ text: string,
87
+ boxWidth: number,
88
+ boxHeight: number,
89
+ opts: BubbleLayoutOptions,
90
+ ): BubbleTextLayout {
91
+ const lineHeightFactor = opts.lineHeightFactor ?? 1.2;
92
+ const speakerScale = opts.speakerScale ?? 0.8;
93
+ const padX = opts.paddingX ?? Math.max(2, boxWidth * 0.06);
94
+ const padY = opts.paddingY ?? Math.max(2, boxHeight * 0.08);
95
+ const availW = Math.max(1, boxWidth - 2 * padX);
96
+ const totalAvailH = Math.max(1, boxHeight - 2 * padY);
97
+
98
+ const maxFont = Math.max(opts.minFontSize, opts.maxFontSize);
99
+ const minFont = Math.max(1, Math.min(opts.minFontSize, maxFont));
100
+
101
+ const fit = (bodyFont: number): { lines: string[]; ok: boolean } => {
102
+ const speakerFont = opts.hasSpeaker ? bodyFont * speakerScale : 0;
103
+ const speakerStrip = opts.hasSpeaker ? speakerFont * lineHeightFactor : 0;
104
+ const bodyAvailH = Math.max(1, totalAvailH - speakerStrip);
105
+ const fontWeight = opts.fontWeight ?? 400;
106
+ const lines = wrapText((line, fontSize) => measure(line, fontSize, fontWeight), text, availW, bodyFont);
107
+ const bodyH = lines.length * bodyFont * lineHeightFactor;
108
+ const widthOk = lines.every((l) => measure(l, bodyFont, fontWeight) <= availW + 0.5);
109
+ return { lines, ok: bodyH <= bodyAvailH && widthOk };
110
+ };
111
+
112
+ if (typeof opts.fontSize === "number" && Number.isFinite(opts.fontSize) && opts.fontSize > 0) {
113
+ const bodyFont = Math.max(1, opts.fontSize);
114
+ const { lines, ok } = fit(bodyFont);
115
+ return {
116
+ lines,
117
+ fontSize: bodyFont,
118
+ lineHeight: bodyFont * lineHeightFactor,
119
+ speakerFontSize: opts.hasSpeaker ? bodyFont * speakerScale : 0,
120
+ overflow: !ok,
121
+ };
122
+ }
123
+
124
+ // Descend from max to min font (0.5px steps) and take the first that fits.
125
+ for (let f = maxFont; f >= minFont; f -= 0.5) {
126
+ const { lines, ok } = fit(f);
127
+ if (ok) {
128
+ return {
129
+ lines,
130
+ fontSize: f,
131
+ lineHeight: f * lineHeightFactor,
132
+ speakerFontSize: opts.hasSpeaker ? f * speakerScale : 0,
133
+ overflow: false,
134
+ };
135
+ }
136
+ }
137
+
138
+ // Nothing fits even at min — best effort: wrap at min font (may overflow).
139
+ const lines = wrapText(measure, text, availW, minFont);
140
+ return {
141
+ lines,
142
+ fontSize: minFont,
143
+ lineHeight: minFont * lineHeightFactor,
144
+ speakerFontSize: opts.hasSpeaker ? minFont * speakerScale : 0,
145
+ overflow: true,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Default body min/max font sizes for a bubble, as fractions of the rendering
151
+ * HEIGHT so the export (natural image size) and the editor preview (displayed
152
+ * size) scale together — identical wrapping at both scales. `renderHeight` is
153
+ * the canvas/image height in the caller's pixel space.
154
+ */
155
+ export function defaultBubbleFontRange(renderHeight: number): { minFontSize: number; maxFontSize: number } {
156
+ return {
157
+ minFontSize: Math.max(1, renderHeight * 0.022),
158
+ maxFontSize: Math.max(1, renderHeight * 0.05),
159
+ };
160
+ }
@@ -0,0 +1,198 @@
1
+ // Persistent cartoon workflow coach (#429).
2
+ //
3
+ // The cartoon production flow is long and non-obvious — create story → bible →
4
+ // Genesis → plan cuts → clean images → letter → export → upload → prepare →
5
+ // publish → verify. Each individual screen was improved across #418–#427, but a
6
+ // normal writer still needs ONE persistent, front-end guide that converts the
7
+ // current story/episode state into a single clear next action, without reading
8
+ // terminal logs or technical warnings.
9
+ //
10
+ // This derives that coach PURELY from the already-built `StoryProgress` (which
11
+ // the route assembles from .story.json, structure.md, genesis.md, the cuts.json
12
+ // files, local assets, exports, uploaded URLs and publish status), plus a small
13
+ // per-episode disk hint (clean images present on disk but not yet recorded). It
14
+ // returns one stage label + one primary action, typed as either an agent
15
+ // copy-paste prompt or a direct in-app UI action. Fiction returns null so the
16
+ // fiction UX is completely untouched.
17
+
18
+ import type { StoryProgress, EpisodeProgress } from "./story-progress";
19
+
20
+ /** A direct, app-driven next step the UI can perform/route to. */
21
+ export type CoachUiAction =
22
+ | "open-cuts" // reveal the cut workspace
23
+ | "open-lettering" // open the cut workspace to letter / export
24
+ | "refresh-assets" // re-scan local clean images (#427)
25
+ | "upload" // upload the final images
26
+ | "generate-markdown" // "Prepare the episode for publish"
27
+ | "publish" // publish the episode to PlotLink
28
+ | "view-progress"; // open the story progress overview (#418)
29
+
30
+ export type CoachActionKind = "agent" | "ui";
31
+
32
+ export interface CartoonCoach {
33
+ /** Short current-stage label, e.g. "Clean images ready". */
34
+ stageLabel: string;
35
+ /** One primary next action in user-facing verbs, e.g. "Review cuts and start lettering". */
36
+ action: string;
37
+ actionKind: CoachActionKind;
38
+ /** Copy-paste agent prompt when `actionKind === "agent"`; null for UI actions. */
39
+ prompt: string | null;
40
+ /** The in-app action key when `actionKind === "ui"`; null for agent actions. */
41
+ uiAction: CoachUiAction | null;
42
+ /** Episode this action concerns (so the overview can deep-link), or null for setup-level steps. */
43
+ episodeFile: string | null;
44
+ }
45
+
46
+ export interface CoachOptions {
47
+ /**
48
+ * Currently-viewed file (e.g. "plot-02.md"). When it names an unfinished
49
+ * cartoon episode the coach speaks about THAT episode, so a future-episode
50
+ * placeholder reads as "Plan this episode first" instead of pointing at the
51
+ * story's active episode. Ignored for non-episode files (structure.md) and
52
+ * already-published episodes — those fall back to the story's active episode.
53
+ */
54
+ focusFile?: string | null;
55
+ /**
56
+ * Per-episode count of clean images present on disk but NOT yet recorded in
57
+ * cuts.json (acceptance #2). When > 0 at the clean-image stage the coach
58
+ * surfaces "Refresh assets" (re-detect) instead of "Generate clean images".
59
+ */
60
+ undetectedCleanByFile?: Record<string, number>;
61
+ }
62
+
63
+ function agent(stageLabel: string, action: string, prompt: string, episodeFile: string | null): CartoonCoach {
64
+ return { stageLabel, action, actionKind: "agent", prompt, uiAction: null, episodeFile };
65
+ }
66
+
67
+ function ui(stageLabel: string, action: string, uiAction: CoachUiAction, episodeFile: string | null): CartoonCoach {
68
+ return { stageLabel, action, actionKind: "ui", prompt: null, uiAction, episodeFile };
69
+ }
70
+
71
+ /** "genesis.cuts.json" | "plot-01.cuts.json" — the cut plan a writer points the agent at. */
72
+ function cutsFileName(episodeFile: string): string {
73
+ return episodeFile === "genesis.md" ? "genesis.cuts.json" : episodeFile.replace(/\.md$/, ".cuts.json");
74
+ }
75
+
76
+ /**
77
+ * Convert the story/episode state into the single next action a cartoon writer
78
+ * should take. Returns null for fiction (so fiction UX is unchanged) and for a
79
+ * cartoon story that is already fully published with nothing queued.
80
+ */
81
+ export function deriveCartoonCoach(progress: StoryProgress, opts: CoachOptions = {}): CartoonCoach | null {
82
+ if (progress.contentType !== "cartoon") return null;
83
+
84
+ // Setup gates block the whole story, so they take priority over any episode —
85
+ // regardless of which file is in focus. These mirror buildStoryProgress's
86
+ // setup ordering so the coach never disagrees with the progress overview.
87
+ if (!progress.setup.hasStructure) {
88
+ return agent(
89
+ "New cartoon story",
90
+ "Write the story bible",
91
+ "Let's build this cartoon. Write the story bible (structure.md) — visual style, character bible, and episode format. Don't generate images, letter, upload, or publish yet.",
92
+ "structure.md",
93
+ );
94
+ }
95
+ if (!progress.setup.hasGenesis) {
96
+ return agent(
97
+ "Story bible ready",
98
+ "Write the Genesis (Episode 1) opening",
99
+ "Write the Genesis (Episode 1) opening for this cartoon, then plan its cuts in genesis.cuts.json. Don't generate images yet.",
100
+ "genesis.md",
101
+ );
102
+ }
103
+
104
+ // The episode the coach speaks about: the focused file when it's an unfinished
105
+ // episode, otherwise the story's active (first unpublished) episode.
106
+ const episodes = progress.episodes;
107
+ const focused = opts.focusFile ? episodes.find((e) => e.file === opts.focusFile) : undefined;
108
+ const active = episodes.find((e) => !e.published);
109
+ const ep = focused && !focused.published ? focused : active;
110
+
111
+ if (!ep) {
112
+ // Every episode is published — nudge toward the next one rather than a wall
113
+ // of "all done".
114
+ return agent(
115
+ "All episodes published",
116
+ "Start the next episode",
117
+ "Plan the cuts for the next episode in a new cuts.json. Don't generate images yet.",
118
+ null,
119
+ );
120
+ }
121
+
122
+ return coachForEpisode(ep, opts.undetectedCleanByFile?.[ep.file] ?? 0);
123
+ }
124
+
125
+ /**
126
+ * The per-episode production pipeline, in the order a writer performs it
127
+ * (#429): plan cuts → clean images → letter → export → upload → prepare →
128
+ * publish. Each stage emits one stage label + one primary action; the
129
+ * pre-image steps are agent prompts, the rest are in-app UI actions.
130
+ */
131
+ function coachForEpisode(ep: EpisodeProgress, undetectedClean: number): CartoonCoach {
132
+ const c = ep.cuts;
133
+ const label = ep.label;
134
+ const file = ep.file;
135
+ const isGenesis = ep.kind === "genesis";
136
+
137
+ // No cut plan yet — a not-started episode or a future-episode placeholder.
138
+ // Acceptance #3: this reads as "plan this first", never a publish warning.
139
+ if (!c || c.total === 0) {
140
+ return agent(
141
+ `${label} not started`,
142
+ isGenesis ? "Plan the Genesis cuts" : "Plan this episode first",
143
+ isGenesis
144
+ ? "Plan the cuts for the Genesis (Episode 1) in genesis.cuts.json. Don't generate images, letter, upload, or publish yet."
145
+ : `Plan the cuts for ${label} in ${cutsFileName(file)}. Don't generate images, letter, upload, or publish yet.`,
146
+ file,
147
+ );
148
+ }
149
+
150
+ // 1) Clean images — agent-generated. If images are already on disk but not yet
151
+ // recorded, the next action is a read-only re-scan instead (#427), not a
152
+ // redundant "generate again".
153
+ if (c.withClean < c.needClean) {
154
+ if (undetectedClean > 0) {
155
+ return ui(
156
+ "Clean images found on disk",
157
+ "Refresh assets to detect them",
158
+ "refresh-assets",
159
+ file,
160
+ );
161
+ }
162
+ return agent(
163
+ `${label} cuts planned`,
164
+ "Generate clean images",
165
+ `Generate clean images for every cut in ${cutsFileName(file)}. Don't letter, upload, or publish yet.`,
166
+ file,
167
+ );
168
+ }
169
+
170
+ // 2) Lettering — place speech bubbles & captions in the cut workspace.
171
+ if (c.withText < c.needClean) {
172
+ return ui("Clean images ready", "Review cuts and start lettering", "open-lettering", file);
173
+ }
174
+
175
+ // 3) Export the lettered final images.
176
+ if (c.exported < c.total) {
177
+ return ui("Lettering in progress", "Finish and export the final images", "open-lettering", file);
178
+ }
179
+
180
+ // 4) Upload the exported final images.
181
+ if (c.uploaded < c.total) {
182
+ return ui("Final images ready", "Upload the final images", "upload", file);
183
+ }
184
+
185
+ // 5) Every cut is uploaded — assemble the publish layout, then publish. Driven
186
+ // by the same readiness state the per-file publish UI uses, so the coach and
187
+ // the publish controls never disagree.
188
+ switch (ep.state) {
189
+ case "ready":
190
+ return ui("Ready to publish", `Publish ${label} to PlotLink`, "publish", file);
191
+ case "blocked":
192
+ return ui("Needs fixes before publishing", "Review and fix the publish issues", "open-cuts", file);
193
+ case "planning":
194
+ default:
195
+ // Images uploaded but the publish layout (cut blocks) isn't built yet.
196
+ return ui("Images uploaded", "Prepare the episode for publish", "generate-markdown", file);
197
+ }
198
+ }