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
@@ -0,0 +1,67 @@
1
+ // What preparing a slide resolves and tells its author — one wording on both
2
+ // clients (src/cli/transcribe.mjs, the add-in's prepare-transcribed-slides.ts).
3
+
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { canonicalLayoutName, contentSlotFromLayoutSource } from "./layout-names.mjs";
7
+
8
+ /**
9
+ * The plan's layout for a slide, resolved against the bundle, and the content
10
+ * slot its children are laid into. `layout` is null for a name the design does
11
+ * not carry; `slot` is null when the layout has no freeform content slot —
12
+ * such a slide is filled from the plan through the layout's own props, and no
13
+ * inventory is read for it (never a made-up slot: the reader would map the
14
+ * mockup into a rectangle the layout does not have).
15
+ */
16
+ export function plannedLayoutSlot(bundleDir, layoutName) {
17
+ let names = [];
18
+ try {
19
+ names = fs.readdirSync(path.join(bundleDir, "layouts")).filter((f) => f.endsWith(".jsx")).map((f) => f.slice(0, -4));
20
+ } catch {}
21
+ const layout = canonicalLayoutName(layoutName, names);
22
+ if (!layout) return { layout: null, slot: null };
23
+ const src = fs.readFileSync(path.join(bundleDir, "layouts", `${layout}.jsx`), "utf8");
24
+ return { layout, slot: contentSlotFromLayoutSource(src) };
25
+ }
26
+
27
+ // The reader's notes the author acts on: a concept drawn against its layout's
28
+ // tone, a drawing defect it flagged, the template marks it left out — and each
29
+ // image binding it could not make (`spec.issues`).
30
+ const AUTHOR_NOTE = /^the concept was drawn on a |^concept flaw —|^left out as the template's own marks/;
31
+ export function specAuthorNotes(spec) {
32
+ return [
33
+ ...(spec?.notes ?? []).filter((note) => AUTHOR_NOTE.test(note)),
34
+ ...(spec?.issues ?? []).map((issue) => `${issue.code} — ${issue.message}${issue.action ? ` ${issue.action}` : ""}`),
35
+ ];
36
+ }
37
+
38
+ // An approved original the deck's registry could not resolve.
39
+ export function unresolvedUseNote(issue) {
40
+ return `image use ${issue.useId ?? "(registry)"}${issue.assetId ? ` → ${issue.assetId}` : ""} did not resolve [${issue.code}]: ${issue.message}`;
41
+ }
42
+
43
+ // Every approved original bound to `slide` that its source does not place. An
44
+ // obligation added after the slide was written, or an edit that dropped the
45
+ // picture, is REPORTED, never repaired by rewriting the file.
46
+ export function unplacedUseNotes(source, bound, slide) {
47
+ const notes = [];
48
+ for (const b of bound.values()) {
49
+ if (b.slide !== slide) continue;
50
+ // `use="<id>"` also matches the rendered `data-asset-use="<id>"`.
51
+ if (String(source ?? "").includes(`use="${b.useId}"`)) continue;
52
+ notes.push(
53
+ `the plan binds image use ${b.useId} (${b.displayName}) to this slide, but its JSX does not place it — add ` +
54
+ `<I id="…" x={…} y={…} w={…} h={…} src="${b.src}" use="${b.useId}" fit="${b.fit ?? "contain"}" />; the source is already on disk, do not search for one`,
55
+ );
56
+ }
57
+ return notes;
58
+ }
59
+
60
+ // What the author is told when a planned slide gets no inventory because of its
61
+ // layout — one wording on both clients.
62
+ export function layoutSlotNote(planned, resolved) {
63
+ if (!resolved.layout) {
64
+ return `approved layout ${planned ?? "(missing)"} is unavailable, so this slide was not prepared. Correct the plan or restore that layout; do not substitute a layout from the concept image.`;
65
+ }
66
+ return `layout ${resolved.layout} has no freeform content slot; fill its native props from the plan.`;
67
+ }
@@ -0,0 +1,236 @@
1
+ // The visual assets a spec asks for, in the two ways a slide can get them:
2
+ // FETCHED from an engine (an unbound `image.query` — attachSpecAsset writes the
3
+ // downloaded bytes under the name the skeleton already references), or BOUND to
4
+ // a picture the user supplied (an `image.assetUseId` — resolveBoundImageUses
5
+ // answers it from the deck's own registry, and nothing is searched for).
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+
9
+ import { effectiveUsesForSlides } from '../bridge/image-intent-envelope.mjs';
10
+ import { imageRenderPlan } from '../lib/image-derivatives.mjs';
11
+ import { resolveImageAsset } from '../lib/image-assets.mjs';
12
+ import { statePath, writeStateFile } from '../lib/deck-paths.mjs';
13
+
14
+ export function attachSpecAsset(row, downloaded, assetsDir) {
15
+ const ext = path.extname(downloaded.path);
16
+ if (!/^\.[a-z0-9]+$/i.test(ext) || ext === '.bin') throw new Error('engine returned an unsupported image format');
17
+ // A fetched asset is named by the skeleton (`NN-<element>.asset`) and lands
18
+ // flat in the deck's assets/. A row naming a path could only come from
19
+ // somewhere else — and `assets/local/` is the immutable registry, whose bytes
20
+ // a search result must never be written over.
21
+ if (/[\\/]/.test(row.file)) throw new Error(`asset ${row.file} is not a fetched asset file name; engine results never overwrite a registered source`);
22
+ const filename = `${path.parse(row.file).name}${ext}`;
23
+ const target = path.join(assetsDir, filename);
24
+ const source = fs.readFileSync(row.slideFile, 'utf8');
25
+ const before = JSON.stringify(`./assets/${row.file}`);
26
+ const after = JSON.stringify(`./assets/${filename}`);
27
+ if (!source.includes(before)) throw new Error(`asset reference ${row.file} is no longer in its slide`);
28
+ if (path.resolve(downloaded.path) !== path.resolve(target)) fs.copyFileSync(downloaded.path, target);
29
+ fs.writeFileSync(row.slideFile, source.split(before).join(after));
30
+ return { ...row, file: filename, mimeType: downloaded.mimeType, size: downloaded.size, recolor: downloaded.recolor };
31
+ }
32
+
33
+ /**
34
+ * The deck's `original` image uses for `slides`, each resolved to bytes that
35
+ * are actually on disk and still hash to what was registered.
36
+ *
37
+ * The uses come from what is PERSISTED — the plan's storyboard fences and the
38
+ * targeted-edit overrides — through the same `effectiveUsesForSlides` the wire
39
+ * envelope is built from, so the transcriber places exactly what was approved.
40
+ *
41
+ * A use that cannot be resolved is an ISSUE, never a substitution: the caller
42
+ * reports it and the slide keeps its unresolved reference. `reconstruct` and
43
+ * `reference` uses are deliberately absent — they oblige no picture, so there
44
+ * is nothing here for the transcriber to place.
45
+ *
46
+ * @returns {{bound: Map<string, object>, issues: Array<{code, slide, useId, assetId, message}>}}
47
+ */
48
+ export function resolveBoundImageUses(deckDir, slides, { planMarkdown = null } = {}) {
49
+ const bound = new Map();
50
+ const issues = [];
51
+ let uses;
52
+ try {
53
+ uses = effectiveUsesForSlides(deckDir, slides, { planMarkdown });
54
+ } catch (err) {
55
+ // An unreadable registry or override file: report it once and transcribe
56
+ // the rest of the deck. A spec that binds a use still fails loudly, in
57
+ // `scaffoldSlideJsx`, because nothing resolved.
58
+ issues.push({ code: err?.code ?? 'image_intent_invalid', slide: null, useId: null, assetId: null, message: err?.message ?? String(err) });
59
+ return { bound, issues, reconstructed: new Set() };
60
+ }
61
+ // Slides rebuilt from a whole supplied reference: transcribed with the
62
+ // reference's own colours, not the design's tokens (workflow/create.md).
63
+ const reconstructed = new Set([...uses].filter(([, list]) => list.some((u) => u.fidelity === 'reconstruct' && u.reconstructionScope === 'slide')).map(([slide]) => slide));
64
+ for (const [slide, list] of uses) {
65
+ for (const use of list) {
66
+ if (use.fidelity !== 'original') continue;
67
+ let resolved;
68
+ try {
69
+ resolved = resolveImageAsset(deckDir, use.assetId);
70
+ } catch (err) {
71
+ issues.push({
72
+ code: err?.code ?? 'image_asset_missing',
73
+ slide,
74
+ useId: use.useId,
75
+ assetId: use.assetId,
76
+ message: err?.message ?? String(err),
77
+ });
78
+ continue;
79
+ }
80
+ // How these bytes reach a slide — as themselves, or through a declared
81
+ // derivative (a WebP source, an EXIF rotation, an animated first frame).
82
+ // The slide references the SOURCE either way; the notes say what the
83
+ // render does with it.
84
+ const plan = imageRenderPlan(resolved.asset, resolved.bytes);
85
+ bound.set(use.useId, {
86
+ useId: use.useId,
87
+ assetId: use.assetId,
88
+ slide,
89
+ src: `./${resolved.asset.workspacePath}`,
90
+ workspacePath: resolved.asset.workspacePath,
91
+ displayName: resolved.asset.displayName,
92
+ sha256: resolved.asset.sha256,
93
+ fit: use.fit ?? 'contain',
94
+ position: use.position ?? { x: 0.5, y: 0.5 },
95
+ crop: use.crop ?? null,
96
+ slot: use.slot ?? null,
97
+ notes: plan.notes,
98
+ });
99
+ }
100
+ }
101
+ return { bound, issues, reconstructed };
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // The asset ledger — what makes a RESUME cheap and safe
106
+ // (openspec/changes/safeguard-slide-editing, task 7.3 / E22)
107
+ // ---------------------------------------------------------------------------
108
+ //
109
+ // An engine search can miss: the motif has no glyph, the engine is down, the
110
+ // download dies halfway. The slide then keeps an unresolved `./assets/NN-<id>`
111
+ // reference, which `view-draft` reports as a blocking `[asset:error]` — the
112
+ // right surfacing, and the reason nothing deletes it to make the build "clean".
113
+ //
114
+ // What was missing is the way back. `transcribe` builds its fetch list from the
115
+ // slides it just WROTE, so on a second run every slide is skipped ("exists"),
116
+ // the list is empty and nothing is retried. The only lever left was `--force`,
117
+ // which regenerates the source from the spec — precisely what a resume must not
118
+ // do. So the rows a run planned are written down here, and a later run reads
119
+ // them back and retries just the unresolved ones.
120
+ //
121
+ // Three rules the ledger exists to keep:
122
+ //
123
+ // * **A resume retries the asset work and nothing else.** These rows carry
124
+ // the query and the colour the transcriber resolved, so no spec is
125
+ // re-extracted, no source is regenerated, and no geometry is settled.
126
+ // * **A manually changed reference is never reclaimed.** A row is retried
127
+ // only while the slide still contains the exact `./assets/<file>` string
128
+ // the machine wrote. Point the slide at your own picture and the fetcher
129
+ // has nothing to match — the same rule text normalization follows, where
130
+ // the machine's own string being present is the evidence.
131
+ // * **The format is whatever arrived.** The ledger stores the reference as
132
+ // it currently stands; `attachSpecAsset` names the file from the bytes the
133
+ // engine actually returned and rewrites the reference to match. Nothing
134
+ // here forces an extension.
135
+
136
+ /** Sidecar name under the deck's `state/`. */
137
+ export const SPEC_ASSETS_FILE = 'spec-assets.json';
138
+
139
+ /** The only schema version this build reads. */
140
+ export const SPEC_ASSETS_VERSION = 1;
141
+
142
+ const rowKey = (row) => `${row.slide}|${row.id}`;
143
+
144
+ /** The ledger as it stands, or an empty one. An unreadable/foreign file reads as empty. */
145
+ export function readSpecAssetLedger(deckDir) {
146
+ let doc;
147
+ try {
148
+ doc = JSON.parse(fs.readFileSync(statePath(deckDir, SPEC_ASSETS_FILE), 'utf8'));
149
+ } catch {
150
+ return { version: SPEC_ASSETS_VERSION, rows: [] };
151
+ }
152
+ if (!doc || doc.version !== SPEC_ASSETS_VERSION || !Array.isArray(doc.rows)) {
153
+ return { version: SPEC_ASSETS_VERSION, rows: [] };
154
+ }
155
+ return { version: SPEC_ASSETS_VERSION, rows: doc.rows.filter((r) => r && r.id != null && r.slide != null) };
156
+ }
157
+
158
+ /**
159
+ * Merge this run's rows into the ledger.
160
+ *
161
+ * A row is keyed by slide + element id, so re-recording it updates the file
162
+ * name and the outcome rather than adding a second entry. Rows for slides this
163
+ * run did not touch are LEFT ALONE: `transcribe --only 3` must not make the
164
+ * deck forget that slide 5 is still waiting for a picture.
165
+ */
166
+ export function recordSpecAssets(deckDir, rows) {
167
+ const ledger = readSpecAssetLedger(deckDir);
168
+ const byKey = new Map(ledger.rows.map((r) => [rowKey(r), r]));
169
+ for (const row of rows ?? []) {
170
+ if (row?.id == null || row.slide == null || !row.file) continue;
171
+ byKey.set(rowKey(row), {
172
+ slide: row.slide,
173
+ id: row.id,
174
+ kind: row.kind ?? 'icon',
175
+ file: row.file,
176
+ query: row.query ?? null,
177
+ ...(Array.isArray(row.keywords) && row.keywords.length ? { keywords: row.keywords } : {}),
178
+ color: row.color ?? null,
179
+ colorHex: row.colorHex ?? null,
180
+ ...(row.tintHex ? { tintHex: row.tintHex } : {}),
181
+ resolved: Boolean(row.resolved),
182
+ ...(row.error ? { error: String(row.error) } : {}),
183
+ ...(!row.resolved && row.code ? { code: row.code, retryable: Boolean(row.retryable), attempts: row.attempts, retryAfterMs: row.retryAfterMs } : {}),
184
+ });
185
+ }
186
+ const next = { version: SPEC_ASSETS_VERSION, rows: [...byKey.values()].sort((a, b) => a.slide - b.slide || String(a.id).localeCompare(String(b.id))) };
187
+ // Idempotent, and silent when there is nothing to say. A run that refused —
188
+ // a hand-owned slide under `--only`, a converted slide — must leave the
189
+ // workspace byte-identical, and a new sidecar where there was none is a
190
+ // mutation like any other (E03).
191
+ const serialized = JSON.stringify(next, null, 2) + '\n';
192
+ let current = null;
193
+ try {
194
+ current = fs.readFileSync(statePath(deckDir, SPEC_ASSETS_FILE), 'utf8');
195
+ } catch {
196
+ if (!next.rows.length) return next;
197
+ }
198
+ if (current !== serialized) writeStateFile(deckDir, SPEC_ASSETS_FILE, serialized);
199
+ return next;
200
+ }
201
+
202
+ /**
203
+ * The rows a resume should retry: the ledger's entries whose asset is still
204
+ * missing from disk AND whose reference is still the one the machine wrote.
205
+ *
206
+ * @param {string} deckDir
207
+ * @param {Map<number,string>|Array<[number,string]>} slideFiles slide number → its .jsx path
208
+ * @returns {Array<object>} fetch rows, each with `slideFile` filled in
209
+ */
210
+ export function pendingSpecAssets(deckDir, slideFiles) {
211
+ const files = slideFiles instanceof Map ? slideFiles : new Map(slideFiles ?? []);
212
+ const assetsDir = path.join(deckDir, 'assets');
213
+ const pending = [];
214
+ for (const row of readSpecAssetLedger(deckDir).rows) {
215
+ const slideFile = files.get(row.slide);
216
+ if (!slideFile) continue;
217
+ // A fetched asset is a flat file name in the deck's assets/. Anything with
218
+ // a separator names the immutable registry, which an engine result may
219
+ // never be written over (`attachSpecAsset` refuses it too).
220
+ if (!row.file || /[\\/]/.test(row.file)) continue;
221
+ if (fs.existsSync(path.join(assetsDir, row.file))) continue;
222
+ let source;
223
+ try {
224
+ source = fs.readFileSync(slideFile, 'utf8');
225
+ } catch {
226
+ continue; // the slide is gone; there is nothing to fill in
227
+ }
228
+ // The evidence that this reference is still ours to resolve. An author who
229
+ // repointed the element at their own file removed this exact string, and
230
+ // the retry stops there.
231
+ if (!source.includes(`./assets/${row.file}`)) continue;
232
+ if (!row.query) continue; // no motif to search for; the note already says so
233
+ pending.push({ ...row, slideFile });
234
+ }
235
+ return pending;
236
+ }
@@ -0,0 +1,56 @@
1
+ // One-to-one matching of two element inventories of the same slide (two reads
2
+ // of one image, or a read of the concept and a read of the deliverable): texts
3
+ // pair by their words, everything else by place and overlap.
4
+ const norm = (t) => String(t ?? "").toLowerCase().replace(/[^a-z0-9%€$.]+/g, " ").trim();
5
+ const words = (t) => new Set(norm(t).split(" ").filter(Boolean));
6
+ export const normText = norm;
7
+ export function textSim(a, b) {
8
+ const A = words(a), B = words(b);
9
+ if (!A.size || !B.size) return 0;
10
+ let k = 0;
11
+ for (const w of A) if (B.has(w)) k++;
12
+ return (2 * k) / (A.size + B.size);
13
+ }
14
+ export const kindOf = (e) => (e.text != null && String(e.text).trim() ? "text"
15
+ : e.kind === "chart" ? "chart"
16
+ : e.kind === "icon" || e.kind === "image" ? "icon"
17
+ : e.kind === "path" || e.kind === "rule" ? "mark"
18
+ : "shape");
19
+ export function iou(a, b) {
20
+ const x0 = Math.max(a.x, b.x), y0 = Math.max(a.y, b.y);
21
+ const x1 = Math.min(a.x + a.w, b.x + b.w), y1 = Math.min(a.y + a.h, b.y + b.h);
22
+ const i = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
23
+ const u = a.w * a.h + b.w * b.h - i;
24
+ return u > 0 ? i / u : 0;
25
+ }
26
+ const cx = (e) => e.x + e.w / 2, cy = (e) => e.y + e.h / 2;
27
+
28
+ // Greedy by cost: texts pay in words (and distance), everything else in
29
+ // distance (normalised by the slot diagonal) and overlap. Shapes and marks may
30
+ // pair with each other (one read's filled path is another's box).
31
+ export function matchElements(A, B, slot) {
32
+ const diag = Math.hypot(slot.w, slot.h);
33
+ const pairs = [];
34
+ for (const a of A) for (const b of B) {
35
+ const ka = kindOf(a), kb = kindOf(b);
36
+ if (ka !== kb && !((ka === "shape" || ka === "mark") && (kb === "shape" || kb === "mark"))) continue;
37
+ const d = Math.hypot(cx(a) - cx(b), cy(a) - cy(b)) / diag;
38
+ let cost;
39
+ if (ka === "text") {
40
+ const s = textSim(a.text, b.text);
41
+ if (s < 0.5) continue;
42
+ cost = (1 - s) + d;
43
+ } else {
44
+ if (d > 0.12 && iou(a, b) < 0.2) continue;
45
+ cost = d + (1 - iou(a, b)) * 0.5 + (ka !== kb ? 0.2 : 0);
46
+ }
47
+ pairs.push({ a, b, cost });
48
+ }
49
+ pairs.sort((p, q) => p.cost - q.cost);
50
+ const ua = new Set(), ub = new Set(), m = new Map();
51
+ for (const p of pairs) {
52
+ if (ua.has(p.a) || ub.has(p.b)) continue;
53
+ ua.add(p.a); ub.add(p.b); m.set(p.a, p.b);
54
+ }
55
+ return m;
56
+ }