slidemind 0.3.19 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,1069 @@
1
+ // Transcribe a concept spec into a slide JSX skeleton — mechanically.
2
+ //
3
+ // The spec (src/builders/concept-spec.mjs) is the add-in's vision inventory of
4
+ // a mockup: every element with its rect in content-slot pixels, verbatim text,
5
+ // role, fill, icon query, chart series, and every connector between ids. Each
6
+ // line becomes one element here; nothing is regrouped, merged or dropped. The
7
+ // agent's job starts AFTER this file exists: icons (the fetcher writes the
8
+ // paths this skeleton already references), the residual defects view-draft
9
+ // measures, and the side-by-side check against the PNG. Sims 2026-09-18/19
10
+ // showed that a coding agent transcribing by hand loses elements at the first
11
+ // write; a generator does not.
12
+ //
13
+ // Pure: no I/O. `scaffoldSlideJsx(spec, opts)` → JSX source.
14
+ import { cleanText, dropChartMarks, dropStandIns, foldLabelsIntoShape, foldTextBlocks, textIsPrinted } from "./concept-spec.mjs";
15
+ import { paragraphIndent } from "./text-blocks.mjs";
16
+ import { bulletItems, textLines } from "./concept-text.mjs";
17
+ import { paintOrdered, seatedAssets, textFrame } from "./concept-layout.mjs";
18
+ import { applySeats } from "./seat.mjs";
19
+ import { applyStacks, gridStacks, remapGrids, remapStacks } from "./stack.mjs";
20
+ import { contrastRatio, hexToRgb, paletteRoles, deltaE, rankTokens, typeLadder } from "./design-tokens.mjs";
21
+ import { chartJsx } from "./chart-jsx.mjs";
22
+ import { centreHoles } from "./chart-hole.mjs";
23
+ import { foldChartLabels } from "./chart-labels.mjs";
24
+ import { geometryPath, nativeChartData, reconcileArc, repairCommands, scaleGeometry, writtenTop } from "./concept-geometry.mjs";
25
+ import { applyLines, readLines, LINE_TOL } from "./concept-lines.mjs";
26
+ import { illustrationOwner as plotOwner } from "./concept-relationships.mjs";
27
+ import { strokeTraces } from "./line-fit.mjs";
28
+ import { alignRails, bandNative, capAttr, chainNative, fitNative, lineDirective, loopCircles, nativePoints, nodePreset, ringNative, weightClass, LINE_WEIGHT_RUNGS_PX } from "./line-native.mjs";
29
+ import { capRoutePx, headRoutePx, runsClear, seatLines, stretched } from "./placement.mjs";
30
+ import { foldListMarkers } from "./concept-lists.mjs";
31
+ import { LIST_HELPER, RUN_HELPER, blockChildren, markerAttrs, wrapRuns } from "./list-jsx.mjs";
32
+ import { ICON_HELPER, KIT, KIT_EXTRAS, PARAGRAPH_HELPER } from "./kit-helpers.mjs";
33
+ import { ICON_STYLE_WORDS, nameWords, glyphMarkOf, glyphMarkStrokes } from "./concept-marks.mjs";
34
+ import { expandSketch, figureJsxAttrs, fitFigureToSlot } from "./figure-sketch.mjs";
35
+ import { buildRepeats, remapRepeats, repeatGroups } from "./repeat-groups.mjs";
36
+ // Measurement knob (scripts/fidelity): names of normalization steps to skip,
37
+ // so a replay can show what each one contributes. Unset in production.
38
+ const ablated = (name) => (process.env.SLIDEMIND_ABLATE ?? "").split(",").includes(name);
39
+ // The presets the exporter derives from a box's own border radius (contract E1).
40
+
41
+ export { glyphMarkOf } from "./concept-marks.mjs";
42
+ export { bulletItems, textLines, LIST_HELPER, ICON_HELPER, KIT, PARAGRAPH_HELPER };
43
+
44
+ // An observed colour closer than this to a design token IS that token; beyond
45
+ // it the design has no such colour (a green status dot on a blue/grey
46
+ // palette) and the tone word decides — reported, never invented as raw hex.
47
+
48
+ const q = (s) => JSON.stringify(String(s));
49
+ // Roles set on the ramp's heading rungs (--fs-h3 bold and up): WCAG large text.
50
+ const LARGE_ROLES = new Set(["number"]);
51
+ // JSX text: braces and angle brackets are syntax, `\n` in the spec is the
52
+ // image's own wrapping (the box width reproduces it).
53
+ const escapeJsx = (s) =>
54
+ String(s)
55
+ .replace(/&/g, "&")
56
+ .replace(/[{}<>]/g, (c) => ({ "{": "&#123;", "}": "&#125;", "<": "&lt;", ">": "&gt;" })[c]);
57
+ // The spec's `\n` is where the image breaks the line (concept-text.mjs textLines).
58
+ const jsxText = (s) => textLines(s).map(escapeJsx).join("<br />");
59
+
60
+ // The layout's chrome props from its `// PLACEHOLDERS: {...}` header: the
61
+ // headline prop (role "headline"), the kicker prop (type "title"), and every
62
+ // other chrome prop, which the skeleton leaves empty.
63
+ /** The layout's own background colour, as its PLACEHOLDERS header states it. */
64
+ export function layoutBackgroundHex(layoutSource) {
65
+ const m = /^\/\/\s*PLACEHOLDERS:\s*(\{.*\})\s*$/m.exec(layoutSource ?? "");
66
+ if (!m) return null;
67
+ try {
68
+ const hex = JSON.parse(m[1])?.background?.hex;
69
+ return hexToRgb(hex) ? hex : null;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export function layoutChromeProps(layoutSource) {
76
+ const m = /^\/\/\s*PLACEHOLDERS:\s*(\{.*\})\s*$/m.exec(layoutSource ?? "");
77
+ const out = { headline: null, kicker: null, others: [] };
78
+ if (!m) return out;
79
+ let ph;
80
+ try {
81
+ ph = JSON.parse(m[1]);
82
+ } catch {
83
+ return out;
84
+ }
85
+ for (const c of ph.chrome ?? []) {
86
+ if (!c?.prop) continue;
87
+ if (c.role === "headline" && !out.headline) out.headline = c.prop;
88
+ else if (c.type === "title" && !out.kicker) out.kicker = c.prop;
89
+ else out.others.push(c.prop);
90
+ }
91
+ return out;
92
+ }
93
+
94
+
95
+ /** A use's normalized `{x, y}` fractions as an `object-position` value. */
96
+ export const positionValue = (p) =>
97
+ `${Math.round((p?.x ?? 0.5) * 1000) / 10}% ${Math.round((p?.y ?? 0.5) * 1000) / 10}%`;
98
+
99
+ /**
100
+ * The approved source region in the two forms one picture needs: the
101
+ * exporter's `<a:srcRect>` (OOXML 100000ths, `"l,t,r,b"`) and the browser's
102
+ * `object-view-box` inset — the same four numbers, so preview and export crop
103
+ * the same pixels. Null when the use asks for no crop.
104
+ */
105
+ export function cropAttrs(crop) {
106
+ if (!crop) return null;
107
+ const k = (v) => Math.max(0, Math.min(100000, Math.round(v * 100000)));
108
+ const sides = [k(crop.x), k(crop.y), k(1 - (crop.x + crop.w)), k(1 - (crop.y + crop.h))];
109
+ const [l, t, r, b] = sides;
110
+ const pct = (v) => `${Math.round(v / 10) / 100}%`;
111
+ return { crop: sides.join(","), view: `inset(${pct(t)} ${pct(r)} ${pct(b)} ${pct(l)})` };
112
+ }
113
+
114
+ // The rung of the slide's ladder each role sets on.
115
+ const ROLE_STEP = { subtitle: "body", section: "body", title: "body", body: "body", label: "small", caption: "small", badge: "small", number: "number" };
116
+ /** KIT's ROLE block on the slide's ladder; the KIT's own map without a ramp. */
117
+ const roleLadder = (block, ladder) => (ladder
118
+ ? block.replace(/(\n\s+(\w+): \{ fontSize: )"var\(--fs-[\w-]+\)"/g, (m, head, role) => (ROLE_STEP[role] ? `${head}"var(${ladder[ROLE_STEP[role]]})"` : m))
119
+ : block);
120
+
121
+ /**
122
+ * One spec → one slide file. `slide` is the slide number (icon file names are
123
+ * `NN-<icon id>.svg`), `layout` the component name, `layoutSource` its JSX
124
+ * (for the chrome prop names). Returns `{ jsx, icons, warnings }` — `icons`
125
+ * is the fetcher's list: `{ id, file, query, color, colorHex }`.
126
+ *
127
+ * `normalize: false` for a re-scaffold after a settle pass: the row/centre
128
+ * normalizers below read the ORIGINAL image's intent, and re-running them on a
129
+ * spec the settle loop just moved snaps every member of a row back to the row
130
+ * median — a shift under 8% of the card height disappears entirely, so the
131
+ * overlap the pass measured ships anyway while `SETTLED: n changes` reports it
132
+ * as fixed.
133
+ *
134
+ * `bound` is `Map<useId, resolved>` — the deck's approved image uses already
135
+ * resolved to verified bytes (src/builders/spec-assets.mjs →
136
+ * `resolveBoundImageUses`). An image element that names one is placed from it
137
+ * and is left OUT of `icons`: the engine is never asked for a picture the user
138
+ * already supplied. Pure either way — the resolution happened in the caller.
139
+ */
140
+ // The kinds a sketched figure may replace (never text, icons, images, charts, discs).
141
+ const FIGURE_KINDS = new Set(["path", "shape", "panel", "card", "badge", "bar"]);
142
+
143
+ /** Every colour a spec's reference paints (any `…Hex` field), as a palette. */
144
+ function referencePalette(spec) {
145
+ const seen = new Map();
146
+ const walk = (v, key) => {
147
+ if (typeof v === "string") {
148
+ const rgb = key.endsWith("Hex") ? hexToRgb(v) : null;
149
+ const hex = rgb && `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
150
+ if (hex) seen.set(hex, { name: hex, hex, rgb });
151
+ } else if (v && typeof v === "object") {
152
+ for (const [k, c] of Object.entries(v)) walk(c, Array.isArray(v) ? key : k);
153
+ }
154
+ };
155
+ walk(spec, "");
156
+ return [...seen.values()];
157
+ }
158
+
159
+ export function scaffoldSlideJsx(spec, { slide, layout, layoutSource = "", tokens = [], ramp = null, contrastFloor = 4.5, normalize = true, bound = new Map(), literal = false }) {
160
+ // A whole-slide reconstruction is faithful to its reference, not to the
161
+ // design's token ladder (workflow/create.md): its palette is the colours the
162
+ // reference paints, written as those literal values.
163
+ if (literal) tokens = referencePalette(spec);
164
+ const nn = String(slide).padStart(2, "0");
165
+ const chrome = layoutChromeProps(layoutSource);
166
+ let byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
167
+ const lines = [];
168
+ const icons = [];
169
+ const warnings = applyLines(spec);
170
+ // The ids this skeleton deliberately leaves out, each also named in
171
+ // `warnings`: preservation seeding releases exactly these (edit-state.mjs).
172
+ const dropped = [];
173
+ const illustrationOwner = (e) => plotOwner(spec.elements ?? [], e);
174
+ const unmatched = new Map();
175
+ // The design's palette is authoritative: an observed colour becomes the
176
+ // design colour it perceptually IS (CIEDE2000 —
177
+ // the mockup's green is the design's green, its near-black navy is the
178
+ // design's ink). A colour far from every design colour is still drawn in
179
+ // the nearest one and named in a note.
180
+ const ref = (t) => (t.name.startsWith("#") ? t.name : `var(${t.name})`);
181
+ const tokenByVar = (v) => tokens.find((t) => ref(t) === v) ?? null;
182
+ const snapVar = (hex, prefer = /^--fg-/) => {
183
+ if (!hex || !tokens.length) return null;
184
+ const best = rankTokens(hex, tokens, { prefer })[0];
185
+ if (!best) return null;
186
+ // Far from every design colour: the reader's tone word (its role —
187
+ // accent, muted, dark) decides, and the colour is named in a note.
188
+ if (best.d > 20) {
189
+ if (!unmatched.has(hex)) unmatched.set(hex, best.t.name);
190
+ return null;
191
+ }
192
+ return ref(best.t);
193
+ };
194
+ const roles = paletteRoles(tokens);
195
+ // A reconstruction's palette has no semantic names, so no token is the
196
+ // accent: take the reference's most chromatic colour (its ink when it has
197
+ // none), or an accent with no hex of its own would paint nothing.
198
+ if (literal && !roles.tone.accent) {
199
+ const chroma = (t) => Math.max(...t.rgb) - Math.min(...t.rgb);
200
+ const vivid = [...tokens].sort((a, b) => chroma(b) - chroma(a))[0];
201
+ roles.tone.accent = roles.fill.accent = vivid && chroma(vivid) >= 40 ? vivid : roles.tone.dark;
202
+ }
203
+ const variable = (t) => t ? ref(t) : null;
204
+ const hexOf = new Map(tokens.map((t) => [variable(t), t.hex]));
205
+ const layoutBgHex = layoutBackgroundHex(layoutSource);
206
+ // The reader is shown this design's palette and may name the colour it
207
+ // means (`fillToken` / `colorToken`). A name this bundle lists is used when
208
+ // it is a plausible reading of the colour the image shows.
209
+ const palette = new Map(tokens.map((t) => [t.name, t]));
210
+ const namedVar = (name, observed = null) => {
211
+ if (typeof name !== "string" || !palette.has(name)) return null;
212
+ const d = observed ? deltaE(observed, palette.get(name).hex) : null;
213
+ return d == null || d <= 15 ? `var(${name})` : null;
214
+ };
215
+ // What a figure sits on in the image: the smallest painted element that
216
+ // contains it. Geometry, not the reader's parent or paint order.
217
+ const groundOf = (e) => (spec.elements ?? []).filter((p) => p !== e && p.fill && p.fill !== "none" && p.fillHex
218
+ && p.x <= e.x + 1 && p.y <= e.y + 1 && p.x + p.w >= e.x + e.w - 1 && p.y + p.h >= e.y + e.h - 1 && p.w * p.h > e.w * e.h)
219
+ .sort((a, b) => a.w * a.h - b.w * b.h)[0] ?? null;
220
+ // A fill is a design token: the reader's named token, else the token of its
221
+ // fill role, else the nearest token to what the image shows. Where the
222
+ // image draws the figure apart from its ground (or a clipped silhouette
223
+ // carries a shadow that never renders), it may not land on the ground's own
224
+ // colour: the next token that differs from the ground is taken.
225
+ const SILHOUETTES = new Set(["chevron", "triangle", "trapezoid", "arrow", "diamond", "hexagon", "pentagon", "parallelogram"]);
226
+ const silhouette = (e) => SILHOUETTES.has(e.shape) || (e.shape === "other" && Array.isArray(e.outline) && e.outline.length >= 3);
227
+ const fillMemo = new Map();
228
+ const fillVarOf = (e) => {
229
+ if (!e.fill || e.fill === "none") return null;
230
+ if (fillMemo.has(e.id)) return fillMemo.get(e.id);
231
+ fillMemo.set(e.id, null);
232
+ let pick = null;
233
+ if (e.fillHex && tokens.length) {
234
+ const named = namedVar(e.fillToken, e.fillHex);
235
+ const all = rankTokens(e.fillHex, tokens, { prefer: /^--bg-/ });
236
+ if (all[0]?.d > 20 && !unmatched.has(e.fillHex)) unmatched.set(e.fillHex, all[0].t.name);
237
+ const role = tokenByVar(variable(roles.fill[e.fill]));
238
+ const ranked = [...(named ? [tokenByVar(named)] : []), ...(role ? [role] : []), ...all.map((v) => v.t)];
239
+ const ground = groundOf(e);
240
+ const seenGround = ground ? ground.fillHex : layoutBgHex;
241
+ const paintedGround = ground ? paintHexOf(ground) : layoutBgHex;
242
+ const apartInImage = (seenGround && (deltaE(e.fillHex, seenGround) ?? 0) >= 3) || (e.elevated && silhouette(e));
243
+ const apart = (t) => !apartInImage || !paintedGround || (deltaE(t.hex, paintedGround) ?? Infinity) >= 3;
244
+ const t = ranked.find(apart) ?? ranked[0];
245
+ if (t && t !== ranked[0]) warnings.push(`${e.id}: ${ranked[0].name} would merge into the colour under it; ${t.name} keeps it apart as the image does`);
246
+ pick = t ? ref(t) : null;
247
+ }
248
+ pick ??= variable(roles.fill[e.fill]);
249
+ fillMemo.set(e.id, pick);
250
+ return pick;
251
+ };
252
+ // The paint of a drawn FIGURE (a silhouette, a sector): its fill only. A
253
+ // stroke is never svg — an outline the reader saw on a figure is named.
254
+ const svgPaint = (e) => {
255
+ if (e.stroke && Number(e.stroke.width) > 0) warnings.push(`${e.id}: a drawn figure's outline is not written (a stroke is a native line, never svg); outline it by hand if the concept needs it`);
256
+ return `fill=${fillVarOf(e) ? q(fillVarOf(e)) : `{FILL[${q(e.fill)}]}`}`;
257
+ };
258
+ // Check the emitted fill, not the model's unsnapped colour or a guessed
259
+ // palette extreme. Unknown backgrounds remain unknown and are reported.
260
+ const groundHexOf = (e) => {
261
+ let p = e;
262
+ const seen = new Set();
263
+ while (p && !seen.has(p.id)) {
264
+ seen.add(p.id);
265
+ if (p.fill && p.fill !== "none") return paintHexOf(p);
266
+ p = byId.get(p.parent);
267
+ }
268
+ const under = (spec.elements ?? []).filter((p) => p.id !== e.id && p.fill && p.fill !== "none"
269
+ && p.x <= e.x && p.y <= e.y && p.x + p.w >= e.x + e.w && p.y + p.h >= e.y + e.h)
270
+ .sort((a, b) => a.w * a.h - b.w * b.h)[0];
271
+ return under ? paintHexOf(under) : layoutBgHex;
272
+ };
273
+ // The colour a fill actually paints.
274
+ function paintHexOf(e) {
275
+ return hexOf.get(fillVarOf(e)) ?? null;
276
+ }
277
+ // The paint of an item's marker, when the reader named one (the concept's
278
+ // accent dots): a design colour, else nothing — the brand's marker applies.
279
+ const markerVar = (e) => (e.markerToken || e.markerHex ? namedVar(e.markerToken, e.markerHex) ?? snapVar(e.markerHex) : null);
280
+ const textPaint = (e, floor = contrastFloor) => {
281
+ // The reader's own pick, when it named one this palette lists.
282
+ const chosen = namedVar(e.colorToken, e.colorHex);
283
+ const colorVar = chosen
284
+ ?? snapVar(e.colorHex)
285
+ ?? variable(roles.tone[e.color ?? "dark"]);
286
+ const groundHex = groundHexOf(e);
287
+ if (!groundHex) {
288
+ if (tokens.length) warnings.push(`${e.id}: background colour unknown; contrast not verified`);
289
+ return { colorVar, tone: e.color };
290
+ }
291
+ const ratio = contrastRatio(hexOf.get(colorVar), groundHex);
292
+ if (ratio != null && ratio >= floor) return { colorVar, tone: e.color };
293
+ // A colour the reader CHOSE is never swapped out from under it: it judged
294
+ // the ground with the image in front of it, and a rule that overrules it
295
+ // silently is a second opinion the agent cannot see. Say so and move on.
296
+ if (chosen) {
297
+ warnings.push(
298
+ `${e.id}: the spec paints this text ${e.colorToken} on its background — ${ratio == null ? "contrast unknown" : `${ratio.toFixed(1)}:1`}, below ${floor}:1; change the colour in the JSX if the mockup disagrees`,
299
+ );
300
+ return { colorVar: chosen, tone: e.color };
301
+ }
302
+ // A deliberate colour — the image paints the words in a hue (a green
303
+ // gain, a red loss) and the design has that hue — is colour coding, not a
304
+ // readability accident: the design's own colour stays and the contrast is
305
+ // reported. Only neutral text is swapped for a more legible neutral.
306
+ const rgbSeen = hexToRgb(e.colorHex), rgbToken = hexToRgb(hexOf.get(colorVar));
307
+ const chroma = (c) => (c ? Math.max(...c) - Math.min(...c) : 0);
308
+ if (chroma(rgbSeen) >= 60 && chroma(rgbToken) >= 60 && (deltaE(hexOf.get(colorVar), groundHex) ?? 0) >= 25) {
309
+ warnings.push(`${e.id}: colour-coded text in ${colorVar.replace(/^var\((.*)\)$/, "$1")} on its background — ${ratio == null ? "contrast unknown" : `${ratio.toFixed(1)}:1`}, below ${floor}:1; kept, as the design's colour for what the image codes`);
310
+ return { colorVar, tone: e.color };
311
+ }
312
+ // No choice was made, so this colour is OUR match of an observed hex and
313
+ // repairing it is fair game.
314
+ const ranked = tokens.map((t) => ({ t, ratio: contrastRatio(t.hex, groundHex) }))
315
+ .filter((v) => v.ratio != null).sort((a, b) => b.ratio - a.ratio);
316
+ const best = ranked[0];
317
+ if (!best || best.ratio < floor) warnings.push(`${e.id}: palette cannot meet contrast ${floor}:1 on its rendered background`);
318
+ return { colorVar: best ? variable(best.t) : colorVar, tone: e.color };
319
+ };
320
+ const maps = (entries) => Object.fromEntries(Object.entries(entries).map(([k, t]) => [k, variable(t) ?? "inherit"]));
321
+ const kit = KIT.replace(/const FILL = .*?;/, `const FILL = ${JSON.stringify({ ...maps(roles.fill), none: "transparent" })};`)
322
+ .replace(/const TONE = .*?;/, `const TONE = ${JSON.stringify(maps(roles.tone))};`)
323
+ .replace(/const ROLE = \{[\s\S]*?\n \};/, (block) => roleLadder(block, typeLadder(ramp, spec.typeStepOffset ?? 0)));
324
+ const slot = spec.slot;
325
+ // Also for a spec written before the read learned these: the model's
326
+ // stand-ins on painted boxes.
327
+ // A chart draws its own marks; anything inside its rect would be painted
328
+ // over the native chart a second time.
329
+ const read = new Map((spec.elements ?? []).map((e) => [e.id, e]));
330
+ spec.elements = dropChartMarks(spec.elements ?? []);
331
+ if (!ablated("foldListMarkers")) spec.elements = foldListMarkers(spec.elements, spec.connectors ?? [], spec.repeats ?? []);
332
+ // A chart's own legend and labels drawn beside it go back into the chart.
333
+ foldChartLabels(spec, warnings);
334
+ if (!ablated("dropStandIns")) dropStandIns(spec.elements ?? []);
335
+ if (normalize) {
336
+ }
337
+ // Folding drops the children it absorbed, so the index and any connector
338
+ // that named one have to follow it. Only those: a connector pointing at an
339
+ // element that never existed is the spec talking, and it still gets reported.
340
+ const beforeFold = new Set((spec.elements ?? []).map((e) => e.id));
341
+ if (!ablated("foldLabelsIntoShape")) spec.elements = foldLabelsIntoShape(spec.elements ?? [], { connectors: spec.connectors ?? [] });
342
+ // A block that still cannot be one text says so (text-blocks.mjs).
343
+ spec.elements = foldTextBlocks(spec.elements ?? [], { connectors: spec.connectors ?? [], notes: warnings });
344
+ byId = new Map((spec.elements ?? []).map((e) => [e.id, e]));
345
+ const absorbed = new Set([...beforeFold].filter((id) => !byId.has(id)));
346
+ spec.connectors = (spec.connectors ?? []).filter((c) => !absorbed.has(c.from) && !absorbed.has(c.to));
347
+ // Repeated organisms the reader declared: one template, one exact
348
+ // arrangement (repeat-groups.mjs) — before any line is fitted to the nodes.
349
+ if (normalize) {
350
+ warnings.push(...remapRepeats(spec, read), ...buildRepeats(spec).filter((n) => n.endsWith("kept as drawn")));
351
+ // A seated child keeps its seat in the template's size (seat.mjs), what
352
+ // stands in a doughnut's hole stays centred in its ring (chart-hole.mjs); a
353
+ // declared stack is laid out over the built members (stack.mjs).
354
+ applySeats(spec);
355
+ centreHoles(spec);
356
+ warnings.push(...remapStacks(spec, read), ...remapGrids(spec, read));
357
+ applyStacks(spec);
358
+ }
359
+
360
+ // What each id is in a declared stack (stack.mjs): "n:r" a part of row r,
361
+ // "n:gr" drawn in the gap under row r. Written onto the lines as `stack` /
362
+ // `data-sm-stack`, so every later reader and writer of the JSX keeps the
363
+ // rows together (settle.mjs parseTranscribedJsx rebuilds the stacks from it).
364
+ const stackMark = new Map();
365
+ (spec.stacks ?? []).forEach((g, gi) => {
366
+ g.rows.forEach((row, ri) => { for (const id of row) stackMark.set(id, `${gi + 1}:${ri + 1}`); });
367
+ for (const it of g.band?.between ?? []) stackMark.set(it.id, `${gi + 1}:g${it.gap + 1}:${Math.round((it.frac ?? 0.5) * 100) / 100}`);
368
+ });
369
+ // A grid's own divider (across its columns): "G<k>:g<r>:<f>" — GRIDS names the member stacks.
370
+ (spec.grids ?? []).forEach((grid, ki) => {
371
+ for (const it of grid.band?.between ?? []) stackMark.set(it.id, `G${ki + 1}:g${it.gap + 1}:${Math.round((it.frac ?? 0.5) * 100) / 100}`);
372
+ });
373
+ const gridsConst = (spec.grids ?? []).length
374
+ ? ` const GRIDS = ${JSON.stringify(Object.fromEntries((spec.grids ?? []).map((grid, ki) => [ki + 1, { stacks: gridStacks(spec, grid).map((g) => (spec.stacks ?? []).indexOf(g) + 1), rows: grid.rows }])))};\n`
375
+ : "";
376
+ const stackAttr = (id) => (stackMark.has(id) ? ` stack=${q(stackMark.get(id))}` : "");
377
+ // A divider between two paragraphs of one block: "block:after:frac" (block-dividers.mjs).
378
+ const divideMark = new Map();
379
+ for (const e of spec.elements ?? []) for (const d of e.dividers ?? []) divideMark.set(d.id, `${e.id}:${d.after}:${Math.round(d.frac * 100) / 100}`);
380
+ // ---- Every line, as ONE native object (line-native.mjs), decided before
381
+ // any element is written: a node a line glues to carries its preset, and an
382
+ // element a line stands for — a rule, a ring, a mark, a plot stroke, the
383
+ // arrowhead or end dot that became its cap — is written as the line only.
384
+ const { lines: readIn } = readLines(spec);
385
+ // The line id each connector is written under, from the list THIS skeleton
386
+ // read (`readLines` returns the connectors' lines first, in order): a spec
387
+ // index read elsewhere drifts once normalization filtered a connector out.
388
+ const connectorLines = (spec.connectors ?? []).map((c, i) => ({ id: readIn[i].id, from: c.from ?? null, to: c.to ?? null }));
389
+ // The reader connector a line was read from (its declared ids and width).
390
+ const rawConnector = (line) => (line.source?.kind === "connector" ? spec.connectors?.[line.source.index] ?? null : null);
391
+ const consumed = new Set(readIn.filter((l) => l.source?.kind !== "connector").map((l) => l.source.id));
392
+ const plotOf = new Map();
393
+ const dashOf = (d) => (d === "dashed" ? "dash" : d === "dotted" ? "sysDot" : "solid");
394
+ const drawnLines = [];
395
+ for (const e of spec.elements ?? []) {
396
+ if (consumed.has(e.id)) continue;
397
+ const owner = illustrationOwner(e);
398
+ // Rules (whatever words the reader put on one) and the strokes of a kept
399
+ // illustration: the lines the intake leaves to the writer.
400
+ const traces = e.kind === "rule" && (e.w > 0 || e.h > 0)
401
+ ? [e.w >= e.h ? [{ x: e.x, y: e.y + e.h / 2 }, { x: e.x + e.w, y: e.y + e.h / 2 }] : [{ x: e.x + e.w / 2, y: e.y }, { x: e.x + e.w / 2, y: e.y + e.h }]]
402
+ : owner ? strokeTraces(e) : [];
403
+ if (!traces.length) continue;
404
+ consumed.add(e.id);
405
+ if (owner) plotOf.set(e.id, owner.id);
406
+ traces.forEach((t, k) => drawnLines.push({
407
+ id: traces.length > 1 ? `${e.id}-${k + 1}` : e.id,
408
+ role: owner ? "plot" : "divider",
409
+ source: { kind: e.kind === "rule" ? "rule" : "path", id: e.id },
410
+ ends: [t[0], t.at(-1)].map((at) => ({ node: null, at, cap: { type: "none" }, contact: "none" })),
411
+ path: { kind: t.length === 2 ? "straight" : "curve", points: t, corners: "sharp" },
412
+ stroke: { widthPx: e.stroke?.width ?? null, colorHex: e.stroke?.colorHex ?? e.colorHex ?? (e.kind === "rule" ? e.fillHex : null) ?? null, token: e.stroke?.colorToken ?? e.colorToken ?? null, dash: dashOf(e.stroke?.dash ?? e.dash) },
413
+ read: { color: e.color ?? null },
414
+ }));
415
+ }
416
+ const allLines = [...readIn, ...drawnLines];
417
+ // A ring stays a glue target as a line shape (it has its own sites).
418
+ const nodes = new Map();
419
+ for (const e of spec.elements ?? []) {
420
+ if (consumed.has(e.id) && !readIn.some((l) => l.role === "frame" && l.source.id === e.id)) continue;
421
+ const n = nodePreset(e);
422
+ if (n) nodes.set(e.id, n);
423
+ }
424
+ const loops = loopCircles(allLines, nodes);
425
+ const natives = [];
426
+ for (const line of allLines) {
427
+ if (line.role === "frame") {
428
+ const n = ringNative(line.id, byId.get(line.source.id)?.geometry);
429
+ if (n) natives.push({ line, n });
430
+ continue;
431
+ }
432
+ const n = fitNative(line, nodes, loops.get(line.id) ?? null, { slot });
433
+ if (!n) {
434
+ const c = rawConnector(line);
435
+ warnings.push(c ? `connector ${c.from ?? "(point)"} -> ${c.to ?? "(point)"} names an unknown element; skipped` : `line ${line.id}: an end names no element on this slide and carries no point; skipped`);
436
+ dropped.push(line.id);
437
+ continue;
438
+ }
439
+ warnings.push(...n.notes);
440
+ natives.push({ line, n });
441
+ }
442
+ // Elbows off one site share one rail where the concept draws them so.
443
+ alignRails(natives.map(({ n }) => n));
444
+ const glueTargets = new Set(natives.flatMap(({ n }) => [n.from?.node, n.to?.node]).filter(Boolean));
445
+ // Width and colour are the design's tokens: the reader's weight class and
446
+ // the design colour its hex snaps to — never a measured px, never a hex.
447
+ // They are defaults; the agent weighs lines against the slide's shapes.
448
+ const accentVar = variable(roles.tone.accent);
449
+ const CLASS_COLOR = { "connector-accent": "var(--fg-accent)", "connector-muted": "var(--fg-3)" };
450
+ const CLASS_WEIGHT = { "connector-accent": "medium", "connector-muted": "default" };
451
+ const capIds = new Set();
452
+ // What a line is drawn on: the smallest painted element holding all of it.
453
+ // null when none does — a line between drawn figures has no one ground this
454
+ // can name, and the gate measures it on the render instead.
455
+ const lineGroundHex = (line) => {
456
+ const pts = [...(line.ends ?? []).map((end) => end.at), ...(line.path?.points ?? [])]
457
+ .filter((p) => p && Number.isFinite(p.x) && Number.isFinite(p.y));
458
+ if (!pts.length) return null;
459
+ const x0 = Math.min(...pts.map((p) => p.x)), x1 = Math.max(...pts.map((p) => p.x));
460
+ const y0 = Math.min(...pts.map((p) => p.y)), y1 = Math.max(...pts.map((p) => p.y));
461
+ const holder = (spec.elements ?? []).filter((p) => p.fill && p.fill !== "none" && p.w > 0 && p.h > 0
462
+ && p.x <= x0 && p.y <= y0 && p.x + p.w >= x1 && p.y + p.h >= y1)
463
+ .sort((a, b) => a.w * a.h - b.w * b.h)[0];
464
+ return holder ? paintHexOf(holder) : null;
465
+ };
466
+ const linePaint = (line, n) => {
467
+ const st = line.stroke ?? {};
468
+ // The width the READER declared (null when it declared none: the design
469
+ // default weight).
470
+ const declared = st.widthPx;
471
+ const colorVar = namedVar(st.token, st.colorHex) ?? snapVar(st.colorHex);
472
+ const tone = line.read?.color ?? null;
473
+ const className = (colorVar ? colorVar === accentVar || colorVar === CLASS_COLOR["connector-accent"] : tone === "accent") ? "connector-accent" : "connector-muted";
474
+ let color = colorVar ?? (tone && tone !== "accent" && roles.tone[tone] ? variable(roles.tone[tone]) : null);
475
+ const style = [];
476
+ const weight = weightClass(declared);
477
+ if (weight !== "default" && weight !== CLASS_WEIGHT[className]) style.push(`borderWidth: "var(--bw-${weight})"`);
478
+ const dash = st.dash === "dash" || st.dash === "sysDot" ? st.dash : null;
479
+ const w = st.widthPx ?? LINE_TOL.defaultWidthPx;
480
+ // The head the route carries: the concept's size, or the largest smaller
481
+ // one the gate accepts on this weight. ponytail: the canonical --bw-*
482
+ // rungs, not the brand's px (a brand bold wider than 4px can still refuse).
483
+ const strokePx = LINE_WEIGHT_RUNGS_PX[weight !== "default" ? weight : CLASS_WEIGHT[className]] ?? 0;
484
+ const caps = (line.ends ?? []).map((end, i) => {
485
+ const c = end.cap;
486
+ if (!c || !c.type || c.type === "none") return null;
487
+ // A dot larger than the largest oval line end, or on a route too short
488
+ // for even the smallest, stays the reader's drawn dot.
489
+ if (c.type === "oval" && (c.sizePx > LINE_TOL.capLen.lg * w || (c.from && headRoutePx("sm", strokePx) > capRoutePx(n, i) - 0.5))) return null;
490
+ if (c.from) capIds.add(c.from);
491
+ return capAttr(c, capRoutePx(n, i), strokePx);
492
+ });
493
+ // The image draws the line apart from what it sits on; a snap onto the
494
+ // ground's own colour draws nothing. Keep the visibility gate's floor
495
+ // (diagram-geometry.ts: 1.3:1 with a head or glued end, 1.05:1 for a
496
+ // plain rule) with the design colour nearest the one the image shows.
497
+ const ground = lineGroundHex(line);
498
+ const strokeHex = hexOf.get(color ?? CLASS_COLOR[className]);
499
+ const floor = caps.some(Boolean) || n?.from?.node || n?.to?.node ? 1.3 : 1.05;
500
+ if (ground && strokeHex && (contrastRatio(strokeHex, ground) ?? Infinity) < floor) {
501
+ const alt = rankTokens(st.colorHex ?? strokeHex, tokens).map((v) => v.t).find((t) => (contrastRatio(t.hex, ground) ?? 0) >= floor);
502
+ if (alt) {
503
+ warnings.push(`${line.id ?? line.source?.id ?? "a line"}: ${color ?? CLASS_COLOR[className]} would vanish into the colour it is drawn on; ${alt.name} keeps it visible`);
504
+ color = ref(alt);
505
+ }
506
+ }
507
+ if (color && color !== CLASS_COLOR[className]) style.push(`color: ${q(color)}`);
508
+ return { className, style, caps, dash, linecap: dash === "sysDot" ? "rnd" : null, strokePx };
509
+ };
510
+ // A free line (no glue, not a mark or a plot stroke) is seated among the
511
+ // shapes it runs through (placement.mjs); every other line paints last.
512
+ const lineOut = natives.map(({ line, n }) => {
513
+ const paint = linePaint(line, n);
514
+ const jsx = lineDirective(n, { ...paint, plotOwner: plotOf.get(line.source?.id) ?? null, stack: stackMark.get(line.id) ?? stackMark.get(line.source?.id) ?? null, divides: divideMark.get(line.id) ?? divideMark.get(line.source?.id) ?? null });
515
+ const free = !n.from?.node && !n.to?.node && line.role !== "mark" && !plotOf.has(line.source?.id);
516
+ return free ? { jsx, points: nativePoints(n), capped: paint.caps.some(Boolean), strokePx: paint.strokePx } : { jsx };
517
+ });
518
+
519
+ // Drawn figures the reader sketched: one <Figure> each, compiled exactly at
520
+ // render (figure-sketch.mjs) — straight edges straight, arcs on their
521
+ // circle, copies and mirrored halves identical. A figure a line glues to
522
+ // keeps its preset box: a custom outline has no connection sites.
523
+ const sketch = spec.sketch ? expandSketch(spec.sketch) : null;
524
+ if (sketch) warnings.push(...sketch.issues);
525
+ const elementById = new Map((spec.elements ?? []).map((e) => [e.id, e]));
526
+ const sketched = new Map([...(sketch?.figures ?? [])].filter(([id]) => {
527
+ const e = elementById.get(id);
528
+ return e && FIGURE_KINDS.has(e.kind) && e.fill && e.fill !== "none" && !glueTargets.has(id);
529
+ }).map(([id, props]) => {
530
+ const fit = fitFigureToSlot(props, slot);
531
+ if (fit.moved) warnings.push(`${id}: its figure reached outside the content slot; moved in`);
532
+ return [id, fit.props];
533
+ }));
534
+ const figureLine = (e, fig) => {
535
+ const fillVar = fillVarOf(e);
536
+ const attrs = [`id=${q(e.id)}`, figureJsxAttrs(fig), `fill=${fillVar ? q(fillVar) : `{FILL[${q(e.fill)}]}`}`];
537
+ if (e.stroke && Number(e.stroke.width) > 0) {
538
+ const v = namedVar(e.stroke.colorToken) ?? snapVar(e.stroke.colorHex);
539
+ attrs.push(`stroke=${v ? q(v) : `{TONE[${q(e.color ?? "dark")}]}`}`, `strokeWidth=${q(`var(--bw-${weightClass(e.stroke.width)})`)}`);
540
+ }
541
+ return `<Figure ${attrs.join(" ")} />`;
542
+ };
543
+ const seated = ablated("seatedAssets") ? new Map() : seatedAssets(spec.elements ?? [], {
544
+ skip: new Set([...consumed, ...capIds, ...sketched.keys()]),
545
+ glued: glueTargets,
546
+ });
547
+ const isMark = (e) => e.kind === "icon" && glyphMarkOf(e.icon?.query, e.icon?.keywords ?? []) != null;
548
+ const assetPaint = (e) => (e.kind === "icon" ? textPaint({ ...e, color: e.icon?.color === "white" ? "light" : e.icon?.color ?? "dark" }, 3) : {});
549
+ // Adds an engine asset's fetch row (flat or seated) and returns its reference.
550
+ const requestAsset = (e, paint = assetPaint(e)) => {
551
+ const file = `${nn}-${e.id}.asset`;
552
+ icons.push({ id: e.id, kind: e.kind, file, query: e.image?.query ?? e.icon?.query ?? e.id,
553
+ ...(e.icon?.keywords?.length ? { keywords: e.icon.keywords } : {}),
554
+ color: e.icon?.color ?? "dark", colorHex: e.kind === "icon" ? hexOf.get(paint.colorVar) ?? null : null,
555
+ // A picture fetched from a pictogram library (no photo engine) is a
556
+ // line drawing in its source colour, black: tint it the design accent.
557
+ ...(e.kind === "image" ? { tintHex: hexOf.get(variable(roles.tone.accent)) ?? null } : {}) });
558
+ return `./assets/${file}`;
559
+ };
560
+
561
+ // Where each element's JSX starts: a free line is seated before the first
562
+ // painted one it runs through (placement.mjs).
563
+ const placed = [];
564
+ for (const raw of paintOrdered(spec.elements ?? [])) {
565
+ if (consumed.has(raw.id) || capIds.has(raw.id)) continue;
566
+ placed.push({ el: raw, at: lines.length });
567
+ // One element that cannot be written is one element reported, never a
568
+ // slide (or a deck) lost: x58 lost a whole deck to one malformed arc.
569
+ try {
570
+ // A text on the slot's bottom edge is written a hair higher (writtenTop).
571
+ const top = writtenTop(raw, slot);
572
+ const e = top !== raw.y ? { ...raw, y: top } : raw;
573
+ const text = cleanText(e.text, { printed: textIsPrinted(e) });
574
+ const base = `id=${q(e.id)} x={${e.x}} y={${e.y}} w={${e.w}}${stackAttr(e.id)}`;
575
+ // A sketched figure: the shape is the Figure; its words (a named shape's
576
+ // label) ride on an unpainted box at the element's rect below.
577
+ const fig = sketched.get(e.id);
578
+ if (fig) {
579
+ lines.push(figureLine(e, fig));
580
+ if (text == null || e.kind === "path") continue;
581
+ }
582
+ // The lead-in and the emphasis spans the spec folded into this text.
583
+ const emphasise = (body, t = e) => {
584
+ if (t.leadIn) {
585
+ const lead = escapeJsx(t.leadIn.trim());
586
+ if (body.startsWith(lead)) body = `<b>${lead}</b>${body.slice(lead.length)}`;
587
+ }
588
+ for (const em of t.emphasis ?? []) {
589
+ const needle = escapeJsx(String(em).trim());
590
+ if (needle && body.includes(needle) && !body.includes(`<b>${needle}</b>`)) body = body.replace(needle, `<b>${needle}</b>`);
591
+ }
592
+ // The pieces of the sentence painted otherwise: one `<R>` each (text-blocks.mjs).
593
+ if (t.runs?.length) {
594
+ const role = t.role ?? (t.kind === "number" ? "number" : "body");
595
+ const floorOf = (r) => (LARGE_ROLES.has(r) ? 3 : contrastFloor);
596
+ const own = textPaint({ ...e, ...t }, floorOf(role));
597
+ body = wrapRuns(body, t.runs, (run) => {
598
+ const runRole = run.role ?? (run.kind === "number" ? "number" : role);
599
+ const paint = textPaint({ ...e, ...t, ...run }, floorOf(runRole));
600
+ const out = runRole !== role ? [`role=${q(runRole)}`] : [];
601
+ if (paint.colorVar && paint.colorVar !== own.colorVar) out.push(`colorVar=${q(paint.colorVar)}`);
602
+ else if (!paint.colorVar && paint.tone && paint.tone !== own.tone) out.push(`tone=${q(paint.tone)}`);
603
+ if (run.weight === "bold" && t.weight !== "bold") out.push("bold");
604
+ return out;
605
+ }, escapeJsx);
606
+ }
607
+ return body;
608
+ };
609
+ const textEl = (id, role) => {
610
+ const frame = textFrame(e);
611
+ const attrs = [`id=${q(id)}`, `x={${frame.x}}`, `y={${frame.y}}`, `w={${frame.w}}`];
612
+ if (stackMark.has(e.id)) attrs.push(`stack=${q(stackMark.get(e.id))}`);
613
+ if (frame.rotation) attrs.push(`h={${frame.h}}`, `rotation={${frame.rotation}}`);
614
+ if (e.placement === "center" && e.valign === "middle") attrs.push(`h={${e.h}}`, 'valign="middle"');
615
+ attrs.push(`role=${q(role)}`);
616
+ // WCAG's large text (≥24px, or bold ≥18.67px) reads at 3:1; holding a
617
+ // coloured heading to 4.5:1 turned it black (2.4% of coloured texts).
618
+ // The heading roles sit on those rungs of the ramp.
619
+ const paint = textPaint(e, LARGE_ROLES.has(role) ? 3 : contrastFloor);
620
+ if (paint.colorVar) attrs.push(`colorVar=${q(paint.colorVar)}`);
621
+ // A lone list item: a list of one, never a glyph typed into a paragraph.
622
+ if (e.marker) {
623
+ if (paint.tone) attrs.push(`tone=${q(paint.tone)}`);
624
+ const marker = markerVar(e);
625
+ if (marker) attrs.push(`markerVar=${q(marker)}`);
626
+ return `<L ${[...attrs, ...markerAttrs(e.marker)].join(" ")}><li>${emphasise(jsxText(text))}</li></L>`;
627
+ }
628
+ // One line in the image stays one line: no wrapping into the row below —
629
+ // unless the settle measured that its room cannot hold it (`wrap`,
630
+ // settle.mjs fitTextToImage): then it wraps inside its box instead of
631
+ // running past it.
632
+ if (!String(text).includes("\n") && String(text).trim().length <= 60 && !raw.wrap) attrs.push("nowrap");
633
+ if (e.weight === "bold") attrs.push("bold");
634
+ if (paint.tone) attrs.push(`tone=${q(paint.tone)}`);
635
+ if (e.align && e.align !== "left") attrs.push(`align=${q(e.align)}`);
636
+ return `<T ${attrs.join(" ")}>${emphasise(jsxText(text))}</T>`;
637
+ };
638
+ // A text block: one T placing the block, one P per paragraph, a run of
639
+ // items as one L (list-jsx.mjs). Each paragraph is painted and wrapped
640
+ // like a T of its own role; a block that is only a list is the L itself.
641
+ if (e.paragraphs?.length) {
642
+ const frame = textFrame(e);
643
+ const paintOf = (para) => {
644
+ const role = para.role ?? (para.kind === "number" ? "number" : "body");
645
+ return { role, paint: textPaint({ ...e, ...para }, LARGE_ROLES.has(role) ? 3 : contrastFloor) };
646
+ };
647
+ // Each paragraph is aligned by its own `align` (one block may mix them:
648
+ // a centred figure over a left label); an indent is written only where
649
+ // it is structure (paragraphIndent).
650
+ const attrs = (para, nowrap = false) => {
651
+ const { role, paint } = paintOf(para);
652
+ const out = [`role=${q(role)}`];
653
+ if (paint.colorVar) out.push(`colorVar=${q(paint.colorVar)}`);
654
+ const indent = paragraphIndent(para, e, spec.elements ?? []);
655
+ if (indent > 1) out.push(`indent={${indent}}`);
656
+ if (nowrap && !String(para.text).includes("\n") && String(para.text).trim().length <= 60 && !raw.wrap) out.push("nowrap");
657
+ if (nowrap && para.weight === "bold") out.push("bold");
658
+ if (paint.tone) out.push(`tone=${q(paint.tone)}`);
659
+ if (para.align && para.align !== "left") out.push(`align=${q(para.align)}`);
660
+ if (para.marker) {
661
+ const marker = markerVar({ ...e, ...para });
662
+ if (marker) out.push(`markerVar=${q(marker)}`);
663
+ }
664
+ return out;
665
+ };
666
+ const { children, single } = blockChildren(e.paragraphs, {
667
+ attrs,
668
+ para: (para) => `<P ${attrs(para, true).join(" ")}>${emphasise(jsxText(para.text), { ...e, ...para })}</P>`,
669
+ item: (para, head) => {
670
+ const body = emphasise(jsxText(para.text), { ...e, ...para });
671
+ const own = paintOf(para).paint;
672
+ const list = paintOf(head).paint;
673
+ if (own.colorVar === list.colorVar && own.tone === list.tone) return body;
674
+ return `<span style={{ color: ${own.colorVar ? q(own.colorVar) : `TONE[${q(own.tone ?? "dark")}]`} }}>${body}</span>`;
675
+ },
676
+ });
677
+ // `block`: this T is ONE text — two elements carrying it are that text split apart (lint, [split:error]).
678
+ const place = `id=${q(e.id)} x={${frame.x}} y={${frame.y}} w={${frame.w}}${stackAttr(e.id)} block=${q(e.id)}`;
679
+ lines.push(single ? children.replace("<L ", `<L ${place} `) : `<T ${place}>${children}</T>`);
680
+ continue;
681
+ }
682
+ if ((e.kind === "text" || e.kind === "number") && !(e.shape && e.fill && e.fill !== "none")) {
683
+ if (text == null) continue;
684
+ lines.push(textEl(e.id, e.role ?? (e.kind === "number" ? "number" : "body")));
685
+ } else if (e.kind === "icon" || e.kind === "image") {
686
+ // A bound original: the user's own picture. It is placed from the deck's
687
+ // registry and never joins the fetch list below — searching for a
688
+ // lookalike is exactly the substitution the contract exists to prevent —
689
+ // and it has no query to fall back to either (normalizeImagePayload drops
690
+ // one the moment a binding is present).
691
+ const useId = e.image?.assetUseId ?? null;
692
+ if (useId) {
693
+ const b = bound.get(useId);
694
+ if (!b) {
695
+ warnings.push(
696
+ `${e.id}: the spec binds image use ${useId}, which this deck's plan and registry do not resolve — nothing was searched for; import the source, record the use, then transcribe again`,
697
+ );
698
+ dropped.push(e.id);
699
+ continue;
700
+ }
701
+ const attrs = [base, `h={${e.h}}`, `src=${q(b.src)}`, `use=${q(useId)}`, `fit=${q(b.fit ?? "contain")}`, `pos=${q(positionValue(b.position))}`];
702
+ const crop = cropAttrs(b.crop);
703
+ if (crop) attrs.push(`crop=${q(crop.crop)}`, `view=${q(crop.view)}`);
704
+ lines.push(`<I ${attrs.join(" ")} />`);
705
+ for (const note of b.notes ?? []) warnings.push(`${e.id}: ${note}`);
706
+ if (b.slot) {
707
+ warnings.push(
708
+ `${e.id}: image use ${useId} names layout image slot "${b.slot}"; this skeleton places the picture at the concept's own rect instead`,
709
+ );
710
+ }
711
+ continue;
712
+ }
713
+ const paint = assetPaint(e);
714
+ // A mark of a few plain strokes is drawn, not searched for: the icon
715
+ // libraries carry pictures of things, and x51 missed every tick, chevron
716
+ // and ellipsis (23 of 79 misses), each shipping as an empty carrier. Its
717
+ // strokes are native line pieces with round caps (a chain group), an
718
+ // arrow one line with its head, an ellipsis three small discs. A seated
719
+ // mark is drawn at its carrier's centre.
720
+ const carrier = seated.get(raw.parent) === raw ? elementById.get(raw.parent) : null;
721
+ const mark = e.kind === "icon" ? glyphMarkStrokes(carrier ? { ...e, x: carrier.x + (carrier.w - e.w) / 2, y: carrier.y + (carrier.h - e.h) / 2 } : e) : null;
722
+ if (mark) {
723
+ const color = paint.colorVar ?? variable(roles.tone[paint.tone ?? "dark"]);
724
+ if (mark.kind === "dots") {
725
+ mark.dots.forEach((d, k) => lines.push(`<B id=${q(`${e.id}-${k + 1}`)} x={${d.x - d.r}} y={${d.y - d.r}} w={${2 * d.r}} h={${2 * d.r}} shape="circle"${color ? ` fillVar=${q(color)}` : ""} />`));
726
+ continue;
727
+ }
728
+ const pieces = mark.strokes.flatMap((st) => st.slice(1).map((p, i) => ({ object: "cxn", prst: "straightConnector1", av: {}, rot: 0, from: { point: st[i] }, to: { point: p }, start: st[i], end: p })));
729
+ let n = pieces.length === 1 ? { ...pieces[0], id: e.id } : chainNative(e.id, pieces);
730
+ // An arrow too short for even the smallest head grows to carry it,
731
+ // where that stays in the free room around the glyph.
732
+ const long = mark.kind === "arrow" ? stretched(n, Math.ceil(headRoutePx("sm"))) : null;
733
+ if (long && runsClear(long.start, long.end, (spec.elements ?? []).filter((o) => o.id !== e.id))) n = long;
734
+ lineOut.push({ jsx: lineDirective(n, {
735
+ className: "connector-muted",
736
+ style: color && color !== CLASS_COLOR["connector-muted"] ? [`color: ${q(color)}`] : [],
737
+ caps: [null, mark.kind === "arrow" ? capAttr({ type: "triangle", size: "med" }, capRoutePx(n, 1)) : null],
738
+ linecap: "rnd",
739
+ }) });
740
+ continue;
741
+ }
742
+ lines.push(`<I ${base} h={${e.h}} src=${q(requestAsset(e, paint))} />`);
743
+ } else if (e.kind === "rule") {
744
+ // Every rule with a length is a line (above); a rule with none draws nothing.
745
+ continue;
746
+ } else if (e.kind === "path" && e.geometry?.type === "arc" && Math.abs(e.geometry.sweep) >= 360 && !(e.fill && e.fill !== "none")) {
747
+ // A full unfilled ring the intake did not take as a line (it carries
748
+ // words): a native ellipse box with the design's line weight.
749
+ const g0 = e.geometry;
750
+ const ringVar = namedVar(e.stroke?.colorToken ?? e.colorToken, e.stroke?.colorHex ?? e.colorHex) ?? snapVar(e.stroke?.colorHex ?? e.colorHex);
751
+ lines.push(`<B id=${q(e.id)} x={${g0.cx - g0.rx}} y={${g0.cy - g0.ry}} w={${2 * g0.rx}} h={${2 * g0.ry}} shape="ellipse" style={{ borderWidth: ${q(`var(--bw-${weightClass(e.stroke?.width)})`)}, borderStyle: ${q(e.stroke?.dash === "dashed" ? "dashed" : "solid")}, borderColor: ${ringVar ? q(ringVar) : `TONE[${q(e.color ?? "muted")}]`} }} />`);
752
+ } else if (e.kind === "path" && ["arc", "sector"].includes(e.geometry?.type) && e.fill && e.fill !== "none" && e.geometry.innerRatio > 0 && e.geometry.innerRatio < 1) {
753
+ // A filled arc with a hole is a band: a native donut (full) or blockArc
754
+ // (a segment), filled with the design colour. The outline takes the
755
+ // fill's own colour at the lightest weight, so no second colour rims it.
756
+ const n = bandNative(e.id, reconcileArc(e.geometry, e));
757
+ if (!n) {
758
+ warnings.push(`${e.id}: its band geometry cannot be drawn; left out — redraw it by hand from the concept`);
759
+ dropped.push(e.id);
760
+ continue;
761
+ }
762
+ const fillVar = fillVarOf(e) ?? variable(roles.fill[e.fill]);
763
+ const fill = fillVar ? q(fillVar) : `FILL[${q(e.fill)}]`;
764
+ // A band is a shape: it paints in its place, under the labels on it.
765
+ lines.push(lineDirective(n, { className: "connector-muted", style: [`borderWidth: "var(--bw-hairline)"`, `color: ${fill}`, `fill: ${fill}`] }));
766
+ } else if (e.kind === "path" && e.geometry) {
767
+ // Only a closed outline can hold paint: an open spline or path is a
768
+ // line, and filling it drew a trend line as a wedge (x53 cloud 5, x54
769
+ // GTM 4) whatever fill the reader named.
770
+ const g0 = e.geometry;
771
+ const closed = g0.type === "sector" || (g0.type === "spline" && g0.closed === true)
772
+ || (g0.type === "path" && (g0.commands ?? []).some((c) => c?.op === "Z"));
773
+ // Every open stroke is a native line (above); one that reaches here
774
+ // could not be read as one — named, never drawn as svg.
775
+ if (!closed || !(e.fill && e.fill !== "none")) {
776
+ warnings.push(`${e.id}: a stroke whose points cannot be read as a line; left out — draw it as a native line directive from the concept`);
777
+ dropped.push(e.id);
778
+ continue;
779
+ }
780
+ const geom0 = g0.type === "path" ? { ...g0, commands: repairCommands(g0.commands, e) } : reconcileArc(g0, e);
781
+ let g = geometryPath(geom0);
782
+ // A drawn shape may not reach outside the content slot (the export
783
+ // refuses it). Where its extent does, its own coordinates are moved in —
784
+ // and shrunk uniformly only if it is larger than the slot — so it is
785
+ // never distorted and its svg stays 1:1. The reader's box is NOT the
786
+ // bound: it can contradict the reader's own points (x66 QBR 6).
787
+ const ext0 = g?.extent;
788
+ const out = ext0 && (ext0.x < -1 || ext0.y < -1 || ext0.x + ext0.w > slot.w + 1 || ext0.y + ext0.h > slot.h + 1);
789
+ if (out) {
790
+ const k = Math.min(1, slot.w / ext0.w, slot.h / ext0.h);
791
+ const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
792
+ const to = { x: clamp(ext0.x, 0, slot.w - ext0.w * k), y: clamp(ext0.y, 0, slot.h - ext0.h * k) };
793
+ g = geometryPath(scaleGeometry(geom0, k, { x: ext0.x, y: ext0.y }, to)) ?? g;
794
+ }
795
+ // Never a rectangle in its place, and never the whole deck lost to it:
796
+ // the element is left out and named (x58: one bad arc aborted every
797
+ // slide and the agent hand-authored them).
798
+ if (!g) {
799
+ warnings.push(`${e.id}: its geometry cannot be drawn (${g0.type}); left out — redraw it by hand from the concept`);
800
+ dropped.push(e.id);
801
+ continue;
802
+ }
803
+ // A filled figure (a sector, a closed silhouette): its fill only, placed
804
+ // in the box it draws.
805
+ const r2 = (v) => Math.round(v * 100) / 100;
806
+ const b = g.extent ?? { x: g.x, y: g.y, w: g.w, h: g.h };
807
+ const [bx, by, bw, bh] = [b.x, b.y, Math.max(2, b.w), Math.max(2, b.h)].map(r2);
808
+ lines.push(`<div id=${q(e.id)} style={{ position: "absolute", left: ${bx}, top: ${by}, width: ${bw}, height: ${bh} }}><svg viewBox={\`${r2(bx - g.x)} ${r2(by - g.y)} ${bw} ${bh}\`} width={${bw}} height={${bh}} style={{ display: "block", overflow: "visible" }}><path d=${q(g.d)} ${svgPaint(e)} /></svg></div>`);
809
+ } else if (e.kind === "path" && Array.isArray(e.points) && e.points.length >= 2) {
810
+ // A filled silhouette (roof, arch, funnel side): one inline svg the size
811
+ // of its rect, points relative to it, fill only. An open stroke never
812
+ // reaches here as svg: it is a native line.
813
+ if (!(e.points.length >= 3 && e.fill && e.fill !== "none")) {
814
+ warnings.push(`${e.id}: a stroke whose points cannot be read as a line; left out — draw it as a native line directive from the concept`);
815
+ dropped.push(e.id);
816
+ continue;
817
+ }
818
+ const x0 = Math.min(...e.points.map((p) => p.x));
819
+ const y0 = Math.min(...e.points.map((p) => p.y));
820
+ const w = Math.max(2, Math.max(...e.points.map((p) => p.x)) - x0);
821
+ const h = Math.max(2, Math.max(...e.points.map((p) => p.y)) - y0);
822
+ const pts = e.points.map((p) => `${p.x - x0},${p.y - y0}`).join(" ");
823
+ lines.push(
824
+ `<div id=${q(e.id)} style={{ position: "absolute", left: ${x0}, top: ${y0}, width: ${w}, height: ${h} }}><svg viewBox={\`0 0 ${w} ${h}\`} width={${w}} height={${h}} style={{ display: "block", overflow: "visible" }}><polygon points="${pts}" ${svgPaint(e)} /></svg></div>`,
825
+ );
826
+ } else if (e.kind === "chart" && e.chart) {
827
+ const data = nativeChartData(e.chart);
828
+ if (!data) {
829
+ // Do not erase the observed marks or fabricate data when the model
830
+ // recognized a chart but did not recover a renderable payload.
831
+ lines.push(`{/* unresolved chart ${escapeJsx(e.id)}: observed marks retained; requires supported type and verified series */}`);
832
+ warnings.push(`chart ${e.id} has no readable values or an unsupported/inconsistent payload; observed marks retained, native chart unresolved — supply verified chart data, never invent values from an unlabeled illustration`);
833
+ dropped.push(e.id);
834
+ continue;
835
+ }
836
+ lines.push(chartJsx(e, spec, tokens, data, base, warnings));
837
+ } else {
838
+ const attrs = [base, `h={${e.h}}`];
839
+ if (!fig && e.fill && e.fill !== "none") attrs.push(`fill=${q(e.fill)}`);
840
+ const fillVar = fig ? null : fillVarOf(e);
841
+ if (fillVar) attrs.push(`fillVar=${q(fillVar)}`);
842
+ if (!fig && e.shape && e.shape !== "rect") attrs.push(`shape=${q(e.shape)}`);
843
+ const boxStyle = [];
844
+ // Legacy bar specs predate explicit strokes. An unfilled bar is an
845
+ // outline, but a transparent grouping shape is not.
846
+ const outline = fig ? null : e.stroke ?? (e.kind === "bar" && e.fill === "none" && !Object.hasOwn(e, "stroke") ? { width: null } : null);
847
+ if (outline && !(Number.isFinite(outline.width) && outline.width <= 0)) {
848
+ // The reader's weight as the design's weight token, never its px.
849
+ const strokeVar = namedVar(outline.colorToken) ?? snapVar(outline.colorHex);
850
+ boxStyle.push(`borderWidth: ${q(`var(--bw-${weightClass(outline.width)})`)}`, `borderStyle: ${q(outline.dash === "dashed" ? "dashed" : "solid")}`, `borderColor: ${strokeVar ? q(strokeVar) : `TONE[${q(e.color ?? "dark")}]`}`);
851
+ }
852
+ // A node a line glues to ALWAYS names the preset its sites were fitted
853
+ // on: the exporter's box rule reads a token radius differently (a
854
+ // pill-radius circle is not an ellipse to it), and a site index means
855
+ // nothing against another preset's site list (x74: every disc's
856
+ // bottom site 4 refused as out of range on a 4-site box).
857
+ if (glueTargets.has(e.id) && nodes.get(e.id)) {
858
+ const node = nodes.get(e.id);
859
+ attrs.push(`prst=${q(node.prst)}`);
860
+ const adj = Object.entries(node.av ?? {}).map(([k, v]) => `${k}:${Math.round(v)}`).join(",");
861
+ if (adj) attrs.push(`adj=${q(adj)}`);
862
+ }
863
+ if (!fig && e.shape === "ring" && !outline) boxStyle.push(`borderWidth: "var(--bw-medium)", borderStyle: "solid", borderColor: TONE.accent`);
864
+ // Silhouettes the box can take with a clip-path (x28: chevrons shipped
865
+ // as pale squares). A chevron/triangle points the way the row runs.
866
+ const CLIP = {
867
+ chevron: "polygon(0 0, 75% 0, 100% 50%, 75% 100%, 0 100%, 25% 50%)",
868
+ triangle: e.w >= e.h ? "polygon(0 0, 100% 50%, 0 100%)" : "polygon(0 100%, 50% 0, 100% 100%)",
869
+ trapezoid: "polygon(12% 0, 88% 0, 100% 100%, 0 100%)",
870
+ // A filled block arrow pointing the way its long side runs.
871
+ arrow: e.w >= e.h ? "polygon(0 28%, 62% 28%, 62% 0, 100% 50%, 62% 100%, 62% 72%, 0 72%)" : "polygon(28% 0, 72% 0, 72% 62%, 100% 62%, 50% 100%, 0 62%, 28% 62%)",
872
+ diamond: "polygon(50% 0, 100% 50%, 50% 100%, 0 50%)",
873
+ hexagon: e.w >= e.h ? "polygon(25% 0, 75% 0, 100% 50%, 75% 100%, 25% 100%, 0 50%)" : "polygon(50% 0, 100% 25%, 100% 75%, 50% 100%, 0 75%, 0 25%)",
874
+ pentagon: "polygon(50% 0, 100% 38%, 81% 100%, 19% 100%, 0 38%)",
875
+ parallelogram: "polygon(18% 0, 100% 0, 82% 100%, 0 100%)",
876
+ };
877
+ // A silhouette none of the named shapes draws: the outline the reader
878
+ // traced, relative to the box (the exporter fits a preset to it, or
879
+ // keeps it as a freeform).
880
+ const traced = e.shape === "other" && Array.isArray(e.outline) && e.outline.length >= 3 && e.w > 0 && e.h > 0
881
+ ? `polygon(${e.outline.map((p) => `${Math.round(((p.x - e.x) / e.w) * 1000) / 10}% ${Math.round(((p.y - e.y) / e.h) * 1000) / 10}%`).join(", ")})`
882
+ : null;
883
+ if (!fig && (CLIP[e.shape] || traced)) boxStyle.push(`clipPath: ${q(CLIP[e.shape] ?? traced)}`);
884
+ if (e.textInsets) for (const side of ["left", "right", "top", "bottom"]) {
885
+ const inset = e.textInsets[side];
886
+ if (Number.isFinite(inset) && inset >= 0) boxStyle.push(`padding${side[0].toUpperCase() + side.slice(1)}: ${inset}`);
887
+ }
888
+ // A shape's own words never sit flush with its edge (BP 04-02: badge
889
+ // labels clipped at the pill's side). On a badge, pill or round carrier
890
+ // they centre; elsewhere they keep the read alignment inside the
891
+ // design's inner padding. Folded words carry their measured insets.
892
+ const ownWords = text != null && !e.textInsets;
893
+ const centred = ownWords && (e.kind === "badge" || e.kind === "disc" || ["pill", "circle", "ring"].includes(e.shape));
894
+ const align = centred ? "center" : e.align;
895
+ if (ownWords && !centred && (align === "left" || align === "right")) boxStyle.push(`paddingLeft: "var(--sp-2)"`, `paddingRight: "var(--sp-2)"`);
896
+ if (align) boxStyle.push(`textAlign: ${q(align)}`);
897
+ if (e.elevated && !fig) attrs.push("shadow");
898
+ if (text == null) {
899
+ // A seated icon or picture rides inside, on one line: the box centres it.
900
+ const seat = seated.get(e.id);
901
+ const inner = seat && !isMark(seat) ? `<I id=${q(seat.id)} w={${Math.min(seat.w, e.w)}} h={${Math.min(seat.h, e.h)}} src=${q(requestAsset(seat))} />` : null;
902
+ if (inner) {
903
+ attrs.push(`align="center"`, `valign="middle"`);
904
+ consumed.add(seat.id);
905
+ }
906
+ if (boxStyle.length) attrs.push(`style={{ ${boxStyle.join(", ")} }}`);
907
+ lines.push(inner ? `<B ${attrs.join(" ")}>${inner}</B>` : `<B ${attrs.join(" ")} />`);
908
+ } else {
909
+ // A badge's digit, a disc's label: the words belong TO the shape. The
910
+ // type goes on the BOX, not on a span inside it — the export reads the
911
+ // anchor off the box that carries the run, so a span would ship a
912
+ // centred-looking preview and a top-anchored shape in PowerPoint. One
913
+ // shape with its own centred text body then sits in its middle on both
914
+ // axes, instead of at whatever rect the read gave a separate text box
915
+ // (a child's rect is the noisiest part of a spec — a glyph or digit
916
+ // half out of its disc, x37 cloud 2 and 6).
917
+ const paint = textPaint(e);
918
+ const v = paint.colorVar;
919
+ boxStyle.unshift(
920
+ // A digit on a disc or badge is a badge label, not a KPI number.
921
+ `...ROLE[${q(e.role === "number" || e.kind === "badge" ? "badge" : (e.role ?? "body"))}]`,
922
+ `color: ${v ? q(v) : `TONE[${q(paint.tone ?? "dark")}]`}`,
923
+ ...(e.weight === "bold" ? [`fontWeight: "var(--fw-bold)"`] : []),
924
+ `lineHeight: "var(--lh-tight)"`,
925
+ );
926
+ // Where the words sit is the geometry the reader measured (the fold
927
+ // pass derives it from the block's margins inside the box); a box's own
928
+ // one-line text centres. No shape name decides this.
929
+ attrs.push(`align=${q(align ?? "center")}`);
930
+ attrs.push(`valign=${q(e.valign ?? "middle")}`);
931
+ attrs.push(`style={{ ${boxStyle.join(", ")} }}`);
932
+ lines.push(`<B ${attrs.join(" ")}>${emphasise(jsxText(text))}</B>`);
933
+ }
934
+ }
935
+ } catch (err) {
936
+ warnings.push(`${raw.id}: not written — ${err?.message ?? err}; the rest of the slide stands`);
937
+ }
938
+ }
939
+ // Retained illustration marks have an owner even though no numeric chart
940
+ // can be emitted. Capture uses that ownership to reject a second, native
941
+ // plot placed on top later. Annotations (text/icons) are not plot marks.
942
+ const illustrations = (spec.elements ?? []).filter(e => e.kind === "chart" && !nativeChartData(e.chart));
943
+ for (const e of spec.elements ?? []) {
944
+ if (e.text != null || !["path", "rule", "bar", "shape"].includes(e.kind)) continue;
945
+ const owner = illustrations.find(c => e.x >= c.x - 2 && e.y >= c.y - 2 && e.x + e.w <= c.x + c.w + 2 && e.y + e.h <= c.y + c.h + 2);
946
+ if (!owner) continue;
947
+ const at = lines.findIndex(line => line.includes(`id=${q(e.id)} `));
948
+ if (at < 0) continue;
949
+ const name = lines[at].startsWith("<div") ? "data-sm-plot-owner" : "plotOwner";
950
+ lines[at] = lines[at].replace(`id=${q(e.id)}`, `id=${q(e.id)} ${name}=${q(owner.id)}`);
951
+ }
952
+ // Lines paint above the shapes they join; a free rail, ring or divider
953
+ // paints behind the shapes on it (placement.mjs).
954
+ const painted = (e) => (e.fill && e.fill !== "none") || ["icon", "image", "chart"].includes(e.kind) || sketched.has(e.id);
955
+ const seats = placed.filter((p, k) => painted(p.el) && (placed[k + 1]?.at ?? lines.length) > p.at);
956
+ lines.splice(0, lines.length, ...seatLines(lines, seats, lineOut));
957
+ // Chrome props fill the layout's own placeholders, and a placeholder wraps
958
+ // its text at its own width and master size. The spec's `\n` is only where
959
+ // the MOCKUP wrapped, so it becomes a space (2026-09-27: a two-line headline
960
+ // shipped with the concept's break although its placeholder needed none;
961
+ // this retires the x37 keep-the-break and x51 char-estimate rules).
962
+ const flat = (t) => String(t ?? "").split("\n").join(" ").replace(/\s+/g, " ").trim();
963
+ if (unmatched.size) {
964
+ warnings.push(
965
+ `${unmatched.size} observed colour${unmatched.size === 1 ? "" : "s"} ha${unmatched.size === 1 ? "s" : "ve"} no token in this design (${[...unmatched].slice(0, 3).map(([h, n]) => `${h} nearest ${n}`).join(", ")}); the tone word decided`,
966
+ );
967
+ }
968
+ const props = [];
969
+ if (chrome.kicker) props.push(`${chrome.kicker}=${q(flat(spec.chrome?.kicker))}`);
970
+ if (chrome.headline) props.push(`${chrome.headline}=${q(flat(spec.chrome?.headline))}`);
971
+ // Meta chrome: the slide number as the layout's page number, nothing else
972
+ // (a layout's own placeholder copy must not ship). The spec's `footer` is
973
+ // deliberately NOT fed back: the mockup was painted over this layout's own
974
+ // chrome, so whatever the model read in the footer band — the wordmark, the
975
+ // date, the section, the page number — is what the layout already draws.
976
+ // Re-emitting it shipped a second wordmark next to the real one and, where
977
+ // the chrome renders the full strip, one crowded string in the footnote
978
+ // placeholder (x36 / hold-out 3: ten judged slides).
979
+ for (const p of chrome.others) {
980
+ if (/pagenumber|slidenum/i.test(p)) props.push(`${p}=${q(String(slide))}`);
981
+ else props.push(`${p}=""`);
982
+ }
983
+ if (!chrome.headline && !chrome.kicker) {
984
+ warnings.push(`layout ${layout} declares no chrome props the skeleton knows; the headline was not placed`);
985
+ }
986
+ const jsx =
987
+ `// Transcribed from the reader's inventory of the concept, at the painting's coordinates. Edit in place: align, even and centre what the painting only approximates; delete what it drew by accident (a chrome-band mark, a guide box, a stray stroke).\n` +
988
+ (literal ? `// A reconstruction: its colours are the reference's own values, not design tokens — keep them.\n` : "") +
989
+ `export default function Slide() {\n${kit}${(spec.stacks ?? []).length ? ` // Declared stacks (reader): how each one's rows are aligned and spaced.\n const STACKS = ${JSON.stringify(Object.fromEntries((spec.stacks ?? []).map((g, gi) => [gi + 1, { align: g.align ?? "middle", spacing: g.spacing ?? "even" }])))};\n${gridsConst}` : ""}${KIT_EXTRAS}\n return (\n <${layout}${props.length ? " " + props.join(" ") : ""}>\n` +
990
+ ` <div style={{ position: "relative", width: ${slot.w}, height: ${slot.h} }}>\n` +
991
+ repeatGroups(spec).map((g) => ` {/* ${g.id}: ${g.members.map((m) => (m.ids ? `(${m.ids.join(" + ")})` : m.id)).join(", ")} are one organism (${g.arrangement}${g.hub ? ` around ${g.hub.id}` : ""}) — one size, one inner layout; change them alike. */}\n`).join("") +
992
+ lines.map((l) => ` ${l}`).join("\n") +
993
+ `\n </div>\n </${layout}>\n );\n}\n`;
994
+ return { jsx, icons, warnings, dropped, connectorLines };
995
+ }
996
+
997
+ // The engine's own ranking is noise for icons: nine results come back inside
998
+ // 0.002 of each other, so "first result" is a coin toss — the judge's most
999
+ // frequent defect across every run is "several icons differ materially from
1000
+ // the concept" ("revenue growth chart" returned Decreasing-Chart first with
1001
+ // Increasing-Chart fourth; "marketing megaphone" returned Loudspeaker with
1002
+ // Megaphone fourth). The file name behind each result is the signal the score
1003
+ // is not, so pick by it: most query words matched, then the plainest name
1004
+ // (`Clock-1` over `School-Clock`), then the engine's order.
1005
+ // ponytail: overlap only — "revenue growth chart" still ties Increasing- and
1006
+ // Decreasing-Chart. Add a direction synonym/antonym table if wrong-direction
1007
+ // glyphs show up as more than the occasional defect.
1008
+ export function iconSearchQueries(query, keywords = []) {
1009
+ const words = String(query).split(/\s+/).filter(Boolean);
1010
+ const content = words.filter(w => !ICON_STYLE_WORDS.has(w.toLowerCase()));
1011
+ // Never reduce a compound motif to its last noun ("magnifying glass" ->
1012
+ // "glass", "check mark" -> "mark"). A retry keeps its semantic content; the
1013
+ // reader's own synonyms follow, as the words a library names files with.
1014
+ const own = new Set(content.map((w) => w.toLowerCase()));
1015
+ const synonyms = keywords.map((k) => String(k).trim().toLowerCase()).filter((k) => k && !own.has(k) && !ICON_STYLE_WORDS.has(k));
1016
+ return [...new Set([query, content.join(' '), ...synonyms].filter(Boolean))].slice(0, 4);
1017
+ }
1018
+ const motifWords = query => {
1019
+ const words = nameWords(query).filter(w => !ICON_STYLE_WORDS.has(w));
1020
+ // A shared object noun alone does not establish a compound motif.
1021
+ return words.length > 1 ? words.filter(w => !['glass', 'coin'].includes(w)) : words;
1022
+ };
1023
+
1024
+ const resultName = (r) => {
1025
+ const raw = String(r?.sourceUrl ?? r?.title ?? "").split("/").pop() ?? "";
1026
+ let name = raw;
1027
+ try {
1028
+ name = decodeURIComponent(raw);
1029
+ } catch {
1030
+ // A malformed escape: the raw segment still carries the words.
1031
+ }
1032
+ return name.replace(/\.[a-z0-9]+$/i, "");
1033
+ };
1034
+
1035
+ /** How many of the query's words the result's file name carries. */
1036
+ // `keywords` are the reader's file-name synonyms for the motif (x51: "person
1037
+ // outline" never matched Male-2, "magnifying glass" never matched Search-Plus,
1038
+ // "speedometer gauge" never matched Speed-Meter). A synonym counts as a hit;
1039
+ // the query's own words still rank first.
1040
+ /** How many name hits make a full match for this motif (its own words, at most two). */
1041
+ export const iconMotifSize = (query) => Math.min(2, Math.max(1, motifWords(query).length));
1042
+
1043
+ export function iconNameHits(query, result, keywords = []) {
1044
+ const parts = nameWords(resultName(result));
1045
+ if (!parts.length) return 0;
1046
+ const own = motifWords(query);
1047
+ const syn = [...new Set(keywords.flatMap((k) => nameWords(k)))].filter((w) => !own.includes(w) && !ICON_STYLE_WORDS.has(w));
1048
+ return own.filter((w) => parts.includes(w)).length + (syn.some((w) => parts.includes(w)) ? 1 : 0);
1049
+ }
1050
+
1051
+ export function pickIconResult(query, results, keywords = []) {
1052
+ const list = Array.isArray(results) ? results.filter(r => r && iconNameHits(query, r, keywords) > 0) : [];
1053
+ if (list.length < 2) return list[0] ?? null;
1054
+ const score = (r) => {
1055
+ const parts = nameWords(resultName(r));
1056
+ if (!parts.length) return -Infinity;
1057
+ return iconNameHits(query, r, keywords) * 10 - parts.length;
1058
+ };
1059
+ let best = list[0];
1060
+ let bestScore = score(list[0]);
1061
+ for (const r of list.slice(1)) {
1062
+ const s = score(r);
1063
+ if (s > bestScore) {
1064
+ best = r;
1065
+ bestScore = s;
1066
+ }
1067
+ }
1068
+ return best;
1069
+ }