slidemind 0.3.4 → 0.3.12
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/.agents/plugins/marketplace.json +2 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +5 -5
- package/bin/slidemind.mjs +535 -134
- package/package.json +1 -1
- package/skills/create-slides/SKILL.md +718 -127
- package/skills/search/SKILL.md +217 -57
- package/src/bridge/build-and-push.mjs +3 -3
- package/src/bridge/bundle.mjs +91 -36
- package/src/bridge/capture.mjs +34 -0
- package/src/bridge/client.mjs +81 -23
- package/src/bridge/config.mjs +21 -2
- package/src/bridge/engine-download.mjs +26 -7
- package/src/bridge/recolor.mjs +46 -0
- package/src/bridge/setup-claim.mjs +7 -1
- package/src/builders/build.mjs +179 -5
- package/src/builders/gate-server.mjs +25 -36
- package/src/builders/gate-session.js +28 -48
- package/src/builders/lint-fix.mjs +88 -0
- package/src/builders/plan-overlay.js +507 -136
- package/src/builders/plan.mjs +337 -60
- package/src/builders/review-overlay.js +126 -109
- package/src/builders/review.mjs +118 -64
- package/src/builders/storyboard-markdown.mjs +15 -0
- package/src/builders/storyboard-schema.mjs +23 -6
- package/src/builders/visual-families.mjs +20 -4
- package/src/lib/adopt.mjs +10 -6
- package/src/lib/conversions.mjs +26 -6
- package/src/lib/overwrite-guard.mjs +14 -1
- package/src/lib/reconcile.mjs +15 -4
- package/src/pptx/tags.mjs +49 -0
package/src/builders/plan.mjs
CHANGED
|
@@ -18,17 +18,12 @@
|
|
|
18
18
|
// - comments -> the user's slide-anchored comments (the agent revises
|
|
19
19
|
// plan.md to address them and re-runs).
|
|
20
20
|
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// navigable, composer
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// those comments (a "drain round": the command prints DECISION: comments and
|
|
28
|
-
// exits without ever parking), so several consecutive comment rounds are the
|
|
29
|
-
// normal shape of a review. An empty backlog never POSTs and the run parks and
|
|
30
|
-
// blocks, exactly as before. Approve is refused once a backlog arrived in this
|
|
31
|
-
// run — that round is already superseded.
|
|
21
|
+
// ROUNDS ARE SYNCHRONOUS (add-in parity). Send resolves the gate with the
|
|
22
|
+
// comment and the command exits with DECISION: comments; the tab then sits in
|
|
23
|
+
// WORKING mode — readable and navigable, composer and Approve off — until the
|
|
24
|
+
// agent's next `slidemind plan` run reloads it with the revised plan. Nothing is
|
|
25
|
+
// queued behind the agent's back; several consecutive comment rounds are the
|
|
26
|
+
// normal shape of a review.
|
|
32
27
|
//
|
|
33
28
|
// Host-free: the plan gate runs BEFORE any slide exists, so it needs no bundle
|
|
34
29
|
// and no open host. A per-brand orb PNG is used when a bundle happens to be
|
|
@@ -57,6 +52,7 @@ import path from "node:path";
|
|
|
57
52
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
58
53
|
|
|
59
54
|
import { openInBrowser } from "./build.mjs";
|
|
55
|
+
import { loadReviewState } from "./review.mjs";
|
|
60
56
|
import { renderPlanMarkdown } from "./plan-md.mjs";
|
|
61
57
|
import { splitPlanFrontmatter } from "./resolve-skill.mjs";
|
|
62
58
|
import {
|
|
@@ -67,7 +63,6 @@ import {
|
|
|
67
63
|
createHeartbeat,
|
|
68
64
|
bundleGateSession,
|
|
69
65
|
normalizeGateComments,
|
|
70
|
-
serveBacklogPost,
|
|
71
66
|
serveDecisionPost,
|
|
72
67
|
stripModuleSyntax,
|
|
73
68
|
listenWithFallback,
|
|
@@ -79,7 +74,7 @@ import {
|
|
|
79
74
|
POLL_MS,
|
|
80
75
|
POLL_TTL_MS,
|
|
81
76
|
} from "./gate-server.mjs";
|
|
82
|
-
import { resolveBrandAsset } from "../bridge/bundle.mjs";
|
|
77
|
+
import { resolveBrandAsset, resolveBundleDirForDeck } from "../bridge/bundle.mjs";
|
|
83
78
|
|
|
84
79
|
const __filename = fileURLToPath(import.meta.url);
|
|
85
80
|
const __dirname = path.dirname(__filename);
|
|
@@ -94,7 +89,12 @@ const STORYBOARD_MARKDOWN_PATH = path.join(__dirname, "storyboard-markdown.mjs")
|
|
|
94
89
|
const RED_THREAD_BACKBONE_PATH = path.join(__dirname, "red-thread-backbone.mjs");
|
|
95
90
|
const PLAN_MARKDOWN_EDIT_PATH = path.join(__dirname, "plan-markdown-edit.mjs");
|
|
96
91
|
const PLAN_STATE_FILE = "plan-review.json";
|
|
97
|
-
|
|
92
|
+
// No decision timeout: the gate returns only on the user's decision (or when
|
|
93
|
+
// no browser could be opened at all — see openFailedGraceMs). An agent cannot
|
|
94
|
+
// proceed past a plan a human has not approved, so the process waits as long as
|
|
95
|
+
// the human does; a harness that backgrounds the call gets the decision when it
|
|
96
|
+
// lands. Pass timeoutMs explicitly to cap the wait (tests).
|
|
97
|
+
const DEFAULT_TIMEOUT_MS = null;
|
|
98
98
|
|
|
99
99
|
// Escape a </script> sequence so an inlined JSON/JS blob can't break out of the
|
|
100
100
|
// surrounding <script> tag (copied from review.mjs — that module is off-limits
|
|
@@ -158,6 +158,39 @@ export function savePlanState(deckDir, state) {
|
|
|
158
158
|
);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// The human checkmark every post-plan command (concepts, build, view-draft,
|
|
162
|
+
// review) requires — enforced by the CLI, not by the skill's prose. A deck
|
|
163
|
+
// without plan.md is the in-chat single-slide path and passes. Otherwise
|
|
164
|
+
// null = approved and unchanged since (planHash is the one persisted with the
|
|
165
|
+
// decision); else the agent-facing reason plus the command that clears it.
|
|
166
|
+
//
|
|
167
|
+
// The gate binds only until the deck has passed a review: from then on the plan
|
|
168
|
+
// has been executed and plan.md is a record, not a contract — the add-in's
|
|
169
|
+
// manager likewise hands an edit delegation `approvedPlan: null` and never
|
|
170
|
+
// re-gates the old plan. Edits to existing slides are the review gate's job
|
|
171
|
+
// (it re-arms on every slide change); the plan rule for slides added to a
|
|
172
|
+
// reviewed deck stays the skill's, as it is in-app.
|
|
173
|
+
export function requirePlanApproved(deckDir) {
|
|
174
|
+
const planPath = path.join(deckDir, "plan.md");
|
|
175
|
+
if (!fs.existsSync(planPath)) return null;
|
|
176
|
+
if (loadReviewState(deckDir).decision === "approve") return null;
|
|
177
|
+
const base = path.basename(deckDir);
|
|
178
|
+
const state = loadPlanState(deckDir);
|
|
179
|
+
if (state.decision !== "approve") {
|
|
180
|
+
return (
|
|
181
|
+
`${base}'s plan is not signed off — a human must approve it first: ` +
|
|
182
|
+
`run \`slidemind plan ${base}\` and wait for DECISION: approved.`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (state.planHash !== hashPlan(planPath)) {
|
|
186
|
+
return (
|
|
187
|
+
`${base}/plan.md changed since the user approved it — ` +
|
|
188
|
+
`re-run \`slidemind plan ${base}\` for a fresh sign-off.`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
// ---------------------------------------------------------------------------
|
|
162
195
|
// harness output
|
|
163
196
|
// ---------------------------------------------------------------------------
|
|
@@ -177,26 +210,15 @@ function commentBullet(c) {
|
|
|
177
210
|
// Render the comments round for the harness — the plan analog of review.mjs's
|
|
178
211
|
// formatCommentsForHarness, on slide-anchored comments instead of spatial pins.
|
|
179
212
|
//
|
|
180
|
-
// The tail is deliberately imperative: the reviewer's tab is
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
export function formatPlanCommentsForHarness(
|
|
186
|
-
deckDir,
|
|
187
|
-
comments,
|
|
188
|
-
{ edited = false, drained = false } = {},
|
|
189
|
-
) {
|
|
213
|
+
// The tail is deliberately imperative: the reviewer's tab is waiting on the
|
|
214
|
+
// next `slidemind plan` run — nothing reaches them until it runs. An agent that
|
|
215
|
+
// ends its turn here strands them, so the re-run instruction says "same turn"
|
|
216
|
+
// and says why more comment rounds may follow.
|
|
217
|
+
export function formatPlanCommentsForHarness(deckDir, comments, { edited = false } = {}) {
|
|
190
218
|
const rows = normalizePlanComments(comments);
|
|
191
219
|
const out = [];
|
|
192
220
|
out.push("DECISION: comments");
|
|
193
221
|
out.push("The user requested changes to the plan. Nothing was authored.");
|
|
194
|
-
if (drained) {
|
|
195
|
-
out.push(
|
|
196
|
-
"(These comments were queued while you were revising and arrived with this " +
|
|
197
|
-
"run — that is why the gate returned immediately instead of waiting.)",
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
222
|
if (edited) {
|
|
201
223
|
out.push(
|
|
202
224
|
"The user also edited plan.md directly — re-read it before revising; the " +
|
|
@@ -213,9 +235,8 @@ export function formatPlanCommentsForHarness(
|
|
|
213
235
|
out.push("Revise plan.md to address ALL comments — change only what the feedback");
|
|
214
236
|
out.push("requires; keep unaffected slides verbatim.");
|
|
215
237
|
out.push(`Then run \`slidemind plan ${path.basename(deckDir)}\` again in the same turn.`);
|
|
216
|
-
out.push("The reviewer
|
|
217
|
-
out.push("
|
|
218
|
-
out.push("normal, not an error). Do NOT end your turn and do NOT send the user back");
|
|
238
|
+
out.push("The reviewer then reviews the revised plan — several comment rounds are");
|
|
239
|
+
out.push("normal, not an error. Do NOT end your turn and do NOT send the user back");
|
|
219
240
|
out.push("to the browser before you re-run the gate.");
|
|
220
241
|
out.push("");
|
|
221
242
|
// Recovery pointer in the tail (truncated captures usually lose the TOP):
|
|
@@ -232,11 +253,13 @@ export function formatPlanCommentsForHarness(
|
|
|
232
253
|
// state (the user stepped away), not a failure — the agent must neither treat
|
|
233
254
|
// it as approval nor abort the task; it wraps up with clear resume instructions.
|
|
234
255
|
export function formatPlanPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
|
|
235
|
-
const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
|
|
236
256
|
const base = path.basename(deckDir);
|
|
257
|
+
const waited = timeoutMs
|
|
258
|
+
? `the gate waited ${Math.max(1, Math.round(timeoutMs / 60000))} minutes and the user hasn't answered.`
|
|
259
|
+
: "the gate closed without an answer from the user.";
|
|
237
260
|
return [
|
|
238
261
|
"DECISION: pending",
|
|
239
|
-
`No decision yet —
|
|
262
|
+
`No decision yet — ${waited}`,
|
|
240
263
|
...pendingBrowserLines({ pageLoaded, url }),
|
|
241
264
|
"This is NOT an approval and NOT a rejection. Do not author anything.",
|
|
242
265
|
"Close out your turn gracefully:",
|
|
@@ -262,6 +285,7 @@ const PLAN_NAMESPACE_EXPORTS = [
|
|
|
262
285
|
"replaceRenderedMarkdownBlock",
|
|
263
286
|
"splitPlanMarkdownIntoSegments",
|
|
264
287
|
"replaceStoryboardSegment",
|
|
288
|
+
"setStoryboardLayoutForAll",
|
|
265
289
|
"extractRedThreadMessages",
|
|
266
290
|
"findRedThreadSection",
|
|
267
291
|
"parseStoryboardCanvas",
|
|
@@ -278,6 +302,7 @@ const PLAN_NAMESPACE_EXPORTS = [
|
|
|
278
302
|
"MAX_BLOCKS",
|
|
279
303
|
"MAX_DESIGN_ELEMENT_LENGTH",
|
|
280
304
|
"MAX_CONTENT_LENGTH",
|
|
305
|
+
"MAX_IMAGE_PROMPT_LENGTH",
|
|
281
306
|
];
|
|
282
307
|
|
|
283
308
|
// stripModuleSyntax now lives in gate-server.mjs (both gates bundle browser
|
|
@@ -358,6 +383,215 @@ display:block;overflow-x:auto;}
|
|
|
358
383
|
#plan-root a{color:#a9c2ff;}
|
|
359
384
|
`;
|
|
360
385
|
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// layout picker data
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
const CHROME_ASSET_RE = /^chrome-[\w-]+\.(png|webp|jpe?g)$/i;
|
|
391
|
+
|
|
392
|
+
// Readable chip per OOXML placeholder type; null = meta chrome drawn as an
|
|
393
|
+
// unlabeled faint outline (footer/date/page number chips would only add noise).
|
|
394
|
+
const PH_OUTLINE_LABELS = {
|
|
395
|
+
title: "Title",
|
|
396
|
+
ctrTitle: "Title",
|
|
397
|
+
subTitle: "Subtitle",
|
|
398
|
+
body: "Body",
|
|
399
|
+
obj: "Body",
|
|
400
|
+
pic: "Image",
|
|
401
|
+
media: "Image",
|
|
402
|
+
chart: "Chart",
|
|
403
|
+
tbl: "Table",
|
|
404
|
+
dt: null,
|
|
405
|
+
ftr: null,
|
|
406
|
+
sldNum: null,
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
// Chrome placeholder outlines for one layout, host-free: prop names + types
|
|
410
|
+
// from the JSX's `// PLACEHOLDERS:` metadata line, geometry from the generated
|
|
411
|
+
// layouts.css positional rules (`.layout-<slug> .slide__<prop> { left: Npx; … }`
|
|
412
|
+
// on the canonical 1280×720 stage). Best-effort: any parse miss just drops that
|
|
413
|
+
// outline.
|
|
414
|
+
export function parsePlaceholderOutlines(source, layoutsCss) {
|
|
415
|
+
if (!layoutsCss) return [];
|
|
416
|
+
const slugMatch = /className="slide(?:\s+slide--dark)?\s+(layout-[a-z0-9-]+)"/.exec(source);
|
|
417
|
+
const metaMatch = /^\s*\/\/ PLACEHOLDERS: (\{.*\})\s*$/m.exec(source);
|
|
418
|
+
if (!slugMatch || !metaMatch) return [];
|
|
419
|
+
let chrome;
|
|
420
|
+
try {
|
|
421
|
+
chrome = JSON.parse(metaMatch[1]).chrome;
|
|
422
|
+
} catch {
|
|
423
|
+
return [];
|
|
424
|
+
}
|
|
425
|
+
if (!Array.isArray(chrome)) return [];
|
|
426
|
+
const outlines = [];
|
|
427
|
+
for (const entry of chrome) {
|
|
428
|
+
if (!entry || typeof entry.prop !== "string") continue;
|
|
429
|
+
const kebab = entry.prop.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
430
|
+
const rule = new RegExp(
|
|
431
|
+
"\\." + slugMatch[1] + "\\s+\\.slide__" + kebab + "\\s*\\{([^}]*)\\}",
|
|
432
|
+
).exec(layoutsCss);
|
|
433
|
+
if (!rule) continue;
|
|
434
|
+
const px = (name) => {
|
|
435
|
+
const m = new RegExp(name + ":\\s*(-?\\d+(?:\\.\\d+)?)px").exec(rule[1]);
|
|
436
|
+
return m ? Number(m[1]) : null;
|
|
437
|
+
};
|
|
438
|
+
const left = px("left");
|
|
439
|
+
const top = px("top");
|
|
440
|
+
const width = px("width");
|
|
441
|
+
const height = px("height");
|
|
442
|
+
if (left == null || top == null || !width || !height) continue;
|
|
443
|
+
const b = { x: left / 1280, y: top / 720, w: width / 1280, h: height / 720 };
|
|
444
|
+
const sane = (v) => Number.isFinite(v) && v >= -0.05 && v <= 1.05;
|
|
445
|
+
if (!(sane(b.x) && sane(b.y) && sane(b.w) && sane(b.h))) continue;
|
|
446
|
+
outlines.push({
|
|
447
|
+
label:
|
|
448
|
+
typeof entry.type === "string" && entry.type in PH_OUTLINE_LABELS
|
|
449
|
+
? PH_OUTLINE_LABELS[entry.type]
|
|
450
|
+
: null,
|
|
451
|
+
// Image slots carry their codegen prop name (`image`, `image2`, …) so the
|
|
452
|
+
// storyboard can author a per-slot `imagePrompts` entry under that key.
|
|
453
|
+
...(entry.type === "pic" || entry.type === "media" ? { key: entry.prop } : {}),
|
|
454
|
+
x: b.x,
|
|
455
|
+
y: b.y,
|
|
456
|
+
w: b.w,
|
|
457
|
+
h: b.h,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
return outlines;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The per-slide layout picker's option list, resolved host-free from the
|
|
464
|
+
// deck's materialized bundle: componentName from `layouts/<Name>.jsx`, the
|
|
465
|
+
// canvas line from the codegen's `// [CONTENT: …]` annotation (present only on
|
|
466
|
+
// workhorse layouts), the purpose from design.md's Layout Catalog, and the
|
|
467
|
+
// chrome-render thumbnail from `assets/chrome-<Name>.*` served at
|
|
468
|
+
// `/brand/chrome/<file>`. No bundle → `{ layouts: [], chromeDir: null }` and
|
|
469
|
+
// the picker degrades to read-only (the host-free contract holds).
|
|
470
|
+
export function resolvePlanLayouts(deckDir) {
|
|
471
|
+
const dir = resolveBundleDirForDeck(deckDir);
|
|
472
|
+
if (!dir) return { layouts: [], chromeDir: null };
|
|
473
|
+
const layoutsDir = path.join(dir, "layouts");
|
|
474
|
+
const assetsDir = path.join(dir, "assets");
|
|
475
|
+
let files;
|
|
476
|
+
try {
|
|
477
|
+
files = fs.readdirSync(layoutsDir).filter((f) => f.endsWith(".jsx"));
|
|
478
|
+
} catch {
|
|
479
|
+
return { layouts: [], chromeDir: null };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// design.md Layout Catalog → `### <Name>` + `- Purpose: …` + `- Content
|
|
483
|
+
// areas: 1. …; 2. …` (or `none (chrome-only)`). Line scan; catalog entries
|
|
484
|
+
// are the only place these bullets follow a ### heading in the generated
|
|
485
|
+
// design.md.
|
|
486
|
+
const purposes = new Map();
|
|
487
|
+
const canvases = new Map();
|
|
488
|
+
try {
|
|
489
|
+
const md = fs.readFileSync(path.join(dir, "design.md"), "utf8");
|
|
490
|
+
let current = null;
|
|
491
|
+
for (const line of md.split(/\r?\n/)) {
|
|
492
|
+
const heading = /^###\s+(\S+)\s*$/.exec(line);
|
|
493
|
+
if (heading) {
|
|
494
|
+
current = heading[1];
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (!current) continue;
|
|
498
|
+
const purpose = /^- Purpose:\s*(.+)$/.exec(line);
|
|
499
|
+
if (purpose) purposes.set(current, purpose[1].trim());
|
|
500
|
+
// Newer add-ins write `- Content areas:`; the deployed one still writes
|
|
501
|
+
// `- Canvas:` — a bundle from either must feed the picker.
|
|
502
|
+
const canvas = /^- (?:Content areas|Canvas):\s*(.+)$/.exec(line);
|
|
503
|
+
if (canvas) canvases.set(current, canvas[1].trim());
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
/* no design.md — names + annotations still make a usable picker */
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
let chromeFiles = [];
|
|
510
|
+
try {
|
|
511
|
+
chromeFiles = fs.readdirSync(assetsDir).filter((f) => CHROME_ASSET_RE.test(f));
|
|
512
|
+
} catch {
|
|
513
|
+
/* no assets dir */
|
|
514
|
+
}
|
|
515
|
+
let layoutsCss = "";
|
|
516
|
+
try {
|
|
517
|
+
layoutsCss = fs.readFileSync(path.join(layoutsDir, "layouts.css"), "utf8");
|
|
518
|
+
} catch {
|
|
519
|
+
/* no positional CSS — placeholder outlines just stay empty */
|
|
520
|
+
}
|
|
521
|
+
const chromeByComponent = new Map();
|
|
522
|
+
for (const file of chromeFiles) {
|
|
523
|
+
chromeByComponent.set(file.replace(/^chrome-/, "").replace(/\.[^.]+$/, ""), file);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const layouts = [];
|
|
527
|
+
for (const file of files) {
|
|
528
|
+
const componentName = file.replace(/\.jsx$/, "");
|
|
529
|
+
let source = "";
|
|
530
|
+
try {
|
|
531
|
+
source = fs.readFileSync(path.join(layoutsDir, file), "utf8");
|
|
532
|
+
} catch {
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
// The `[CONTENT: …]` annotation is emitted only above a `children` prop —
|
|
536
|
+
// its presence IS the workhorse test, its text the canvas line, and its
|
|
537
|
+
// numbers (px on the canonical 1280×720 stage) normalize into the
|
|
538
|
+
// content-zone rectangles the storyboard frame draws. The head is the
|
|
539
|
+
// union bbox; a multi-area layout appends `Allowed regions
|
|
540
|
+
// (content-relative): 1: W×H px @ (x, y); …` — the gaps between those
|
|
541
|
+
// regions are not writable, so each becomes its own rectangle.
|
|
542
|
+
const content = /\[CONTENT:\s*([^\]]+)\]/.exec(source);
|
|
543
|
+
const annotated = content ? content[1].trim() : null;
|
|
544
|
+
const canvas = canvases.get(componentName) ?? annotated;
|
|
545
|
+
let canvasBounds = null;
|
|
546
|
+
let contentAreas = [];
|
|
547
|
+
const nums = content
|
|
548
|
+
? /^(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*px\s*@\s*\((\d+(?:\.\d+)?),\s*(\d+(?:\.\d+)?)\)/.exec(
|
|
549
|
+
annotated,
|
|
550
|
+
)
|
|
551
|
+
: null;
|
|
552
|
+
if (nums) {
|
|
553
|
+
const b = {
|
|
554
|
+
x: Number(nums[3]) / 1280,
|
|
555
|
+
y: Number(nums[4]) / 720,
|
|
556
|
+
w: Number(nums[1]) / 1280,
|
|
557
|
+
h: Number(nums[2]) / 720,
|
|
558
|
+
};
|
|
559
|
+
const sane = (v) => Number.isFinite(v) && v >= 0 && v <= 1.05;
|
|
560
|
+
const clamp = (r) =>
|
|
561
|
+
sane(r.x) && sane(r.y) && sane(r.w) && sane(r.h)
|
|
562
|
+
? { x: Math.min(r.x, 1), y: Math.min(r.y, 1), w: Math.min(r.w, 1), h: Math.min(r.h, 1) }
|
|
563
|
+
: null;
|
|
564
|
+
canvasBounds = clamp(b);
|
|
565
|
+
const regions = /Allowed regions \(content-relative\):\s*([^.]+)\./.exec(annotated);
|
|
566
|
+
if (canvasBounds && regions) {
|
|
567
|
+
const re = /(\d+(?:\.\d+)?)\s*[×x]\s*(\d+(?:\.\d+)?)\s*px\s*@\s*\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)/g;
|
|
568
|
+
for (const m of regions[1].matchAll(re)) {
|
|
569
|
+
const area = clamp({
|
|
570
|
+
x: (Number(nums[3]) + Number(m[3])) / 1280,
|
|
571
|
+
y: (Number(nums[4]) + Number(m[4])) / 720,
|
|
572
|
+
w: Number(m[1]) / 1280,
|
|
573
|
+
h: Number(m[2]) / 720,
|
|
574
|
+
});
|
|
575
|
+
if (area) contentAreas.push(area);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (canvasBounds && contentAreas.length === 0) contentAreas = [canvasBounds];
|
|
579
|
+
}
|
|
580
|
+
const chrome = chromeByComponent.get(componentName);
|
|
581
|
+
layouts.push({
|
|
582
|
+
componentName,
|
|
583
|
+
purpose: purposes.get(componentName) ?? null,
|
|
584
|
+
mode: content ? "workhorse" : "purpose-built",
|
|
585
|
+
canvas,
|
|
586
|
+
canvasBounds,
|
|
587
|
+
contentAreas,
|
|
588
|
+
placeholders: parsePlaceholderOutlines(source, layoutsCss),
|
|
589
|
+
previewUrl: chrome ? `/brand/chrome/${chrome}` : null,
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return { layouts, chromeDir: chromeFiles.length ? assetsDir : null };
|
|
593
|
+
}
|
|
594
|
+
|
|
361
595
|
// Build the standalone plan-review page: the rendered markdown in #plan-root
|
|
362
596
|
// (the no-JS fallback and the preview's initial state), the injected overlay
|
|
363
597
|
// config, the bundled plan-review modules, then the overlay script itself.
|
|
@@ -375,15 +609,17 @@ export function buildPlanPage({
|
|
|
375
609
|
round,
|
|
376
610
|
deck,
|
|
377
611
|
ledgerSeed,
|
|
612
|
+
layouts,
|
|
378
613
|
}) {
|
|
379
614
|
const cfg = safeInlineJson({
|
|
380
615
|
endpoint: "/plan-review",
|
|
381
|
-
backlogEndpoint: "/plan-review/backlog",
|
|
382
616
|
heartbeat: "/__plan/heartbeat",
|
|
383
617
|
round: round ?? null,
|
|
384
618
|
pollMs: POLL_MS,
|
|
385
619
|
pollTtlMs: POLL_TTL_MS,
|
|
386
620
|
logoMark: logoMark ?? null,
|
|
621
|
+
// Brand layouts for the per-slide layout picker (empty → picker read-only).
|
|
622
|
+
layouts: Array.isArray(layouts) ? layouts : [],
|
|
387
623
|
planBody: planBody ?? "",
|
|
388
624
|
bodyStartLine: bodyStartLine ?? 1,
|
|
389
625
|
// deck: the sessionStorage namespace for this deck's comment ledger.
|
|
@@ -439,6 +675,8 @@ export async function createPlanReviewServer({
|
|
|
439
675
|
logoMark = null,
|
|
440
676
|
brandAssetPath = null,
|
|
441
677
|
ledgerSeed = [],
|
|
678
|
+
layouts = [],
|
|
679
|
+
chromeDir = null,
|
|
442
680
|
port = 0,
|
|
443
681
|
}) {
|
|
444
682
|
const gate = createDecisionGate({
|
|
@@ -460,6 +698,7 @@ export async function createPlanReviewServer({
|
|
|
460
698
|
round: hb.round,
|
|
461
699
|
deck: path.basename(path.resolve(deckDir)),
|
|
462
700
|
ledgerSeed: normalizePlanComments(ledgerSeed),
|
|
701
|
+
layouts,
|
|
463
702
|
});
|
|
464
703
|
const deckRoot = path.resolve(deckDir);
|
|
465
704
|
|
|
@@ -467,23 +706,11 @@ export async function createPlanReviewServer({
|
|
|
467
706
|
const u = new URL(req.url, "http://127.0.0.1");
|
|
468
707
|
const pathname = decodeURIComponent(u.pathname);
|
|
469
708
|
|
|
470
|
-
//
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// posting into one run is the normal multi-window case, not an anomaly).
|
|
476
|
-
if (req.method === "POST" && pathname === "/plan-review/backlog") {
|
|
477
|
-
serveBacklogPost(req, res, {
|
|
478
|
-
normalize: normalizePlanComments,
|
|
479
|
-
isSuperseded: () => gate.settled(),
|
|
480
|
-
onDrained: (comments) => {
|
|
481
|
-
gate.resolve({ decision: "comments", comments, editedPlan: null, drained: true });
|
|
482
|
-
},
|
|
483
|
-
});
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
|
|
709
|
+
// The decision POST is gate-server's shared handler: the 409 guard behaves
|
|
710
|
+
// identically in both gates. `gate.settled()` refuses a late POST from
|
|
711
|
+
// another waiting tab instead of acking and silently dropping it (every
|
|
712
|
+
// waiter reloads into a new round, so two tabs posting into one run is the
|
|
713
|
+
// normal multi-window case, not an anomaly).
|
|
487
714
|
if (req.method === "POST" && pathname === "/plan-review") {
|
|
488
715
|
serveDecisionPost(req, res, {
|
|
489
716
|
normalize: normalizePlanComments,
|
|
@@ -493,7 +720,6 @@ export async function createPlanReviewServer({
|
|
|
493
720
|
decision,
|
|
494
721
|
comments,
|
|
495
722
|
editedPlan: typeof payload.editedPlan === "string" ? payload.editedPlan : null,
|
|
496
|
-
drained: false,
|
|
497
723
|
});
|
|
498
724
|
},
|
|
499
725
|
});
|
|
@@ -521,7 +747,10 @@ export async function createPlanReviewServer({
|
|
|
521
747
|
// actually opened in front of the user (see gate-server's page-load
|
|
522
748
|
// tracker; serveAndOpen and the pending contract report from it).
|
|
523
749
|
hb.notePageLoad();
|
|
524
|
-
res.writeHead(200, {
|
|
750
|
+
res.writeHead(200, {
|
|
751
|
+
"Content-Type": MIME[".html"],
|
|
752
|
+
"Cache-Control": "no-store, max-age=0",
|
|
753
|
+
});
|
|
525
754
|
res.end(indexHtml);
|
|
526
755
|
return;
|
|
527
756
|
}
|
|
@@ -539,7 +768,35 @@ export async function createPlanReviewServer({
|
|
|
539
768
|
return;
|
|
540
769
|
}
|
|
541
770
|
const ext = path.extname(brandAssetPath).toLowerCase();
|
|
542
|
-
res.writeHead(200, {
|
|
771
|
+
res.writeHead(200, {
|
|
772
|
+
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
773
|
+
"Cache-Control": "no-store, max-age=0",
|
|
774
|
+
});
|
|
775
|
+
res.end(data);
|
|
776
|
+
});
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Layout chrome thumbnails for the picker — served from the materialized
|
|
781
|
+
// bundle's assets dir, basename-guarded to the chrome-<Name>.<ext> shape.
|
|
782
|
+
if (pathname.startsWith("/brand/chrome/")) {
|
|
783
|
+
const name = path.basename(pathname);
|
|
784
|
+
if (!chromeDir || !CHROME_ASSET_RE.test(name)) {
|
|
785
|
+
res.writeHead(404);
|
|
786
|
+
res.end("not found");
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
fs.readFile(path.join(chromeDir, name), (err, data) => {
|
|
790
|
+
if (err) {
|
|
791
|
+
res.writeHead(404);
|
|
792
|
+
res.end("not found");
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const ext = path.extname(name).toLowerCase();
|
|
796
|
+
res.writeHead(200, {
|
|
797
|
+
"Content-Type": MIME[ext] || "application/octet-stream",
|
|
798
|
+
"Cache-Control": "no-store, max-age=0",
|
|
799
|
+
});
|
|
543
800
|
res.end(data);
|
|
544
801
|
});
|
|
545
802
|
return;
|
|
@@ -614,6 +871,24 @@ export async function runPlanReview({
|
|
|
614
871
|
return;
|
|
615
872
|
}
|
|
616
873
|
|
|
874
|
+
// Headless simulation runs (scripts/codex-sim.mjs): approve without a browser.
|
|
875
|
+
if (process.env.SLIDEMIND_GATE_AUTOAPPROVE === "1") {
|
|
876
|
+
savePlanState(deckDir, {
|
|
877
|
+
version: 3,
|
|
878
|
+
decision: "approve",
|
|
879
|
+
comments: [],
|
|
880
|
+
edited: false,
|
|
881
|
+
planHash: hashPlan(planPath),
|
|
882
|
+
at: new Date().toISOString(),
|
|
883
|
+
});
|
|
884
|
+
process.stdout.write(
|
|
885
|
+
"DECISION: approved\n" +
|
|
886
|
+
"Auto-approved via SLIDEMIND_GATE_AUTOAPPROVE — no human reviewed this plan. " +
|
|
887
|
+
"Proceed to author the slides.\n",
|
|
888
|
+
);
|
|
889
|
+
return { decision: "approve", comments: [] };
|
|
890
|
+
}
|
|
891
|
+
|
|
617
892
|
const { frontmatterRaw, body, bodyStartLine } = splitPlanFrontmatter(
|
|
618
893
|
fs.readFileSync(planPath, "utf8"),
|
|
619
894
|
);
|
|
@@ -624,6 +899,7 @@ export async function runPlanReview({
|
|
|
624
899
|
const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
|
|
625
900
|
const logoMark = mark ? "/brand/logo" : null;
|
|
626
901
|
const brandAssetPath = mark?.path ?? null;
|
|
902
|
+
const { layouts, chromeDir } = resolvePlanLayouts(deckDir);
|
|
627
903
|
|
|
628
904
|
const prev = loadPlanState(deckDir);
|
|
629
905
|
|
|
@@ -637,6 +913,8 @@ export async function runPlanReview({
|
|
|
637
913
|
// A fresh tab renders the previous round's comments from the persisted file
|
|
638
914
|
// (the plan gate's analogue of review.json's ghost pins).
|
|
639
915
|
ledgerSeed: prev.comments || [],
|
|
916
|
+
layouts,
|
|
917
|
+
chromeDir,
|
|
640
918
|
port: wantPort,
|
|
641
919
|
});
|
|
642
920
|
// A waiting tab can only exist when the previous round ended in comments — so
|
|
@@ -648,9 +926,8 @@ export async function runPlanReview({
|
|
|
648
926
|
reuseNoun: "plan tab",
|
|
649
927
|
gateNoun: "plan review",
|
|
650
928
|
waitLine:
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
)} min) while they review. A long quiet wait is normal; do not cancel it.`,
|
|
929
|
+
"Waiting for the user's decision — this command BLOCKS until they decide; there is no " +
|
|
930
|
+
"timeout. A long quiet wait is normal; do not cancel it, and do nothing else meanwhile.",
|
|
654
931
|
});
|
|
655
932
|
|
|
656
933
|
let decision;
|
|
@@ -721,7 +998,7 @@ export async function runPlanReview({
|
|
|
721
998
|
}
|
|
722
999
|
} else {
|
|
723
1000
|
process.stdout.write(
|
|
724
|
-
formatPlanCommentsForHarness(deckDir, comments, { edited
|
|
1001
|
+
formatPlanCommentsForHarness(deckDir, comments, { edited }),
|
|
725
1002
|
);
|
|
726
1003
|
}
|
|
727
1004
|
return decision;
|