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.
- package/CLAUDE.md +10 -7
- package/README.md +20 -7
- package/bin/slidemind.mjs +393 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +68 -16
- 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 +83 -11
- package/src/builders/gate-server.mjs +364 -0
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-md.mjs +216 -0
- package/src/builders/plan-overlay.js +1778 -0
- package/src/builders/plan.mjs +696 -0
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/resolve-skill.mjs +40 -5
- package/src/builders/review-overlay.js +191 -13
- package/src/builders/review.mjs +196 -68
- 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
|
@@ -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
|
-
`
|
|
137
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|