slidemind 0.1.8 → 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.
- package/CLAUDE.md +7 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +377 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +66 -15
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -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/review-overlay.js +44 -7
- package/src/builders/review.mjs +72 -10
- 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/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -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";
|
|
@@ -141,6 +146,17 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
141
146
|
// Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
|
|
142
147
|
// after a comments round the user's tab waits for the NEXT review run — an
|
|
143
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.
|
|
153
|
+
out.push(
|
|
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.`,
|
|
158
|
+
);
|
|
159
|
+
out.push("");
|
|
144
160
|
out.push("The user's browser tab is still open, waiting for the next round. They are");
|
|
145
161
|
out.push("waiting on YOU now — do both steps in this same turn:");
|
|
146
162
|
out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
|
|
@@ -154,12 +170,13 @@ export function formatCommentsForHarness(deckDir, comments) {
|
|
|
154
170
|
// Render the no-answer outcome for the harness — same contract as plan.mjs's
|
|
155
171
|
// formatPlanPendingForHarness: an expected state with resume instructions, not
|
|
156
172
|
// a failure and never an implicit approval.
|
|
157
|
-
export function formatReviewPendingForHarness(deckDir, { timeoutMs } = {}) {
|
|
173
|
+
export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
158
174
|
const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
|
|
159
175
|
const base = path.basename(deckDir);
|
|
160
176
|
return [
|
|
161
177
|
"DECISION: pending",
|
|
162
178
|
`No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
|
|
179
|
+
...pendingBrowserLines({ pageLoaded, url }),
|
|
163
180
|
"This is NOT an approval and NOT a rejection. Do not push or export anything.",
|
|
164
181
|
"Close out your turn gracefully:",
|
|
165
182
|
" 1. Tell the user the preview is still waiting for their review in the browser.",
|
|
@@ -279,7 +296,12 @@ export async function createReviewServer({
|
|
|
279
296
|
// The `service` marker is the shape guard: a waiting tab only reloads for
|
|
280
297
|
// a genuine slidemind review round, never because a foreign app grabbed
|
|
281
298
|
// the same port. The `round` nonce distinguishes a newer round from self.
|
|
282
|
-
|
|
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
|
+
});
|
|
283
305
|
return;
|
|
284
306
|
}
|
|
285
307
|
|
|
@@ -288,6 +310,10 @@ export async function createReviewServer({
|
|
|
288
310
|
pathname === "/index.html" ||
|
|
289
311
|
pathname === "/presentation.html"
|
|
290
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();
|
|
291
317
|
res.writeHead(200, { "Content-Type": MIME[".html"] });
|
|
292
318
|
res.end(indexHtml);
|
|
293
319
|
return;
|
|
@@ -315,6 +341,15 @@ export async function createReviewServer({
|
|
|
315
341
|
waitForHeartbeat(ms) {
|
|
316
342
|
return hb.waitForHeartbeat(ms);
|
|
317
343
|
},
|
|
344
|
+
markOpenedFresh() {
|
|
345
|
+
hb.markFresh();
|
|
346
|
+
},
|
|
347
|
+
waitForPageLoad(ms) {
|
|
348
|
+
return hb.waitForPageLoad(ms);
|
|
349
|
+
},
|
|
350
|
+
pageLoaded() {
|
|
351
|
+
return hb.pageLoaded();
|
|
352
|
+
},
|
|
318
353
|
waitForDecision(opts = {}) {
|
|
319
354
|
return gate.waitForDecision(opts);
|
|
320
355
|
},
|
|
@@ -331,18 +366,25 @@ export async function createReviewServer({
|
|
|
331
366
|
// Print the review URL, then decide whether to open a browser. If a tab left
|
|
332
367
|
// waiting from a previous comments round pings /__review/heartbeat within the
|
|
333
368
|
// grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
334
|
-
// Returns
|
|
335
|
-
// a thin wrapper over the shared gate primitive
|
|
336
|
-
//
|
|
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.
|
|
337
374
|
export function serveAndOpen(server, opts = {}) {
|
|
338
|
-
return gateServeAndOpen(server, {
|
|
375
|
+
return gateServeAndOpen(server, {
|
|
376
|
+
open: (url) => openInBrowser(url, { quiet: true }),
|
|
377
|
+
...opts,
|
|
378
|
+
});
|
|
339
379
|
}
|
|
340
380
|
|
|
341
381
|
export async function runReview({
|
|
342
382
|
deckDir,
|
|
343
383
|
port = null,
|
|
344
384
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
345
|
-
open
|
|
385
|
+
// quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
|
|
386
|
+
open = (url) => openInBrowser(url, { quiet: true }),
|
|
387
|
+
openFailedGraceMs = OPEN_FAILED_GRACE_MS,
|
|
346
388
|
} = {}) {
|
|
347
389
|
await buildDeck(deckDir);
|
|
348
390
|
const prev = loadReviewState(deckDir);
|
|
@@ -362,7 +404,7 @@ export async function runReview({
|
|
|
362
404
|
});
|
|
363
405
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
364
406
|
// first runs / post-approve runs open the browser immediately (graceMs 0).
|
|
365
|
-
await serveAndOpen(server, {
|
|
407
|
+
const browser = await serveAndOpen(server, {
|
|
366
408
|
open,
|
|
367
409
|
graceMs: prev.decision === "comments" ? GRACE_MS : 0,
|
|
368
410
|
waitLine:
|
|
@@ -373,12 +415,32 @@ export async function runReview({
|
|
|
373
415
|
|
|
374
416
|
let decision;
|
|
375
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
|
+
}
|
|
376
430
|
decision = await server.waitForDecision({ timeoutMs });
|
|
377
431
|
} catch (err) {
|
|
378
432
|
if (err && err.code === "GATE_TIMEOUT") {
|
|
379
433
|
// Expected outcome, not an error: exit 0 with the pending contract so the
|
|
380
434
|
// agent wraps up cleanly instead of reading a scary non-zero failure.
|
|
381
|
-
|
|
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
|
+
);
|
|
382
444
|
return null;
|
|
383
445
|
}
|
|
384
446
|
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
|
+
}
|