slidemind 0.3.19 → 0.4.0

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 (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,308 @@
1
+ // Image-intent evidence, CLI side — the local half of design decision 7.
2
+ //
3
+ // Three questions, and only three, because the other half of the answer is the
4
+ // add-in's:
5
+ //
6
+ // 1. **Are the sources still there?** Every `original` use resolves to bytes
7
+ // on disk that still hash to what the registry recorded. Purely local, so
8
+ // it runs host-free in `view-draft` and again before any render.
9
+ // 2. **Did the slide actually place them?** Counted off the CAPTURED boxes
10
+ // (`box.assetUseId`, set by the add-in's DOM serialiser from
11
+ // `data-asset-use`), never off the .jsx text: a use id in a comment, in a
12
+ // prop the helper never forwards, or beside an unused file is not
13
+ // fulfillment (guide §6, A20). The transcriber emits `use="…"` as a helper
14
+ // prop, so a text scan would not even find the real bindings.
15
+ // 3. **Did the server validate?** The verdict is the add-in's
16
+ // `imageIntentValidation`, consumed verbatim. This module measures no
17
+ // geometry, no crop, no visible intersection — those are renderer rules
18
+ // and they live where the renderer lives (AGENTS.md: "No CLI fallbacks for
19
+ // add-in-owned rules").
20
+ //
21
+ // The asymmetry between the two consumers is deliberate:
22
+ //
23
+ // * `view-draft` REPORTS. A missing source or an unplaced use is an
24
+ // actionable repair issue printed next to the preview, recorded as a draft
25
+ // defect so `review` holds — but the preview is still written, because
26
+ // looking at the slide is how the repair gets made.
27
+ // * `build-pptx` / `push` REFUSE. An unresolved obligation, a failed verdict,
28
+ // or a verdict that never ran all stop the build before any .pptx exists.
29
+ // "The validation was skipped" is NEVER success: the export gate next door
30
+ // may degrade to a note when the backend is unreachable, and this one may
31
+ // not — a skipped picture check ships a deck missing the user's picture.
32
+
33
+ import { resolveBoundImageUses } from "./spec-assets.mjs";
34
+
35
+ /** The slide number a deck file name carries, or null (unnumbered file). */
36
+ export function slideNumberOf(fileName) {
37
+ const m = /^(\d{1,3})[-_.]/.exec(String(fileName ?? ""));
38
+ return m ? Number(m[1]) : null;
39
+ }
40
+
41
+ /** The distinct slide numbers a set of deck file names covers, ascending. */
42
+ export function slideNumbersOf(fileNames) {
43
+ const out = [];
44
+ for (const file of fileNames ?? []) {
45
+ const n = slideNumberOf(file);
46
+ if (n !== null && !out.includes(n)) out.push(n);
47
+ }
48
+ return out.sort((a, b) => a - b);
49
+ }
50
+
51
+ const stemOf = (fileName) => String(fileName ?? "").replace(/\.[^.]+$/, "");
52
+
53
+ /** One issue as a printable draft line. */
54
+ export function formatIssue(issue) {
55
+ const where = issue.file ? stemOf(issue.file) : issue.slide ? `slide ${issue.slide}` : "deck";
56
+ const action = issue.action ? ` ${issue.action}` : "";
57
+ return `[image-intent:error] ${where} — ${issue.message}.${action}`;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // 1 + 2: what this machine can answer
62
+ // ---------------------------------------------------------------------------
63
+
64
+ /** Every `assetUseId` the captured render actually placed: `Map<useId, count>`. */
65
+ export function capturedUseIds(render) {
66
+ const counts = new Map();
67
+ const walk = (box) => {
68
+ if (!box || typeof box !== "object") return;
69
+ if (typeof box.assetUseId === "string" && box.assetUseId) {
70
+ counts.set(box.assetUseId, (counts.get(box.assetUseId) ?? 0) + 1);
71
+ }
72
+ for (const child of box.children ?? []) walk(child);
73
+ };
74
+ for (const box of render?.boxes ?? []) walk(box);
75
+ return counts;
76
+ }
77
+
78
+ /**
79
+ * Local image-intent issues for a set of captured slides.
80
+ *
81
+ * @param {string} deckDir
82
+ * @param {Array<{fileName: string, status?: string, render?: object}>} slides
83
+ * the `view-draft` slide records — `render` present means the slide was
84
+ * captured, so its bindings can be counted; absent means only the source
85
+ * half can be answered for it.
86
+ * @returns {{issues: Array, byFile: Map<string, Array>}}
87
+ */
88
+ export function localImageIntentIssues(deckDir, slides, { planMarkdown = null } = {}) {
89
+ const files = (slides ?? []).map((s) => s.fileName).filter(Boolean);
90
+ const numbers = slideNumbersOf(files);
91
+ const issues = [];
92
+ const byFile = new Map();
93
+ if (!numbers.length) return { issues, byFile };
94
+
95
+ const fileForSlide = new Map();
96
+ for (const file of files) {
97
+ const n = slideNumberOf(file);
98
+ if (n !== null && !fileForSlide.has(n)) fileForSlide.set(n, file);
99
+ }
100
+ const push = (issue) => {
101
+ issues.push(issue);
102
+ const key = issue.file ?? "";
103
+ if (!byFile.has(key)) byFile.set(key, []);
104
+ byFile.get(key).push(issue);
105
+ };
106
+
107
+ // The source half. `resolveBoundImageUses` is the same resolver the
108
+ // transcriber places pictures with, so "the draft says it is missing" and
109
+ // "transcribe could not place it" are one answer, not two.
110
+ let bound;
111
+ let sourceIssues;
112
+ try {
113
+ ({ bound, issues: sourceIssues } = resolveBoundImageUses(deckDir, numbers, { planMarkdown }));
114
+ } catch (err) {
115
+ push({
116
+ code: err?.code ?? "image_intent_invalid",
117
+ message: `the deck's image obligations could not be read (${err?.message ?? err})`,
118
+ action: "Fix state/image-assets.json or state/image-use-overrides.json, then re-run.",
119
+ file: null,
120
+ slide: null,
121
+ });
122
+ return { issues, byFile };
123
+ }
124
+ for (const issue of sourceIssues) {
125
+ push({
126
+ code: issue.code,
127
+ message: issue.message,
128
+ action:
129
+ issue.code === "image_asset_changed"
130
+ ? "Re-import the new bytes as their own asset and rebind the use through the plan."
131
+ : "Restore or re-import the source (`slidemind assets import <deck> <file>`) and rebind the use.",
132
+ file: issue.slide == null ? null : fileForSlide.get(issue.slide) ?? null,
133
+ slide: issue.slide,
134
+ useId: issue.useId,
135
+ assetId: issue.assetId,
136
+ });
137
+ }
138
+
139
+ // The placement half, per captured slide. Only originals oblige a picture.
140
+ const wanted = new Map();
141
+ for (const use of bound.values()) {
142
+ if (!wanted.has(use.slide)) wanted.set(use.slide, []);
143
+ wanted.get(use.slide).push(use);
144
+ }
145
+ for (const slide of slides ?? []) {
146
+ const n = slideNumberOf(slide.fileName);
147
+ const expected = n === null ? null : wanted.get(n);
148
+ if (!expected?.length) continue;
149
+ // Without a captured render there is nothing to count — a native .pptx
150
+ // slide, or one the capture could not produce. Its evidence is the server
151
+ // verdict's job, not a guess made here.
152
+ if (!slide.render?.boxes) continue;
153
+ const placed = capturedUseIds(slide.render);
154
+ for (const use of expected) {
155
+ const count = placed.get(use.useId) ?? 0;
156
+ if (count === 1) continue;
157
+ push(
158
+ count === 0
159
+ ? {
160
+ code: "image_use_unresolved",
161
+ message:
162
+ `${use.displayName} is an approved original for this slide and the render places it nowhere ` +
163
+ `(no picture carries data-asset-use="${use.useId}")`,
164
+ action: `Place it: <img data-asset-use="${use.useId}" src="./${use.workspacePath}" /> — never a search result or a look-alike.`,
165
+ file: slide.fileName,
166
+ slide: n,
167
+ useId: use.useId,
168
+ assetId: use.assetId,
169
+ }
170
+ : {
171
+ code: "image_use_mismatch",
172
+ message:
173
+ `${use.displayName} is placed ${count} times on this slide; one use is one occurrence`,
174
+ action: "Remove the extra picture, or record a second use in the plan for the intended repeat.",
175
+ file: slide.fileName,
176
+ slide: n,
177
+ useId: use.useId,
178
+ assetId: use.assetId,
179
+ },
180
+ );
181
+ }
182
+ }
183
+ return { issues, byFile };
184
+ }
185
+
186
+ /** `Map<fileName, string[]>` of printable draft lines, deck-level under "". */
187
+ export function draftImageIntentLines(deckDir, slides, opts = {}) {
188
+ const { byFile } = localImageIntentIssues(deckDir, slides, opts);
189
+ const out = new Map();
190
+ for (const [file, issues] of byFile) out.set(file, issues.map(formatIssue));
191
+ return out;
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // 3: the server's verdict
196
+ // ---------------------------------------------------------------------------
197
+
198
+ // The request only gets this far on a backend that ADVERTISED `imageIntentV1`
199
+ // (the capability gate in the transport refuses the rest before sending), so a
200
+ // response with no verdict is that backend breaking its own contract — not an
201
+ // old deployment. Either way the deck is not built: nothing proved the pictures
202
+ // reached the slides, and a skipped check is not a pass.
203
+ const MISSING_VERDICT =
204
+ "the add-in advertises image-intent support but returned no imageIntentValidation for a deck " +
205
+ "that carries approved image obligations, so nothing proved the pictures reached the slides. " +
206
+ "A check that did not run is not a pass: this deck is not built";
207
+
208
+ const MISSING_VERDICT_ACTION =
209
+ "Report the backend version — it answers the capability but not the check. Nothing was written " +
210
+ "and the deck, its plan and its imported sources are exactly as they were.";
211
+
212
+ /**
213
+ * The verdict as the CLI reads it.
214
+ *
215
+ * `ran: false` is the fail-closed case — the request carried expectations and
216
+ * the response carried no verdict. It is never downgraded to a warning on the
217
+ * final path, and the `issues` it returns say why.
218
+ *
219
+ * The wire shape (add-in `RT/image-intent-validation.ts`, agreed 2026-09-21):
220
+ *
221
+ * {version: 1, ok, uses: [{useId, assetId, sourceSha256, slide, stem,
222
+ * pictureId, mediaPart, rect, fit, crop?, derivative?}],
223
+ * issues: [{code, message, action, useId?, assetId?, slide?, stem?}]}
224
+ *
225
+ * Only `ok` and `issues` are read here. `uses` is the evidence array (named
226
+ * `uses`, not `checked`), its `crop` is the EMITTED `<a:srcRect>` in OOXML
227
+ * 100000ths rather than the contract's `{x,y,w,h}` fractions, and `derivative`
228
+ * carries `{sha256, parentSha256, mediaPart, transforms}` (the `renderTransforms`
229
+ * names of `src/lib/image-derivatives.mjs`) — three shapes
230
+ * worth knowing before anything starts reading them, because none is the shape
231
+ * the plan-side record uses.
232
+ *
233
+ * **What the verdict does and does not prove.** It compares the bytes that
234
+ * actually became the `ppt/media` part against the digest the request DECLARED.
235
+ * It cannot see this machine, so it never proves that digest still describes a
236
+ * file on disk — `localImageIntentIssues` above is that half. Neither alone is
237
+ * the whole guarantee; the build path runs both.
238
+ *
239
+ * @param {object|null} envelope the `imageIntent` the request carried
240
+ * @param {object|null} result the render/translate response
241
+ */
242
+ export function imageIntentVerdict(envelope, result) {
243
+ if (!envelope) return { expected: false, ran: true, ok: true, issues: [] };
244
+ const verdict = result?.imageIntentValidation;
245
+ if (!verdict || typeof verdict !== "object") {
246
+ return {
247
+ expected: true,
248
+ ran: false,
249
+ ok: false,
250
+ issues: [
251
+ {
252
+ code: "image_intent_unsupported",
253
+ message: MISSING_VERDICT,
254
+ action: MISSING_VERDICT_ACTION,
255
+ file: null,
256
+ slide: null,
257
+ },
258
+ ],
259
+ };
260
+ }
261
+ const raw = Array.isArray(verdict.issues) ? verdict.issues : [];
262
+ const issues = raw.map((issue) => ({
263
+ code: issue?.code ?? "image_use_mismatch",
264
+ message: issue?.message ?? "the rendered deck did not satisfy an image obligation",
265
+ action: issue?.action ?? "Fix the slide's binding and re-run the build.",
266
+ // The add-in attributes an issue by `stem` — the `<name>` of `<name>.jsx`,
267
+ // which is the key it has and we have. Taking it is what puts a server
268
+ // issue on the right slide in the draft instead of on the deck.
269
+ file: issue?.file ?? (issue?.stem ? `${issue.stem}.jsx` : null),
270
+ slide: Number.isInteger(issue?.slide) ? issue.slide : null,
271
+ useId: issue?.useId,
272
+ assetId: issue?.assetId,
273
+ }));
274
+ // `ok` is the verdict's own word. An `ok: true` that still carries issues is
275
+ // reported (they are advisory there), an `ok` that is not exactly true fails
276
+ // — a verdict that cannot say it passed did not pass.
277
+ const ok = verdict.ok === true;
278
+ if (!ok && issues.length === 0) {
279
+ issues.push({
280
+ code: "image_use_unresolved",
281
+ message: "the add-in refused the deck's image obligations without naming one",
282
+ action: "Re-run `slidemind view-draft <deck>` to see which slide is missing its picture.",
283
+ file: null,
284
+ slide: null,
285
+ });
286
+ }
287
+ return { expected: true, ran: true, ok, issues };
288
+ }
289
+
290
+ /**
291
+ * Refuse a final deliverable whose image obligations are unproven.
292
+ *
293
+ * Called on the ONE choke point every .pptx passes through, after the render
294
+ * and before any bytes are written or inserted, so a refusal leaves the deck
295
+ * exactly as it was.
296
+ */
297
+ export function assertImageIntentSatisfied(envelope, result) {
298
+ const verdict = imageIntentVerdict(envelope, result);
299
+ if (!verdict.expected || verdict.ok) return verdict;
300
+ const err = new Error(
301
+ `this deck's image obligations are not satisfied — no .pptx was written:\n` +
302
+ verdict.issues.map((i) => ` ${formatIssue(i)}`).join("\n"),
303
+ );
304
+ err.code = verdict.ran ? verdict.issues[0]?.code ?? "image_use_unresolved" : "image_intent_unsupported";
305
+ err.imageIntentIssues = verdict.issues;
306
+ throw err;
307
+ }
308
+
@@ -0,0 +1,107 @@
1
+ // Folding targeted-edit image uses back into an approved plan.
2
+ //
3
+ // A targeted edit records a slide's uses in `state/image-use-overrides.json`
4
+ // (src/lib/image-use-overrides.mjs) because there is no plan stage to approve
5
+ // them at. The moment a plan revision IS approved, the plan becomes the source
6
+ // of approved intent again — so each override whose slide the new plan still
7
+ // carries is written into that slide's storyboard and its entry is cleared.
8
+ //
9
+ // Three properties this exists to hold:
10
+ //
11
+ // * **Only what it supersedes is cleared.** An override whose slide the plan
12
+ // does not have (the slide was deleted, or the deck has no slide file for
13
+ // that GUID yet) is left exactly where it is. Clearing it would silently
14
+ // discard an obligation nobody re-approved.
15
+ // * **A cleared obligation is never resurrected.** An override recording an
16
+ // empty list writes "no uses" into the plan and is then cleared, so the
17
+ // next read of the plan agrees with the edit instead of restoring the uses
18
+ // the edit removed.
19
+ // * **Atomic in the order that matters.** plan.md is written first, the
20
+ // overrides file second, each by write-then-rename. A crash between the two
21
+ // leaves an entry that still describes what the plan already says — the
22
+ // next reconcile re-applies it to the same result. The reverse order would
23
+ // lose the obligation outright.
24
+ //
25
+ // Called from the plan gate's two approve paths (src/builders/plan.mjs) BEFORE
26
+ // the approved plan is hashed, so the recorded planHash is the hash of the plan
27
+ // the uses were folded into.
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+
32
+ import {
33
+ clearSlideOverrides,
34
+ readImageUseOverrides,
35
+ slideNumberForId,
36
+ } from "../lib/image-use-overrides.mjs";
37
+ import { replaceStoryboardSegment, splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
38
+
39
+ /**
40
+ * Fold every applicable override into `<deckDir>/plan.md` and clear it.
41
+ *
42
+ * Never throws for a deck that has none: a plan with no image intent is the
43
+ * ordinary case, and approving it must cost nothing. A registry or overrides
44
+ * file that does not parse is reported, not thrown — refusing a human's
45
+ * approval over a sidecar would strand the deck.
46
+ *
47
+ * @returns {{applied: Array<{slideId: string, slide: number, uses: number}>,
48
+ * kept: Array<{slideId: string, why: string}>, error: string|null}}
49
+ */
50
+ export function reconcileImageUseOverrides(deckDir) {
51
+ const out = { applied: [], kept: [], error: null };
52
+ const planPath = path.join(deckDir, "plan.md");
53
+
54
+ let overrides;
55
+ try {
56
+ overrides = readImageUseOverrides(deckDir);
57
+ } catch (err) {
58
+ return { ...out, error: err?.message ?? String(err) };
59
+ }
60
+ if (!overrides.slides.length) return out;
61
+
62
+ let markdown;
63
+ try {
64
+ markdown = fs.readFileSync(planPath, "utf8");
65
+ } catch {
66
+ // No plan to fold into: every entry stays, still governing its slide.
67
+ return {
68
+ ...out,
69
+ kept: overrides.slides.map((entry) => ({ slideId: entry.slideId, why: "no plan.md" })),
70
+ };
71
+ }
72
+
73
+ const keep = [];
74
+ for (const entry of overrides.slides) {
75
+ const slide = slideNumberForId(deckDir, entry.slideId);
76
+ if (slide == null) {
77
+ keep.push({ slideId: entry.slideId, why: "no slide carries this id" });
78
+ continue;
79
+ }
80
+ // Re-segment each round: every splice moves the offsets of the fences after
81
+ // it, and two overrides on one plan is the normal case.
82
+ const board = splitPlanMarkdownIntoSegments(markdown).find(
83
+ (segment) => segment.type === "storyboard" && segment.data?.slide === slide,
84
+ );
85
+ if (!board) {
86
+ keep.push({ slideId: entry.slideId, why: `the plan has no storyboard for slide ${slide}` });
87
+ continue;
88
+ }
89
+ const next = { ...board.data };
90
+ if (entry.assetUses.length) next.assetUses = entry.assetUses;
91
+ else delete next.assetUses;
92
+ markdown = replaceStoryboardSegment(markdown, board, next);
93
+ out.applied.push({ slideId: entry.slideId, slide, uses: entry.assetUses.length });
94
+ }
95
+
96
+ if (!out.applied.length) return { ...out, kept: keep };
97
+
98
+ // Write-then-rename, as this module's header promises: `respondPlan` hashes
99
+ // plan.md immediately after this returns, so a crash mid-write would leave a
100
+ // truncated file as the APPROVED plan — and, since clearSlideOverrides had
101
+ // not run, the next reconcile would re-splice into that truncation.
102
+ const tmp = `${planPath}.${process.pid}.sm-tmp`;
103
+ fs.writeFileSync(tmp, markdown);
104
+ fs.renameSync(tmp, planPath);
105
+ clearSlideOverrides(deckDir, out.applied.map((a) => a.slideId));
106
+ return { ...out, kept: keep };
107
+ }
@@ -0,0 +1,121 @@
1
+ // The generated text-and-shape kit of a transcribed slide: the helper
2
+ // definitions transcribe.mjs writes at the top of every slide (`KIT`'s B and T,
3
+ // then `KIT_EXTRAS`' I, L, P and R — always all of them, so an author who adds
4
+ // a picture, a list, a paragraph or a run finds its helper already there; x111–
5
+ // x113 each lost a draft round to "<R> is not a registered layout component"). They are the one owner of
6
+ // how a box, a text, a picture, a paragraph, a list and a run render: sizes
7
+ // come from ROLE, paint from FILL/TONE, list markers from the design's CSS.
8
+ // The slide lint holds every helper on a transcribed slide to these exact
9
+ // definitions (build-pptx/lint-role-type.ts): a helper edited by hand — a list
10
+ // whose markers it hides, a paragraph it shrinks — is refused, and a helper a
11
+ // slide uses without declaring it is named with its definition to restore
12
+ // (build-pptx/lint-kit-helpers.ts, for slides transcribed before KIT_EXTRAS).
13
+ // Pure; mirrored into the add-in by scripts/sync-transcription-runtime.mjs.
14
+ import { LIST_HELPER, RUN_HELPER } from "./list-jsx.mjs";
15
+
16
+ // I = an icon, or a picture the user supplied. Its `<img src={src}>` is
17
+ // dynamic; the lint checks the asset literals at its call sites, and a slide
18
+ // with no call site renders no image (holdout QBR 3 refused the unused helper
19
+ // before lint-kit-helpers.ts knew the canonical I).
20
+ //
21
+ // The five extra props are how an approved image use reaches BOTH renderers at
22
+ // once, and an engine icon passes none of them, so its markup is unchanged:
23
+ // use — `data-asset-use`, the identity the render evidence is matched on.
24
+ // fit — `object-fit`, which the exporter's own image-fit adapter reads.
25
+ // pos — `object-position`, same.
26
+ // crop — the approved source region on the renderer's DECLARED-crop channel
27
+ // (`data-sm-src-crop`, OOXML 100000ths "l,t,r,b" — the same channel a
28
+ // converted deck's `<a:srcRect>` rides).
29
+ // view — those same four numbers as an `object-view-box` inset, so the
30
+ // preview shows the region the .pptx will carry. A crop expressed
31
+ // only in CSS would export as the whole picture.
32
+ export const ICON_HELPER = ` const I = ({ id, x, y, w, h, src, use, fit = "contain", pos, crop, view, stack }) => (
33
+ <img id={id} src={src} data-asset-use={use} data-sm-src-crop={crop} data-sm-stack={stack}
34
+ style={{ position: "absolute", left: x, top: y, width: w, height: h, objectFit: fit,
35
+ ...(pos ? { objectPosition: pos } : {}), ...(view ? { objectViewBox: view } : {}) }} />
36
+ );
37
+ `;
38
+
39
+ // L = a native list (list-jsx.mjs).
40
+
41
+ // P = one paragraph of a text block (a title and the description under it).
42
+ // The block's T places the block; its paragraphs stack in normal flow on the
43
+ // design's line-height, so a longer title pushes its description down instead
44
+ // of running into it, and the export ships the block as ONE text frame.
45
+ export const PARAGRAPH_HELPER = ` const P = ({ role = "body", bold, tone = "dark", colorVar, align = "left", nowrap, indent, children }) => (
46
+ <div style={{ textAlign: align, ...ROLE[role], color: colorVar ?? TONE[tone], lineHeight: "var(--lh-tight)", ...(indent ? { marginLeft: indent } : {}),
47
+ ...(nowrap ? { whiteSpace: "nowrap" } : {}), ...(bold ? { fontWeight: "var(--fw-bold)" } : {}) }}>{children}</div>
48
+ );
49
+ `;
50
+
51
+ export const KIT = ` // Maps from the spec's vocabulary to the design's tokens.
52
+ const FILL = {};
53
+ const TONE = {};
54
+ const ROLE = {
55
+ subtitle: { fontSize: "var(--fs-caption)", color: TONE.muted },
56
+ section: { fontSize: "var(--fs-body-sm)", fontWeight: "var(--fw-bold)" },
57
+ title: { fontSize: "var(--fs-body-sm)", fontWeight: "var(--fw-bold)" },
58
+ body: { fontSize: "var(--fs-caption)" },
59
+ label: { fontSize: "var(--fs-caption)", color: TONE.muted },
60
+ caption: { fontSize: "var(--fs-caption)" },
61
+ number: { fontSize: "var(--fs-h3)", fontWeight: "var(--fw-bold)" },
62
+ badge: { fontSize: "var(--fs-caption)", fontWeight: "var(--fw-bold)" },
63
+ };
64
+ const RADIUS = { rounded: "var(--r-md)", pill: "var(--r-pill)", circle: "var(--r-pill)", ring: "var(--r-pill)", ellipse: "50%" };
65
+ // ROLE is the slide's one type hierarchy on the design's ramp, on tight
66
+ // leading: titles and body on one step, labels and captions one under it,
67
+ // numbers above. A slide whose text does not fit moves the whole ladder
68
+ // down one step, never one text. The transcriber never reads a size off
69
+ // the image. \`px\` on a T or L is the author's step off the ramp (never
70
+ // below the design's minimum), on PowerPoint's half-point grid.
71
+ const HALF_PT = (px) => Math.round(px * 1.5) / 1.5;
72
+ // B = a painted or positioned box. T = a text line (no height: text takes what it needs;
73
+ // a single-line text never wraps). I = an icon or picture. A T or an I inside
74
+ // a B with no x/y is seated: the B centres it (align/valign), so a glyph,
75
+ // digit or icon on a disc is never placed by its own coordinates. Every line — connector, rule,
76
+ // arc, bracket, mark — is written out in full below as a native line directive.
77
+ // Figure = a drawn shape as a structural sketch (circles, outline points, typed
78
+ // edges), compiled exactly at render: edit its points, circles and relations.
79
+ // prst / adj on a B: the preset a line glues to (its connection sites).
80
+ // fillVar / colorVar: the design token nearest the colour the image shows
81
+ // (snapped by the CLI from corporate-design.css); the tone word is the
82
+ // fallback when the design has no such colour.
83
+ // stack="n:r": this part belongs to row r of declared stack n ("n:gR:F" =
84
+ // drawn in the gap under row R, at fraction F of it); STACKS holds each
85
+ // stack's align and spacing. A
86
+ // row moves as one — move all its parts, or let preparation re-lay it.
87
+ // align / valign: the box's own words ride ON the shape — one PowerPoint
88
+ // shape with a text body, never a text box parked over it. Where they sit
89
+ // inside the box is what the image measured, so this works the same for a
90
+ // circle, a chip, a band or a card.
91
+ const PLACE = { left: "flex-start", top: "flex-start", center: "center", middle: "center", right: "flex-end", bottom: "flex-end" };
92
+ const B = ({ id, x, y, w, h, plotOwner, prst, adj, fill = "none", fillVar, shape, shadow, align, valign, stack, style, children }) => (
93
+ <div id={id} data-sm-plot-owner={plotOwner} data-prst={prst} data-adj={adj} data-sm-stack={stack} style={{ position: "absolute", left: x, top: y, width: w, height: h, boxSizing: "border-box",
94
+ background: fillVar ?? FILL[fill], borderRadius: RADIUS[shape], ...(shadow ? { boxShadow: "var(--shadow-md)" } : {}),
95
+ ...(align || valign ? { display: "flex", justifyContent: PLACE[align] ?? "center", alignItems: PLACE[valign] ?? "center", textAlign: align ?? "center" } : {}), ...style }}>{children}</div>
96
+ );
97
+ const T = ({ id, x, y, w, h, rotation, valign, px, role = "body", bold, tone = "dark", colorVar, align = "left", nowrap, stack, block, children }) => (
98
+ <div id={id} data-sm-stack={stack} data-sm-block={block} style={{ position: "absolute", left: x, top: y, width: w, ...(valign === "middle" ? { height: h, display: "flex", alignItems: "center", justifyContent: "center" } : {}), textAlign: align, ...ROLE[role], color: colorVar ?? TONE[tone],
99
+ ...(rotation ? { height: h, transform: "rotate(" + rotation + "deg)", transformOrigin: "center" } : {}),
100
+ ...(px ? { fontSize: HALF_PT(px) } : {}), lineHeight: role === "number" ? 1 : "var(--lh-tight)",
101
+ ...(nowrap ? { whiteSpace: "nowrap" } : {}), ...(bold ? { fontWeight: "var(--fw-bold)" } : {}) }}>{children}</div>
102
+ );
103
+ `;
104
+
105
+ // The canonical definition of each kit helper, `const X = (…) => …;` exactly
106
+ // as a slide carries it, by name.
107
+ function definitions(src) {
108
+ const out = {};
109
+ for (const m of src.matchAll(/^ {2}const ([A-Z]) = \([\s\S]*?\n {2}(?:\);|\};)$/gm)) out[m[1]] = m[0];
110
+ return out;
111
+ }
112
+ // The helpers after `KIT` on every transcribed slide, in this order.
113
+ export const KIT_EXTRAS = ICON_HELPER + LIST_HELPER + PARAGRAPH_HELPER + RUN_HELPER;
114
+
115
+ export const KIT_HELPER_SOURCES = Object.freeze({
116
+ ...definitions(KIT),
117
+ ...definitions(ICON_HELPER),
118
+ ...definitions(PARAGRAPH_HELPER),
119
+ ...definitions(LIST_HELPER),
120
+ ...definitions(RUN_HELPER),
121
+ });
@@ -0,0 +1,32 @@
1
+ // Layout names and content slots — canonical here, mirrored byte-identically
2
+ // into the add-in twice: beside the transcription modules (runtime/transcription)
3
+ // and into the browser-safe plan contract (shared/plan-contract/builders), where
4
+ // the add-in's own layout-naming.ts takes its fold from. It imports nothing.
5
+
6
+ // `// [CONTENT: 1133×503 px @ (74, 171) …]` in a layout's JSX header names the
7
+ // content slot the slide's children are laid into.
8
+ // The slot's size, and where it sits on the stage (`@ (x, y)`, stage px) when
9
+ // the layout says so: the reader drops the template's own marks outside it.
10
+ export function contentSlotFromLayoutSource(src) {
11
+ const m = /\[CONTENT:\s*(\d+)\s*[×x]\s*(\d+)\s*px(?:\s*@\s*\(\s*(-?\d+)\s*,\s*(-?\d+)\s*\))?/i.exec(src ?? "");
12
+ if (!m) return null;
13
+ return { w: Number(m[1]), h: Number(m[2]), ...(m[3] != null ? { x: Number(m[3]), y: Number(m[4]) } : {}) };
14
+ }
15
+
16
+ // A layout name folded for comparison: case, accents, spaces, hyphens and
17
+ // underscores do not tell two names apart.
18
+ export function foldLayoutName(value) {
19
+ return String(value).normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[\s_-]+/g, "").toLowerCase();
20
+ }
21
+
22
+ // The plan's layout name against the design's own: exact, else the one it
23
+ // differs from only in folding (a planner's "TitleMitInhalt" is the design's
24
+ // "TitelMitInhalt" only if the fold says so), else null. One rule on both
25
+ // clients: the add-in's canonicalizer (concept-images.ts) calls this too.
26
+ export function canonicalLayoutName(name, names) {
27
+ if (!name) return null;
28
+ const list = [...names];
29
+ if (list.includes(name)) return name;
30
+ const folded = foldLayoutName(name);
31
+ return list.find((candidate) => foldLayoutName(candidate) === folded) ?? null;
32
+ }