slidemind 0.3.20 → 0.4.1

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 (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -16,7 +16,7 @@
16
16
 
17
17
  import fs from "node:fs";
18
18
  import path from "node:path";
19
- import { randomUUID } from "node:crypto";
19
+ import { createHash, randomUUID } from "node:crypto";
20
20
 
21
21
  import {
22
22
  exportSlides,
@@ -37,11 +37,29 @@ import {
37
37
  writeDeckBundleRef,
38
38
  } from "./bundle.mjs";
39
39
  import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
40
+ import { buildImageIntentEnvelope } from "./image-intent-envelope.mjs";
41
+ import { writePackagePreview } from "./package-preview.mjs";
42
+ import {
43
+ assertImageIntentSatisfied,
44
+ draftImageIntentLines,
45
+ formatIssue,
46
+ imageIntentVerdict,
47
+ slideNumberOf,
48
+ slideNumbersOf,
49
+ } from "../builders/image-intent-check.mjs";
50
+ import { IMAGE_INTENT_META_KEY, imageIntentTagMetaForSlides } from "../lib/image-intent-tags.mjs";
51
+ import {
52
+ PRESERVATION_META_KEY,
53
+ preservationDocForBake,
54
+ preservationTagMeta,
55
+ } from "../lib/preservation-tags.mjs";
40
56
  import { lintConvertedOrThrow } from "../lib/lint-converted.mjs";
41
57
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
42
58
  import {
43
59
  applyConversionGates,
60
+ CONVERSIONS_DIR,
44
61
  conversionIntegrityErrors,
62
+ conversionProvenance,
45
63
  loadConversions,
46
64
  rehomeConversions,
47
65
  } from "../lib/conversions.mjs";
@@ -63,7 +81,9 @@ import {
63
81
  SLIDEMIND_TAG_PREFIX,
64
82
  TAG_GUID,
65
83
  TAG_PROV,
84
+ augmentSourceMeta,
66
85
  bakeSlideSourceTags,
86
+ computeProjection,
67
87
  encodeSourceTags,
68
88
  mintSlideGuid,
69
89
  readSlideTags,
@@ -71,19 +91,72 @@ import {
71
91
  } from "../pptx/tags.mjs";
72
92
  import { unzip, zip } from "../pptx/zip.mjs";
73
93
  import { slideOrderPaths } from "../pptx/ooxml.mjs";
94
+ import { assembleDeck, normalizeMasters, planFromDeck, validatePackage } from "../pptx/deck-assemble.mjs";
95
+ import {
96
+ acceptedBasePath,
97
+ acquireOwner,
98
+ computeDirty,
99
+ materializedInputs,
100
+ inputDigest,
101
+ readAcceptedBase,
102
+ readRevision,
103
+ saveCandidate,
104
+ slideFileOrder,
105
+ } from "../lib/revision.mjs";
106
+ import {
107
+ bundleOwnedFiles,
108
+ deckPptxPath,
109
+ deckRoot,
110
+ deckSlug,
111
+ previewPath,
112
+ } from "../lib/deck-paths.mjs";
74
113
 
75
114
  // Deck-embedded source, translate-path bake (spec §5.4): the server never
76
115
  // sees the jsx on the local-capture path, so tag any UNTAGGED jsx slide in
77
116
  // the returned deck locally. Deck slide order == sorted deck filename order
78
117
  // (jsx + native .pptx share one namespace), which maps 1:1 onto the
79
118
  // package's slide order. Returns new base64 or null when nothing changed.
80
- function ensureDeckSourceTags(deckDir, base64) {
119
+ // `metaExtra` per slide file: the slide's `SRC_META.imageIntent`, or undefined
120
+ // so a slide with no image use adds nothing at all to its tags. Resolved for
121
+ // the whole bake in one pass — the lookup re-hashes every slide file to map
122
+ // overrides onto slide numbers, and doing that per slide is quadratic.
123
+ function intentMetaByFile(deckDir, fileNames) {
124
+ const bySlide = imageIntentTagMetaForSlides(deckDir, slideNumbersOf(fileNames));
125
+ const out = new Map();
126
+ if (!bySlide.size) return out;
127
+ for (const fileName of fileNames) {
128
+ const intent = bySlide.get(slideNumberOf(fileName));
129
+ if (intent) out.set(fileName, { [IMAGE_INTENT_META_KEY]: intent });
130
+ }
131
+ return out;
132
+ }
133
+
134
+ // Merge the optional SRC_META extras of one slide, dropping the ones it has
135
+ // none of. `undefined` when there are none at all, so a slide that carries
136
+ // neither an image obligation nor a preservation record bakes exactly the tags
137
+ // it baked before either contract existed.
138
+ function slideMetaExtra(...parts) {
139
+ const out = {};
140
+ for (const part of parts) {
141
+ for (const [key, value] of Object.entries(part ?? {})) {
142
+ if (value != null) out[key] = value;
143
+ }
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ }
147
+
148
+ // Exported for the round-trip tests: bake → adopt → edit → bake is the contract
149
+ // the deck-embedded records live or die by (task 5.7 / E19), and imitating the
150
+ // bake in a test would prove only that the imitation works.
151
+ export function ensureDeckSourceTags(deckDir, base64, only = null) {
81
152
  const slidesDir = path.join(deckDir, "slides");
82
153
  if (!fs.existsSync(slidesDir)) return null;
154
+ const wanted = only ? new Set(only) : null;
83
155
  const deckFiles = sortSlideFilenames(
84
156
  fs
85
157
  .readdirSync(slidesDir)
86
- .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
158
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"))
159
+ .filter((f) => !wanted || wanted.has(f)),
87
160
  );
88
161
  if (deckFiles.length === 0) return null;
89
162
 
@@ -101,13 +174,36 @@ function ensureDeckSourceTags(deckDir, base64) {
101
174
  }
102
175
 
103
176
  const bundleRef = readDeckBundleRef(deckDir);
177
+ const intentMeta = intentMetaByFile(deckDir, deckFiles);
104
178
  const guids = loadSlideGuids(deckDir);
179
+ // The deck's preservation records, read once for the whole bake and looked up
180
+ // per slide by its GUID below (src/lib/preservation-tags.mjs).
181
+ const preservationDoc = preservationDocForBake(deckDir);
105
182
  const minted = {};
106
183
  const entries = [];
184
+ // What the bake adds beyond the source itself, per slide: the approved image
185
+ // obligations and the slide's preservation record.
186
+ const extrasFor = (fileName, guid) =>
187
+ slideMetaExtra(intentMeta.get(fileName), {
188
+ [PRESERVATION_META_KEY]: preservationTagMeta(deckDir, guid, {
189
+ preservation: preservationDoc,
190
+ }),
191
+ });
192
+ let augmented = false;
107
193
  for (let i = 0; i < deckFiles.length; i++) {
108
194
  const fileName = deckFiles[i];
109
195
  if (!fileName.endsWith(".jsx")) continue; // natives carry their own tags
110
- if (readSlideTags(files, order[i])[TAG_GUID]) continue; // server already baked
196
+ const serverGuid = readSlideTags(files, order[i])[TAG_GUID];
197
+ if (serverGuid) {
198
+ // The server baked this slide's source. It could not have written the
199
+ // extras — `imageIntent` needs the deck's registry, which only this side
200
+ // holds — so they are added to ITS meta rather than baked a second time
201
+ // under the same key (two writers, one key, two answers).
202
+ if (augmentSourceMeta(files, order[i], extrasFor(fileName, serverGuid))) {
203
+ augmented = true;
204
+ }
205
+ continue;
206
+ }
111
207
  let jsx;
112
208
  try {
113
209
  jsx = fs.readFileSync(path.join(slidesDir, fileName), "utf8");
@@ -132,12 +228,31 @@ function ensureDeckSourceTags(deckDir, base64) {
132
228
  // budget, spec §2.2) — without them `adopt` rebuilds a deck whose
133
229
  // slides point at images that were never handed over.
134
230
  assets: referencedAssets(jsx, path.join(deckDir, "assets")),
231
+ // The DRIFT BASELINE: the slide's structural projection as it stands in
232
+ // the package being baked. Without it the payload can later be proved
233
+ // intact but never proved CURRENT, so `adopt` has to convert the slide
234
+ // instead of restoring its jsx, and `detectManualEdits` has nothing to
235
+ // compare an Office edit against. The bake only appends a custDataLst
236
+ // after </p:spTree>, which the projection does not read, so computing it
237
+ // here and baking it are the same picture.
238
+ baseline: computeProjection(files, order[i]),
135
239
  pushedAt: new Date().toISOString(),
240
+ // The slide's approved image obligations, so a .pptx that travels can
241
+ // be adopted back with its picture identities intact instead of as a
242
+ // slide that merely HAS a picture (design decision 8). Absent — and the
243
+ // tags unchanged — for every slide with no image use.
244
+ //
245
+ // Next to them, what the slide must still CONTAIN: its preservation
246
+ // record, so a deck adopted into a fresh workspace comes back owing what
247
+ // it owed — including the requirements it intentionally superseded, so
248
+ // a deletion is not silently owed again (task 5.7 / E19–E20).
249
+ metaExtra: extrasFor(fileName, guid),
136
250
  }),
137
251
  });
138
252
  }
139
- if (entries.length === 0 || !bakeSlideSourceTags(files, entries)) return null;
140
- saveSlideGuids(deckDir, minted);
253
+ const baked = entries.length > 0 && bakeSlideSourceTags(files, entries);
254
+ if (!baked && !augmented) return null;
255
+ if (baked) saveSlideGuids(deckDir, minted);
141
256
  return Buffer.from(zip(files)).toString("base64");
142
257
  }
143
258
 
@@ -167,7 +282,7 @@ function collectSourceTagsBySlide(base64) {
167
282
  }
168
283
  }
169
284
 
170
- function gatherSlides(deckDir) {
285
+ function gatherSlides(deckDir, only = null) {
171
286
  const slidesDir = path.join(deckDir, "slides");
172
287
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
173
288
  throw new Error(`Expected 'slides/' folder in ${deckDir}`);
@@ -176,12 +291,22 @@ function gatherSlides(deckDir) {
176
291
  // namespace (filename order, numeric-aware). The server splices the native
177
292
  // .pptx in by the SAME sorted-filename key, so the two kinds interleave by
178
293
  // their numeric prefix regardless of extension.
179
- const all = sortSlideFilenames(
294
+ //
295
+ // `only` narrows the request to the slides that actually changed — the
296
+ // targeted-assembly path, where the rest of the deck comes from the accepted
297
+ // base instead of being re-rendered. The SUBSET keeps its relative order, so
298
+ // the returned package's slide N is the Nth entry of `only`.
299
+ let all = sortSlideFilenames(
180
300
  fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
181
301
  );
182
302
  if (all.length === 0) {
183
303
  throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
184
304
  }
305
+ if (only) {
306
+ const wanted = new Set(only);
307
+ all = all.filter((f) => wanted.has(f));
308
+ if (all.length === 0) throw new Error(`none of the requested slides exist under ${slidesDir}`);
309
+ }
185
310
  const slides = {};
186
311
  const nativeSlides = {};
187
312
  for (const f of all) {
@@ -194,20 +319,27 @@ function gatherSlides(deckDir) {
194
319
  return { slides, nativeSlides, orderWarning: slideOrderWarning(all) };
195
320
  }
196
321
 
197
- // Collect deck-local assets (icons the agent downloaded, deck photos) as
322
+ // Collect DECK-OWNED assets (icons the agent downloaded, deck photos) as
198
323
  // base64, keyed by their `assets/<rel>` path so the server-side renderer mounts
199
324
  // them where the slide JSX references them (`<img src="./assets/<rel>">`).
325
+ //
326
+ // Copies of the design bundle a pre-workspace deck still carries are never
327
+ // uploaded: the server resolves brand assets from the design itself ("brand asset
328
+ // wins"), and a deck carrying them would ship megabytes on every render. A copy is
329
+ // one the bundle owns AND whose bytes match it — a user's own `master-data.png`
330
+ // merely shares a name and is uploaded like any other deck media.
200
331
  function gatherDeckAssets(deckDir) {
201
332
  const assetsDir = path.join(deckDir, "assets");
202
333
  const out = {};
203
334
  if (!fs.existsSync(assetsDir)) return out;
335
+ const bundleOwned = bundleOwnedFiles(deckDir, resolveBundleDirForDeck(deckDir));
204
336
  const walk = (dir, prefix) => {
205
337
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
206
338
  const abs = path.join(dir, entry.name);
207
339
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
208
340
  if (entry.isDirectory()) {
209
341
  walk(abs, rel);
210
- } else {
342
+ } else if (!bundleOwned.has(`assets/${rel}`)) {
211
343
  out[`assets/${rel}`] = fs.readFileSync(abs).toString("base64");
212
344
  }
213
345
  }
@@ -232,15 +364,30 @@ export async function renderDeckToBase64(
232
364
  backendURL = null,
233
365
  reconcile = null,
234
366
  onProgress = null,
367
+ only = null,
235
368
  },
236
369
  ) {
237
370
  if (!presId && !brandId) {
238
371
  throw new Error("renderDeckToBase64: presId or brandId is required");
239
372
  }
240
- const deckName = path.basename(deckDir);
241
- const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
373
+ const deckName = deckSlug(deckDir);
374
+ const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir, only);
242
375
  const hasJsx = Object.keys(slides).length > 0;
243
376
  const deckAssets = gatherDeckAssets(deckDir);
377
+ // The deck's approved image obligations for exactly the slides this request
378
+ // carries. Built BEFORE anything is rendered, because the builder resolves
379
+ // every registered source on the way: a deleted or overwritten original
380
+ // raises `image_asset_missing` / `image_asset_changed` here, with no .pptx
381
+ // written and nothing silently substituted (A14). `null` for every deck that
382
+ // has no image uses — which is every deck that predates the contract — so the
383
+ // request body is byte-for-byte what it always was.
384
+ const imageIntent = buildImageIntentEnvelope(deckDir, {
385
+ slides: slideNumbersOf([...Object.keys(slides), ...Object.keys(nativeSlides)]),
386
+ // Render/translate resolve the descriptors against the byte map the request
387
+ // already ships (`deckAssets`, keyed `assets/local/<id>.<ext>` — the asset's
388
+ // own workspacePath), instead of inlining every screenshot a second time.
389
+ inline: false,
390
+ });
244
391
  // Bind the deck to a materialized bundle if it isn't already. Without a
245
392
  // `.bundle-ref.json` the etag below is null (server-side skew detection
246
393
  // silently no-ops), the local HTML/editor preview can't resolve the deck's
@@ -279,7 +426,29 @@ export async function renderDeckToBase64(
279
426
  // file it was renamed away from, used to be a note AFTER the render — by
280
427
  // which time the slide had already come back on a fallback layout. It is a
281
428
  // hard stop: the render request would carry a lie about the source.
282
- const integrity = conversionIntegrityErrors(deckDir, conversions);
429
+ //
430
+ // A record that exists but will not PARSE is the same lie with nobody left
431
+ // to tell it: `loadConversions` skips it, so the slide is simply absent from
432
+ // `conversions` — which reads as "not converted". It would then render as
433
+ // ordinary jsx with its preserved natives (SmartArt, OLE, an unmodeled
434
+ // chart) silently gone, and `conversionIntegrityErrors` /
435
+ // `conversionsMissingPreserveSource` cannot warn, because both iterate that
436
+ // same map. So ask per slide, where absence and unreadable are different
437
+ // answers, and stop on unreadable exactly as on a missing package: at
438
+ // delivery, unknown provenance is refused, never rendered past.
439
+ const integrity = [
440
+ ...Object.keys(slides)
441
+ .filter((f) => conversionProvenance(deckDir, f) === undefined)
442
+ .sort()
443
+ .map(
444
+ (f) =>
445
+ `${f}: its ${CONVERSIONS_DIR}/${f.replace(/\.jsx$/, "")}.json exists but could not be read, ` +
446
+ `so whether this slide is converted is unknown — it would render as ordinary jsx and lose ` +
447
+ `any preserved native objects. Repair or delete that record, or re-run 'slidemind adopt' ` +
448
+ `for this deck.`,
449
+ ),
450
+ ...conversionIntegrityErrors(deckDir, conversions),
451
+ ];
283
452
  if (integrity.length > 0) {
284
453
  throw new Error(
285
454
  `conversion records are out of sync with the deck:\n ${integrity.join("\n ")}`,
@@ -328,6 +497,7 @@ export async function renderDeckToBase64(
328
497
  sourceGuids,
329
498
  conversions: hasConversions ? conversions : null,
330
499
  reconcile,
500
+ imageIntent,
331
501
  onProgress,
332
502
  });
333
503
  const attemptRender = async (etag) => {
@@ -346,7 +516,7 @@ export async function renderDeckToBase64(
346
516
  }
347
517
  const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
348
518
  onProgress?.({ done: n, total: n, stage: "assembling" });
349
- return translateDeck({ ...target, captured, nativeSlides, etag });
519
+ return translateDeck({ ...target, captured, nativeSlides, etag, imageIntent });
350
520
  }
351
521
  return serverRender(target, etag);
352
522
  };
@@ -399,6 +569,13 @@ export async function renderDeckToBase64(
399
569
  if (!result?.base64) {
400
570
  throw new Error("render returned no pptx bytes");
401
571
  }
572
+ // The image-obligation gate, on the ONE path every deliverable .pptx passes
573
+ // through and before a single byte is written or inserted. Two refusals, one
574
+ // rule: a verdict that failed, and a verdict that never came. The export gate
575
+ // next door may degrade to a note when the backend cannot answer; this one may
576
+ // not — "the picture check was skipped" and "the picture is on the slide" are
577
+ // not the same sentence, and only the second one is a built deck.
578
+ assertImageIntentSatisfied(imageIntent, result);
402
579
  // Persist GUIDs the server minted for new slides (render path), with the
403
580
  // jsx hash alongside so a later rename can still be reconciled to them.
404
581
  if (result.sourceGuids) {
@@ -413,14 +590,17 @@ export async function renderDeckToBase64(
413
590
  // and refuse the build when an edit needs human judgment. Deliberately BEFORE
414
591
  // the bake/normalize below — on a refusal those bytes are thrown away anyway.
415
592
  applyReconcileResponse({ deckDir, sent: reconcile, result });
416
- // Make sure EVERY jsx slide carries source tags. Only needed when the server
417
- // never saw the jsx: the translate path (local capture), or a backend too old
418
- // to bake tags — either way it reports no sourceGuids. When the server DID
419
- // bake (sourceGuids present), unzipping the whole deck here just to find
420
- // TAG_GUID on every slide is pure waste.
421
- if (useLocalCapture || !result.sourceGuids) {
593
+ // Make sure EVERY jsx slide carries source tags. Needed when the server never
594
+ // saw the jsx: the translate path (local capture), or a backend too old to
595
+ // bake tags — either way it reports no sourceGuids. When the server DID bake
596
+ // (sourceGuids present), unzipping the whole deck here just to find TAG_GUID
597
+ // on every slide would be pure waste — EXCEPT for a deck carrying image
598
+ // obligations, whose provenance the server cannot write (the transport
599
+ // descriptor it holds carries no `provenance`, only this side's registry
600
+ // does). There the pass runs to top its meta up in place.
601
+ if (useLocalCapture || !result.sourceGuids || imageIntent) {
422
602
  try {
423
- const baked = ensureDeckSourceTags(deckDir, result.base64);
603
+ const baked = ensureDeckSourceTags(deckDir, result.base64, only);
424
604
  if (baked) result = { ...result, base64: baked };
425
605
  } catch {
426
606
  /* best-effort — an unbaked deck still opens fine, it just isn't adoptable */
@@ -487,9 +667,12 @@ function mapCapturedSlide(fileName, cap, warnings) {
487
667
 
488
668
  if (cap.previewDataUrl) {
489
669
  // The serialised boxes ride along so view-draft can run the measured
490
- // checks (fill band, content grid) the add-in's see_draft runs.
491
- const { root = null, stage = null, boxes = null } = cap.render ?? {};
492
- return { fileName, status: "ok", dataUri: cap.previewDataUrl, render: { root, stage, boxes } };
670
+ // checks (fill band, content grid) the add-in's see_draft runs; the
671
+ // chrome text feeds its chrome rule. That rule quotes the text into a
672
+ // one-line verdict, so a title set on two lines reads as one.
673
+ const { root = null, stage = null, boxes = null, chromeText = null } = cap.render ?? {};
674
+ const chrome = chromeText?.map((f) => ({ ...f, text: String(f.text ?? "").replace(/\s+/g, " ") })) ?? null;
675
+ return { fileName, status: "ok", dataUri: cap.previewDataUrl, render: { root, stage, boxes, chromeText: chrome } };
493
676
  }
494
677
  // A degraded render is a non-editable raster; push/build-pptx will REJECT it.
495
678
  // Attach it anyway (so the agent isn't blind) but flag the named cause.
@@ -647,12 +830,17 @@ export async function viewDraft(
647
830
  };
648
831
  }
649
832
  const slide = mapCapturedSlide(fileName, byFile.get(fileName), warnings);
650
- if (conversions[fileName] && !slide.note) slide.note = convertedNote;
833
+ if (conversions[fileName]) {
834
+ // The measured draft checks are authored-slide only (the add-in's
835
+ // translate-deck exempts the converted dialect the same way).
836
+ slide.converted = true;
837
+ if (!slide.note) slide.note = convertedNote;
838
+ }
651
839
  return slide;
652
840
  });
653
841
 
654
842
  // --translate: run the captured slides through the server's translate gate
655
- // (slot-fit, diagram-geometry, connector-endpoints — the checks the local
843
+ // (slot-fit, diagram-geometry, text escape — the checks the local
656
844
  // capture cannot make) and hand its verdict back per slide. The returned
657
845
  // .pptx is discarded: this is validation, not delivery, so no review gate
658
846
  // stands in front of it and a per-slide builder can prove its ONE slide
@@ -671,7 +859,22 @@ export async function viewDraft(
671
859
  let translateErrors = null;
672
860
  let translateWarnings = [];
673
861
  let translateRan = false;
862
+ let gateResult = null;
863
+ let draftIntent = null;
674
864
  if (translate && gateDeck.slides.length) {
865
+ // The deck's image obligations, so the gate validates against what was
866
+ // approved and not against whatever the JSX happens to say. A draft never
867
+ // dies of one: a source that cannot be resolved comes back as a repair line
868
+ // below (that is what the draft is FOR), where the final build refuses on
869
+ // the same fact.
870
+ try {
871
+ draftIntent = buildImageIntentEnvelope(deckDir, {
872
+ slides: slideNumbersOf(gateDeck.slides.map((s) => s.fileName)),
873
+ inline: false,
874
+ });
875
+ } catch {
876
+ draftIntent = null;
877
+ }
675
878
  const skip = (why) => {
676
879
  if (translate !== "auto") throw new Error(why);
677
880
  warnings.push(`export gate skipped: ${why}`);
@@ -693,10 +896,12 @@ export async function viewDraft(
693
896
  const res = await translateDeck({
694
897
  brandId: ref.presFP,
695
898
  backendURL: mat.backendURL,
696
- deckName: path.basename(deckDir),
899
+ deckName: deckSlug(deckDir),
697
900
  captured: gateDeck,
698
901
  etag: mat.etag,
902
+ imageIntent: draftIntent,
699
903
  });
904
+ gateResult = res;
700
905
  translateErrors = new Map();
701
906
  translateWarnings = Array.isArray(res?.warnings) ? res.warnings : [];
702
907
  translateRan = true;
@@ -719,6 +924,26 @@ export async function viewDraft(
719
924
  }
720
925
  if (translateErrors?.has("")) warnings.push(...translateErrors.get(""));
721
926
 
927
+ // Image obligations, draft flavour: the local half always (is the source
928
+ // still there, did the captured render actually place it), plus the server's
929
+ // own verdict when the export gate ran and carried the expectations. Reported
930
+ // per slide so the agent can repair from the preview it is looking at — the
931
+ // refusal lives in the build path, not here.
932
+ const intentLines = draftImageIntentLines(deckDir, slides);
933
+ if (draftIntent && translateRan && gateResult) {
934
+ const verdict = imageIntentVerdict(draftIntent, gateResult);
935
+ for (const issue of verdict.issues) {
936
+ const key = issue.file ?? "";
937
+ if (!intentLines.has(key)) intentLines.set(key, []);
938
+ intentLines.get(key).push(formatIssue(issue));
939
+ }
940
+ }
941
+ for (const slide of slides) {
942
+ const lines = intentLines.get(slide.fileName);
943
+ if (lines?.length) slide.imageIntentIssues = lines;
944
+ }
945
+ if (intentLines.has("")) warnings.push(...intentLines.get(""));
946
+
722
947
  return { slides, warnings, translateWarnings, translateRan };
723
948
  }
724
949
 
@@ -743,7 +968,7 @@ function isBrandRouteMiss(err) {
743
968
  // involved. Returns null when the brand route can't serve this deck (caller
744
969
  // falls back to the pane flow); render failures (lint, stale-after-retry)
745
970
  // propagate, they are real.
746
- async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null) {
971
+ async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null, only = null) {
747
972
  const brandId = deckRef.presFP;
748
973
  let backendURL;
749
974
  try {
@@ -760,6 +985,7 @@ async function tryDetachedRender(deckDir, deckRef, onProgress, reconcile = null)
760
985
  backendURL,
761
986
  reconcile,
762
987
  onProgress,
988
+ only,
763
989
  });
764
990
  return { result, backendURL };
765
991
  }
@@ -770,6 +996,7 @@ async function renderWithRetarget(
770
996
  onProgress,
771
997
  registry = [],
772
998
  reconcile = null,
999
+ only = null,
773
1000
  ) {
774
1001
  const run = (target) =>
775
1002
  renderDeckToBase64(deckDir, {
@@ -777,6 +1004,7 @@ async function renderWithRetarget(
777
1004
  backendURL: target.backendURL ?? null,
778
1005
  reconcile,
779
1006
  onProgress,
1007
+ only,
780
1008
  });
781
1009
  try {
782
1010
  return { entry, result: await run(entry) };
@@ -903,16 +1131,20 @@ export async function buildAndPush({
903
1131
  // `--replace` wipes the presentation and re-pushes the built deck: for a
904
1132
  // converted deck that IS the first replacement of the user's original slides.
905
1133
  // A slide the server gated has not been confirmed for that, so the destructive
906
- // half stops here and asks. Insert mode leaves the originals alone, so it only
907
- // warns (the gate lines are already on `result.warnings`).
1134
+ // half stops here and asks. The confirmation is the USER's, as in the add-in,
1135
+ // where the same gated route opens the preview review: the agent asks and
1136
+ // passes --accept-conversions only on their go-ahead. Insert mode leaves the
1137
+ // originals alone, so it only warns (the gate lines are already on
1138
+ // `result.warnings`).
908
1139
  const gatedConversions = result.gatedConversions ?? [];
909
1140
  if (replace && gatedConversions.length > 0 && !acceptConversions) {
910
1141
  const err = new Error(
911
1142
  `${gatedConversions.length} converted slide(s) are not confirmed for replacement — ` +
912
- `--replace would overwrite the originals with an unreviewed conversion.\n` +
1143
+ `--replace would overwrite the originals with a conversion nobody reviewed.\n` +
913
1144
  gatedConversions.map((g) => ` ${g.note}`).join("\n") +
914
- `\nRe-run with --accept-conversions to replace anyway, or push without --replace ` +
915
- `to insert them and leave the originals in place.`,
1145
+ `\nThis is the user's call, not a retry: name these slides to the user and re-run with ` +
1146
+ `--accept-conversions only after they agree, or push without --replace to insert them ` +
1147
+ `and leave the originals in place.`,
916
1148
  );
917
1149
  err.code = "conversions_not_confirmed";
918
1150
  err.gatedConversions = gatedConversions;
@@ -928,15 +1160,15 @@ export async function buildAndPush({
928
1160
  // find one — save the .pptx next to the deck and tell the caller (bin prints
929
1161
  // the recovery instructions).
930
1162
  const saveFallback = (presIdForResult) => {
931
- let fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
1163
+ let fallbackFile = deckPptxPath(deckDir);
932
1164
  // This save is the ONLY copy of a render nobody could take delivery of, so
933
1165
  // it must land somewhere — it can never be the thing that destroys manual
934
1166
  // edits, and it can never refuse either. Edited sibling ⇒ write next to it.
935
1167
  if (detectManualEdits(fallbackFile).length > 0) {
936
1168
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
937
1169
  fallbackFile = path.join(
938
- deckDir,
939
- `${path.basename(deckDir)}-${stamp}.pptx`,
1170
+ deckRoot(deckDir),
1171
+ `${deckSlug(deckDir)}-${stamp}.pptx`,
940
1172
  );
941
1173
  }
942
1174
  fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
@@ -1064,9 +1296,9 @@ export async function buildAndPush({
1064
1296
  };
1065
1297
  }
1066
1298
 
1067
- // After a successful build-pptx: per-slide PNGs of what was just shipped, into
1068
- // `<deck>/.build-preview/NN-name.png`. The .pptx itself is opaque here, so
1069
- // without this the agent hands over a deck it has never seen. Best-effort — a
1299
+ // Browser source previews after build-pptx. These are NOT a render of the
1300
+ // emitted package; writePackagePreview separately inspects those exact bytes.
1301
+ // Best-effort — a
1070
1302
  // missing engine or a Chrome that won't launch returns a note, never a failure:
1071
1303
  // the .pptx is already written.
1072
1304
  export async function writeBuildPreview(deckDir, onProgress = null) {
@@ -1077,7 +1309,7 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
1077
1309
  files: [],
1078
1310
  note:
1079
1311
  "visual check skipped — the deck's bundle carries no capture engine " +
1080
- `(run 'slidemind download-bundle ${path.basename(deckDir)}')`,
1312
+ `(run 'slidemind download-bundle ${deckSlug(deckDir)}')`,
1081
1313
  };
1082
1314
  }
1083
1315
  let captured;
@@ -1090,7 +1322,7 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
1090
1322
  note: `visual check skipped — local capture failed (${err instanceof Error ? err.message : String(err)})`,
1091
1323
  };
1092
1324
  }
1093
- const dir = path.join(deckDir, ".build-preview");
1325
+ const dir = previewPath(deckDir, "build");
1094
1326
  fs.mkdirSync(dir, { recursive: true });
1095
1327
  const files = [];
1096
1328
  for (const slide of captured?.slides ?? []) {
@@ -1103,7 +1335,161 @@ export async function writeBuildPreview(deckDir, onProgress = null) {
1103
1335
  fs.writeFileSync(file, Buffer.from(b64, "base64"));
1104
1336
  files.push(file);
1105
1337
  }
1106
- return { dir, files, note: null };
1338
+ return { kind: "browser", status: "source-only", dir, files, note: "Browser source preview; not a render of the exported PPTX." };
1339
+ }
1340
+
1341
+
1342
+ // ---------------------------------------------------------------------------
1343
+ // targeted assembly — a deck that HAS an accepted base
1344
+ // ---------------------------------------------------------------------------
1345
+
1346
+ // GUID per slide FILE. An adopted deck's authority is its revision record (it
1347
+ // carries the native slides too, which the jsx-only GUID sidecar cannot); the
1348
+ // sidecar fills in for slides authored since.
1349
+ function guidsByFile(deckDir, revision) {
1350
+ const byFile = new Map();
1351
+ for (const [key, guid] of Object.entries(reconcileSlideGuids(deckDir))) {
1352
+ byFile.set(key.replace(/^slides\//, ""), guid);
1353
+ }
1354
+ for (const slide of revision?.base?.slides ?? []) {
1355
+ if (slide.file && slide.guid && !byFile.has(slide.file)) byFile.set(slide.file, slide.guid);
1356
+ }
1357
+ return byFile;
1358
+ }
1359
+
1360
+ /**
1361
+ * Build the next deck by applying only what changed to the accepted base.
1362
+ *
1363
+ * Returns null when this deck has no base (nothing to be targeted about — the
1364
+ * caller renders the whole deck as before). Otherwise `{ base64, report,
1365
+ * candidate, rendered }`, where `rendered` is the slide files that actually
1366
+ * went to the renderer.
1367
+ *
1368
+ * Three properties this exists for, none of which whole-deck rendering has:
1369
+ * • two changed slides in a forty-slide deck cost two slide renders;
1370
+ * • an order-only change costs NONE — nothing is re-rendered at all;
1371
+ * • every part the change does not touch keeps its exact bytes, which is the
1372
+ * only way notes, comments, animations, embeddings and native objects
1373
+ * survive a deck slidemind did not author.
1374
+ */
1375
+ function renderSetOf(dirty, files, refreshDesign) {
1376
+ const changed = new Set([...dirty.dirtySlides, ...dirty.added]);
1377
+ if (refreshDesign) for (const f of files) changed.add(f);
1378
+ return changed;
1379
+ }
1380
+
1381
+ /**
1382
+ * The slide files `build-pptx` will render: every slide of a deck without an
1383
+ * accepted base, otherwise exactly `buildAgainstBase`'s render set. The
1384
+ * delivery draft gate asks each of them for a current draft check.
1385
+ */
1386
+ export function deliveredSlideFiles(deckDir, { refreshDesign = false } = {}) {
1387
+ const revision = readRevision(deckDir);
1388
+ const files = slideFileOrder(deckDir);
1389
+ if (!revision?.base || !fs.existsSync(acceptedBasePath(deckDir))) return files;
1390
+ return [...renderSetOf(computeDirty(deckDir, revision), files, refreshDesign)];
1391
+ }
1392
+
1393
+ async function buildAgainstBase(deckDir, { render, onProgress, refreshDesign = false }) {
1394
+ const revision = readRevision(deckDir);
1395
+ const base = readAcceptedBase(deckDir);
1396
+ if (!revision?.base || !base) return null;
1397
+
1398
+ const dirty = computeDirty(deckDir, revision);
1399
+ const files = slideFileOrder(deckDir);
1400
+ const byFile = guidsByFile(deckDir, revision);
1401
+ // The render set is what the user changed — never more. A design that moved
1402
+ // under the deck is announced, and re-rendered only on --refresh-design.
1403
+ const changed = renderSetOf(dirty, files, refreshDesign);
1404
+ const designStale = dirty.sharedChanged && !refreshDesign ? files.filter((f) => !changed.has(f)) : [];
1405
+ process.stderr.write(
1406
+ ` rendering ${changed.size} of ${files.length} slide(s)` +
1407
+ (changed.size ? `: ${[...changed].sort().join(", ")}` : " — nothing changed since the accepted base") +
1408
+ "\n",
1409
+ );
1410
+ if (designStale.length) {
1411
+ process.stderr.write(
1412
+ ` note: the corporate design moved since the accepted base — ${designStale.length} untouched slide(s) ` +
1413
+ `keep the design they were last rendered with; \`slidemind build-pptx --refresh-design\` re-renders them\n`,
1414
+ );
1415
+ }
1416
+ const { plan, render: toRender } = planFromDeck({
1417
+ slides: files,
1418
+ guidFor: (f) => byFile.get(f) ?? null,
1419
+ partFor: (f) => revision.base.slides.find((s) => s.file === f)?.part ?? null,
1420
+ dirty: changed,
1421
+ });
1422
+
1423
+ let rendered = null;
1424
+ if (toRender.length > 0) {
1425
+ const result = await render(toRender);
1426
+ rendered = Buffer.from(result.base64, "base64");
1427
+ } else if (onProgress) {
1428
+ onProgress({ stage: "assembling", done: 0, total: 0 });
1429
+ }
1430
+
1431
+ const { bytes, report } = assembleDeck({ base, rendered, plan });
1432
+ if (!bytes) {
1433
+ const err = new Error(
1434
+ `the deck could not be assembled onto the file it was adopted from:\n ` +
1435
+ report.errors.join("\n ") +
1436
+ (report.blocked.length
1437
+ ? `\nThese slides carry something a re-render cannot reproduce. Either leave them ` +
1438
+ `alone, or delete the slide and add a new one in its place.`
1439
+ : ""),
1440
+ );
1441
+ err.code = "assembly_blocked";
1442
+ err.report = report;
1443
+ throw err;
1444
+ }
1445
+ return {
1446
+ base64: Buffer.from(bytes).toString("base64"),
1447
+ report,
1448
+ rendered: toRender,
1449
+ plan,
1450
+ designStale,
1451
+ renderedAll: changed.size === files.length,
1452
+ };
1453
+ }
1454
+
1455
+ function renderInputs(deckDir) {
1456
+ const inputs = materializedInputs(deckDir);
1457
+ for (const file of Object.keys(inputs.inputs)) {
1458
+ if (!file.startsWith("conversions/") || !file.endsWith(".json")) continue;
1459
+ // An unreadable record is NOT fatal here: the conversion gate below owns
1460
+ // that case and says something actionable about it. Throwing a raw
1461
+ // SyntaxError from the etag inputs would pre-empt it with a stack trace.
1462
+ let record;
1463
+ try {
1464
+ record = JSON.parse(fs.readFileSync(path.join(deckDir, file), "utf8"));
1465
+ } catch {
1466
+ continue;
1467
+ }
1468
+ // This is a render RESULT, stamped by this very request, not an input.
1469
+ delete record.firstReplace;
1470
+ inputs.inputs[file] = JSON.stringify(record);
1471
+ }
1472
+ return inputs;
1473
+ }
1474
+
1475
+ function renderInputDigest(deckDir) {
1476
+ return inputDigest(renderInputs(deckDir));
1477
+ }
1478
+
1479
+ // Which inputs moved between two snapshots — printed when a render restarts,
1480
+ // because "inputs changed" without a name is a full re-render nobody can act on.
1481
+ function renderInputChanges(before, after) {
1482
+ const hash = (v) => createHash("sha256").update(typeof v === "string" ? v : Buffer.from(v)).digest("hex");
1483
+ const changed = [];
1484
+ for (const key of new Set([...Object.keys(before.inputs), ...Object.keys(after.inputs)])) {
1485
+ const a = before.inputs[key], b = after.inputs[key];
1486
+ if (a === undefined) changed.push(`${key} (added)`);
1487
+ else if (b === undefined) changed.push(`${key} (removed)`);
1488
+ else if (hash(a) !== hash(b)) changed.push(key);
1489
+ }
1490
+ if (JSON.stringify(before.order) !== JSON.stringify(after.order)) changed.push("slide order");
1491
+ if (before.shared?.design !== after.shared?.design) changed.push(`design bundle (${before.shared?.design} -> ${after.shared?.design})`);
1492
+ return changed;
1107
1493
  }
1108
1494
 
1109
1495
  // Render a deck to a .pptx file (the `slidemind build-pptx` path). For a BOUND
@@ -1116,11 +1502,12 @@ export async function buildPptxFile({
1116
1502
  presentationId = null,
1117
1503
  outPath = null,
1118
1504
  force = false,
1505
+ refreshDesign = false,
1119
1506
  onProgress = null,
1120
1507
  }) {
1121
1508
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
1122
1509
  const deckRef = readDeckBundleRef(deckDir);
1123
- const out = outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);
1510
+ const out = outPath || deckPptxPath(deckDir);
1124
1511
 
1125
1512
  // Before ANY render work: what did the user change by hand in the file this
1126
1513
  // build is about to replace? An edit we cannot address refuses here and now
@@ -1133,41 +1520,129 @@ export async function buildPptxFile({
1133
1520
  // the registry path — reading the design off an open presentation is the one
1134
1521
  // thing a render legitimately needs a window for.
1135
1522
  const brandId = presentationId ? null : await resolveWindowlessBrand(deckDir);
1136
- if (brandId) {
1137
- const detached = await tryDetachedRender(deckDir, { presFP: brandId }, onProgress, reconcile);
1138
- if (detached) {
1139
- fs.writeFileSync(out, Buffer.from(detached.result.base64, "base64"));
1140
- return {
1141
- outPath: out,
1142
- presId: null,
1143
- windowless: true,
1144
- slideCount: detached.result.slideCount ?? null,
1145
- warnings: detached.result.warnings ?? [],
1146
- skippedSlides: detached.result.skippedSlides ?? [],
1147
- preview: await writeBuildPreview(deckDir, onProgress),
1148
- };
1523
+
1524
+ // One render closure, whichever route answers — so the targeted path below
1525
+ // does not have to know which of the two it is riding on.
1526
+ let presId = null;
1527
+ let renderedWindowless = false;
1528
+ let lastResult = null;
1529
+ let previewTarget = null;
1530
+ const renderSubset = async (only) => {
1531
+ if (brandId) {
1532
+ const detached = await tryDetachedRender(
1533
+ deckDir,
1534
+ { presFP: brandId },
1535
+ onProgress,
1536
+ reconcile,
1537
+ only,
1538
+ );
1539
+ if (detached) {
1540
+ renderedWindowless = true;
1541
+ lastResult = detached.result;
1542
+ previewTarget = { brandId, backendURL: detached.backendURL };
1543
+ return detached.result;
1544
+ }
1149
1545
  }
1546
+ const registry = await requireRenderHost();
1547
+ const entry = pickPresentationEntry(registry, presentationId, {
1548
+ preferBrandId: deckRef?.presFP ?? null,
1549
+ });
1550
+ const { entry: renderEntry, result } = await renderWithRetarget(
1551
+ deckDir,
1552
+ entry,
1553
+ onProgress,
1554
+ [],
1555
+ reconcile,
1556
+ only,
1557
+ );
1558
+ presId = renderEntry.presId;
1559
+ previewTarget = { brandId: renderEntry.brandId ?? deckRef?.presFP, backendURL: renderEntry.backendURL };
1560
+ lastResult = result;
1561
+ return result;
1562
+ };
1563
+
1564
+ // A deck with an accepted base assembles onto it: only the changed slides
1565
+ // render, and everything the change does not touch keeps its exact bytes.
1566
+ // That whole stretch — read the base, work out the dirty set, render, assemble,
1567
+ // capture the candidate — runs under the deck's ownership, so an `adopt` or a
1568
+ // `rebase` cannot move the base out from under it halfway through. Under an
1569
+ // open edit session (SLIDEMIND_OWNER) it re-enters that session. Fresh decks
1570
+ // take the same lock and persist an unaccepted candidate for first publish.
1571
+ const held = acquireOwner(deckDir, { command: "build-pptx" });
1572
+ let targeted;
1573
+ let candidate = null;
1574
+ let base64;
1575
+ let result;
1576
+ let preview;
1577
+ let packagePreview;
1578
+ try {
1579
+ // A bundle refresh or source edit during rendering invalidates those bytes.
1580
+ // Restart once from current inputs; persistent churn refuses certification.
1581
+ for (let attempt = 0; attempt < 2; attempt++) {
1582
+ const startingInputs = renderInputs(deckDir);
1583
+ targeted = await buildAgainstBase(deckDir, { render: renderSubset, onProgress, refreshDesign });
1584
+ result = targeted ?? (await renderSubset(null));
1585
+ const changed = renderInputChanges(startingInputs, renderInputs(deckDir));
1586
+ if (!changed.length) break;
1587
+ if (attempt === 1) throw new Error(`Deck inputs changed during rendering (${changed.join(", ")}); no candidate was certified. Build again from stable inputs.`);
1588
+ process.stderr.write(` inputs changed during the render (${changed.slice(0, 5).join(", ")}${changed.length > 5 ? `, +${changed.length - 5} more` : ""}); rendering again from the current inputs…\n`);
1589
+ }
1590
+ base64 = targeted ? targeted.base64 : result.base64;
1591
+ // The build proposes; it does not decide. The bytes are kept as an immutable
1592
+ // candidate with the evidence they were built from, the accepted base does
1593
+ // not move and localDirty does not clear — so edit → build → build keeps the
1594
+ // first edit and nothing is accepted that a human did not accept.
1595
+ {
1596
+ // The render's own package is normalized the way an assembled one is:
1597
+ // its design master lists layouts twice, which PowerPoint repairs.
1598
+ const files = unzip(Buffer.from(base64, "base64"));
1599
+ const fixed = normalizeMasters(files);
1600
+ if (Object.values(fixed).some(Boolean)) base64 = Buffer.from(zip(files)).toString("base64");
1601
+ const packageValidation = validatePackage(files);
1602
+ if (!packageValidation.ok) throw new Error(`Rendered package is invalid: ${packageValidation.errors.join('; ')}`);
1603
+ candidate = saveCandidate(deckDir, {
1604
+ bytes: Buffer.from(base64, "base64"),
1605
+ validation: {
1606
+ ok: true,
1607
+ errors: [],
1608
+ checks: ["opc-relationships", "content-types", "slide-identity"],
1609
+ },
1610
+ renderedAll: targeted ? targeted.renderedAll : true,
1611
+ });
1612
+ }
1613
+ // The delivered file and its package render are one unit, so they stay
1614
+ // under the lock: a second build that wrote its bytes between them left a
1615
+ // file no render had verified (x97).
1616
+ fs.writeFileSync(out, Buffer.from(base64, "base64"));
1617
+ preview = await writeBuildPreview(deckDir, onProgress);
1618
+ packagePreview = await writePackagePreview({deckDir, bytes: Buffer.from(base64, "base64"), target: previewTarget, onProgress});
1619
+ } finally {
1620
+ held?.release();
1150
1621
  }
1151
1622
 
1152
- const registry = await requireRenderHost();
1153
- const entry = pickPresentationEntry(registry, presentationId, {
1154
- preferBrandId: deckRef?.presFP ?? null,
1155
- });
1156
- const { entry: renderEntry, result } = await renderWithRetarget(
1157
- deckDir,
1158
- entry,
1159
- onProgress,
1160
- [],
1161
- reconcile,
1162
- );
1163
- const presId = renderEntry.presId;
1164
- fs.writeFileSync(out, Buffer.from(result.base64, "base64"));
1165
1623
  return {
1166
1624
  outPath: out,
1167
1625
  presId,
1168
- slideCount: result.slideCount ?? null,
1169
- warnings: result.warnings ?? [],
1170
- skippedSlides: result.skippedSlides ?? [],
1171
- preview: await writeBuildPreview(deckDir, onProgress),
1626
+ windowless: renderedWindowless && !presId ? true : undefined,
1627
+ slideCount: lastResult?.slideCount ?? null,
1628
+ warnings: [
1629
+ ...(lastResult?.warnings ?? []),
1630
+ ...(targeted?.report.warnings ?? []),
1631
+ ],
1632
+ skippedSlides: lastResult?.skippedSlides ?? [],
1633
+ targeted: targeted
1634
+ ? {
1635
+ rendered: targeted.rendered,
1636
+ designStale: targeted.designStale,
1637
+ kept: targeted.report.kept.length,
1638
+ replaced: targeted.report.replaced.length,
1639
+ inserted: targeted.report.inserted.length,
1640
+ deleted: targeted.report.deleted.length,
1641
+ reusedLayouts: targeted.report.reusedLayouts,
1642
+ }
1643
+ : null,
1644
+ candidate: candidate ? { candidateId: candidate.candidateId, outputSha256: candidate.outputSha256 } : null,
1645
+ preview,
1646
+ packagePreview,
1172
1647
  };
1173
1648
  }