slidemind 0.3.20 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -4,19 +4,20 @@
4
4
  // A converted slide invokes a server-minted ConvertedHost layout, so the
5
5
  // add-in's capability-table lint rejects it as an unregistered layout and
6
6
  // src/builders/build.mjs skips it. That left the authored half of an adopted
7
- // deck completely unchecked, and a real proposal shipped with the master's
7
+ // deck completely unchecked, and a real deck shipped with the master's
8
8
  // prompt text still in it ("[Action title …]", "xxx", "adjust footer"), a
9
9
  // sentence written into the slide-number field frame, and 12pt bold body copy
10
10
  // spilling out of fixed `noAutofit` boxes.
11
11
  //
12
12
  // These four rules are the ones that would have caught that, and they are
13
13
  // structural — they read the dialect's own attributes (data-sm-text-frame,
14
- // data-sm-para, data-sm-rpr, data-sm-field), not the brand.
14
+ // data-sm-para, data-sm-rpr, data-sm-field), not the brand. A fifth, (e),
15
+ // measures the one-line `wrap: "none"` frames the height rule cannot see.
15
16
 
16
17
  import fs from "node:fs";
17
18
  import path from "node:path";
18
19
 
19
- import { estimateLines } from "./text-fit.mjs";
20
+ import { estimateLines, textWidth } from "./text-fit.mjs";
20
21
 
21
22
  const PT_TO_PX = 96 / 72;
22
23
 
@@ -195,6 +196,45 @@ function textOf(source) {
195
196
 
196
197
  const lineAt = (src, offset) => src.slice(0, offset).split("\n").length;
197
198
 
199
+ // How far a measured wrap:none line may run past its frame before rule (e)
200
+ // reports it. The Arial table is exact for Arial runs, so this only absorbs
201
+ // rounding and the flat bold factor: 1px, or 3% of a wide frame.
202
+ const NOWRAP_SLACK = 0.03;
203
+
204
+ // The widest line of a wrap:none frame, measured run by run at each run's own
205
+ // size and weight (a <br /> starts a new line; trailing spaces take no room).
206
+ // Text outside a data-sm-rpr run is not measured — the dialect writes none.
207
+ function widestNoWrapLine(paras) {
208
+ let widest = null;
209
+ for (const para of paras) {
210
+ const paraPt = px(styleProp(para, "fontSize")) || 18;
211
+ let line = { width: 0, text: "", pt: 0, tail: { pt: paraPt, bold: false } };
212
+ const close = () => {
213
+ const trailing = /\s+$/.exec(line.text)?.[0] ?? "";
214
+ const width = line.width - textWidth(trailing, line.tail.pt, { bold: line.tail.bold });
215
+ if (line.text.trim() && (!widest || width > widest.width)) widest = { width, text: line.text.trim(), pt: line.pt };
216
+ line = { width: 0, text: "", pt: 0, tail: { pt: paraPt, bold: false } };
217
+ };
218
+ for (const node of walk(para)) {
219
+ if (node.name === "br") {
220
+ close();
221
+ continue;
222
+ }
223
+ if (attr(node, "data-sm-rpr") === null) continue;
224
+ const rpr = jsonAttr(node, "data-sm-rpr") ?? {};
225
+ const pt = px(rpr.sz) / 100 || px(styleProp(node, "fontSize")) || paraPt;
226
+ const bold = rpr.b === true;
227
+ const text = textOf(node.inner);
228
+ line.width += textWidth(text, pt, { bold });
229
+ line.text += text;
230
+ line.pt = Math.max(line.pt, pt);
231
+ line.tail = { pt, bold };
232
+ }
233
+ close();
234
+ }
235
+ return widest;
236
+ }
237
+
198
238
  // ---------------------------------------------------------------------------
199
239
 
200
240
  /**
@@ -311,9 +351,33 @@ export function lintConvertedSource(fileName, source, { strict = false } = {}) {
311
351
  const frameHeight = px(styleProp(frame, "height"));
312
352
  if (!(innerWidth > 0) || !(frameHeight > 0)) continue;
313
353
  // `wrap: "none"` shapes never break a line — the text runs on past the
314
- // shape edge, which is a width problem this rule does not judge.
354
+ // shape edge, which is a width problem the height rule below does not
355
+ // judge; rule (e) does.
315
356
  const wraps = meta.wrap !== "none";
316
357
 
358
+ // (e) A `wrap: "none"` line wider than its `spAutoFit` frame. PowerPoint
359
+ // sized that frame to the text it held; it re-sizes only when PowerPoint
360
+ // re-lays the text out, which an edit to the jsx never triggers, so the
361
+ // stored width ships and LibreOffice breaks the word at it. A Gantt month
362
+ // tick relabelled from "Jan" to "May" in the master's 25px frame came out
363
+ // as `Ma/y` (2026-10-06 proposal sim). A `noAutofit` wrap:none label that
364
+ // overhangs is left alone: that is how the source deck draws it (every such
365
+ // hit across the sim decks was an untouched library reference). Field
366
+ // frames are left out too: the slide number or date replaces their text.
367
+ if (!wraps && meta.autofit === "spAutoFit" && !paras.some((p) => [...walk(p)].some((n) => attr(n, "data-sm-field") !== null))) {
368
+ const widest = widestNoWrapLine(paras);
369
+ if (widest && widest.width > innerWidth + Math.max(1, innerWidth * NOWRAP_SLACK)) {
370
+ add(
371
+ "warn",
372
+ "converted:nowrap-overflow",
373
+ frame.start,
374
+ `converted:nowrap-overflow — ${JSON.stringify(widest.text.slice(0, 40))} measures ${Math.round(widest.width)}px at ${widest.pt}pt in a ${Math.round(innerWidth)}px wrap:none frame` +
375
+ `${sourceId ? ` (data-sm-source-id={${sourceId}})` : ""}. The frame keeps its stored width and does not wrap: widen it to at least ${Math.ceil(widest.width)}px or shorten the text.`,
376
+ { width: Math.round(widest.width * 10) / 10, frame: Math.round(innerWidth * 10) / 10 },
377
+ );
378
+ }
379
+ }
380
+
317
381
  // A trailing empty paragraph (PowerPoint's endParaRPr carrier) takes a line
318
382
  // but pushes nothing out of view — it is not an overflow.
319
383
  const fitParas = [...paras];
@@ -0,0 +1,163 @@
1
+ // Pure three-way JSON merge — the one policy both the local save path and the
2
+ // shared-plan service apply, so a conflict looks the same whether it was found
3
+ // against another process on this machine or against SharePoint.
4
+ //
5
+ // Parity partner: the backend's record-service three-way merge (merge3).
6
+ // Both are pinned to test/fixtures/workspace/merge/*.json; change the fixtures
7
+ // first, then both implementations. No storage, no plan knowledge: the only
8
+ // structural convention is that an array whose every element is an object with
9
+ // a string `id` is a keyed collection, matched by id instead of by position.
10
+ //
11
+ // Rules, in order, at every path:
12
+ // 1. theirs == mine → mine
13
+ // 2. base == mine (only they moved it) → theirs, path recorded in `applied`
14
+ // 3. base == theirs (only we moved it) → mine
15
+ // 4. all plain objects → per key (union of keys)
16
+ // 5. all keyed arrays → per id; order below
17
+ // 6. anything else → conflict; `merged` takes theirs
18
+ //
19
+ // Keyed arrays: delete vs unchanged is a deletion; delete vs edit is a conflict;
20
+ // a duplicate id is invalid input (Merge3Error, never a silent shadow). The
21
+ // order of the surviving items follows whichever side reordered them; if both
22
+ // reordered incompatibly that is a conflict on the array path carrying both
23
+ // candidate layouts, never "theirs wins" — a reorder is an edit like any other.
24
+ //
25
+ // `SERVER_OWNED_TOP_LEVEL` keys are stripped from all three inputs before the
26
+ // walk and are absent from `merged`: the caller's owner rule (the service's
27
+ // approval rule, the pack's amendment rule) decides them, never the merge.
28
+
29
+ import { isDeepStrictEqual } from "node:util";
30
+
31
+ export const SERVER_OWNED_TOP_LEVEL = Object.freeze(["approval", "status", "amends", "revision"]);
32
+
33
+ export class Merge3Error extends Error {
34
+ constructor(code, path, message) {
35
+ super(message);
36
+ this.name = "Merge3Error";
37
+ this.code = code;
38
+ this.path = path;
39
+ }
40
+ }
41
+
42
+ /** @returns {{ merged: unknown, conflicts: Array<{path:string, base:unknown, theirs:unknown, mine:unknown}>, applied: string[] }} */
43
+ export function merge3(base, theirs, mine) {
44
+ const conflicts = [];
45
+ const applied = [];
46
+ const merged = walk("", strip(base), strip(theirs), strip(mine), conflicts, applied);
47
+ return { merged, conflicts, applied };
48
+ }
49
+
50
+ function strip(value) {
51
+ if (!isPlainObject(value)) return value;
52
+ const copy = { ...value };
53
+ for (const key of SERVER_OWNED_TOP_LEVEL) delete copy[key];
54
+ return copy;
55
+ }
56
+
57
+ function walk(path, base, theirs, mine, conflicts, applied) {
58
+ if (isDeepStrictEqual(theirs, mine)) return mine;
59
+ if (isDeepStrictEqual(base, mine)) {
60
+ applied.push(path);
61
+ return theirs;
62
+ }
63
+ if (isDeepStrictEqual(base, theirs)) return mine;
64
+
65
+ if (isPlainObject(base) && isPlainObject(theirs) && isPlainObject(mine)) {
66
+ const out = {};
67
+ for (const key of unionKeys(theirs, mine, base)) {
68
+ const value = walk(`${path}/${key}`, base[key], theirs[key], mine[key], conflicts, applied);
69
+ if (value !== undefined) out[key] = value; // a surviving deletion, not a value
70
+ }
71
+ return out;
72
+ }
73
+
74
+ if (isIdArray(base) && isIdArray(theirs) && isIdArray(mine)) {
75
+ return mergeById(path, base, theirs, mine, conflicts, applied);
76
+ }
77
+
78
+ conflicts.push({ path, base, theirs, mine });
79
+ return theirs;
80
+ }
81
+
82
+ function mergeById(path, base, theirs, mine, conflicts, applied) {
83
+ const b = byId(base, path);
84
+ const t = byId(theirs, path);
85
+ const m = byId(mine, path);
86
+ const kept = new Map(); // id → merged item, for every id that survives
87
+
88
+ for (const [id, theirItem] of t) {
89
+ const itemPath = `${path}/${id}`;
90
+ const myItem = m.get(id);
91
+ if (myItem !== undefined) {
92
+ kept.set(id, walk(itemPath, b.get(id), theirItem, myItem, conflicts, applied));
93
+ continue;
94
+ }
95
+ const baseItem = b.get(id);
96
+ if (baseItem === undefined) {
97
+ applied.push(itemPath); // they added it
98
+ kept.set(id, theirItem);
99
+ continue;
100
+ }
101
+ if (isDeepStrictEqual(baseItem, theirItem)) continue; // we removed it, they left it alone
102
+ conflicts.push({ path: itemPath, base: baseItem, theirs: theirItem, mine: null });
103
+ kept.set(id, theirItem); // we removed it while they edited it: theirs stands
104
+ }
105
+ for (const [id, myItem] of m) {
106
+ if (t.has(id)) continue;
107
+ const itemPath = `${path}/${id}`;
108
+ const baseItem = b.get(id);
109
+ if (baseItem === undefined) {
110
+ kept.set(id, myItem); // we added it
111
+ continue;
112
+ }
113
+ if (isDeepStrictEqual(baseItem, myItem)) continue; // they removed it, we left it alone
114
+ conflicts.push({ path: itemPath, base: baseItem, theirs: null, mine: myItem }); // merged takes theirs: removed
115
+ }
116
+
117
+ // Order. Among the ids both sides still have, whoever moved them decides;
118
+ // the other side's additions follow in its own order.
119
+ const common = [...kept.keys()].filter((id) => t.has(id) && m.has(id));
120
+ const inCommon = new Set(common);
121
+ const order = (items) => items.map((item) => item.id).filter((id) => inCommon.has(id));
122
+ const tOrder = order(theirs);
123
+ const mOrder = order(mine);
124
+ const bOrder = order(base);
125
+ const inBase = new Set(bOrder);
126
+ const unmoved = (side) => isDeepStrictEqual(side.filter((id) => inBase.has(id)), bOrder);
127
+ const layout = (primary, other) => [
128
+ ...primary.map((item) => item.id).filter((id) => kept.has(id)),
129
+ ...other.map((item) => item.id).filter((id) => kept.has(id) && !primary.some((p) => p.id === id)),
130
+ ].map((id) => kept.get(id));
131
+
132
+ if (isDeepStrictEqual(tOrder, mOrder) || unmoved(mOrder)) {
133
+ if (!isDeepStrictEqual(tOrder, mOrder)) applied.push(path); // only they reordered
134
+ return layout(theirs, mine);
135
+ }
136
+ if (unmoved(tOrder)) return layout(mine, theirs);
137
+ const asTheirs = layout(theirs, mine);
138
+ conflicts.push({ path, base, theirs: asTheirs, mine: layout(mine, theirs) });
139
+ return asTheirs;
140
+ }
141
+
142
+ function byId(items, path) {
143
+ const map = new Map();
144
+ for (const item of items) {
145
+ if (map.has(item.id)) throw new Merge3Error("duplicate_id", path, `duplicate id "${item.id}" at ${path || "/"}`);
146
+ map.set(item.id, item);
147
+ }
148
+ return map;
149
+ }
150
+
151
+ function isIdArray(value) {
152
+ return Array.isArray(value) && value.every((item) => isPlainObject(item) && typeof item.id === "string");
153
+ }
154
+
155
+ function unionKeys(...objects) {
156
+ const seen = new Set();
157
+ for (const object of objects) for (const key of Object.keys(object)) seen.add(key);
158
+ return [...seen];
159
+ }
160
+
161
+ function isPlainObject(value) {
162
+ return typeof value === "object" && value !== null && !Array.isArray(value);
163
+ }
@@ -63,9 +63,9 @@ export function detectManualEdits(outPath) {
63
63
  // …with the chart hash dropped from BOTH sides: this repo has no chart
64
64
  // reader, so it always projects `chart: null` (see the CHART HASH note in
65
65
  // pptx/tags.mjs). Comparing an add-in baseline's real hash against that null
66
- // would refuse EVERY rebuild of a deck that holds a chart, which is a
67
- // certain harm; missing a chart-ONLY manual edit is the bounded one, and the
68
- // add-in's own drift check still catches it on the next push.
66
+ // would refuse EVERY rebuild of a deck that holds a chart. A chart-ONLY edit
67
+ // is the add-in's to see: reconcile.mjs sends every chart-holding slide to
68
+ // it as its package, whatever this comparison says.
69
69
  const current = computeProjection(files, slidePath);
70
70
  if (
71
71
  JSON.stringify(withoutChartHashes(current)) ===