slidemind 0.1.7 → 0.1.9

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.
@@ -16,6 +16,18 @@
16
16
  // previously-materialized bundle (like `slidemind build`) but no open host.
17
17
  // Each round's comments persist to <deck>/review.json so the next run shows them
18
18
  // as read-only "ghost" pins for 1:1 verification.
19
+ //
20
+ // Port + tab-reuse contract: each run binds a deterministic per-deck port (a
21
+ // hash of the deck path folded into a fixed high band, 40000..40999) so a tab
22
+ // left waiting from the previous round can reconnect to the same URL. A taken
23
+ // port never fails — it falls back to an OS-assigned port with a stderr notice.
24
+ // On a comments round the browser tab does NOT close: it polls
25
+ // /__review/heartbeat and reloads itself once the next run binds the same port
26
+ // (matched by a service marker + a fresh round nonce), so a fresh tab isn't
27
+ // opened every round. Reuse is granted only to a VISIBLE waiting tab — if the
28
+ // tab is buried in a background window (or none is polling), the browser is
29
+ // ALWAYS opened and the stale tab retires itself; the review must never sit
30
+ // invisible while the user thinks nothing happened.
19
31
 
20
32
  import fs from "node:fs";
21
33
  import http from "node:http";
@@ -25,6 +37,20 @@ import { fileURLToPath, pathToFileURL } from "node:url";
25
37
  import { buildDeck, openInBrowser } from "./build.mjs";
26
38
  import { resolveSkillForDeck } from "./resolve-skill.mjs";
27
39
  import { resolveBrandAsset } from "../bridge/bundle.mjs";
40
+ import {
41
+ MIME,
42
+ serveStaticFile,
43
+ createDecisionGate,
44
+ createHeartbeat,
45
+ listenWithFallback,
46
+ preferredGatePort,
47
+ serveAndOpen as gateServeAndOpen,
48
+ pendingBrowserLines,
49
+ GRACE_MS,
50
+ OPEN_FAILED_GRACE_MS,
51
+ POLL_MS,
52
+ POLL_TTL_MS,
53
+ } from "./gate-server.mjs";
28
54
 
29
55
  const __filename = fileURLToPath(import.meta.url);
30
56
  const __dirname = path.dirname(__filename);
@@ -32,21 +58,6 @@ const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
32
58
  const REVIEW_STATE_FILE = "review.json";
33
59
  const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
34
60
 
35
- const MIME = {
36
- ".html": "text/html; charset=utf-8",
37
- ".css": "text/css; charset=utf-8",
38
- ".js": "text/javascript; charset=utf-8",
39
- ".json": "application/json; charset=utf-8",
40
- ".svg": "image/svg+xml",
41
- ".png": "image/png",
42
- ".jpg": "image/jpeg",
43
- ".jpeg": "image/jpeg",
44
- ".gif": "image/gif",
45
- ".webp": "image/webp",
46
- ".woff": "font/woff",
47
- ".woff2": "font/woff2",
48
- };
49
-
50
61
  function clamp01(n) {
51
62
  return n < 0 ? 0 : n > 1 ? 1 : n;
52
63
  }
@@ -132,16 +143,57 @@ export function formatCommentsForHarness(deckDir, comments) {
132
143
  out.push(` Comment: ${c.comment}`);
133
144
  out.push("");
134
145
  });
146
+ // Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
147
+ // after a comments round the user's tab waits for the NEXT review run — an
148
+ // agent that ends its turn here deadlocks the session.
149
+ //
150
+ // The recovery pointer sits in the TAIL deliberately: when a harness capture
151
+ // truncates long output it usually drops the TOP, so the pointer to the full
152
+ // persisted round is what survives.
135
153
  out.push(
136
- `Address every comment by editing the cited slides/*.jsx file(s) at the given ` +
137
- `line, then re-run: slidemind review ${path.basename(deckDir)}`,
154
+ `This round (all ${n} comment${n === 1 ? "" : "s"}) is also saved verbatim in ` +
155
+ `${path.join(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
156
+ `you can't see every numbered comment above, read that file for the full list ` +
157
+ `instead of guessing or asking the user to re-type anything.`,
138
158
  );
159
+ out.push("");
160
+ out.push("The user's browser tab is still open, waiting for the next round. They are");
161
+ out.push("waiting on YOU now — do both steps in this same turn:");
162
+ out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
163
+ out.push(` 2. Re-run: slidemind review ${path.basename(deckDir)}`);
164
+ out.push("The re-run rebuilds the preview and reopens their same tab (the prior round");
165
+ out.push("shows as ghost pins). Do NOT end your turn, do NOT export anything, and do");
166
+ out.push("NOT send the user back to the browser before step 2.");
139
167
  return out.join("\n") + "\n";
140
168
  }
141
169
 
142
- function injectReview(html, previousComments, overlayJs, logoMark = null) {
170
+ // Render the no-answer outcome for the harness — same contract as plan.mjs's
171
+ // formatPlanPendingForHarness: an expected state with resume instructions, not
172
+ // a failure and never an implicit approval.
173
+ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
174
+ const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
175
+ const base = path.basename(deckDir);
176
+ return [
177
+ "DECISION: pending",
178
+ `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
179
+ ...pendingBrowserLines({ pageLoaded, url }),
180
+ "This is NOT an approval and NOT a rejection. Do not push or export anything.",
181
+ "Close out your turn gracefully:",
182
+ " 1. Tell the user the preview is still waiting for their review in the browser.",
183
+ " 2. Tell them that when they're ready, you'll re-open the gate — then, on",
184
+ ` their go-ahead, re-run: slidemind review ${base}`,
185
+ "A still-open tab reconnects to the re-run automatically; a closed one just",
186
+ "gets a fresh window. Nothing is lost by the wait.",
187
+ ].join("\n") + "\n";
188
+ }
189
+
190
+ function injectReview(html, previousComments, overlayJs, logoMark = null, round = null) {
143
191
  const cfg = safeInlineJson({
144
192
  endpoint: "/review",
193
+ heartbeat: "/__review/heartbeat",
194
+ round,
195
+ pollMs: POLL_MS,
196
+ pollTtlMs: POLL_TTL_MS,
145
197
  previousComments,
146
198
  logoMark,
147
199
  });
@@ -154,12 +206,16 @@ function injectReview(html, previousComments, overlayJs, logoMark = null) {
154
206
 
155
207
  export function loadReviewState(deckDir) {
156
208
  const f = path.join(deckDir, REVIEW_STATE_FILE);
157
- if (!fs.existsSync(f)) return { version: 1, comments: [] };
209
+ if (!fs.existsSync(f)) return { version: 1, decision: null, comments: [] };
158
210
  try {
159
211
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
160
- return { version: 1, comments: Array.isArray(data.comments) ? data.comments : [] };
212
+ return {
213
+ version: 1,
214
+ decision: data.decision ?? null,
215
+ comments: Array.isArray(data.comments) ? data.comments : [],
216
+ };
161
217
  } catch {
162
- return { version: 1, comments: [] };
218
+ return { version: 1, decision: null, comments: [] };
163
219
  }
164
220
  }
165
221
 
@@ -170,7 +226,15 @@ function saveReviewState(deckDir, state) {
170
226
  );
171
227
  }
172
228
 
173
- // Start the local review server. Returns { url, port, waitForDecision, close }.
229
+ // Deterministic per-deck port so a tab left waiting from the previous round can
230
+ // reconnect to the same URL. Thin wrapper over the shared gate primitive, salted
231
+ // with this gate's name so the preview and plan gates of one deck get distinct
232
+ // ports and their waiting tabs never sit on each other's URL.
233
+ export function preferredPortForDeck(deckDir) {
234
+ return preferredGatePort(deckDir, "preview");
235
+ }
236
+
237
+ // Start the local review server. Returns { url, port, waitForDecision, waitForHeartbeat, close }.
174
238
  // presentation.html must already exist in deckDir (build it first).
175
239
  export async function createReviewServer({
176
240
  deckDir,
@@ -179,10 +243,11 @@ export async function createReviewServer({
179
243
  logoMark = null,
180
244
  port = 0,
181
245
  }) {
182
- let resolveDecision;
183
- const decisionPromise = new Promise((res) => {
184
- resolveDecision = res;
185
- });
246
+ const gate = createDecisionGate();
247
+ // The heartbeat carries a per-run nonce so a waiting tab can tell a genuinely
248
+ // NEWER round (different nonce) from this run's own heartbeat (same nonce) and
249
+ // never self-reload; the service marker guards against a foreign app.
250
+ const hb = createHeartbeat({ service: "slidemind-review" });
186
251
  const overlay =
187
252
  overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
188
253
  const indexHtml = injectReview(
@@ -190,6 +255,7 @@ export async function createReviewServer({
190
255
  previousComments,
191
256
  overlay,
192
257
  logoMark,
258
+ hb.round,
193
259
  );
194
260
  const deckRoot = path.resolve(deckDir);
195
261
 
@@ -212,7 +278,7 @@ export async function createReviewServer({
212
278
  }
213
279
  res.writeHead(200, { "Content-Type": MIME[".json"] });
214
280
  res.end(JSON.stringify({ ok: true }));
215
- resolveDecision({
281
+ gate.resolve({
216
282
  decision: payload.decision === "comments" ? "comments" : "approve",
217
283
  comments: Array.isArray(payload.comments) ? payload.comments : [],
218
284
  });
@@ -226,39 +292,45 @@ export async function createReviewServer({
226
292
  return;
227
293
  }
228
294
 
295
+ if (pathname === "/__review/heartbeat") {
296
+ // The `service` marker is the shape guard: a waiting tab only reloads for
297
+ // a genuine slidemind review round, never because a foreign app grabbed
298
+ // the same port. The `round` nonce distinguishes a newer round from self.
299
+ // `visible`/`round` from the poll drive the reuse-vs-fresh-tab decision:
300
+ // only a VISIBLE waiting tab may claim reuse (see gate-server.mjs).
301
+ hb.respond(res, {
302
+ visible: u.searchParams.get("visible") === "1",
303
+ tabRound: u.searchParams.get("round"),
304
+ });
305
+ return;
306
+ }
307
+
229
308
  if (
230
309
  pathname === "/" ||
231
310
  pathname === "/index.html" ||
232
311
  pathname === "/presentation.html"
233
312
  ) {
313
+ // A browser fetched the gate page — the end-to-end proof the review
314
+ // actually opened in front of the user (see gate-server's page-load
315
+ // tracker; serveAndOpen and the pending contract report from it).
316
+ hb.notePageLoad();
234
317
  res.writeHead(200, { "Content-Type": MIME[".html"] });
235
318
  res.end(indexHtml);
236
319
  return;
237
320
  }
238
321
 
239
- const normalized = path.resolve(deckRoot, pathname.replace(/^\/+/, ""));
240
- if (normalized !== deckRoot && !normalized.startsWith(deckRoot + path.sep)) {
241
- res.writeHead(403);
242
- res.end("forbidden");
243
- return;
244
- }
245
- fs.readFile(normalized, (err, data) => {
246
- if (err) {
247
- res.writeHead(404);
248
- res.end("not found");
249
- return;
250
- }
251
- const ext = path.extname(normalized).toLowerCase();
252
- res.writeHead(200, {
253
- "Content-Type": MIME[ext] || "application/octet-stream",
254
- });
255
- res.end(data);
256
- });
322
+ // Traversal-guarded static serving from the deck root; a miss or an escape
323
+ // attempt falls through to the caller's 404 (serveStaticFile never writes on
324
+ // a non-match).
325
+ if (serveStaticFile({ roots: [deckRoot], pathname, res })) return;
326
+ res.writeHead(404);
327
+ res.end("not found");
257
328
  });
258
329
 
259
- await new Promise((resolve, reject) => {
260
- server.once("error", reject);
261
- server.listen(port, "127.0.0.1", resolve);
330
+ await listenWithFallback(server, port, "127.0.0.1", (wanted, err) => {
331
+ process.stderr.write(
332
+ `review: port ${wanted} unavailable (${err.code}); using an OS-assigned port instead.\n`,
333
+ );
262
334
  });
263
335
  const addr = server.address();
264
336
  const url = `http://127.0.0.1:${addr.port}/`;
@@ -266,18 +338,20 @@ export async function createReviewServer({
266
338
  return {
267
339
  url,
268
340
  port: addr.port,
269
- waitForDecision({ timeoutMs } = {}) {
270
- if (!timeoutMs) return decisionPromise;
271
- return new Promise((resolve, reject) => {
272
- const t = setTimeout(
273
- () => reject(new Error("review timed out — no decision received")),
274
- timeoutMs,
275
- );
276
- decisionPromise.then((d) => {
277
- clearTimeout(t);
278
- resolve(d);
279
- });
280
- });
341
+ waitForHeartbeat(ms) {
342
+ return hb.waitForHeartbeat(ms);
343
+ },
344
+ markOpenedFresh() {
345
+ hb.markFresh();
346
+ },
347
+ waitForPageLoad(ms) {
348
+ return hb.waitForPageLoad(ms);
349
+ },
350
+ pageLoaded() {
351
+ return hb.pageLoaded();
352
+ },
353
+ waitForDecision(opts = {}) {
354
+ return gate.waitForDecision(opts);
281
355
  },
282
356
  close() {
283
357
  try {
@@ -289,10 +363,28 @@ export async function createReviewServer({
289
363
  };
290
364
  }
291
365
 
366
+ // Print the review URL, then decide whether to open a browser. If a tab left
367
+ // waiting from a previous comments round pings /__review/heartbeat within the
368
+ // grace window, reuse it (it reloads itself) instead of opening a duplicate.
369
+ // Returns gate-server's { reused, launched, confirmed, url } status. The
370
+ // testable seam for runReview — a thin wrapper over the shared gate primitive
371
+ // that supplies this gate's browser opener (quiet: the gate's BROWSER: line is
372
+ // the single source of open/failed messaging); the preview-gate message nouns
373
+ // are gate-server's defaults.
374
+ export function serveAndOpen(server, opts = {}) {
375
+ return gateServeAndOpen(server, {
376
+ open: (url) => openInBrowser(url, { quiet: true }),
377
+ ...opts,
378
+ });
379
+ }
380
+
292
381
  export async function runReview({
293
382
  deckDir,
294
- port = 0,
383
+ port = null,
295
384
  timeoutMs = DEFAULT_TIMEOUT_MS,
385
+ // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
386
+ open = (url) => openInBrowser(url, { quiet: true }),
387
+ openFailedGraceMs = OPEN_FAILED_GRACE_MS,
296
388
  } = {}) {
297
389
  await buildDeck(deckDir);
298
390
  const prev = loadReviewState(deckDir);
@@ -303,21 +395,55 @@ export async function runReview({
303
395
  // a plain slide glyph — the CLI no longer re-derives an orb.
304
396
  const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
305
397
  const logoMark = mark ? "/assets/" + mark.filename : null;
398
+ const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
306
399
  const server = await createReviewServer({
307
400
  deckDir,
308
401
  previousComments: prev.comments || [],
309
402
  logoMark,
310
- port,
403
+ port: wantPort,
404
+ });
405
+ // A waiting tab can only exist when the previous round ended in comments — so
406
+ // first runs / post-approve runs open the browser immediately (graceMs 0).
407
+ const browser = await serveAndOpen(server, {
408
+ open,
409
+ graceMs: prev.decision === "comments" ? GRACE_MS : 0,
410
+ waitLine:
411
+ `Waiting for the user's decision — this command BLOCKS (up to ${Math.round(
412
+ timeoutMs / 60000,
413
+ )} min) while they review. A long quiet wait is normal; do not cancel it.`,
311
414
  });
312
- process.stderr.write(`Preview review ready at ${server.url}\n`);
313
- openInBrowser(server.url);
314
- process.stderr.write(
315
- "Opened your browser — leave comments or approve, then return here.\n",
316
- );
317
415
 
318
416
  let decision;
319
417
  try {
418
+ if (browser.launched === false) {
419
+ // The opener chain failed outright: the user can only reach the gate via
420
+ // the printed URL. Give a manually-opened tab a short window, then bail
421
+ // out with the pending contract (carrying the failed-open status + link)
422
+ // instead of blocking the agent for the full timeout on an invisible gate.
423
+ const loaded = await server.waitForPageLoad(openFailedGraceMs);
424
+ if (!loaded) {
425
+ const err = new Error("browser never opened — gate unseen");
426
+ err.code = "GATE_TIMEOUT";
427
+ throw err;
428
+ }
429
+ }
320
430
  decision = await server.waitForDecision({ timeoutMs });
431
+ } catch (err) {
432
+ if (err && err.code === "GATE_TIMEOUT") {
433
+ // Expected outcome, not an error: exit 0 with the pending contract so the
434
+ // agent wraps up cleanly instead of reading a scary non-zero failure.
435
+ // pageLoaded is re-checked NOW (not serveAndOpen's early snapshot) so a
436
+ // tab that opened late still counts as "the user saw the gate".
437
+ process.stdout.write(
438
+ formatReviewPendingForHarness(deckDir, {
439
+ timeoutMs,
440
+ pageLoaded: server.pageLoaded(),
441
+ url: server.url,
442
+ }),
443
+ );
444
+ return null;
445
+ }
446
+ throw err;
321
447
  } finally {
322
448
  server.close();
323
449
  }
@@ -342,7 +468,9 @@ export async function runReview({
342
468
  }
343
469
 
344
470
  function parseArgs(argv) {
345
- const args = { deck: null, port: 0 };
471
+ // port null ⇒ derive the deterministic per-deck port; an explicit --port
472
+ // (incl. --port 0 for OS-random) overrides it.
473
+ const args = { deck: null, port: null };
346
474
  for (let i = 0; i < argv.length; i++) {
347
475
  const a = argv[i];
348
476
  if (a === "--deck" && i + 1 < argv.length) {
@@ -0,0 +1,118 @@
1
+ // Storyboard-markdown: segment a plan.md into alternating markdown / storyboard
2
+ // runs, splice an edited storyboard fence back in, and pull the red-thread beats
3
+ // out of the `## Red thread` section. Ported 1:1 from the add-in's
4
+ // src/ui/src/pages/plan-review/storyboard-markdown.ts (TS types dropped; regexes
5
+ // and control flow identical). Ships into the plan gate's browser bundle in one
6
+ // shared IIFE with storyboard-schema and the other plan modules, so module-scope
7
+ // helpers use an `sm` prefix to stay collision-free; exported names match the
8
+ // add-in exactly. The import below is a single line on purpose — the browser
9
+ // bundler strips lines matching `import … from "…";`.
10
+ import { parseStoryboardCanvas, serializeStoryboardCanvas, STORYBOARD_CANVAS_LANG } from "./storyboard-schema.mjs";
11
+
12
+ const SM_FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g;
13
+ const SM_RED_THREAD_HEADING_RE = /^##\s+Red thread\s*$/im;
14
+ const SM_NEXT_H2_RE = /^##\s+/im;
15
+
16
+ function smCountLines(text) {
17
+ if (!text) return 0;
18
+ return text.split("\n").length - 1;
19
+ }
20
+
21
+ function smNormalizeFenceLang(raw) {
22
+ return raw.trim().split(/\s+/)[0] ?? "";
23
+ }
24
+
25
+ function smCleanRedThreadLine(line) {
26
+ return line
27
+ .replace(/^\s*(?:[-*]|\d+[.)])\s+/, "")
28
+ .replace(/^\s*Slide\s+\d+\s*:\s*/i, "")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
31
+ }
32
+
33
+ function smSplitRedThreadBody(body) {
34
+ const lines = body.split("\n").map(smCleanRedThreadLine).filter(Boolean);
35
+ if (lines.length !== 1) return lines;
36
+ const [line] = lines;
37
+ if (!line) return [];
38
+ return line
39
+ .split(/\s+(?:->|→|=>|↦)\s+/)
40
+ .map(smCleanRedThreadLine)
41
+ .filter(Boolean);
42
+ }
43
+
44
+ /** Absolute bounds of the `## Red thread` section body (after the heading). */
45
+ export function findRedThreadSection(markdown) {
46
+ const heading = SM_RED_THREAD_HEADING_RE.exec(markdown);
47
+ if (!heading) return null;
48
+ const bodyStart = (heading.index ?? 0) + heading[0].length;
49
+ const rest = markdown.slice(bodyStart);
50
+ const nextHeading = SM_NEXT_H2_RE.exec(rest);
51
+ return {
52
+ bodyStart,
53
+ bodyEnd: bodyStart + (nextHeading?.index ?? rest.length),
54
+ };
55
+ }
56
+
57
+ export function extractRedThreadMessages(markdown) {
58
+ const section = findRedThreadSection(markdown);
59
+ if (!section) return [];
60
+ return smSplitRedThreadBody(markdown.slice(section.bodyStart, section.bodyEnd));
61
+ }
62
+
63
+ export function splitPlanMarkdownIntoSegments(markdown) {
64
+ const segments = [];
65
+ let cursor = 0;
66
+ let lineOffset = 0;
67
+
68
+ for (const match of markdown.matchAll(SM_FENCE_RE)) {
69
+ const full = match[0] ?? "";
70
+ const matchStart = match.index ?? 0;
71
+ const matchEnd = matchStart + full.length;
72
+ const lang = smNormalizeFenceLang(match[1] ?? "");
73
+ const body = match[2] ?? "";
74
+
75
+ if (lang !== STORYBOARD_CANVAS_LANG) continue;
76
+
77
+ if (matchStart > cursor) {
78
+ const text = markdown.slice(cursor, matchStart);
79
+ segments.push({ type: "markdown", text, lineOffset });
80
+ lineOffset += smCountLines(text);
81
+ }
82
+
83
+ const parsed = parseStoryboardCanvas(body.trim());
84
+ segments.push({
85
+ type: "storyboard",
86
+ raw: body,
87
+ data: parsed.data,
88
+ error: parsed.error,
89
+ warnings: parsed.warnings,
90
+ start: matchStart,
91
+ end: matchEnd,
92
+ lineOffset,
93
+ });
94
+ lineOffset += smCountLines(full);
95
+ cursor = matchEnd;
96
+ }
97
+
98
+ if (cursor < markdown.length || segments.length === 0) {
99
+ segments.push({
100
+ type: "markdown",
101
+ text: markdown.slice(cursor),
102
+ lineOffset,
103
+ });
104
+ }
105
+
106
+ return segments;
107
+ }
108
+
109
+ export function replaceStoryboardSegment(markdown, segment, nextData) {
110
+ const nextBlock = [
111
+ `\`\`\`${STORYBOARD_CANVAS_LANG}`,
112
+ serializeStoryboardCanvas(nextData),
113
+ "```",
114
+ ].join("\n");
115
+ return `${markdown.slice(0, segment.start)}${nextBlock}${markdown.slice(
116
+ segment.end,
117
+ )}`;
118
+ }
@@ -0,0 +1,180 @@
1
+ // Storyboard-canvas schema: the defensive parser/serializer for the JSON block a
2
+ // plan.md carries inside each ```storyboard-canvas``` fence. Ported 1:1 from the
3
+ // add-in's src/ui/src/pages/plan-review/storyboard-schema.ts (TS types dropped;
4
+ // every constant, regex, clamp and branch identical). This module also ships into
5
+ // the plan gate's browser bundle, concatenated with the other plan modules into a
6
+ // single IIFE — so module-scope helpers use an `sb`/`SB_` prefix to stay unique
7
+ // across that shared scope, while the exported API names match the add-in exactly.
8
+
9
+ export const STORYBOARD_CANVAS_LANG = "storyboard-canvas";
10
+ export const STORYBOARD_WIDTH = 1600;
11
+ export const STORYBOARD_HEIGHT = 900;
12
+
13
+ export const BODY_SAFE_AREA = {
14
+ x: 80,
15
+ y: 110,
16
+ w: 1440,
17
+ h: 670,
18
+ };
19
+
20
+ export const MIN_BLOCK_WIDTH = 180;
21
+ export const MIN_BLOCK_HEIGHT = 96;
22
+ export const MAX_BLOCKS = 8;
23
+ const SB_MAX_ID_LENGTH = 64;
24
+ export const MAX_DESIGN_ELEMENT_LENGTH = 56;
25
+ export const MAX_CONTENT_LENGTH = 28;
26
+ const SB_MAX_KEY_MESSAGE_LENGTH = 180;
27
+ const SB_MAX_VISUAL_FAMILY_LENGTH = 64;
28
+ const SB_MAX_DEPTH_NOTES = 6;
29
+ const SB_MAX_DEPTH_NOTE_LENGTH = 220;
30
+
31
+ function sbIsRecord(value) {
32
+ return value !== null && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ function sbAsTrimmedString(value, maxLength) {
36
+ if (typeof value !== "string") return "";
37
+ return value.trim().slice(0, maxLength);
38
+ }
39
+
40
+ function sbAsFiniteNumber(value, fallback) {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
42
+ }
43
+
44
+ function sbClamp(value, min, max) {
45
+ return Math.min(Math.max(value, min), max);
46
+ }
47
+
48
+ function sbNormalizeBlock(value, index, warnings) {
49
+ if (!sbIsRecord(value)) {
50
+ warnings.push(`Block ${index + 1} is not an object and was ignored.`);
51
+ return null;
52
+ }
53
+
54
+ const id = sbAsTrimmedString(value.id, SB_MAX_ID_LENGTH) || `block-${index + 1}`;
55
+ const designElement = sbAsTrimmedString(
56
+ value.designElement,
57
+ MAX_DESIGN_ELEMENT_LENGTH,
58
+ );
59
+ const content = sbAsTrimmedString(value.content, MAX_CONTENT_LENGTH);
60
+ if (!designElement || !content) {
61
+ warnings.push(
62
+ `Block "${id}" needs both designElement and content and was ignored.`,
63
+ );
64
+ return null;
65
+ }
66
+
67
+ const minW = MIN_BLOCK_WIDTH;
68
+ const minH = MIN_BLOCK_HEIGHT;
69
+ const maxW = BODY_SAFE_AREA.w;
70
+ const maxH = BODY_SAFE_AREA.h;
71
+ const w = sbClamp(sbAsFiniteNumber(value.w, 360), minW, maxW);
72
+ const h = sbClamp(sbAsFiniteNumber(value.h, 150), minH, maxH);
73
+ const x = sbClamp(
74
+ sbAsFiniteNumber(value.x, BODY_SAFE_AREA.x),
75
+ BODY_SAFE_AREA.x,
76
+ BODY_SAFE_AREA.x + BODY_SAFE_AREA.w - w,
77
+ );
78
+ const y = sbClamp(
79
+ sbAsFiniteNumber(value.y, BODY_SAFE_AREA.y),
80
+ BODY_SAFE_AREA.y,
81
+ BODY_SAFE_AREA.y + BODY_SAFE_AREA.h - h,
82
+ );
83
+ const emphasis =
84
+ value.emphasis === "primary" ||
85
+ value.emphasis === "secondary" ||
86
+ value.emphasis === "tertiary"
87
+ ? value.emphasis
88
+ : undefined;
89
+
90
+ return {
91
+ id,
92
+ x,
93
+ y,
94
+ w,
95
+ h,
96
+ ...(emphasis ? { emphasis } : {}),
97
+ designElement,
98
+ content,
99
+ };
100
+ }
101
+
102
+ export function parseStoryboardCanvas(raw) {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ } catch {
107
+ return {
108
+ data: null,
109
+ warnings: [],
110
+ error: "Storyboard JSON could not be parsed.",
111
+ };
112
+ }
113
+
114
+ if (!sbIsRecord(parsed)) {
115
+ return {
116
+ data: null,
117
+ warnings: [],
118
+ error: "Storyboard data must be a JSON object.",
119
+ };
120
+ }
121
+
122
+ const warnings = [];
123
+ if (parsed.version !== 1) {
124
+ warnings.push("Storyboard version was normalized to 1.");
125
+ }
126
+
127
+ const slide = Math.max(1, Math.round(sbAsFiniteNumber(parsed.slide, 1)));
128
+ const rawBlocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
129
+ if (!Array.isArray(parsed.blocks)) {
130
+ warnings.push("Storyboard blocks must be an array.");
131
+ }
132
+
133
+ const blocks = rawBlocks
134
+ .slice(0, MAX_BLOCKS)
135
+ .map((block, index) => sbNormalizeBlock(block, index, warnings))
136
+ .filter((block) => block !== null);
137
+
138
+ if (rawBlocks.length > MAX_BLOCKS) {
139
+ warnings.push(`Only the first ${MAX_BLOCKS} storyboard blocks are shown.`);
140
+ }
141
+
142
+ if (blocks.length === 0) {
143
+ return {
144
+ data: null,
145
+ warnings,
146
+ error: "Storyboard needs at least one valid block.",
147
+ };
148
+ }
149
+
150
+ const keyMessage = sbAsTrimmedString(parsed.keyMessage, SB_MAX_KEY_MESSAGE_LENGTH);
151
+ const visualFamily = sbAsTrimmedString(
152
+ parsed.visualFamily,
153
+ SB_MAX_VISUAL_FAMILY_LENGTH,
154
+ );
155
+ const rawContentNotes = Array.isArray(parsed.contentNotes)
156
+ ? parsed.contentNotes
157
+ : parsed.depthNotes;
158
+ const contentNotes = Array.isArray(rawContentNotes)
159
+ ? rawContentNotes
160
+ .slice(0, SB_MAX_DEPTH_NOTES)
161
+ .map((note) => sbAsTrimmedString(note, SB_MAX_DEPTH_NOTE_LENGTH))
162
+ .filter(Boolean)
163
+ : undefined;
164
+
165
+ return {
166
+ data: {
167
+ version: 1,
168
+ slide,
169
+ blocks,
170
+ ...(visualFamily ? { visualFamily } : {}),
171
+ ...(keyMessage ? { keyMessage } : {}),
172
+ ...(contentNotes?.length ? { contentNotes } : {}),
173
+ },
174
+ warnings,
175
+ };
176
+ }
177
+
178
+ export function serializeStoryboardCanvas(data) {
179
+ return JSON.stringify(data, null, 2);
180
+ }