slidemind 0.1.8 → 0.2.0
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.
- package/CLAUDE.md +10 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +819 -86
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +70 -18
- package/skills/search/SKILL.md +25 -6
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +36 -7
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +513 -7
- package/src/bridge/engine-download.mjs +229 -5
- package/src/bridge/image-dimensions.mjs +181 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +45 -1
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +76 -11
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/fill/engine.mjs +1273 -0
- package/src/pptx/fill/freeform.mjs +156 -0
- package/src/pptx/fill/parse-slides.mjs +154 -0
- package/src/pptx/fill/xml.mjs +486 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +176 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
package/src/builders/review.mjs
CHANGED
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
// On a comments round the browser tab does NOT close: it polls
|
|
25
25
|
// /__review/heartbeat and reloads itself once the next run binds the same port
|
|
26
26
|
// (matched by a service marker + a fresh round nonce), so a fresh tab isn't
|
|
27
|
-
// opened every round.
|
|
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.
|
|
28
31
|
|
|
29
32
|
import fs from "node:fs";
|
|
30
33
|
import http from "node:http";
|
|
@@ -42,7 +45,9 @@ import {
|
|
|
42
45
|
listenWithFallback,
|
|
43
46
|
preferredGatePort,
|
|
44
47
|
serveAndOpen as gateServeAndOpen,
|
|
48
|
+
pendingBrowserLines,
|
|
45
49
|
GRACE_MS,
|
|
50
|
+
OPEN_FAILED_GRACE_MS,
|
|
46
51
|
POLL_MS,
|
|
47
52
|
POLL_TTL_MS,
|
|
48
53
|
} from "./gate-server.mjs";
|
|
@@ -51,7 +56,10 @@ const __filename = fileURLToPath(import.meta.url);
|
|
|
51
56
|
const __dirname = path.dirname(__filename);
|
|
52
57
|
const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
|
|
53
58
|
const REVIEW_STATE_FILE = "review.json";
|
|
54
|
-
|
|
59
|
+
// Keep the review preview available for a working session, then let the
|
|
60
|
+
// existing finally block close the local server when the decision or timeout
|
|
61
|
+
// resolves.
|
|
62
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 60 * 1000;
|
|
55
63
|
|
|
56
64
|
function clamp01(n) {
|
|
57
65
|
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
@@ -141,6 +149,17 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
141
149
|
// Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
|
|
142
150
|
// after a comments round the user's tab waits for the NEXT review run — an
|
|
143
151
|
// agent that ends its turn here deadlocks the session.
|
|
152
|
+
//
|
|
153
|
+
// The recovery pointer sits in the TAIL deliberately: when a harness capture
|
|
154
|
+
// truncates long output it usually drops the TOP, so the pointer to the full
|
|
155
|
+
// persisted round is what survives.
|
|
156
|
+
out.push(
|
|
157
|
+
`This round (all ${n} comment${n === 1 ? "" : "s"}) is also saved verbatim in ` +
|
|
158
|
+
`${path.join(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
|
|
159
|
+
`you can't see every numbered comment above, read that file for the full list ` +
|
|
160
|
+
`instead of guessing or asking the user to re-type anything.`,
|
|
161
|
+
);
|
|
162
|
+
out.push("");
|
|
144
163
|
out.push("The user's browser tab is still open, waiting for the next round. They are");
|
|
145
164
|
out.push("waiting on YOU now — do both steps in this same turn:");
|
|
146
165
|
out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
|
|
@@ -154,12 +173,13 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
154
173
|
// Render the no-answer outcome for the harness — same contract as plan.mjs's
|
|
155
174
|
// formatPlanPendingForHarness: an expected state with resume instructions, not
|
|
156
175
|
// a failure and never an implicit approval.
|
|
157
|
-
export function formatReviewPendingForHarness(deckDir, { timeoutMs } = {}) {
|
|
176
|
+
export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
158
177
|
const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
|
|
159
178
|
const base = path.basename(deckDir);
|
|
160
179
|
return [
|
|
161
180
|
"DECISION: pending",
|
|
162
181
|
`No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
|
|
182
|
+
...pendingBrowserLines({ pageLoaded, url }),
|
|
163
183
|
"This is NOT an approval and NOT a rejection. Do not push or export anything.",
|
|
164
184
|
"Close out your turn gracefully:",
|
|
165
185
|
" 1. Tell the user the preview is still waiting for their review in the browser.",
|
|
@@ -279,7 +299,12 @@ export async function createReviewServer({
|
|
|
279
299
|
// The `service` marker is the shape guard: a waiting tab only reloads for
|
|
280
300
|
// a genuine slidemind review round, never because a foreign app grabbed
|
|
281
301
|
// the same port. The `round` nonce distinguishes a newer round from self.
|
|
282
|
-
|
|
302
|
+
// `visible`/`round` from the poll drive the reuse-vs-fresh-tab decision:
|
|
303
|
+
// only a VISIBLE waiting tab may claim reuse (see gate-server.mjs).
|
|
304
|
+
hb.respond(res, {
|
|
305
|
+
visible: u.searchParams.get("visible") === "1",
|
|
306
|
+
tabRound: u.searchParams.get("round"),
|
|
307
|
+
});
|
|
283
308
|
return;
|
|
284
309
|
}
|
|
285
310
|
|
|
@@ -288,6 +313,10 @@ export async function createReviewServer({
|
|
|
288
313
|
pathname === "/index.html" ||
|
|
289
314
|
pathname === "/presentation.html"
|
|
290
315
|
) {
|
|
316
|
+
// A browser fetched the gate page — the end-to-end proof the review
|
|
317
|
+
// actually opened in front of the user (see gate-server's page-load
|
|
318
|
+
// tracker; serveAndOpen and the pending contract report from it).
|
|
319
|
+
hb.notePageLoad();
|
|
291
320
|
res.writeHead(200, { "Content-Type": MIME[".html"] });
|
|
292
321
|
res.end(indexHtml);
|
|
293
322
|
return;
|
|
@@ -315,6 +344,15 @@ export async function createReviewServer({
|
|
|
315
344
|
waitForHeartbeat(ms) {
|
|
316
345
|
return hb.waitForHeartbeat(ms);
|
|
317
346
|
},
|
|
347
|
+
markOpenedFresh() {
|
|
348
|
+
hb.markFresh();
|
|
349
|
+
},
|
|
350
|
+
waitForPageLoad(ms) {
|
|
351
|
+
return hb.waitForPageLoad(ms);
|
|
352
|
+
},
|
|
353
|
+
pageLoaded() {
|
|
354
|
+
return hb.pageLoaded();
|
|
355
|
+
},
|
|
318
356
|
waitForDecision(opts = {}) {
|
|
319
357
|
return gate.waitForDecision(opts);
|
|
320
358
|
},
|
|
@@ -331,18 +369,25 @@ export async function createReviewServer({
|
|
|
331
369
|
// Print the review URL, then decide whether to open a browser. If a tab left
|
|
332
370
|
// waiting from a previous comments round pings /__review/heartbeat within the
|
|
333
371
|
// grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
334
|
-
// Returns
|
|
335
|
-
// a thin wrapper over the shared gate primitive
|
|
336
|
-
//
|
|
372
|
+
// Returns gate-server's { reused, launched, confirmed, url } status. The
|
|
373
|
+
// testable seam for runReview — a thin wrapper over the shared gate primitive
|
|
374
|
+
// that supplies this gate's browser opener (quiet: the gate's BROWSER: line is
|
|
375
|
+
// the single source of open/failed messaging); the preview-gate message nouns
|
|
376
|
+
// are gate-server's defaults.
|
|
337
377
|
export function serveAndOpen(server, opts = {}) {
|
|
338
|
-
return gateServeAndOpen(server, {
|
|
378
|
+
return gateServeAndOpen(server, {
|
|
379
|
+
open: (url) => openInBrowser(url, { quiet: true }),
|
|
380
|
+
...opts,
|
|
381
|
+
});
|
|
339
382
|
}
|
|
340
383
|
|
|
341
384
|
export async function runReview({
|
|
342
385
|
deckDir,
|
|
343
386
|
port = null,
|
|
344
387
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
345
|
-
open
|
|
388
|
+
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
389
|
+
open = (url) => openInBrowser(url, { quiet: true }),
|
|
390
|
+
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
346
391
|
} = {}) {
|
|
347
392
|
await buildDeck(deckDir);
|
|
348
393
|
const prev = loadReviewState(deckDir);
|
|
@@ -362,7 +407,7 @@ export async function runReview({
|
|
|
362
407
|
});
|
|
363
408
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
364
409
|
// first runs / post-approve runs open the browser immediately (graceMs 0).
|
|
365
|
-
await serveAndOpen(server, {
|
|
410
|
+
const browser = await serveAndOpen(server, {
|
|
366
411
|
open,
|
|
367
412
|
graceMs: prev.decision === "comments" ? GRACE_MS : 0,
|
|
368
413
|
waitLine:
|
|
@@ -373,12 +418,32 @@ export async function runReview({
|
|
|
373
418
|
|
|
374
419
|
let decision;
|
|
375
420
|
try {
|
|
421
|
+
if (browser.launched === false) {
|
|
422
|
+
// The opener chain failed outright: the user can only reach the gate via
|
|
423
|
+
// the printed URL. Give a manually-opened tab a short window, then bail
|
|
424
|
+
// out with the pending contract (carrying the failed-open status + link)
|
|
425
|
+
// instead of blocking the agent for the full timeout on an invisible gate.
|
|
426
|
+
const loaded = await server.waitForPageLoad(openFailedGraceMs);
|
|
427
|
+
if (!loaded) {
|
|
428
|
+
const err = new Error("browser never opened — gate unseen");
|
|
429
|
+
err.code = "GATE_TIMEOUT";
|
|
430
|
+
throw err;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
376
433
|
decision = await server.waitForDecision({ timeoutMs });
|
|
377
434
|
} catch (err) {
|
|
378
435
|
if (err && err.code === "GATE_TIMEOUT") {
|
|
379
436
|
// Expected outcome, not an error: exit 0 with the pending contract so the
|
|
380
437
|
// agent wraps up cleanly instead of reading a scary non-zero failure.
|
|
381
|
-
|
|
438
|
+
// pageLoaded is re-checked NOW (not serveAndOpen's early snapshot) so a
|
|
439
|
+
// tab that opened late still counts as "the user saw the gate".
|
|
440
|
+
process.stdout.write(
|
|
441
|
+
formatReviewPendingForHarness(deckDir, {
|
|
442
|
+
timeoutMs,
|
|
443
|
+
pageLoaded: server.pageLoaded(),
|
|
444
|
+
url: server.url,
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
382
447
|
return null;
|
|
383
448
|
}
|
|
384
449
|
throw err;
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Split a per-slide concept batch into the dependency waves expected by the
|
|
5
|
+
* add-in: independent slides and family anchors first, family variants second.
|
|
6
|
+
*/
|
|
7
|
+
export function conceptGenerationWaves(planMarkdown, slideCount) {
|
|
8
|
+
const groups = new Map();
|
|
9
|
+
for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
|
|
10
|
+
if (segment.type !== "storyboard" || !segment.data) continue;
|
|
11
|
+
const family = segment.data.visualFamily?.trim();
|
|
12
|
+
const slide = segment.data.slide;
|
|
13
|
+
if (!family || !Number.isFinite(slide)) continue;
|
|
14
|
+
groups.set(family, [...(groups.get(family) ?? []), slide]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const variants = new Set();
|
|
18
|
+
for (const unsorted of groups.values()) {
|
|
19
|
+
const members = [...new Set(unsorted)].sort((a, b) => a - b);
|
|
20
|
+
if (members.length < 2) continue;
|
|
21
|
+
for (const slide of members.slice(1)) variants.add(slide);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const remaining = Array.from(
|
|
25
|
+
{ length: Math.max(0, slideCount - 1) },
|
|
26
|
+
(_, index) => index + 2,
|
|
27
|
+
);
|
|
28
|
+
return {
|
|
29
|
+
firstWave: remaining.filter((slide) => !variants.has(slide)),
|
|
30
|
+
variants: remaining.filter((slide) => variants.has(slide)),
|
|
31
|
+
};
|
|
32
|
+
}
|