slidemind 0.3.13 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,608 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fail, requirePlanApproved, requireReviewApproved, resolveDeckArg, runNode, splitDeckArg, renderProgressPrinter, dataUriToPng, freshenDeckBundle, withUnboundHint, SRC_DIR, BUILDERS_DIR } from "./common.mjs";
5
+
6
+ export async function cmdPlan(rest) {
7
+ const { deck, passthrough } = splitDeckArg(rest, "plan");
8
+ await runNode(path.join(BUILDERS_DIR, "plan.mjs"), [
9
+ "--deck",
10
+ resolveDeckArg(deck),
11
+ ...passthrough,
12
+ ]);
13
+ }
14
+
15
+ export async function cmdReview(rest) {
16
+ const { deck, passthrough } = splitDeckArg(rest, "review");
17
+ const deckDir = resolveDeckArg(deck);
18
+ await requirePlanApproved(deckDir);
19
+ await freshenDeckBundle(deckDir);
20
+ await runNode(path.join(BUILDERS_DIR, "review.mjs"), ["--deck", deckDir, ...passthrough]);
21
+ }
22
+
23
+ // Generate one regular image via the paired add-in's image model and write it
24
+ // into the deck's assets/. Pane-free: routes by the deck's bound design
25
+ // (.bundle-ref.json), so it works with PowerPoint backgrounded or closed.
26
+ export async function cmdImagen(rest) {
27
+ let prompt = null;
28
+ let deck = null;
29
+ let outName = null;
30
+ let size = null;
31
+ let quality = null;
32
+ for (let i = 0; i < rest.length; i++) {
33
+ const a = rest[i];
34
+ if (a === "--deck") {
35
+ deck = rest[++i];
36
+ if (!deck) fail("--deck expects a deck");
37
+ } else if (a === "--out" || a === "-o") {
38
+ outName = rest[++i];
39
+ if (!outName) fail("--out expects a file name");
40
+ } else if (a === "--size") {
41
+ size = rest[++i];
42
+ if (!["auto", "1024x1024", "1536x1024", "1024x1536"].includes(size ?? "")) {
43
+ fail("--size expects auto|1024x1024|1536x1024|1024x1536");
44
+ }
45
+ } else if (a === "--quality") {
46
+ quality = rest[++i];
47
+ if (!["low", "medium", "high", "auto"].includes(quality ?? "")) {
48
+ fail("--quality expects low|medium|high|auto");
49
+ }
50
+ } else if (a.startsWith("-")) {
51
+ fail(`unknown flag: ${a}`);
52
+ } else if (prompt === null) {
53
+ prompt = a;
54
+ } else {
55
+ fail(`unexpected argument: ${a} (quote the prompt as one argument)`);
56
+ }
57
+ }
58
+ if (!prompt || !prompt.trim() || !deck) {
59
+ fail(
60
+ 'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
61
+ "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto]",
62
+ );
63
+ }
64
+ const deckDir = resolveDeckArg(deck);
65
+ const bundleMod = await import(
66
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
67
+ );
68
+ const ref = bundleMod.readDeckBundleRef(deckDir);
69
+ if (!ref?.presFP) {
70
+ fail(
71
+ `deck ${path.basename(deckDir)} is not bound to a design yet — run ` +
72
+ `'slidemind ready ${deck}' first (imagen routes by the deck's bound design).`,
73
+ );
74
+ }
75
+ const client = await import(
76
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
77
+ );
78
+ process.stdout.write(" generating image (this can take a minute)…\n");
79
+ let result;
80
+ try {
81
+ result = await client.generateImageForBrand(ref.presFP, {
82
+ prompt,
83
+ size,
84
+ quality,
85
+ });
86
+ } catch (err) {
87
+ if (err?.code === "imagen_route_unavailable") {
88
+ fail(
89
+ `${err.message}\n The paired add-in does not offer image generation yet — ` +
90
+ `it ships with the next add-in update; until then add pictures to assets/ by hand ` +
91
+ `or via 'slidemind download-result'.`,
92
+ );
93
+ }
94
+ throw err;
95
+ }
96
+ const m = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(result?.image ?? "");
97
+ if (!m) fail("backend returned no image data");
98
+ const ext = m[1] === "png" ? "png" : "jpeg";
99
+ const assetsDir = path.join(deckDir, "assets");
100
+ fs.mkdirSync(assetsDir, { recursive: true });
101
+ let file = outName;
102
+ if (!file) {
103
+ let n = 1;
104
+ while (fs.existsSync(path.join(assetsDir, `imagen-${n}.${ext}`))) n++;
105
+ file = `imagen-${n}.${ext}`;
106
+ }
107
+ const outPath = path.join(assetsDir, file);
108
+ fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
109
+ process.stdout.write(
110
+ `Wrote ${path.relative(process.cwd(), outPath)}\n` +
111
+ ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
112
+ );
113
+ }
114
+
115
+ export async function cmdInspectTemplateDeck(rest) {
116
+ const { deck, passthrough } = splitDeckArg(rest, "inspect-template-deck");
117
+ let packDir = null;
118
+ let json = false;
119
+ let allowUnrenderedFreeform = false;
120
+ for (let i = 0; i < passthrough.length; i++) {
121
+ const a = passthrough[i];
122
+ if (a === "--pack") {
123
+ packDir = passthrough[++i];
124
+ if (!packDir) fail("--pack expects a directory");
125
+ } else if (a === "--json") {
126
+ json = true;
127
+ } else if (a === "--allow-unrendered-freeform") {
128
+ allowUnrenderedFreeform = true;
129
+ } else if (a.startsWith("-")) {
130
+ fail(`unknown flag: ${a}`);
131
+ } else {
132
+ fail(`unexpected argument: ${a}`);
133
+ }
134
+ }
135
+ if (!packDir) fail("inspect-template-deck requires --pack <dir>");
136
+
137
+ const deckDir = resolveDeckArg(deck);
138
+ const resolveMod = await import(
139
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
140
+ );
141
+ const skill = resolveMod.resolveSkillForDeck(deckDir, {
142
+ packDir: path.resolve(packDir),
143
+ });
144
+ if (skill.mode !== "fill-template") {
145
+ fail(`pack ${skill.skillDir} is mode ${skill.mode}, not fill-template`);
146
+ }
147
+ const engine = await import(
148
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
149
+ );
150
+ const result = engine.inspectFillTemplateDeck({
151
+ deckDir,
152
+ skill,
153
+ allowUnrenderedFreeform,
154
+ });
155
+ const payload = {
156
+ version: 1,
157
+ pack: {
158
+ name: skill.skillName,
159
+ mode: skill.mode,
160
+ directory: skill.skillDir,
161
+ template: result.templateKey,
162
+ locale: result.locale,
163
+ },
164
+ slides: result.sequence,
165
+ warnings: result.warnings,
166
+ problems: result.problems,
167
+ };
168
+ if (json) {
169
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
170
+ } else {
171
+ for (const item of result.sequence) {
172
+ const suffix = item.kind === "master" ? ` → template ${item.no}` : "";
173
+ process.stdout.write(`${item.file}: ${item.kind}${suffix}\n`);
174
+ }
175
+ for (const warning of result.warnings) process.stderr.write(` warning: ${warning}\n`);
176
+ }
177
+ if (result.problems.length) {
178
+ fail(`inspection failed:\n - ${result.problems.join("\n - ")}`);
179
+ }
180
+ }
181
+
182
+ export async function cmdBuildPptx(rest) {
183
+ const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
184
+ let to = null;
185
+ let outPath = null;
186
+ let packDir = null;
187
+ let force = false;
188
+ let asJson = false;
189
+ for (let i = 0; i < passthrough.length; i++) {
190
+ const a = passthrough[i];
191
+ if (a === "--force") {
192
+ force = true;
193
+ } else if (a === "--json") {
194
+ asJson = true;
195
+ } else if (a === "--to") {
196
+ to = passthrough[++i];
197
+ if (!to) fail("--to expects a presId");
198
+ } else if (a === "--pack") {
199
+ packDir = passthrough[++i];
200
+ if (!packDir) fail("--pack expects a directory");
201
+ } else if (a === "--out" || a === "-o") {
202
+ outPath = passthrough[++i];
203
+ if (!outPath) fail("--out expects a path");
204
+ } else if (a.startsWith("-")) {
205
+ fail(`unknown flag: ${a}`);
206
+ } else {
207
+ fail(`unexpected argument: ${a}`);
208
+ }
209
+ }
210
+ const deckDir = resolveDeckArg(deck);
211
+ if (!fs.existsSync(path.join(deckDir, "slides"))) {
212
+ fail(`expected 'slides/' folder in ${deckDir}`);
213
+ }
214
+
215
+ // Template packs (mode: fill-template) build LOCALLY: the deck's slides are
216
+ // declarative fills over a template .pptx that ships in the pack, so no
217
+ // add-in render is involved and no PowerPoint host is needed. Compose decks
218
+ // take the bridge path below UNTOUCHED — the mode probe never throws, so a
219
+ // compose deck with an unmaterialized bundle still self-heals in the render.
220
+ const resolveMod = await import(
221
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
222
+ );
223
+ const resolveOpts = packDir ? { packDir: path.resolve(packDir) } : {};
224
+ const deckMode = resolveMod.resolveDeckMode(deckDir, resolveOpts);
225
+ if (packDir && deckMode.mode !== "fill-template") {
226
+ fail(`explicit --pack ${path.resolve(packDir)} is mode ${deckMode.mode}, not fill-template`);
227
+ }
228
+ // Template packs have no HTML preview to review and carry their own plan
229
+ // sign-off; compose decks ship only from an approved, unchanged preview.
230
+ if (deckMode.mode !== "fill-template") {
231
+ await requireReviewApproved(deckDir);
232
+ await freshenDeckBundle(deckDir);
233
+ }
234
+ if (deckMode.mode === "fill-template") {
235
+ const skill = resolveMod.resolveSkillForDeck(deckDir, resolveOpts);
236
+ if (to) {
237
+ fail(
238
+ "--to targets a PowerPoint window, but fill-template decks build " +
239
+ "locally from the pack's template — drop --to (use 'slidemind push' " +
240
+ "to get slides into a window).",
241
+ );
242
+ }
243
+ // In-deck freeform slides (create-slides JSX next to the master-backed
244
+ // ones) render to sibling NN.pptx artifacts first — the only step that
245
+ // talks to the add-in; unchanged freeform slides keep the build offline.
246
+ const freeform = await import(
247
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "freeform.mjs")).href
248
+ );
249
+ const ff = await freeform.ensureFreeformRenders({
250
+ deckDir,
251
+ locale: skill.locale ?? "en",
252
+ srcDir: SRC_DIR,
253
+ // Both are the PACK's contract, read from its skill.json — this CLI
254
+ // knows no layout names and no template-fixed titles of its own.
255
+ components: {
256
+ ...(skill.manifest?.components ?? {}),
257
+ skillDir: skill.skillDir,
258
+ },
259
+ policy: skill.manifest?.policy ?? {},
260
+ onLog: (line) => process.stdout.write(` ${line}\n`),
261
+ });
262
+ for (const w of ff.warnings) process.stderr.write(` warning: ${w}\n`);
263
+
264
+ const engine = await import(
265
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
266
+ );
267
+ const result = await engine.buildFillTemplateDeck({
268
+ deckDir,
269
+ skill,
270
+ outPath,
271
+ onLog: (line) => process.stdout.write(` ${line}\n`),
272
+ });
273
+ if (asJson) {
274
+ process.stdout.write(`${JSON.stringify({ outPath: result.outPath, slideCount: result.slideCount, template: skill.templateKey, windowless: true, warnings: result.warnings ?? [] }, null, 2)}\n`);
275
+ } else {
276
+ process.stdout.write(
277
+ `Wrote ${result.outPath} (${result.slideCount} slides) from template ` +
278
+ `'${skill.templateKey}' — host-free\n`,
279
+ );
280
+ }
281
+ for (const w of result.warnings ?? []) {
282
+ process.stderr.write(` warning: ${w}\n`);
283
+ }
284
+ return;
285
+ }
286
+
287
+ // The .pptx is rendered server-side by the add-in (one exporter, one truth).
288
+ // A bound deck renders pane-free over the brand routes — no PowerPoint
289
+ // window needed; unbound decks (or --to) resolve through the pane registry.
290
+ const mod = await import(
291
+ pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
292
+ );
293
+ const result = await mod
294
+ .buildPptxFile({
295
+ deckDir,
296
+ presentationId: to,
297
+ outPath,
298
+ force,
299
+ onProgress: renderProgressPrinter(),
300
+ })
301
+ .catch((err) => fail(withUnboundHint(deckDir, err)));
302
+ if (asJson) {
303
+ process.stdout.write(
304
+ `${JSON.stringify({ outPath: result.outPath, slideCount: result.slideCount ?? null, presId: result.presId ?? null, windowless: Boolean(result.windowless), warnings: result.warnings ?? [], skippedSlides: result.skippedSlides ?? [] }, null, 2)}\n`,
305
+ );
306
+ } else {
307
+ process.stdout.write(
308
+ `Wrote ${result.outPath} (${result.slideCount ?? "?"} slides) via the slidemind add-in` +
309
+ `${result.windowless ? " (no PowerPoint window needed)" : ""}\n`,
310
+ );
311
+ }
312
+ for (const w of result.warnings ?? []) {
313
+ process.stderr.write(` warning: ${w}\n`);
314
+ }
315
+ for (const s of result.skippedSlides ?? []) {
316
+ process.stderr.write(` skipped ${s.stem}: ${s.reason}\n`);
317
+ }
318
+ }
319
+
320
+ export async function cmdViewDraft(rest) {
321
+ const { deck, passthrough } = splitDeckArg(rest, "view-draft");
322
+ let to = null;
323
+ let outDir = null;
324
+ let open = false;
325
+ let onlySpec = null;
326
+ // The server's export gate runs by default: the 422 it would raise at
327
+ // build-pptx (after the user approved) surfaces here instead. "auto" skips
328
+ // with a note when the backend is unreachable; --translate insists, and
329
+ // --no-translate keeps the check purely local.
330
+ let translate = "auto";
331
+ let fix = false;
332
+ let lintOnly = false;
333
+ let asJson = false;
334
+ for (let i = 0; i < passthrough.length; i++) {
335
+ const a = passthrough[i];
336
+ if (a === "--fix") {
337
+ fix = true;
338
+ } else if (a === "--lint-only") {
339
+ lintOnly = true;
340
+ } else if (a === "--json") {
341
+ asJson = true;
342
+ } else if (a === "--no-translate") {
343
+ translate = false;
344
+ } else if (a === "--to") {
345
+ to = passthrough[++i];
346
+ if (!to) fail("--to expects a presId");
347
+ } else if (a === "--out" || a === "-o") {
348
+ outDir = passthrough[++i];
349
+ if (!outDir) fail("--out expects a directory");
350
+ } else if (a === "--open") {
351
+ open = true;
352
+ } else if (a === "--translate") {
353
+ translate = true;
354
+ } else if (a === "--only") {
355
+ onlySpec = passthrough[++i];
356
+ if (!onlySpec) fail("--only expects a slide file or number (comma-separated for several)");
357
+ } else if (a.startsWith("-")) {
358
+ fail(`unknown flag: ${a}`);
359
+ } else {
360
+ fail(`unexpected argument: ${a}`);
361
+ }
362
+ }
363
+ const deckDir = resolveDeckArg(deck);
364
+ if (!fs.existsSync(path.join(deckDir, "slides"))) {
365
+ fail(`expected 'slides/' folder in ${deckDir}`);
366
+ }
367
+ // --only: a per-slide builder re-checks its own file. Accepts file names
368
+ // (`03-loop.jsx`) or slide numbers (`3`, `03`), resolved by numeric prefix.
369
+ let only = null;
370
+ if (onlySpec) {
371
+ const files = fs.readdirSync(path.join(deckDir, "slides")).filter((f) => f.endsWith(".jsx"));
372
+ only = onlySpec.split(",").map((t) => t.trim()).filter(Boolean).map((t) => {
373
+ if (files.includes(t)) return t;
374
+ if (/^\d{1,3}$/.test(t)) {
375
+ const hit = files.find((f) => Number(/^(\d{1,3})[-_.]/.exec(f)?.[1]) === Number(t));
376
+ if (hit) return hit;
377
+ }
378
+ fail(`--only: no slide file matches '${t}' under ${path.join(deckDir, "slides")}`);
379
+ });
380
+ }
381
+ await requirePlanApproved(deckDir);
382
+ await freshenDeckBundle(deckDir);
383
+ // The HTML build runs FIRST — it is what lints every slide against the
384
+ // bundle's capability table and copies the bundle's corporate-design.css +
385
+ // assets into the deck, which the type-ramp check below reads. (This is the
386
+ // work the retired `slidemind build` command used to do in a separate call;
387
+ // `--fix` is its autofix, folded in with it.)
388
+ const { buildDeck } = await import(
389
+ pathToFileURL(path.join(BUILDERS_DIR, "build.mjs")).href
390
+ );
391
+ if (fix) {
392
+ const { fixDeckSlides } = await import(
393
+ pathToFileURL(path.join(BUILDERS_DIR, "lint-fix.mjs")).href
394
+ );
395
+ const fixed = fixDeckSlides(deckDir);
396
+ if (fixed.fixes > 0) {
397
+ process.stdout.write(
398
+ `Autofixed ${fixed.fixes} lint issue(s) in ${fixed.files} slide file(s) — re-read any file before patching it.\n`,
399
+ );
400
+ }
401
+ }
402
+ await buildDeck(deckDir);
403
+ if (lintOnly) {
404
+ if (asJson) process.stdout.write(`${JSON.stringify({ deck: path.basename(deckDir), lint: "clean" })}\n`);
405
+ else process.stdout.write("Lint clean (no render — drop --lint-only for the draft previews).\n");
406
+ return;
407
+ }
408
+ // Draft previews are produced locally by the add-in's shipped capture engine
409
+ // (no push, no .pptx) — the same module that hosts the render+push path.
410
+ const mod = await import(
411
+ pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
412
+ );
413
+ // The capture engine lints every slide again and prints the same lines the
414
+ // build just did; mute that second copy (its warnings still ride the result).
415
+ const stderrWrite = process.stderr.write.bind(process.stderr);
416
+ process.stderr.write = (chunk, ...args) =>
417
+ /^\s*\[lint:(warn|error|note)\]/.test(String(chunk)) ? true : stderrWrite(chunk, ...args);
418
+ let result;
419
+ try {
420
+ result = await mod.viewDraft(deckDir, {
421
+ presentationId: to,
422
+ onProgress: renderProgressPrinter(),
423
+ only,
424
+ // The server's export gate (slot-fit, diagram geometry, connector
425
+ // endpoints) on the captured slides, verdict only, no .pptx, no review
426
+ // gate — so a slide is proven buildable before hand-over.
427
+ translate,
428
+ });
429
+ } catch (err) {
430
+ process.stderr.write = stderrWrite;
431
+ fail(withUnboundHint(deckDir, err));
432
+ }
433
+ process.stderr.write = stderrWrite;
434
+ // Everything below prints through `out` so --json can hand back one payload
435
+ // instead of the human lines (the lint lines already went to stderr).
436
+ const out = asJson ? () => {} : (text) => process.stdout.write(text);
437
+ const slidesOut = [];
438
+
439
+ const previewDir = outDir
440
+ ? path.resolve(process.cwd(), outDir)
441
+ : path.join(deckDir, ".draft-preview");
442
+ fs.mkdirSync(previewDir, { recursive: true });
443
+
444
+ // The measured half of the draft check — the same mechanics the add-in's
445
+ // see_draft runs (fill bands, inventory rows) so a low-effort agent reads
446
+ // defects as text instead of judging pixels. Images stay CLEAN: no overlay
447
+ // on the render and none on the concept — a magenta grid dominated both
448
+ // and hid whether the slide was actually built well; the content area is
449
+ // enforced by the lint and the slot-fit check, not by eye.
450
+ const check = await import(
451
+ pathToFileURL(path.join(BUILDERS_DIR, "draft-check.mjs")).href
452
+ );
453
+ const conceptsDir = path.join(deckDir, "concepts");
454
+ const conceptFor = (fileName) => {
455
+ const m = /^(\d{1,3})[-_.]/.exec(fileName);
456
+ if (!m) return null;
457
+ const file = path.join(conceptsDir, `concept-slide-${String(Number(m[1])).padStart(2, "0")}.png`);
458
+ return fs.existsSync(file) ? file : null;
459
+ };
460
+ const inventory = check.inventoryWarnings(
461
+ deckDir,
462
+ (result.slides ?? []).map((s) => s.fileName).filter((f) => f.endsWith(".jsx")),
463
+ );
464
+ const deckLevel = inventory.filter((l) => l.includes("] assets/"));
465
+ // The measured rules come from the add-in's capture engine in the bundle —
466
+ // the same fill / type-scale / overlap checks see_draft runs in-app.
467
+ const { loadDraftChecks } = await import(
468
+ pathToFileURL(path.join(SRC_DIR, "bridge", "capture.mjs")).href
469
+ );
470
+ const { resolveBundleDirForDeck } = await import(
471
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
472
+ );
473
+ const rules = await loadDraftChecks(resolveBundleDirForDeck(deckDir));
474
+ if (!rules) {
475
+ fail(
476
+ "the design bundle's capture engine exports no draft checks — the connected add-in predates them. Update the add-in deployment, then run `slidemind download-bundle <deck>` and retry.",
477
+ );
478
+ }
479
+ // Type ramp from the deck's own copy of the bundle css (build copies it in).
480
+ let typeTokens = null;
481
+ try {
482
+ typeTokens = rules.typeScaleTokens(
483
+ fs.readFileSync(path.join(deckDir, "corporate-design.css"), "utf8"),
484
+ );
485
+ } catch {
486
+ typeTokens = null;
487
+ }
488
+ const measuredLines = (stem, render) =>
489
+ [
490
+ ...rules.fill([{ stem, render }]),
491
+ ...rules.typeScale([{ stem, render }], typeTokens),
492
+ ...rules.overlap([{ stem, render }]),
493
+ ].map((l) => l.trim());
494
+
495
+ const written = [];
496
+ const record = [];
497
+ let measured = 0;
498
+ for (const slide of result.slides ?? []) {
499
+ const png = slide.png ?? (slide.dataUri ? dataUriToPng(slide.dataUri) : null);
500
+ if (png) {
501
+ const stem = slide.fileName.replace(/\.[^.]+$/, "");
502
+ const file = path.join(previewDir, `${stem}.png`);
503
+ fs.writeFileSync(file, png);
504
+ written.push(file);
505
+ const flag = slide.status === "degraded" ? " (DEGRADED)" : "";
506
+ out(` ${slide.fileName}${flag} → ${file}\n`);
507
+ if (slide.note) process.stderr.write(` note: ${slide.note}\n`);
508
+ const concept = conceptFor(slide.fileName);
509
+ const defects =
510
+ slide.status === "ok"
511
+ ? [
512
+ ...measuredLines(stem, slide.render),
513
+ ...inventory.filter((l) => l.includes(` slides/${slide.fileName} `)),
514
+ ...(slide.translateErrors ?? []),
515
+ ]
516
+ : [];
517
+ if (concept) {
518
+ out(
519
+ ` concept: ${concept} — compare against the render above: every organism present at the same relative position and share, every connector present, no region emptier than in the concept.\n`,
520
+ );
521
+ }
522
+ for (const line of defects) out(` ${line}\n`);
523
+ measured += defects.length;
524
+ slidesOut.push({ file: slide.fileName, status: slide.status, png: file, concept, defects, note: slide.note ?? null });
525
+ const jsxFile = path.join(deckDir, "slides", slide.fileName);
526
+ if (slide.status === "ok" && fs.existsSync(jsxFile)) {
527
+ record.push({ fileName: slide.fileName, hash: check.hashFile(jsxFile), defects });
528
+ }
529
+ } else {
530
+ out(` ${slide.fileName} — ${slide.note ?? slide.status}\n`);
531
+ slidesOut.push({ file: slide.fileName, status: slide.status, png: null, concept: null, defects: [], note: slide.note ?? null });
532
+ }
533
+ }
534
+ for (const line of deckLevel) out(` ${line}\n`);
535
+ measured += deckLevel.length;
536
+ const translateRan = translate && result.translateRan !== false;
537
+ if (translateRan) {
538
+ const failed = (result.slides ?? []).filter((s) => s.translateErrors?.length).length;
539
+ out(
540
+ failed
541
+ ? ` TRANSLATE GATE: ${failed} slide${failed === 1 ? "" : "s"} refused by the export gate (lines above) — these are the errors build-pptx would stop on.\n`
542
+ : " TRANSLATE GATE: every rendered slide passed the export gate (slot-fit, diagram geometry, connector endpoints).\n",
543
+ );
544
+ for (const w of result.translateWarnings ?? []) process.stderr.write(` note: ${w}\n`);
545
+ }
546
+ const verdict = check.verdictBlocker(deckDir);
547
+ if (verdict) {
548
+ out(
549
+ ` self-check: ${verdict.replace(/\n\s+/g, "; ")}\n` +
550
+ " (under each slide in asset-plan.md: `draft-check:` then `- [x] <organism>` per organism found in the render, `- [ ] <organism> — <what differs>` otherwise; `review` refuses until every organism is ticked [x])\n",
551
+ );
552
+ }
553
+ if (record.length > 0 && !outDir) {
554
+ // Deck-level asset lines are recorded on the first slide so they stand
555
+ // until that slide (or the asset plan) changes.
556
+ if (deckLevel.length > 0) record[0].defects.push(...deckLevel);
557
+ // A partial run (--only) updates its slides and keeps the others' record.
558
+ const prior = only ? check.loadDraftCheck(deckDir)?.slides ?? {} : {};
559
+ const rendered = new Set(record.map((r) => r.fileName));
560
+ const kept = Object.entries(prior)
561
+ .filter(([f]) => !rendered.has(f))
562
+ .map(([fileName, e]) => ({ fileName, hash: e.hash, defects: e.defects ?? [] }));
563
+ check.saveDraftCheck(deckDir, [...record, ...kept]);
564
+ }
565
+ if (written.length === 0) {
566
+ out(
567
+ "No draft preview images were produced (no freeform slides to capture).\n",
568
+ );
569
+ } else if (measured > 0) {
570
+ out(
571
+ `DRAFT CHECK: ${measured} measured defect${measured === 1 ? "" : "s"} (above) — fix them in the JSX and re-run ` +
572
+ "`slidemind view-draft`; `slidemind review` does not open while they stand. " +
573
+ `Previews in ${previewDir} — NOT pushed.\n`,
574
+ );
575
+ } else {
576
+ out(
577
+ `DRAFT CHECK: no measured defects. Wrote ${written.length} draft preview${written.length === 1 ? "" : "s"} to ${previewDir} — NOT pushed. ` +
578
+ "Now diff each preview against its concept; fix anything visibly wrong in the JSX, then re-run, or 'slidemind review' / 'push' when sound.\n",
579
+ );
580
+ }
581
+ // The capture lints again on its own; those lines already printed above.
582
+ const warnings = (result.warnings ?? []).filter((w) => !/\[lint:(warn|error|note)\]/.test(w));
583
+ for (const w of warnings) process.stderr.write(` warning: ${w}\n`);
584
+ if (asJson) {
585
+ process.stdout.write(
586
+ `${JSON.stringify(
587
+ {
588
+ deck: path.basename(deckDir),
589
+ previewDir,
590
+ slides: slidesOut,
591
+ measuredDefects: measured,
592
+ translateGate: translateRan ? (slidesOut.some((s) => s.defects.some((d) => /:error\]/.test(d))) ? "failed" : "passed") : "skipped",
593
+ selfCheck: verdict ? "incomplete" : "ok",
594
+ reviewCanOpen: measured === 0 && !verdict,
595
+ warnings,
596
+ },
597
+ null,
598
+ 2,
599
+ )}\n`,
600
+ );
601
+ }
602
+ if (open && written.length > 0) {
603
+ const { openInBrowser } = await import(
604
+ pathToFileURL(path.join(BUILDERS_DIR, "build.mjs")).href
605
+ );
606
+ for (const file of written) openInBrowser(file);
607
+ }
608
+ }