slidemind 0.3.20 → 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 (185) 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/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1342 @@
1
+ // Did the slide keep its promises? — the two comparisons behind the verdict.
2
+ //
3
+ // Contract from `openspec/changes/safeguard-slide-editing/implementation-guide.md`
4
+ // section 5 ("Preservation and render verification"), task 5.3. Mirrored
5
+ // byte-for-byte into the add-in (`scripts/sync-transcription-runtime.mjs`,
6
+ // group `edit-contracts`), because a preservation check that runs in only one
7
+ // client is not a safeguard — it is a local opinion.
8
+ //
9
+ // Two complementary comparisons, exactly as the guide asks:
10
+ //
11
+ // 1. compareSlideSource() — source/IR. What the edit INTENDED: which
12
+ // elements were added, removed or had a
13
+ // property changed, plus every construct the
14
+ // reader could not statically resolve. This is
15
+ // the half that can prove a property was NOT
16
+ // touched, where text layout has normal
17
+ // rendering variability.
18
+ // 2. compareRenderEvidence() — rendered IR. Did the object actually SURVIVE
19
+ // comparePackageEvidence() rendering and splicing? "Visible nonzero
20
+ // bounds and endpoints matter; an element in
21
+ // source but absent in output is not success."
22
+ //
23
+ // Three rules the shape of this module exists to keep:
24
+ //
25
+ // • **Three outcomes, never two.** Every check answers `passed`, `failed`,
26
+ // `unknown` or `unsupported` (`EVIDENCE_STATUSES`). Missing evidence is
27
+ // `unknown` and rolls the verdict up to `unknown`; it never reads as a pass.
28
+ // An element the render says nothing about is not an element that survived.
29
+ // • **No ZIP byte streams.** Package timestamps, relationship ids and part
30
+ // ordering legitimately change on every save. What is compared here is the
31
+ // NORMALIZED projection of a slide (`computeProjection`, src/pptx/tags.mjs)
32
+ // and the RESOLVED targets of its relationships — never bytes of the
33
+ // container. Native binary payloads are the exception that proves it: their
34
+ // digests must hold, because nothing but the user editing that object in
35
+ // PowerPoint may change them.
36
+ // • **The verdict carries its binding context.** sourceHash, liveRevision,
37
+ // operationId and requirementSetHash travel with it, so a later phase can
38
+ // say "this verdict was about a slide that no longer exists" instead of
39
+ // trusting it (`verdictIsStale`).
40
+ //
41
+ // Everything here is pure: no disk, no network, no mutation of an argument and
42
+ // no throw. The caller computes the projections (the CLI through
43
+ // `src/pptx/tags.mjs`, the add-in through its own reader) and hands them in —
44
+ // that dependency injection is what lets one comparison serve both clients
45
+ // without this file importing either client's package machinery.
46
+
47
+ import { createHash } from "node:crypto";
48
+
49
+ import { PROPERTY_SCOPES } from "./edit-operations.mjs";
50
+ import { EVIDENCE_STATUSES } from "./slide-preservation.mjs";
51
+ import { isDurableId } from "./durable-ids.mjs";
52
+ import { inlineComponents } from "./inline-components.mjs";
53
+
54
+ /** Schema version of the verdict this module returns. */
55
+ export const PRESERVATION_VERDICT_VERSION = 1;
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Numeric tolerances
59
+ // ---------------------------------------------------------------------------
60
+ //
61
+ // Named, exported and justified — a magic number inline is a tolerance nobody
62
+ // can argue with later. The guide's rule: reuse the renderer's own units and
63
+ // rounding where they exist, and otherwise allow at most one OOXML EMU of
64
+ // quantization for an exact transform.
65
+
66
+ /**
67
+ * The quantization the structural projection ALREADY applies to every EMU
68
+ * coordinate (`roundEmu` in src/pptx/tags.mjs and its TypeScript twin:
69
+ * `Math.round(n / 100) * 100`). It is the renderer's own rounding, so it is
70
+ * reused rather than re-derived — which is why the tolerance ON TOP of it is
71
+ * zero. Worst-case error of the pair is therefore one quantum, 100 EMU
72
+ * ≈ 0.0079 pt ≈ 1/9600 inch: far below anything a PowerPoint user can nudge.
73
+ */
74
+ export const PROJECTION_QUANTUM_EMU = 100;
75
+
76
+ /**
77
+ * Slack allowed on an already-quantized EMU coordinate: none. Two renders of an
78
+ * untouched transform land in the same 100-EMU bucket, so any difference at all
79
+ * is a real move — and accepting "one bucket either way" would silently permit
80
+ * a 200 EMU drift per pass, which accumulates across the repair budget's three
81
+ * passes.
82
+ */
83
+ export const TRANSFORM_TOLERANCE_EMU = 0;
84
+
85
+ /**
86
+ * Slack on a RENDERED rect, in slot px. Both clients measure with the same
87
+ * pinned Chrome (AGENTS.md: identical Chromium ⇒ identical text measurement),
88
+ * so this absorbs `getBoundingClientRect`'s double-precision noise, not layout
89
+ * differences. Half a CSS px is under one device pixel at 1x — smaller than
90
+ * anything that can paint differently.
91
+ */
92
+ export const RENDER_GEOMETRY_TOLERANCE_PX = 0.5;
93
+
94
+ /**
95
+ * The smallest rendered extent that can still paint, in slot px. Below it the
96
+ * element is hidden however present it is in the source. Chosen at half a CSS
97
+ * px for the same reason as above: a genuine hairline rule (1px) stays above
98
+ * it, a collapsed box (0px) does not.
99
+ */
100
+ export const RENDER_MIN_VISIBLE_PX = 0.5;
101
+
102
+ /**
103
+ * The smallest opacity that still reads as visible. 1% is below the JPEG-noise
104
+ * floor of any capture; a value beneath it is an element that was hidden, not
105
+ * an element that was faint.
106
+ */
107
+ export const RENDER_MIN_VISIBLE_OPACITY = 0.01;
108
+
109
+ /**
110
+ * How far past the canvas edge a rect may sit before it counts as off-canvas,
111
+ * in slot px. One px: a shape flush with the edge rounds outwards, a shape
112
+ * parked outside the slide does not.
113
+ */
114
+ export const OFF_CANVAS_SLACK_PX = 1;
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Small shared helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const isPlainObject = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
121
+ const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
122
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null);
123
+
124
+ /** Stable JSON: object keys sorted at every depth, so a hash is order-free. */
125
+ function canonical(value) {
126
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
127
+ if (isPlainObject(value)) {
128
+ return `{${Object.keys(value)
129
+ .sort()
130
+ .map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`)
131
+ .join(",")}}`;
132
+ }
133
+ return JSON.stringify(value ?? null);
134
+ }
135
+
136
+ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // 1. Source / IR comparison
140
+ // ---------------------------------------------------------------------------
141
+ //
142
+ // A very small JSX reader. Both dialects the product writes are regular:
143
+ // transcribed helpers (`<B id="…" x={44} …>`) and the converted dialect
144
+ // (`<div data-sm-source-id={10} style={{…}}>`). This reads the tag tree, buckets
145
+ // each element's attributes into the SAME property scopes the operation and
146
+ // preservation records speak (`PROPERTY_SCOPES`), and refuses to guess about
147
+ // anything it cannot resolve statically.
148
+ //
149
+ // ponytail: this scanner is a near-twin of the unexported one in
150
+ // src/lib/lint-converted.mjs. It is copied rather than imported because this
151
+ // module is byte-mirrored into the add-in and may only import its own group;
152
+ // lift lint-converted's reader onto this file once that module is free to
153
+ // depend on a mirrored one.
154
+
155
+ /**
156
+ * Where the string, template literal or comment opening at `i` ends (the index
157
+ * of its last character), or `i` itself when none opens there. Comments and
158
+ * template literals exist only inside an expression (`inExpr`, i.e. within
159
+ * `{…}`); at depth 0 a quote is an attribute string.
160
+ *
161
+ * A quote that never closes is not a string — a valid file has none — so it is
162
+ * a stray one the reader failed to see past (an apostrophe in a construct this
163
+ * reader does not model) and reads as one character. Read as a string instead,
164
+ * it swallows the rest of the file and every later element reads as removed.
165
+ */
166
+ function literalEnd(src, i, inExpr) {
167
+ const c = src[i];
168
+ if (inExpr && c === "/" && src[i + 1] === "/") {
169
+ const nl = src.indexOf("\n", i);
170
+ return nl < 0 ? src.length - 1 : nl;
171
+ }
172
+ if (inExpr && c === "/" && src[i + 1] === "*") {
173
+ const close = src.indexOf("*/", i + 2);
174
+ return close < 0 ? i : close + 1;
175
+ }
176
+ if (c === '"' || c === "'" || (inExpr && c === "`")) {
177
+ for (let k = i + 1; k < src.length; k++) {
178
+ if (inExpr && src[k] === "\\") k++;
179
+ else if (src[k] === c) return k;
180
+ }
181
+ }
182
+ return i;
183
+ }
184
+
185
+ function* scanTags(src) {
186
+ for (let i = 0; i < src.length; i++) {
187
+ if (src[i] !== "<") continue;
188
+ const head = /^<(\/?)([A-Za-z][\w.:-]*)/.exec(src.slice(i, i + 64));
189
+ if (!head) continue;
190
+ let j = i + head[0].length;
191
+ let depth = 0;
192
+ for (; j < src.length; j++) {
193
+ const c = src[j];
194
+ const end = literalEnd(src, j, depth > 0);
195
+ if (end !== j) {
196
+ j = end;
197
+ continue;
198
+ }
199
+ if (c === "{") depth++;
200
+ else if (c === "}") depth--;
201
+ else if (c === ">" && depth === 0) break;
202
+ }
203
+ const raw = src.slice(i, j + 1);
204
+ yield { close: head[1] === "/", name: head[2], raw, selfClose: raw.endsWith("/>"), start: i, end: j + 1 };
205
+ i = j;
206
+ }
207
+ }
208
+
209
+ /** Tag tree. Each node keeps its open tag, its content span and its children. */
210
+ function parseJsx(src) {
211
+ const root = { name: "#root", raw: "", start: 0, contentStart: 0, contentEnd: src.length, end: src.length, children: [] };
212
+ const stack = [root];
213
+ for (const t of scanTags(src)) {
214
+ if (t.close) {
215
+ if (stack.length > 1) {
216
+ const node = stack.pop();
217
+ node.contentEnd = t.start;
218
+ node.end = t.end;
219
+ }
220
+ continue;
221
+ }
222
+ const node = {
223
+ name: t.name,
224
+ raw: t.raw,
225
+ start: t.start,
226
+ contentStart: t.end,
227
+ contentEnd: t.end,
228
+ end: t.end,
229
+ children: [],
230
+ };
231
+ stack[stack.length - 1].children.push(node);
232
+ if (!t.selfClose) stack.push(node);
233
+ }
234
+ // An unclosed tag runs to the end of the file; the reader reports it rather
235
+ // than silently re-parenting what follows.
236
+ while (stack.length > 1) {
237
+ const node = stack.pop();
238
+ node.contentEnd = src.length;
239
+ node.end = src.length;
240
+ node.unclosed = true;
241
+ }
242
+ return root;
243
+ }
244
+
245
+ /**
246
+ * Attributes of one open tag: `{ name: "quoted string" | { expr } | true }`.
247
+ * Brace values are read depth-aware, so `style={{ a: "b" }}` is one value.
248
+ */
249
+ function attrsOf(raw) {
250
+ const out = {};
251
+ const body = raw.replace(/^<[A-Za-z][\w.:-]*/, "").replace(/\/?>$/, "");
252
+ let i = 0;
253
+ while (i < body.length) {
254
+ while (i < body.length && /\s/.test(body[i])) i++;
255
+ const name = /^[A-Za-z_][\w:.-]*/.exec(body.slice(i))?.[0];
256
+ if (!name) {
257
+ i += 1;
258
+ continue;
259
+ }
260
+ i += name.length;
261
+ let j = i;
262
+ while (j < body.length && /\s/.test(body[j])) j++;
263
+ if (body[j] !== "=") {
264
+ out[name] = true; // valueless flag: `nowrap`, `data-connector`
265
+ i = j;
266
+ continue;
267
+ }
268
+ j += 1;
269
+ while (j < body.length && /\s/.test(body[j])) j++;
270
+ const opener = body[j];
271
+ if (opener === '"' || opener === "'") {
272
+ const end = body.indexOf(opener, j + 1);
273
+ out[name] = end < 0 ? body.slice(j + 1) : body.slice(j + 1, end);
274
+ i = end < 0 ? body.length : end + 1;
275
+ } else if (opener === "{") {
276
+ let depth = 0;
277
+ let k = j;
278
+ for (; k < body.length; k++) {
279
+ const c = body[k];
280
+ const end = literalEnd(body, k, depth > 0);
281
+ if (end !== k) {
282
+ k = end;
283
+ continue;
284
+ }
285
+ if (c === "{") depth += 1;
286
+ else if (c === "}") {
287
+ depth -= 1;
288
+ if (depth === 0) break;
289
+ }
290
+ }
291
+ out[name] = { expr: body.slice(j + 1, k).trim() };
292
+ i = k + 1;
293
+ } else {
294
+ i = j; // `name=` with nothing after it — malformed, skipped
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
300
+ /**
301
+ * An expression this reader will not pretend to understand: a call, a template
302
+ * literal, a spread, an arrow, a ternary or a boolean operator. The add-in's
303
+ * own gate says the same thing about computed style values — unreadable is a
304
+ * reported state, not an error and not a pass.
305
+ */
306
+ const UNREADABLE_EXPR = /[(`]|\.\.\.|=>|\?|&&|\|\|/;
307
+
308
+ /**
309
+ * String literals (a template literal without `${` is one) and comments. They
310
+ * are stripped BEFORE `UNREADABLE_EXPR` is tested: a `(` inside
311
+ * `"var(--x)"` or `"polygon(…)"` is part of a value, not a call.
312
+ */
313
+ const LITERAL_OR_COMMENT =
314
+ /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\$]|\\.|\$(?!\{))*`|\/\/[^\n]*|\/\*[\s\S]*?\*\//g;
315
+
316
+ /** Words that ARE their own value, as opposed to naming one somewhere else. */
317
+ const LITERAL_WORDS = new Set(["true", "false", "null", "undefined"]);
318
+
319
+ /**
320
+ * An expression is readable when every word in it is a literal or an object
321
+ * key. A bare identifier (`x={col * 96}`, `src={m}`) names a value this reader
322
+ * has no access to — it is reported, never resolved and never assumed equal.
323
+ */
324
+ function exprIsUnreadable(expr) {
325
+ const stripped = String(expr ?? "").replace(LITERAL_OR_COMMENT, (m) => (m[0] === "/" ? " " : '""'));
326
+ if (UNREADABLE_EXPR.test(stripped)) return true;
327
+ for (const m of stripped.matchAll(/[A-Za-z_$][\w$]*/g)) {
328
+ if (LITERAL_WORDS.has(m[0])) continue;
329
+ if (/^\s*:/.test(stripped.slice(m.index + m[0].length))) continue;
330
+ return true;
331
+ }
332
+ return false;
333
+ }
334
+
335
+ const isUnreadable = (value) => isPlainObject(value) && exprIsUnreadable(value.expr);
336
+
337
+ const attrText = (value) => {
338
+ if (value === true) return "true";
339
+ if (isPlainObject(value)) return `{${value.expr}}`;
340
+ return String(value ?? "");
341
+ };
342
+
343
+ const ENTITIES = { "&lt;": "<", "&gt;": ">", "&quot;": '"', "&apos;": "'", "&nbsp;": " ", "&amp;": "&" };
344
+
345
+ /**
346
+ * Readable text of a source fragment: JSX string expressions become their
347
+ * value, every other expression contributes nothing (it is reported as
348
+ * unreadable elsewhere), entities are decoded once and whitespace collapses.
349
+ */
350
+ function cleanText(raw) {
351
+ return String(raw ?? "")
352
+ .replace(/\{\s*"((?:[^"\\]|\\.)*)"\s*\}/g, (_, q) => {
353
+ try {
354
+ return JSON.parse(`"${q}"`);
355
+ } catch {
356
+ return q;
357
+ }
358
+ })
359
+ .replace(/\{[^{}]*\}/g, "")
360
+ .replace(/&(?:lt|gt|quot|apos|nbsp|amp);/g, (m) => ENTITIES[m])
361
+ .replace(/\s+/g, " ")
362
+ .trim();
363
+ }
364
+
365
+ /**
366
+ * The element's OWN text: its direct text plus that of inline children which
367
+ * carry no identity of their own (`<b>`, `<span>`). A child element with an id
368
+ * is a separate obligation and its text belongs to it, not to its parent.
369
+ */
370
+ function ownText(node, src) {
371
+ let out = "";
372
+ let cursor = node.contentStart;
373
+ for (const child of node.children) {
374
+ out += src.slice(cursor, child.start);
375
+ if (!elementIdOf(child)) out += ownText(child, src);
376
+ cursor = child.end;
377
+ }
378
+ return cleanText(out + src.slice(cursor, node.contentEnd));
379
+ }
380
+
381
+ /**
382
+ * Attribute → property scope. The scope vocabulary is the operation record's
383
+ * (`PROPERTY_SCOPES`), which is itself the structural projection's own field
384
+ * set — so a scope always names something the render evidence can compare.
385
+ * Anything absent from both tables lands in `other`, and a change there is
386
+ * reported as `unknown` rather than ignored.
387
+ */
388
+ const ATTR_SCOPE = {
389
+ x: "geom", y: "geom", w: "geom", h: "geom", rot: "geom", transform: "geom",
390
+ fill: "fill", bg: "fill",
391
+ stroke: "line", head: "line",
392
+ // A native line directive's own geometry and ends (nativeLinesV1).
393
+ "data-prst": "line", "data-adj": "line", "data-rot": "line", "data-flip": "line",
394
+ "data-from-site": "line", "data-to-site": "line", "data-cap-start": "line", "data-cap-end": "line",
395
+ "data-dash": "line", "data-linecap": "line",
396
+ src: "media", fit: "media", imgFit: "media", "data-asset-use": "media", "data-sm-src-crop": "media",
397
+ "data-sm-chart": "chart",
398
+ "data-sm-native-object": "native", "data-sm-preserved": "native",
399
+ px: "text", role: "text", align: "text", nowrap: "text", bold: "text",
400
+ };
401
+
402
+ const STYLE_SCOPE = {
403
+ left: "geom", top: "geom", width: "geom", height: "geom", position: "geom",
404
+ transform: "geom", clipPath: "geom", "clip-path": "geom",
405
+ background: "fill", backgroundColor: "fill", backgroundImage: "fill", opacity: "fill",
406
+ border: "line", borderTop: "line", borderRight: "line", borderBottom: "line", borderLeft: "line",
407
+ borderWidth: "line", borderStyle: "line", borderColor: "line", fill: "fill",
408
+ outline: "line", borderRadius: "line", boxShadow: "line",
409
+ color: "text", fontSize: "text", fontWeight: "text", fontFamily: "text",
410
+ fontStyle: "text", lineHeight: "text", textAlign: "text", letterSpacing: "text",
411
+ };
412
+
413
+ /**
414
+ * Ids that name a slide element, in the two dialects the product writes. An id
415
+ * bound to a name (`<div id={id}>`) is a helper's template, stamped with a real
416
+ * id at each call site: it names no element of its own.
417
+ */
418
+ function elementIdOf(node) {
419
+ const attrs = node.attrs ?? (node.attrs = attrsOf(node.raw));
420
+ const id = attrs.id;
421
+ if (typeof id === "string" && id.trim()) return id.trim();
422
+ const literal = isPlainObject(id) && /^(?:(["'`])([\w.:-]+)\1|(\d+))$/.exec(id.expr);
423
+ if (literal) return literal[2] ?? literal[3];
424
+ // The converted dialect's handle on a source shape. Normalized to the bare
425
+ // number string, which is exactly the `p:cNvPr/@id` the package carries — so
426
+ // a requirement, the source and the projection all spell it one way.
427
+ const src = attrs["data-sm-source-id"];
428
+ const raw = isPlainObject(src) ? src.expr : src;
429
+ if (typeof raw === "string" && /^["']?\d+["']?$/.test(raw.trim())) return raw.trim().replace(/["']/g, "");
430
+ return null;
431
+ }
432
+
433
+ /** `#node-a` → `node-a`; a bare id stays as it is. */
434
+ const refTarget = (value) => String(attrText(value)).replace(/^\{?["']?#?/, "").replace(/["']?\}?$/, "").trim();
435
+
436
+ /**
437
+ * Read one slide's source into an element inventory.
438
+ *
439
+ * @param {string} jsx
440
+ * @returns {{
441
+ * elements: Array<object>,
442
+ * byId: Map<string, object>,
443
+ * unsupported: Array<{elementId: string|null, line: number, construct: string, reason: string}>,
444
+ * ambiguous: Array<{elementId: string|null, line: number, reason: string}>,
445
+ * }}
446
+ */
447
+ export function readSlideElements(jsx) {
448
+ // A set component's members are read where they are drawn: each call site
449
+ // inlined with its literal props, on its own lines (inline-components.mjs).
450
+ const inlined = inlineComponents(String(jsx ?? ""));
451
+ const src = inlined.src;
452
+ const root = parseJsx(src);
453
+ const lineAt = (offset) => src.slice(0, offset).split("\n").length;
454
+ const elements = [];
455
+ const byId = new Map();
456
+ const unsupported = inlined.unreadable.map((u) => ({
457
+ elementId: null,
458
+ line: u.line,
459
+ construct: `<${u.component}>`,
460
+ reason: "unreadable_component",
461
+ }));
462
+ const ambiguous = [];
463
+
464
+ // `wrapperFrame`: the text frame that follows an id-less wrapper around one
465
+ // identified child (see `textFrameAfter`), handed down to that child.
466
+ const visit = (node, insideIdentified, wrapperFrame = null) => {
467
+ node.children.forEach((child, i) => {
468
+ if (child.unclosed) {
469
+ unsupported.push({
470
+ elementId: elementIdOf(child),
471
+ line: lineAt(child.start),
472
+ construct: `<${child.name}>`,
473
+ reason: "unclosed_element",
474
+ });
475
+ }
476
+ const elementId = elementIdOf(child);
477
+ const attrs = child.attrs ?? (child.attrs = attrsOf(child.raw));
478
+ if (elementId) {
479
+ const frame = textFrameAfter(node.children[i + 1]) ?? wrapperFrame;
480
+ const entry = describeElement(child, elementId, attrs, src, lineAt, frame);
481
+ for (const note of entry.unreadable) {
482
+ unsupported.push({ elementId, line: entry.line, construct: note, reason: "unreadable_expression" });
483
+ }
484
+ if (byId.has(elementId)) {
485
+ ambiguous.push({ elementId, line: entry.line, reason: "duplicate_element_id" });
486
+ } else {
487
+ byId.set(elementId, entry);
488
+ }
489
+ elements.push(entry);
490
+ } else if (isSlideElement(child, attrs) && !insideIdentified) {
491
+ // An element that paints but names nothing, and sits under nothing that
492
+ // names it either, cannot carry an obligation. Surfaced, never matched
493
+ // by shape — the guide asks for ambiguity to be reported rather than
494
+ // resolved with a confident guess. (A `<Chart>` inside `<B id="…">` is
495
+ // not ambiguous: the JSX itself says whose chart it is, and
496
+ // `describeElement` folds its attributes into that parent.)
497
+ ambiguous.push({ elementId: null, line: lineAt(child.start), reason: `unidentified_${child.name}` });
498
+ }
499
+ const wraps = !elementId && child.children.length === 1 && elementIdOf(child.children[0]);
500
+ visit(child, insideIdentified || Boolean(elementId), wraps ? textFrameAfter(node.children[i + 1]) : null);
501
+ });
502
+ };
503
+ visit(root, false);
504
+
505
+ // List rendering multiplies one source element into many rendered ones, so a
506
+ // per-element source comparison cannot speak about what is inside it.
507
+ for (const m of src.matchAll(/\.map\s*\(/g)) {
508
+ unsupported.push({ elementId: null, line: lineAt(m.index), construct: ".map(", reason: "list_rendering" });
509
+ }
510
+
511
+ return { elements, byId, unsupported, ambiguous };
512
+ }
513
+
514
+ /**
515
+ * Bind concept requirements to the ids the transcriber actually writes. One
516
+ * spec element becomes several when it is drawn as a multi-trace line or a
517
+ * mark's dots — `<id>-1`, `<id>-2`, … (src/builders/transcribe.mjs,
518
+ * concept-lines.mjs) — so a requirement seeded on the spec id names an element
519
+ * the source never has, and fails `removed_from_source` on untouched output.
520
+ * Such a requirement is replaced by one per piece, owing the same.
521
+ *
522
+ * Unchanged: a requirement whose own id is in the source, one with no pieces
523
+ * there, and any piece id that already has a requirement of its own.
524
+ */
525
+ export function bindSplitIds(requirements, jsx) {
526
+ const present = [...readSlideElements(jsx).byId.keys()];
527
+ const owned = new Set(requirements.map((r) => r?.elementId));
528
+ const out = [];
529
+ for (const req of requirements) {
530
+ const id = req?.elementId;
531
+ const pieces =
532
+ req?.kind === "element" && typeof id === "string" && !present.includes(id)
533
+ ? present.filter((p) => p.startsWith(`${id}-`) && /^\d+$/.test(p.slice(id.length + 1)) && !owned.has(p))
534
+ : [];
535
+ if (pieces.length === 0) {
536
+ out.push(req);
537
+ continue;
538
+ }
539
+ for (const piece of pieces) {
540
+ out.push({ ...req, requirementId: `${req.requirementId}${piece.slice(id.length)}`, elementId: piece });
541
+ }
542
+ }
543
+ return out;
544
+ }
545
+
546
+ /**
547
+ * The converted dialect writes a filled shape as TWO siblings: its paint
548
+ * element carries `data-sm-source-id`, its text frame (`data-sm-text-frame`)
549
+ * does not — exactly one of them declares the id (reverse/codegen.ts,
550
+ * `sourceIdAttr`). That frame's text is the shape's text. A non-rect shape's
551
+ * paint is an `<svg>` inside an id-less wrapper `<div>`, and the frame follows
552
+ * the WRAPPER (reverse/codegen-svg.ts), so `readSlideElements` hands a
553
+ * wrapper's frame down to the one identified child it wraps.
554
+ */
555
+ function textFrameAfter(next) {
556
+ if (!next || elementIdOf(next)) return null;
557
+ return next.attrs["data-sm-text-frame"] !== undefined ? next : null;
558
+ }
559
+
560
+ /** Does this tag paint something on the slide (as opposed to being a wrapper)? */
561
+ function isSlideElement(node, attrs) {
562
+ if (/^(B|T|I|R|L|Chart|img|table)$/.test(node.name)) return true;
563
+ return attrs["data-connector"] !== undefined || attrs["data-sm-native-object"] !== undefined;
564
+ }
565
+
566
+ /** One element's per-scope fingerprints, relationships and unreadable parts. */
567
+ function describeElement(node, elementId, attrs, src, lineAt, frame = null) {
568
+ const buckets = Object.fromEntries([...PROPERTY_SCOPES, "other"].map((s) => [s, []]));
569
+ const relationships = [];
570
+ const unreadable = [];
571
+ let kind = node.name;
572
+
573
+ // The element's own attributes, plus those of every descendant that carries
574
+ // no identity of its own — an unnamed `<Chart>` or `<b>` is part of THIS
575
+ // obligation, not a second one.
576
+ const absorb = (n, own) => {
577
+ const a = n.attrs ?? (n.attrs = attrsOf(n.raw));
578
+ const prefix = own ? "" : `${n.name}.`;
579
+ for (const [name, value] of Object.entries(a)) {
580
+ if (name === "id" || name === "data-sm-source-id" || name === "key") continue;
581
+ if (name === "data-from" || name === "data-to") {
582
+ relationships.push({ role: name === "data-from" ? "start" : "end", target: refTarget(value) });
583
+ continue;
584
+ }
585
+ if (isUnreadable(value)) unreadable.push(`${prefix}${name}={${value.expr}}`);
586
+ if (name === "style") {
587
+ // The style object is split per property, so a geometry change and a
588
+ // colour change in the same attribute do not read as one opaque diff.
589
+ for (const [prop, raw] of styleEntries(value)) {
590
+ (buckets[STYLE_SCOPE[prop]] ?? buckets.other).push(`${prefix}style.${prop}=${raw}`);
591
+ }
592
+ continue;
593
+ }
594
+ // `type`/`data` mean a chart only on a chart element; elsewhere they are
595
+ // ordinary attributes and must not be mistaken for chart evidence.
596
+ const scope = n.name === "Chart" && (name === "type" || name === "data") ? "chart" : ATTR_SCOPE[name];
597
+ (buckets[scope] ?? buckets.other).push(`${prefix}${name}=${attrText(value)}`);
598
+ }
599
+ if (n.name === "Chart" || a["data-sm-chart"] !== undefined) kind = "chart";
600
+ for (const child of n.children) if (!elementIdOf(child)) absorb(child, false);
601
+ };
602
+ absorb(node, true);
603
+
604
+ const text = [ownText(node, src), frame ? ownText(frame, src) : ""].filter(Boolean).join(" ");
605
+ if (text) buckets.text.push(`#text=${text}`);
606
+
607
+ const scopes = {};
608
+ for (const scope of [...PROPERTY_SCOPES, "other"]) {
609
+ scopes[scope] = buckets[scope].length ? buckets[scope].sort().join("\u001f") : null;
610
+ }
611
+
612
+ return {
613
+ elementId,
614
+ tag: node.name,
615
+ line: lineAt(node.start),
616
+ kind,
617
+ scopes,
618
+ text,
619
+ relationships,
620
+ unreadable,
621
+ };
622
+ }
623
+
624
+ /** `{{ left: "66px", top: "36px" }}` → [["left", '"66px"'], …]. */
625
+ function* styleEntries(value) {
626
+ const expr = isPlainObject(value) ? value.expr : String(value ?? "");
627
+ const inner = expr.replace(/^\{/, "").replace(/\}$/, "");
628
+ for (const m of inner.matchAll(/([A-Za-z-][\w-]*)\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^,}]+)/g)) {
629
+ yield [m[1], m[2].trim()];
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Compare two versions of one slide's source.
635
+ *
636
+ * `changes` is what the edit INTENDED, keyed to the operation record's own
637
+ * property scopes. `unknown` holds elements whose only difference is in an
638
+ * attribute this reader does not model — seen, unattributable, and therefore
639
+ * never a silent pass. `unsupported` and `ambiguous` carry everything the
640
+ * reader refused to resolve.
641
+ *
642
+ * @param {string} before
643
+ * @param {string} after
644
+ */
645
+ export function compareSlideSource(before, after) {
646
+ const a = readSlideElements(before);
647
+ const b = readSlideElements(after);
648
+ const changes = [];
649
+ const unknown = [];
650
+
651
+ const ids = new Set([...a.byId.keys(), ...b.byId.keys()]);
652
+ for (const elementId of ids) {
653
+ const prev = a.byId.get(elementId);
654
+ const next = b.byId.get(elementId);
655
+ if (!prev) {
656
+ changes.push({
657
+ elementId,
658
+ action: "add",
659
+ properties: PROPERTY_SCOPES.filter((s) => next.scopes[s] != null),
660
+ relationships: next.relationships,
661
+ });
662
+ continue;
663
+ }
664
+ if (!next) {
665
+ changes.push({
666
+ elementId,
667
+ action: "remove",
668
+ properties: PROPERTY_SCOPES.filter((s) => prev.scopes[s] != null),
669
+ relationships: prev.relationships,
670
+ });
671
+ continue;
672
+ }
673
+ const properties = PROPERTY_SCOPES.filter((s) => prev.scopes[s] !== next.scopes[s]);
674
+ const relationshipsChanged = canonical(prev.relationships) !== canonical(next.relationships);
675
+ if (prev.scopes.other !== next.scopes.other) {
676
+ unknown.push({
677
+ elementId,
678
+ reason: "unmodelled_attribute_change",
679
+ detail: "an attribute outside the property-scope vocabulary changed; the scope of this edit cannot be named",
680
+ });
681
+ }
682
+ if (properties.length || relationshipsChanged) {
683
+ changes.push({
684
+ elementId,
685
+ action: "update",
686
+ properties,
687
+ ...(relationshipsChanged ? { relationships: { before: prev.relationships, after: next.relationships } } : {}),
688
+ });
689
+ }
690
+ }
691
+
692
+ changes.sort((x, y) => String(x.elementId).localeCompare(String(y.elementId)));
693
+ return {
694
+ changes,
695
+ unknown,
696
+ unsupported: [...a.unsupported, ...b.unsupported],
697
+ ambiguous: [...a.ambiguous, ...b.ambiguous],
698
+ before: a,
699
+ after: b,
700
+ };
701
+ }
702
+
703
+ // ---------------------------------------------------------------------------
704
+ // 2a. Rendered-IR comparison — did the object actually paint?
705
+ // ---------------------------------------------------------------------------
706
+
707
+ /** Reasons an element is present in the render tree but cannot be seen. */
708
+ export const HIDDEN_REASONS = [
709
+ "hidden:display-none",
710
+ "hidden:zero-size",
711
+ "hidden:zero-opacity",
712
+ "hidden:clipped",
713
+ "hidden:off-canvas",
714
+ ];
715
+
716
+ /**
717
+ * Is this clip-path empty? Returns true (clips everything), false (leaves area)
718
+ * or null (this reader cannot tell — the caller reports `unsupported`, never a
719
+ * pass). `polygon()` is resolved by area rather than refused, because the
720
+ * transcriber writes chevrons and trapezoids that way on ordinary slides.
721
+ */
722
+ function clipHidesEverything(clipPath) {
723
+ const value = String(clipPath ?? "").trim();
724
+ if (!value || value === "none") return false;
725
+ const inset = /^inset\(\s*([^)]*?)\s*(?:round[^)]*)?\)$/.exec(value);
726
+ if (inset) {
727
+ const parts = inset[1].trim().split(/\s+/).map((p) => percent(p));
728
+ if (parts.some((p) => p === null)) return null;
729
+ const [t, r = t, b = t, l = r] = parts;
730
+ return t + b >= 100 || l + r >= 100;
731
+ }
732
+ const circle = /^circle\(\s*([^\s)]+)/.exec(value);
733
+ if (circle) {
734
+ const radius = percent(circle[1]) ?? num(Number.parseFloat(circle[1]));
735
+ return radius === null ? null : radius <= 0;
736
+ }
737
+ const polygon = /^polygon\(\s*([^)]*)\)$/.exec(value);
738
+ if (polygon) {
739
+ const points = [];
740
+ for (const pair of polygon[1].split(",")) {
741
+ const [px, py] = pair.trim().split(/\s+/).map((p) => percent(p));
742
+ if (px === null || py === null) return null;
743
+ points.push([px, py]);
744
+ }
745
+ if (points.length < 3) return true;
746
+ // Shoelace: a polygon with no area paints nothing.
747
+ let twiceArea = 0;
748
+ for (let i = 0; i < points.length; i++) {
749
+ const [x1, y1] = points[i];
750
+ const [x2, y2] = points[(i + 1) % points.length];
751
+ twiceArea += x1 * y2 - x2 * y1;
752
+ }
753
+ return Math.abs(twiceArea) / 2 < 1e-6;
754
+ }
755
+ return null;
756
+ }
757
+
758
+ /** "50%" → 50, "0" → 0, "12px" → null (no basis to compare against). */
759
+ function percent(token) {
760
+ const t = String(token ?? "").trim();
761
+ if (/^-?\d+(\.\d+)?%$/.test(t)) return Number.parseFloat(t);
762
+ if (/^-?0(\.0+)?$/.test(t)) return 0;
763
+ return null;
764
+ }
765
+
766
+ /** Which sides of a rect moved past the rendered-px tolerance. */
767
+ function rectDrift(want, got) {
768
+ if (!isPlainObject(want) || !isPlainObject(got)) return null;
769
+ return ["x", "y", "w", "h"].filter((k) => {
770
+ const a = num(want[k]);
771
+ const b = num(got[k]);
772
+ return a !== null && b !== null && Math.abs(b - a) > RENDER_GEOMETRY_TOLERANCE_PX;
773
+ });
774
+ }
775
+
776
+ /** Index a captured box tree by element identity, carrying inherited hiding. */
777
+ function indexRender(boxes, canvas) {
778
+ const byId = new Map();
779
+ let seen = 0;
780
+ const walk = (box, inherited) => {
781
+ if (!isPlainObject(box)) return;
782
+ seen += 1;
783
+ const local = boxHidden(box, canvas);
784
+ const state = inherited ?? local;
785
+ const id = isNonEmptyString(box.elementId)
786
+ ? box.elementId
787
+ : box.smSourceId != null
788
+ ? String(box.smSourceId)
789
+ : null;
790
+ if (id && !byId.has(id)) byId.set(id, { box, hidden: state });
791
+ for (const child of box.children ?? []) walk(child, state);
792
+ };
793
+ for (const box of Array.isArray(boxes) ? boxes : []) walk(box, null);
794
+ return { byId, seen };
795
+ }
796
+
797
+ /**
798
+ * Why this box cannot be seen, or null. `{ unsupported: "…" }` when the reader
799
+ * will not decide — a clip it cannot resolve is not a clip that passed.
800
+ */
801
+ function boxHidden(box, canvas) {
802
+ const style = isPlainObject(box.style) ? box.style : {};
803
+ if (style.display === "none") return { reason: "hidden:display-none" };
804
+
805
+ const rect = isPlainObject(box.rect) ? box.rect : null;
806
+ // A connector is measured from its endpoints, not from its own rect: the
807
+ // native `<p:cxnSp>` for a horizontal link legitimately has zero height.
808
+ const measuredByEndpoints = Boolean(box.connector);
809
+ if (rect && !measuredByEndpoints) {
810
+ const w = num(rect.w);
811
+ const h = num(rect.h);
812
+ if (w === null || h === null) return { unsupported: "rect_unreadable" };
813
+ if (w < RENDER_MIN_VISIBLE_PX || h < RENDER_MIN_VISIBLE_PX) return { reason: "hidden:zero-size" };
814
+ } else if (!rect && !measuredByEndpoints) {
815
+ return { unsupported: "rect_missing" };
816
+ }
817
+
818
+ const opacity = num(style.opacity);
819
+ if (opacity !== null && opacity < RENDER_MIN_VISIBLE_OPACITY) return { reason: "hidden:zero-opacity" };
820
+
821
+ const clipped = clipHidesEverything(style.clipPath);
822
+ if (clipped === null) return { unsupported: "clip_path_unreadable" };
823
+ if (clipped) return { reason: "hidden:clipped" };
824
+
825
+ // A connector's rect is degenerate by construction (see above), so the
826
+ // off-canvas test cannot read it either: a zero-width link at x=0 has
827
+ // `right <= OFF_CANVAS_SLACK_PX` and would report hidden:off-canvas — a
828
+ // false deletion on a slide nobody touched.
829
+ if (canvas && rect && !measuredByEndpoints) {
830
+ const w = num(canvas.w);
831
+ const h = num(canvas.h);
832
+ if (w !== null && h !== null) {
833
+ const right = num(rect.x) + num(rect.w);
834
+ const bottom = num(rect.y) + num(rect.h);
835
+ const outside =
836
+ right <= OFF_CANVAS_SLACK_PX ||
837
+ bottom <= OFF_CANVAS_SLACK_PX ||
838
+ num(rect.x) >= w - OFF_CANVAS_SLACK_PX ||
839
+ num(rect.y) >= h - OFF_CANVAS_SLACK_PX;
840
+ if (outside) return { reason: "hidden:off-canvas" };
841
+ }
842
+ }
843
+ return null;
844
+ }
845
+
846
+ /**
847
+ * Prove each element survived the render.
848
+ *
849
+ * `render` is one captured slide (`{ boxes: [...] }` — the CapturedDeck slide
850
+ * both clients already produce). `elementIds` are the identities being asked
851
+ * about; each gets one of:
852
+ *
853
+ * passed — a box with that identity paints something visible
854
+ * failed — the render HAS evidence and the element is absent from it,
855
+ * or is present and hidden (zero-size, zero-opacity,
856
+ * display:none, fully clipped, off-canvas)
857
+ * unknown — there is no render evidence at all to speak from
858
+ * unsupported — the box is there but this reader cannot decide (an
859
+ * unresolvable clip-path, a missing rect)
860
+ *
861
+ * The failed case is the point of this function: an element present in the JSX
862
+ * and missing or hidden in the render must never read as preserved.
863
+ *
864
+ * `expectRects`, when given, additionally holds each element to the rect it
865
+ * occupied in an earlier capture, within `RENDER_GEOMETRY_TOLERANCE_PX`. That
866
+ * is the only place a rendered-px tolerance applies: EMU geometry is the
867
+ * package comparison's business, at its own quantization.
868
+ *
869
+ * @param {{boxes?: Array<object>}} render
870
+ * @param {{
871
+ * canvas?: {w:number,h:number}|null,
872
+ * elementIds?: Array<string>,
873
+ * expectRects?: Record<string, {x:number,y:number,w:number,h:number}>|null,
874
+ * }} [opts]
875
+ */
876
+ export function compareRenderEvidence(render, { canvas = null, elementIds = [], expectRects = null } = {}) {
877
+ const boxes = isPlainObject(render) ? render.boxes : null;
878
+ const { byId, seen } = indexRender(boxes, canvas);
879
+ const hasEvidence = seen > 0;
880
+ const byIdOut = {};
881
+
882
+ for (const elementId of elementIds) {
883
+ if (!hasEvidence) {
884
+ byIdOut[elementId] = {
885
+ status: "unknown",
886
+ reason: "no_render_evidence",
887
+ detail: "the slide was not captured, so nothing can be said about what survived",
888
+ };
889
+ continue;
890
+ }
891
+ const found = byId.get(elementId);
892
+ if (!found) {
893
+ byIdOut[elementId] = {
894
+ status: "failed",
895
+ reason: "absent_from_render",
896
+ detail: "the source declares this element and the render does not contain it",
897
+ };
898
+ continue;
899
+ }
900
+ const hidden = found.hidden;
901
+ if (hidden?.unsupported) {
902
+ byIdOut[elementId] = { status: "unsupported", reason: hidden.unsupported, rect: found.box.rect ?? null };
903
+ continue;
904
+ }
905
+ if (hidden?.reason) {
906
+ byIdOut[elementId] = {
907
+ status: "failed",
908
+ reason: hidden.reason,
909
+ detail: "the element is in the render but paints nothing a viewer can see",
910
+ rect: found.box.rect ?? null,
911
+ };
912
+ continue;
913
+ }
914
+ const want = expectRects?.[elementId];
915
+ const drift = want ? rectDrift(want, found.box.rect) : null;
916
+ if (drift?.length) {
917
+ byIdOut[elementId] = {
918
+ status: "failed",
919
+ reason: "geometry_moved",
920
+ detail: `past ${RENDER_GEOMETRY_TOLERANCE_PX}px on: ${drift.join(", ")}`,
921
+ rect: found.box.rect ?? null,
922
+ };
923
+ continue;
924
+ }
925
+ byIdOut[elementId] = { status: "passed", rect: found.box.rect ?? null };
926
+ }
927
+
928
+ return { byId: byIdOut, evidence: hasEvidence ? "present" : "absent", indexed: [...byId.keys()] };
929
+ }
930
+
931
+ // ---------------------------------------------------------------------------
932
+ // 2b. Final-package comparison — did it survive the splice, unchanged?
933
+ // ---------------------------------------------------------------------------
934
+
935
+ /**
936
+ * Compare two NORMALIZED projections of one slide, keyed by element identity.
937
+ *
938
+ * Both arguments are `{ [elementId]: shape }`, where `shape` is a structural
939
+ * projection entry (`computeProjection`, src/pptx/tags.mjs, or the add-in's
940
+ * twin): `{ geom, text, fill, line, mediaSha256, chart }`. The caller supplies
941
+ * the keying, because only the caller knows how its package spells identity —
942
+ * an identity this module invented would be a confident guess, which the guide
943
+ * forbids. A `native` field may be attached per shape (`{ parts: { path: sha } }`)
944
+ * to carry an opaque object's dependency closure.
945
+ *
946
+ * ZIP bytes are deliberately NOT compared: timestamps, relationship ids and
947
+ * part ordering legitimately change on a save that changed nothing.
948
+ *
949
+ * @param {Record<string, object>} before
950
+ * @param {Record<string, object>} after
951
+ * @param {{resolvedTargets?: Array<string>|Set<string>|null}} [opts]
952
+ */
953
+ export function comparePackageEvidence(before, after, { resolvedTargets = null } = {}) {
954
+ const a = isPlainObject(before) ? before : {};
955
+ const b = isPlainObject(after) ? after : {};
956
+ const targets = resolvedTargets == null ? null : new Set(resolvedTargets);
957
+ const byId = {};
958
+
959
+ for (const elementId of new Set([...Object.keys(a), ...Object.keys(b)])) {
960
+ const prev = a[elementId];
961
+ const next = b[elementId];
962
+ if (!isPlainObject(next)) {
963
+ byId[elementId] = {
964
+ status: "failed",
965
+ reason: "absent_from_package",
966
+ detail: "the element is not in the delivered package",
967
+ scopes: {},
968
+ };
969
+ continue;
970
+ }
971
+ if (!isPlainObject(prev)) {
972
+ byId[elementId] = {
973
+ status: "unknown",
974
+ reason: "no_package_baseline",
975
+ detail: "nothing to compare the delivered element against",
976
+ scopes: {},
977
+ };
978
+ continue;
979
+ }
980
+ const scopes = {
981
+ geom: compareGeom(prev.geom, next.geom),
982
+ text: sameOrChanged(canonical(prev.text ?? null), canonical(next.text ?? null)),
983
+ fill: sameOrChanged(prev.fill ?? null, next.fill ?? null),
984
+ line: sameOrChanged(prev.line ?? null, next.line ?? null),
985
+ media: compareDigest(prev.mediaSha256, next.mediaSha256, "media_digest_unavailable"),
986
+ // The CLI's projection cannot produce a chart hash (src/pptx/tags.mjs
987
+ // documents why: it would need the add-in's whole chart reader). Both
988
+ // sides null is therefore `unsupported`, never a pass.
989
+ chart: compareDigest(prev.chart, next.chart, "chart_hash_unavailable"),
990
+ native: compareNative(prev.native, next.native),
991
+ };
992
+ // The element-level status rolls up only the scopes the projection always
993
+ // models, plus the three payload scopes that are actually present on one
994
+ // side or the other. A text shape has no chart, so "no chart hash here" is
995
+ // not an unsupported CHECK — it is the absence of a thing to check. Where a
996
+ // payload really does exist and its digest is unavailable (the CLI's chart
997
+ // hash), the `unsupported` stays in `scopes` and reaches the verdict
998
+ // through the requirement that protects it.
999
+ const carried = [
1000
+ ...["geom", "text", "fill", "line"],
1001
+ ...(prev.mediaSha256 != null || next.mediaSha256 != null ? ["media"] : []),
1002
+ ...(prev.chart != null || next.chart != null ? ["chart"] : []),
1003
+ ...(isPlainObject(prev.native) || isPlainObject(next.native) ? ["native"] : []),
1004
+ ];
1005
+ byId[elementId] = { status: rollUp(carried.map((s) => scopes[s].status)), scopes };
1006
+ }
1007
+
1008
+ if (targets) {
1009
+ byId["#relationships"] = { status: "passed", resolved: [...targets] };
1010
+ }
1011
+ return { byId, resolvedTargets: targets };
1012
+ }
1013
+
1014
+ function compareGeom(prev, next) {
1015
+ if (!isPlainObject(prev) || !isPlainObject(next)) {
1016
+ return { status: "unknown", reason: "geometry_unavailable" };
1017
+ }
1018
+ const moved = ["x", "y", "w", "h", "rot"].filter((k) => {
1019
+ const dp = num(prev[k]);
1020
+ const dn = num(next[k]);
1021
+ if (dp === null || dn === null) return false;
1022
+ return Math.abs(dn - dp) > TRANSFORM_TOLERANCE_EMU;
1023
+ });
1024
+ return moved.length
1025
+ ? { status: "failed", reason: "geometry_changed", detail: `changed: ${moved.join(", ")}`, tolerance: { quantumEmu: PROJECTION_QUANTUM_EMU, slackEmu: TRANSFORM_TOLERANCE_EMU } }
1026
+ : { status: "passed" };
1027
+ }
1028
+
1029
+ const sameOrChanged = (prev, next) =>
1030
+ prev === next ? { status: "passed" } : { status: "failed", reason: "property_changed" };
1031
+
1032
+ function compareDigest(prev, next, missingReason) {
1033
+ if (prev == null && next == null) return { status: "unsupported", reason: missingReason };
1034
+ if (prev == null || next == null) return { status: "failed", reason: "digest_appeared_or_vanished" };
1035
+ return prev === next
1036
+ ? { status: "passed" }
1037
+ : { status: "failed", reason: "digest_changed", detail: "a binary payload changed without the user editing it" };
1038
+ }
1039
+
1040
+ function compareNative(prev, next) {
1041
+ const prevParts = isPlainObject(prev?.parts) ? prev.parts : null;
1042
+ const nextParts = isPlainObject(next?.parts) ? next.parts : null;
1043
+ if (!prevParts && !nextParts) return { status: "unsupported", reason: "native_closure_not_supplied" };
1044
+ if (!prevParts || !nextParts) return { status: "unknown", reason: "native_closure_partial" };
1045
+ const missing = Object.keys(prevParts).filter((p) => nextParts[p] === undefined);
1046
+ if (missing.length) {
1047
+ return { status: "failed", reason: "native_part_missing", detail: `dropped: ${missing.join(", ")}` };
1048
+ }
1049
+ const rehashed = Object.keys(prevParts).filter((p) => nextParts[p] !== prevParts[p]);
1050
+ if (rehashed.length) {
1051
+ return { status: "failed", reason: "native_part_changed", detail: `rewritten: ${rehashed.join(", ")}` };
1052
+ }
1053
+ return { status: "passed" };
1054
+ }
1055
+
1056
+ /** failed beats unsupported beats unknown beats passed. */
1057
+ function rollUp(statuses) {
1058
+ if (statuses.includes("failed")) return "failed";
1059
+ if (statuses.includes("unsupported")) return "unsupported";
1060
+ if (statuses.includes("unknown")) return "unknown";
1061
+ return statuses.length ? "passed" : "unknown";
1062
+ }
1063
+
1064
+ // ---------------------------------------------------------------------------
1065
+ // The verdict
1066
+ // ---------------------------------------------------------------------------
1067
+
1068
+ /**
1069
+ * Hash of the obligations a verdict was about. Covers the active requirements'
1070
+ * identity, kind, protected scopes and relationships — not the baseline hash,
1071
+ * because source changes on every edit while the promises do not. A later
1072
+ * phase compares this against the record on disk to know the verdict is stale.
1073
+ */
1074
+ export function requirementSetHash(entry) {
1075
+ const active = Array.isArray(entry?.active) ? entry.active : [];
1076
+ const shape = active
1077
+ .filter(isPlainObject)
1078
+ .map((r) => ({
1079
+ requirementId: r.requirementId ?? null,
1080
+ elementId: r.elementId ?? null,
1081
+ kind: r.kind ?? null,
1082
+ properties: [...(Array.isArray(r.properties) ? r.properties : [])].sort(),
1083
+ relationships: [...(Array.isArray(r.relationships) ? r.relationships : [])]
1084
+ .filter(isPlainObject)
1085
+ .map((rel) => ({ role: rel.role ?? null, target: rel.target ?? null }))
1086
+ .sort((x, y) => canonical(x).localeCompare(canonical(y))),
1087
+ useId: r.useId ?? null,
1088
+ }))
1089
+ .sort((x, y) => String(x.requirementId).localeCompare(String(y.requirementId)));
1090
+ return sha256(canonical(shape));
1091
+ }
1092
+
1093
+ /** What a restyle may change on any element: how it looks, not what it says. */
1094
+ const LOOK_SCOPES = ["geom", "fill", "line"];
1095
+ const CONTENT_SCOPES = new Set(["text", "media", "chart", "native"]);
1096
+
1097
+ /**
1098
+ * Only an IMPORT's decoration may be restyled away. Its seed claims a content
1099
+ * scope exactly where the shape HAS that content (its own text, a picture
1100
+ * fill), so a requirement without one owes only a look. Elsewhere the absence
1101
+ * proves nothing: a concept's drawn marks (a bar, a sparkline, a rule) carry
1102
+ * data with no text, and `legacy-current` claims only media/native for every
1103
+ * element.
1104
+ */
1105
+ const WAIVABLE_ORIGIN = "converted";
1106
+
1107
+ /**
1108
+ * What this operation declared it may change on the requirement's element.
1109
+ *
1110
+ * Two sources: the target's explicit `changes`, and the operation's KIND. A
1111
+ * redesign is the user asking for a new look, so on a slide it targets the look
1112
+ * scopes are declared by the kind itself, and an imported element that carried
1113
+ * none of the slide's content may go. Content — text, pictures, charts, native objects,
1114
+ * connections — stays owed: a restyle must keep the user's words and images,
1115
+ * which is what keeps a real loss refusable.
1116
+ */
1117
+ function declaredFor(operation, slideKey, req, origin) {
1118
+ const out = { scopes: new Set(), removable: false, declared: false };
1119
+ for (const target of Array.isArray(operation?.targets) ? operation.targets : []) {
1120
+ if (!isPlainObject(target)) continue;
1121
+ if (slideKey != null && target.slideKey !== slideKey) continue;
1122
+ if (operation.kind === "redesign") {
1123
+ out.declared = true;
1124
+ for (const scope of LOOK_SCOPES) out.scopes.add(scope);
1125
+ const properties = Array.isArray(req.properties) ? req.properties : [];
1126
+ if (req.kind === "element" && origin === WAIVABLE_ORIGIN && !properties.some((s) => CONTENT_SCOPES.has(s))) {
1127
+ out.removable = true;
1128
+ }
1129
+ }
1130
+ for (const change of Array.isArray(target.changes) ? target.changes : []) {
1131
+ if (!isPlainObject(change) || change.elementId !== req.elementId) continue;
1132
+ out.declared = true;
1133
+ if (change.action === "remove") out.removable = true;
1134
+ for (const scope of Array.isArray(change.properties) ? change.properties : []) out.scopes.add(scope);
1135
+ }
1136
+ }
1137
+ return out;
1138
+ }
1139
+
1140
+ /** A declared removal the evidence confirms: the element is simply gone. */
1141
+ const declaredRemoval = (at) => ({ ...at, scope: null, status: "passed", reason: "declared_removal" });
1142
+
1143
+ /**
1144
+ * Compose the two comparisons into one verdict about one slide.
1145
+ *
1146
+ * `entry` is the slide's preservation record (its `active` requirements),
1147
+ * `operation` the record authorizing this run, and `source` / `render` /
1148
+ * `packageEvidence` are the results of the three comparisons above — each
1149
+ * optional, because a draft has no package and an offline check has no render.
1150
+ * Whatever is absent produces `unknown` checks, never passes.
1151
+ *
1152
+ * @param {{
1153
+ * entry?: object|null,
1154
+ * operation?: object|null,
1155
+ * slideKey?: string|null,
1156
+ * source?: object|null,
1157
+ * render?: object|null,
1158
+ * packageEvidence?: object|null,
1159
+ * binding?: {sourceHash?: string, liveRevision?: string, operationId?: string},
1160
+ * }} [input]
1161
+ */
1162
+ export function preservationVerdict({
1163
+ entry = null,
1164
+ operation = null,
1165
+ slideKey = null,
1166
+ source = null,
1167
+ render = null,
1168
+ packageEvidence = null,
1169
+ binding = {},
1170
+ } = {}) {
1171
+ const active = Array.isArray(entry?.active) ? entry.active.filter(isPlainObject) : [];
1172
+ const key = slideKey ?? (isDurableId(entry?.slideKey) ? entry.slideKey : null);
1173
+ const checks = [];
1174
+
1175
+ for (const req of active) {
1176
+ const elementId = req.elementId;
1177
+ const at = { requirementId: req.requirementId ?? null, elementId: elementId ?? null, kind: req.kind ?? null };
1178
+ const allowed = declaredFor(operation, key, req, entry?.origin ?? null);
1179
+
1180
+ // --- source: is the obligation still in the file the agent wrote?
1181
+ if (source) {
1182
+ const unsupported = source.unsupported?.find((u) => u.elementId === elementId);
1183
+ const unknown = source.unknown?.find((u) => u.elementId === elementId);
1184
+ const ambiguous = source.ambiguous?.find((u) => u.elementId === elementId);
1185
+ const change = source.changes?.find((c) => c.elementId === elementId);
1186
+ if (unsupported) {
1187
+ checks.push({ ...at, scope: null, status: "unsupported", reason: unsupported.reason });
1188
+ } else if (ambiguous) {
1189
+ checks.push({ ...at, scope: null, status: "unknown", reason: ambiguous.reason });
1190
+ } else if (change?.action === "remove" && !allowed.removable) {
1191
+ checks.push({
1192
+ ...at,
1193
+ scope: null,
1194
+ status: "failed",
1195
+ reason: "removed_from_source",
1196
+ detail: "the element is gone from the source and this operation did not declare its removal",
1197
+ });
1198
+ } else if (unknown) {
1199
+ checks.push({ ...at, scope: null, status: "unknown", reason: unknown.reason });
1200
+ }
1201
+ }
1202
+
1203
+ // --- render: did it paint?
1204
+ if (render && elementId != null) {
1205
+ const seen = render.byId?.[elementId];
1206
+ if (seen?.reason === "absent_from_render" && allowed.removable) {
1207
+ checks.push(declaredRemoval(at));
1208
+ } else if (seen) {
1209
+ checks.push({
1210
+ ...at,
1211
+ scope: null,
1212
+ status: seen.status,
1213
+ reason: seen.reason ?? null,
1214
+ ...(seen.detail ? { detail: seen.detail } : {}),
1215
+ });
1216
+ } else {
1217
+ checks.push({ ...at, scope: null, status: "unknown", reason: "not_checked_in_render" });
1218
+ }
1219
+ }
1220
+
1221
+ // --- package: did the protected properties survive the splice?
1222
+ if (packageEvidence && elementId != null) {
1223
+ const seen = packageEvidence.byId?.[elementId];
1224
+ if (!seen) {
1225
+ checks.push({ ...at, scope: null, status: "unknown", reason: "not_checked_in_package" });
1226
+ } else if (seen.status === "failed" && seen.reason === "absent_from_package") {
1227
+ checks.push(
1228
+ allowed.removable
1229
+ ? declaredRemoval(at)
1230
+ : { ...at, scope: null, status: "failed", reason: seen.reason, detail: seen.detail },
1231
+ );
1232
+ } else {
1233
+ const protectedScopes = Array.isArray(req.properties) && req.properties.length
1234
+ ? req.properties
1235
+ : PROPERTY_SCOPES;
1236
+ for (const scope of protectedScopes) {
1237
+ const result = seen.scopes?.[scope];
1238
+ if (!result) {
1239
+ checks.push({ ...at, scope, status: "unknown", reason: "scope_not_compared" });
1240
+ continue;
1241
+ }
1242
+ // A property the operation declared changeable is allowed to differ;
1243
+ // it is still reported, so the verdict says what it let through.
1244
+ if (result.status === "failed" && allowed.scopes.has(scope)) {
1245
+ checks.push({ ...at, scope, status: "passed", reason: "declared_change" });
1246
+ continue;
1247
+ }
1248
+ checks.push({
1249
+ ...at,
1250
+ scope,
1251
+ status: result.status,
1252
+ reason: result.reason ?? null,
1253
+ ...(result.detail ? { detail: result.detail } : {}),
1254
+ });
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ // --- relationships: an endpoint or package part that no longer resolves.
1260
+ const targets = packageEvidence?.resolvedTargets ?? null;
1261
+ for (const rel of Array.isArray(req.relationships) ? req.relationships : []) {
1262
+ if (!isPlainObject(rel)) continue;
1263
+ const scope = req.kind === "native-object" ? "native" : "geom";
1264
+ if (!targets) {
1265
+ checks.push({ ...at, scope, status: "unknown", reason: "relationships_not_resolved", detail: rel.role });
1266
+ continue;
1267
+ }
1268
+ checks.push(
1269
+ targets.has(rel.target)
1270
+ ? { ...at, scope, status: "passed", reason: `relationship:${rel.role}` }
1271
+ : {
1272
+ ...at,
1273
+ scope,
1274
+ status: "failed",
1275
+ reason: "relationship_broken",
1276
+ detail: `${rel.role} → ${rel.target} no longer resolves`,
1277
+ },
1278
+ );
1279
+ }
1280
+
1281
+ if (!checks.some((c) => c.requirementId === at.requirementId)) {
1282
+ checks.push({ ...at, scope: null, status: "unknown", reason: "no_evidence_supplied" });
1283
+ }
1284
+ }
1285
+
1286
+ // A construct the reader could not resolve that belongs to no single element
1287
+ // — list rendering, an unclosed tag — makes the source comparison unable to
1288
+ // speak for the WHOLE slide, so it is a check of its own rather than a note
1289
+ // nobody reads.
1290
+ for (const u of source?.unsupported ?? []) {
1291
+ if (u.elementId != null) continue;
1292
+ checks.push({ requirementId: null, elementId: null, kind: null, scope: null, status: "unsupported", reason: u.reason });
1293
+ }
1294
+
1295
+ const counts = Object.fromEntries(EVIDENCE_STATUSES.map((s) => [s, 0]));
1296
+ for (const c of checks) counts[c.status] = (counts[c.status] ?? 0) + 1;
1297
+
1298
+ // `unsupported` is not a pass: it rolls the verdict to `unknown`, which is
1299
+ // what a final delivery refuses on.
1300
+ const status = checks.length === 0 && active.length === 0
1301
+ ? "unknown"
1302
+ : rollUp(checks.map((c) => c.status)) === "unsupported"
1303
+ ? "unknown"
1304
+ : rollUp(checks.map((c) => c.status));
1305
+
1306
+ return {
1307
+ version: PRESERVATION_VERDICT_VERSION,
1308
+ status,
1309
+ slideKey: key,
1310
+ binding: {
1311
+ sourceHash: binding.sourceHash ?? null,
1312
+ liveRevision: binding.liveRevision ?? null,
1313
+ operationId: isPlainObject(operation) ? (operation.operationId ?? null) : (binding.operationId ?? null),
1314
+ requirementSetHash: requirementSetHash(entry),
1315
+ },
1316
+ checks,
1317
+ counts,
1318
+ unsupported: [
1319
+ ...(source?.unsupported ?? []),
1320
+ ...checks.filter((c) => c.status === "unsupported").map((c) => ({
1321
+ elementId: c.elementId,
1322
+ reason: c.reason,
1323
+ construct: c.scope ? `scope:${c.scope}` : "element",
1324
+ })),
1325
+ ],
1326
+ };
1327
+ }
1328
+
1329
+ /**
1330
+ * Has anything the verdict was bound to moved since? Any difference in the
1331
+ * source hash, the live revision, the operation or the requirement set
1332
+ * invalidates it — a verdict is about one exact state of one exact slide.
1333
+ */
1334
+ export function verdictIsStale(verdict, current = {}) {
1335
+ if (!isPlainObject(verdict) || verdict.version !== PRESERVATION_VERDICT_VERSION) return true;
1336
+ const bound = isPlainObject(verdict.binding) ? verdict.binding : {};
1337
+ for (const field of ["sourceHash", "liveRevision", "operationId", "requirementSetHash"]) {
1338
+ if (current[field] === undefined) continue;
1339
+ if (bound[field] !== current[field]) return true;
1340
+ }
1341
+ return false;
1342
+ }