slidemind 0.3.20 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/render.mjs +27 -1
  16. package/src/bridge/client/transport.mjs +499 -23
  17. package/src/bridge/client.mjs +10 -0
  18. package/src/bridge/config.mjs +67 -6
  19. package/src/bridge/engine-download.mjs +0 -10
  20. package/src/bridge/graph-auth.mjs +55 -3
  21. package/src/bridge/image-intent-envelope.mjs +239 -0
  22. package/src/bridge/package-preview.mjs +67 -0
  23. package/src/bridge/sharepoint.mjs +914 -0
  24. package/src/builders/asset-fetch.mjs +203 -0
  25. package/src/builders/block-dividers.mjs +83 -0
  26. package/src/builders/build.mjs +171 -87
  27. package/src/builders/card-height.mjs +294 -0
  28. package/src/builders/chart-hole.mjs +183 -0
  29. package/src/builders/chart-jsx.mjs +410 -0
  30. package/src/builders/chart-labels.mjs +326 -0
  31. package/src/builders/concept-geometry.mjs +370 -0
  32. package/src/builders/concept-intent.mjs +200 -0
  33. package/src/builders/concept-layout.mjs +79 -0
  34. package/src/builders/concept-lines.mjs +509 -0
  35. package/src/builders/concept-lists.mjs +270 -0
  36. package/src/builders/concept-marks.mjs +84 -0
  37. package/src/builders/concept-relationships.mjs +12 -0
  38. package/src/builders/concept-spec.mjs +645 -0
  39. package/src/builders/concept-text.mjs +140 -0
  40. package/src/builders/concept-waves.mjs +57 -0
  41. package/src/builders/copy-fit.mjs +139 -0
  42. package/src/builders/design-tokens.mjs +347 -0
  43. package/src/builders/draft-check.mjs +552 -340
  44. package/src/builders/draft-lines.mjs +369 -0
  45. package/src/builders/edit-route.mjs +29 -0
  46. package/src/builders/figure-sketch.mjs +375 -0
  47. package/src/builders/fold-sets.mjs +639 -0
  48. package/src/builders/gate-server.mjs +9 -4
  49. package/src/builders/gate-session.js +273 -12
  50. package/src/builders/gate-shell.js +1494 -0
  51. package/src/builders/image-intent-check.mjs +308 -0
  52. package/src/builders/image-use-reconcile.mjs +107 -0
  53. package/src/builders/kit-helpers.mjs +121 -0
  54. package/src/builders/layout-names.mjs +32 -0
  55. package/src/builders/legacy-reference-uses.mjs +179 -0
  56. package/src/builders/line-fit.mjs +224 -0
  57. package/src/builders/line-native.mjs +895 -0
  58. package/src/builders/line-relations.mjs +230 -0
  59. package/src/builders/lint-fix.mjs +117 -9
  60. package/src/builders/lint.mjs +25 -20
  61. package/src/builders/list-jsx.mjs +88 -0
  62. package/src/builders/placement.mjs +133 -0
  63. package/src/builders/plan-checks.mjs +135 -0
  64. package/src/builders/plan-comments.mjs +19 -0
  65. package/src/builders/plan-handlers.mjs +95 -0
  66. package/src/builders/plan-markdown-edit.mjs +3 -3
  67. package/src/builders/plan-overlay.js +1530 -1608
  68. package/src/builders/plan.mjs +689 -186
  69. package/src/builders/preset-definitions.json +1 -0
  70. package/src/builders/preset-eval.mjs +244 -0
  71. package/src/builders/preview.html +51 -120
  72. package/src/builders/red-thread-backbone.mjs +135 -36
  73. package/src/builders/repair.mjs +205 -0
  74. package/src/builders/repeat-groups.mjs +540 -0
  75. package/src/builders/resolve-skill.mjs +5 -4
  76. package/src/builders/review-overlay.js +296 -293
  77. package/src/builders/review.mjs +305 -84
  78. package/src/builders/rule-shift.mjs +96 -0
  79. package/src/builders/seat.mjs +354 -0
  80. package/src/builders/settle-driver.mjs +236 -0
  81. package/src/builders/settle.mjs +1448 -0
  82. package/src/builders/slide-prep.mjs +67 -0
  83. package/src/builders/spec-assets.mjs +236 -0
  84. package/src/builders/spec-match.mjs +56 -0
  85. package/src/builders/stack.mjs +413 -0
  86. package/src/builders/storyboard-markdown.mjs +70 -27
  87. package/src/builders/storyboard-schema.mjs +54 -114
  88. package/src/builders/text-blocks.mjs +212 -0
  89. package/src/builders/text-card-fit.mjs +552 -0
  90. package/src/builders/text-stack.mjs +276 -0
  91. package/src/builders/transcribe.mjs +1069 -0
  92. package/src/cli/assets.mjs +164 -0
  93. package/src/cli/common.mjs +173 -12
  94. package/src/cli/concept-spec-support.mjs +76 -0
  95. package/src/cli/concepts.mjs +1002 -103
  96. package/src/cli/deck.mjs +401 -65
  97. package/src/cli/diagnose.mjs +4 -2
  98. package/src/cli/engine-helpers.mjs +17 -10
  99. package/src/cli/engine-results.mjs +76 -3
  100. package/src/cli/help.mjs +232 -28
  101. package/src/cli/install.mjs +9 -0
  102. package/src/cli/maintenance.mjs +104 -0
  103. package/src/cli/read.mjs +2 -1
  104. package/src/cli/ready.mjs +6 -3
  105. package/src/cli/record.mjs +522 -0
  106. package/src/cli/remote.mjs +818 -0
  107. package/src/cli/revision.mjs +686 -0
  108. package/src/cli/search.mjs +30 -11
  109. package/src/cli/session.mjs +56 -13
  110. package/src/cli/setup.mjs +46 -2
  111. package/src/cli/status.mjs +142 -33
  112. package/src/cli/tasks.mjs +246 -0
  113. package/src/cli/transcribe.mjs +854 -0
  114. package/src/cli/workspace-ui.mjs +260 -0
  115. package/src/cli/workspace.mjs +212 -34
  116. package/src/install-channel.mjs +64 -7
  117. package/src/lib/adopt.mjs +346 -25
  118. package/src/lib/concept-spec-binding.mjs +96 -0
  119. package/src/lib/conversions.mjs +70 -6
  120. package/src/lib/convert-folded.mjs +107 -0
  121. package/src/lib/deck-paths.mjs +418 -0
  122. package/src/lib/durable-ids.mjs +47 -0
  123. package/src/lib/edit-operations.mjs +316 -0
  124. package/src/lib/edit-state.mjs +1244 -0
  125. package/src/lib/edit-transitions.mjs +120 -0
  126. package/src/lib/extensions.mjs +501 -0
  127. package/src/lib/image-assets.mjs +465 -0
  128. package/src/lib/image-derivatives.mjs +234 -0
  129. package/src/lib/image-intent-fingerprint.mjs +70 -0
  130. package/src/lib/image-intent-tags.mjs +176 -0
  131. package/src/lib/image-intent.mjs +802 -0
  132. package/src/lib/image-use-overrides.mjs +279 -0
  133. package/src/lib/inline-components.mjs +613 -0
  134. package/src/lib/lint-converted.mjs +68 -4
  135. package/src/lib/merge-json.mjs +163 -0
  136. package/src/lib/overwrite-guard.mjs +3 -3
  137. package/src/lib/presentation-context.mjs +531 -0
  138. package/src/lib/preservation-tags.mjs +157 -0
  139. package/src/lib/preservation-verdict.mjs +1342 -0
  140. package/src/lib/publish.mjs +1557 -0
  141. package/src/lib/rebase.mjs +945 -0
  142. package/src/lib/reconcile-ack.mjs +4 -6
  143. package/src/lib/reconcile.mjs +62 -24
  144. package/src/lib/record-client.mjs +175 -0
  145. package/src/lib/record-state.mjs +151 -0
  146. package/src/lib/remote.mjs +661 -0
  147. package/src/lib/revision.mjs +1210 -0
  148. package/src/lib/slide-guids.mjs +4 -6
  149. package/src/lib/slide-preservation.mjs +423 -0
  150. package/src/lib/slide-selection.mjs +9 -0
  151. package/src/lib/workspace-transaction.mjs +117 -0
  152. package/src/pptx/content-digest.mjs +334 -0
  153. package/src/pptx/deck-assemble.mjs +1278 -0
  154. package/src/pptx/fill/engine.mjs +2 -3
  155. package/src/pptx/fill/freeform.mjs +6 -3
  156. package/src/pptx/ooxml.mjs +20 -0
  157. package/src/pptx/package-evidence.mjs +383 -0
  158. package/src/pptx/tags.mjs +163 -11
  159. package/src/update-check.mjs +22 -4
  160. package/src/workspace/adapters.mjs +222 -0
  161. package/src/workspace/auth.mjs +256 -0
  162. package/src/workspace/autostart.mjs +204 -0
  163. package/src/workspace/daemon.mjs +360 -0
  164. package/src/workspace/discovery.mjs +180 -0
  165. package/src/workspace/edit.mjs +167 -0
  166. package/src/workspace/events.mjs +52 -0
  167. package/src/workspace/gate-client.mjs +290 -0
  168. package/src/workspace/handback.mjs +73 -0
  169. package/src/workspace/lifecycle.mjs +635 -0
  170. package/src/workspace/operations.mjs +200 -0
  171. package/src/workspace/pick-folder.mjs +152 -0
  172. package/src/workspace/registry.mjs +359 -0
  173. package/src/workspace/remote-links.mjs +397 -0
  174. package/src/workspace/remote-status.mjs +359 -0
  175. package/src/workspace/routes.mjs +1331 -0
  176. package/src/workspace/server.mjs +159 -0
  177. package/src/workspace/sync.mjs +1119 -0
  178. package/src/workspace/task-actions.mjs +215 -0
  179. package/src/workspace/tasks.mjs +507 -0
  180. package/src/workspace/ui/app.js +1886 -0
  181. package/src/workspace/ui/index.html +141 -0
  182. package/src/workspace/ui/plan-bridge.js +120 -0
  183. package/src/workspace/ui/sharepoint.svg +59 -0
  184. package/src/workspace/ui/styles.css +423 -0
  185. package/src/builders/visual-families.mjs +0 -46
@@ -0,0 +1,347 @@
1
+ // The design's colour tokens, read from the bundle's corporate-design.css, and
2
+ // the snap from an observed colour to the nearest one. The vision spec reports
3
+ // what it sees (`fillHex`, `colorHex`); the design's palette lives here and
4
+ // only here — no second colour vocabulary anywhere.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+
8
+ export function readTypeRamp(bundleDir) {
9
+ try {
10
+ return parseTypeRamp(fs.readFileSync(path.join(bundleDir, "corporate-design.css"), "utf8"));
11
+ } catch {
12
+ return { stageWidthPx: null, steps: [] };
13
+ }
14
+ }
15
+
16
+ // The design's type ramp: every `--fs-*` step in px, smallest first, and the
17
+ // stage width (`--stage-width-px`, the master's slide width in CSS px) that
18
+ // sets their scale.
19
+ export function parseTypeRamp(css) {
20
+ const clean = String(css).replace(/\/\*[\s\S]*?\*\//g, "");
21
+ const px = (v) => { const m = /^\s*(\d+(?:\.\d+)?)px\s*$/.exec(v ?? ""); return m ? Number(m[1]) : null; };
22
+ const steps = [];
23
+ let stageWidthPx = null;
24
+ for (const m of clean.matchAll(/(--[\w-]+)\s*:\s*([^;{}]+)/g)) {
25
+ if (m[1] === "--stage-width-px") stageWidthPx = px(m[2]);
26
+ else if (m[1].startsWith("--fs-") && px(m[2]) != null && !steps.some((s) => s.name === m[1])) steps.push({ name: m[1], px: px(m[2]) });
27
+ }
28
+ return { stageWidthPx, steps: steps.sort((a, b) => a.px - b.px) };
29
+ }
30
+
31
+ // Concepts set body copy at ~1.2% of the stage width on every brand (measured
32
+ // on four ramps, 09-30): the slide's text sits on the largest step at or
33
+ // under it. Calibration knob.
34
+ const BODY_SHARE = 0.012;
35
+ // Where two steps share a size, the one a slide's text is named after.
36
+ const PREFERRED = ["--fs-body", "--fs-body-sm", "--fs-caption", "--fs-h3", "--fs-h2", "--fs-h1", "--fs-display"];
37
+
38
+ /**
39
+ * One slide's type hierarchy on the design's ramp: titles and body on one
40
+ * step, labels and captions one step under it, numbers on --fs-h3 (two
41
+ * steps up without one). `offset` moves the whole ladder along the ramp
42
+ * (-1: one step down, for a slide whose text does not fit). Null without a
43
+ * usable ramp.
44
+ */
45
+ export function typeLadder(ramp, offset = 0) {
46
+ if (!ramp?.stageWidthPx || !ramp.steps?.length) return null;
47
+ const sizes = [...new Set(ramp.steps.map((s) => s.px))].sort((a, b) => a - b);
48
+ const nameOf = (px) => {
49
+ const names = ramp.steps.filter((s) => s.px === px).map((s) => s.name);
50
+ return PREFERRED.find((n) => names.includes(n)) ?? names[0];
51
+ };
52
+ const body = Math.max(0, sizes.findLastIndex((px) => px <= BODY_SHARE * ramp.stageWidthPx + 0.01));
53
+ const h3 = ramp.steps.find((s) => s.name === "--fs-h3");
54
+ const number = Math.max(body, h3 ? sizes.indexOf(h3.px) : Math.min(body + 2, sizes.length - 1));
55
+ const at = (i) => nameOf(sizes[Math.min(sizes.length - 1, Math.max(0, i + offset))]);
56
+ return { body: at(body), small: at(body - 1), number: at(number) };
57
+ }
58
+
59
+ export function readColorTokens(bundleDir) {
60
+ let css = "";
61
+ try {
62
+ css = fs.readFileSync(path.join(bundleDir, "corporate-design.css"), "utf8");
63
+ } catch {
64
+ return [];
65
+ }
66
+ return parseColorTokens(css);
67
+ }
68
+
69
+ export function parseColorTokens(css) {
70
+ // Custom-property names belong to the bundle. Resolve aliases without
71
+ // assuming a foreground/background naming scheme; cycles stay unresolved.
72
+ const declarations = new Map();
73
+ for (const m of String(css).replace(/\/\*[\s\S]*?\*\//g, "").matchAll(/(--[\w-]+)\s*:\s*([^;{}]+)/g)) declarations.set(m[1], m[2].trim());
74
+ const resolve = (name, seen = new Set()) => {
75
+ if (seen.has(name)) return null;
76
+ seen.add(name);
77
+ const value = (declarations.get(name) ?? "").replace(/\s*!important\s*$/, "");
78
+ const alias = /^var\(\s*(--[\w-]+)\s*(?:,\s*(.+))?\)$/.exec(value);
79
+ return alias ? resolve(alias[1], seen) ?? parseOpaqueColor(alias[2]) : parseOpaqueColor(value);
80
+ };
81
+ return [...declarations.keys()].flatMap((name) => {
82
+ const rgb = resolve(name);
83
+ return rgb ? [{ name, hex: rgbToHex(rgb), rgb }] : [];
84
+ });
85
+ }
86
+
87
+ function parseOpaqueColor(value) {
88
+ if (!value) return null;
89
+ if (value.startsWith("#")) return hexToRgb(value);
90
+ const m = /^rgba?\(([^)]+)\)$/i.exec(value);
91
+ if (!m) return null;
92
+ const parts = m[1].trim().split(/[\s,\/]+/);
93
+ if (parts.length < 3 || parts.length > 4) return null;
94
+ const number = (s, max) => /^[-+]?(?:\d*\.)?\d+%?$/.test(s)
95
+ ? Number(s.replace(/%$/, "")) * (s.endsWith("%") ? max / 100 : 1) : NaN;
96
+ if (parts.length === 4 && number(parts[3], 1) !== 1) return null;
97
+ const rgb = parts.slice(0, 3).map((s) => number(s, 255));
98
+ return rgb.every((n) => Number.isFinite(n) && n >= 0 && n <= 255) ? rgb.map(Math.round) : null;
99
+ }
100
+
101
+ export function hexToRgb(hex) {
102
+ let h = String(hex).trim().replace(/^#/, "");
103
+ if (!/^(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(h)) return null;
104
+ if (h.length <= 4) h = h.split("").map((c) => c + c).join("");
105
+ // Translucent paint needs compositing before contrast can be evaluated.
106
+ if (h.length === 8 && h.slice(6).toLowerCase() !== "ff") return null;
107
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
108
+ }
109
+
110
+ // Use declared semantic tokens when present. Extrema are useful neutral
111
+ // fallbacks, but saturation cannot tell a brand accent from a warning colour.
112
+ export function paletteRoles(tokens) {
113
+ const named = (...names) => names.map((name) => tokens.find((t) => t.name === name)).find(Boolean);
114
+ const ordered = [...tokens].sort((a, b) => relLuminance(a.rgb) - relLuminance(b.rgb));
115
+ const dark = named("--fg-1") ?? ordered[0];
116
+ const light = named("--fg-on-dark") ?? ordered.at(-1);
117
+ const accent = named("--fg-accent", "--color-accent", "--accent");
118
+ const muted = named("--fg-3") ?? dark;
119
+ return {
120
+ tone: { dark, light, accent, muted },
121
+ fill: { white: named("--bg-1") ?? light, light: named("--bg-2") ?? light,
122
+ dark: named("--bg-ink") ?? dark, accent, muted: named("--bg-3") ?? light },
123
+ };
124
+ }
125
+
126
+ const rgbToHex = ([r, g, b]) => `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("").toUpperCase()}`;
127
+
128
+ // Perceptual-ish distance (redmean weighting) — good enough to tell a green
129
+ // status dot from the brand accent and white from a pale surface.
130
+ export function colorDistance(a, b) {
131
+ const rm = (a[0] + b[0]) / 2;
132
+ const dr = a[0] - b[0];
133
+ const dg = a[1] - b[1];
134
+ const db = a[2] - b[2];
135
+ return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
136
+ }
137
+
138
+ export function luminance(rgb) {
139
+ return (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]) / 255;
140
+ }
141
+
142
+ // Hue angle (0-360) and saturation/lightness of an rgb triple.
143
+ export function hsl([r, g, b]) {
144
+ const R = r / 255, G = g / 255, B = b / 255;
145
+ const max = Math.max(R, G, B), min = Math.min(R, G, B), d = max - min;
146
+ const l = (max + min) / 2;
147
+ if (!d) return { h: 0, s: 0, l };
148
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
149
+ let h;
150
+ if (max === R) h = ((G - B) / d) % 6;
151
+ else if (max === G) h = (B - R) / d + 2;
152
+ else h = (R - G) / d + 4;
153
+ h *= 60;
154
+ return { h: h < 0 ? h + 360 : h, s, l };
155
+ }
156
+
157
+
158
+ // WCAG relative luminance and contrast ratio. `luminance()` above is a cheap
159
+ // light/dark test; this is the one to use when the question is "can this be
160
+ // read on that", because a mid-grey on a mid-blue passes the cheap test and
161
+ // fails the eye.
162
+ const channel = (v) => {
163
+ const c = v / 255;
164
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
165
+ };
166
+
167
+ export function relLuminance(rgb) {
168
+ return 0.2126 * channel(rgb[0]) + 0.7152 * channel(rgb[1]) + 0.0722 * channel(rgb[2]);
169
+ }
170
+
171
+ export function contrastRatio(hexA, hexB) {
172
+ const a = hexToRgb(hexA);
173
+ const b = hexToRgb(hexB);
174
+ if (!a || !b) return null;
175
+ const la = relLuminance(a);
176
+ const lb = relLuminance(b);
177
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
178
+ }
179
+
180
+ // ---- Chart colours: the design's, never the painting's.
181
+ //
182
+ // A chart is painted from the design bundle. Its series take the bundle's chart
183
+ // palette in order; a mark that MEANS something takes the bundle's colour for
184
+ // that role: `highlight` (the one the slide is about), `context` (the rest,
185
+ // shown quietly), `positive` / `negative` (a rise and a fall, a gain and a
186
+ // loss), `reference` (a target, a plan, a benchmark). The CD pipeline writes
187
+ // them into corporate-design.css as `--chart-series-N` and `--chart-<role>`
188
+ // (bundle-canonical/tokens-css.ts, from `chartPaletteFromTheme` below); a
189
+ // bundle written before them derives the same from its own theme slots by the
190
+ // same rule. A painted hex is never copied: x111 QBR 3 mapped two pale painted
191
+ // slices onto the theme and x109's painted blue ramp came out as greys.
192
+ export const CHART_ROLES = ["highlight", "context", "positive", "negative", "reference"];
193
+ const THEME_ORDER = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "tx1", "tx2", "bg1", "bg2"];
194
+ // A filled mark the ground swallows is no colour for a chart: a pale slot on
195
+ // the slide's own ground (statworx: accent6 #EBF0F2 and accent4 #FFFF00 on
196
+ // white, 1.13:1 and 1.07:1) leaves the palette; a light grey context bar
197
+ // (#D9D9D9, 1.35:1) stays.
198
+ const CHART_VISIBLE = 1.25;
199
+ const QUIET_VISIBLE = 1.1;
200
+ const CHROMA = 0.25;
201
+
202
+ /**
203
+ * The chart palette and roles a theme yields, as theme slot names
204
+ * (`"accent1"`): the series cycle is accent1..6 in the theme's own order,
205
+ * minus the slots its ground (`bg1`) swallows; `highlight` is the brand accent
206
+ * (`accent1`); `context` the lightest visible neutral and `reference` the next
207
+ * darker one; `positive` / `negative` the visible chromatic accents nearest
208
+ * green and red, else the highlight and the reference. `theme` maps slot
209
+ * names to hex.
210
+ */
211
+ export function chartPaletteFromTheme(theme) {
212
+ const has = (n) => typeof theme?.[n] === "string" && hexToRgb(theme[n]);
213
+ const ground = has("bg1") ? theme.bg1 : "#FFFFFF";
214
+ const visible = (n) => has(n) && (contrastRatio(theme[n], ground) ?? 0) >= CHART_VISIBLE;
215
+ const tone = (n) => hsl(hexToRgb(theme[n]));
216
+ const accents = THEME_ORDER.slice(0, 6).filter(visible);
217
+ const series = accents.length ? accents : ["tx2", "tx1"].filter(visible).slice(0, 1);
218
+ const highlight = visible("accent1") ? "accent1" : series[0] ?? null;
219
+ const lum = (n) => relLuminance(hexToRgb(theme[n]));
220
+ const neutrals = ["bg2", "accent2", "accent3", "accent4", "accent5", "accent6", "tx2"]
221
+ .filter((n) => visible(n) && tone(n).s < CHROMA)
222
+ .sort((a, b) => lum(b) - lum(a));
223
+ const context = neutrals[0] ?? series[1] ?? highlight;
224
+ const reference = neutrals.find((n) => n !== context && n !== "tx2") ?? neutrals.find((n) => n !== context) ?? context;
225
+ // Context marks recede but stay apart: a chart that sets several marks back
226
+ // (the slices beside a highlighted one, x113 QBR 3: three slices in one grey)
227
+ // paints them in the theme's neutrals one by one — the context colour, then
228
+ // the darker neutrals, then the lighter — never the ink (tx1/tx2). A quiet
229
+ // mark only needs to part from the ground (QUIET_VISIBLE), not to carry a
230
+ // series of its own.
231
+ const quietOk = (n) => has(n) && (contrastRatio(theme[n], ground) ?? 0) >= QUIET_VISIBLE && tone(n).s < CHROMA;
232
+ const quietPool = ["bg2", "accent2", "accent3", "accent4", "accent5", "accent6"].filter((n) => n !== context && quietOk(n));
233
+ const quiet = [
234
+ context,
235
+ ...quietPool.filter((n) => lum(n) < lum(context)).sort((a, b) => lum(b) - lum(a)),
236
+ ...quietPool.filter((n) => lum(n) >= lum(context)).sort((a, b) => lum(a) - lum(b)),
237
+ ];
238
+ const chromatic = accents.filter((n) => tone(n).s >= CHROMA && tone(n).l > 0.15 && tone(n).l < 0.9);
239
+ const nearest = (hue, within) => {
240
+ const gap = (n) => { const d = Math.abs(tone(n).h - hue) % 360; return Math.min(d, 360 - d); };
241
+ return chromatic.filter((n) => gap(n) <= within).sort((a, b) => gap(a) - gap(b))[0] ?? null;
242
+ };
243
+ return {
244
+ series,
245
+ roles: {
246
+ highlight,
247
+ context,
248
+ positive: nearest(120, 50) ?? highlight,
249
+ negative: nearest(0, 40) ?? reference,
250
+ reference,
251
+ },
252
+ quiet: quiet.length ? quiet : [context],
253
+ };
254
+ }
255
+
256
+ /**
257
+ * The design's chart palette from the bundle's colour tokens: the
258
+ * `--chart-*` tokens as theme paints (`{ scheme }`, so the chart stays native
259
+ * and follows a theme change; a bundle colour no theme slot holds stays as it
260
+ * is, `{ rgb }`), else the same palette derived from the bundle's `--theme-*`
261
+ * slots. Null when the bundle declares no theme (a reconstruction's literal
262
+ * palette): the master then paints its own series.
263
+ */
264
+ export function chartPalette(tokens) {
265
+ const theme = Object.fromEntries(
266
+ THEME_ORDER.map((n) => [n, tokens.find((t) => t.name === `--theme-${n}`)?.hex]).filter(([, hex]) => hex),
267
+ );
268
+ if (!Object.keys(theme).length) return null;
269
+ const paint = (hex) => {
270
+ const slot = THEME_ORDER.find((n) => theme[n] && theme[n].toUpperCase() === String(hex).toUpperCase());
271
+ return slot ? { scheme: slot } : { rgb: String(hex).replace(/^#/, "").toUpperCase() };
272
+ };
273
+ const token = (name) => tokens.find((t) => t.name === name)?.hex ?? null;
274
+ const declared = [];
275
+ for (let i = 1; token(`--chart-series-${i}`); i++) declared.push(paint(token(`--chart-series-${i}`)));
276
+ const derived = chartPaletteFromTheme(theme);
277
+ const roles = Object.fromEntries(CHART_ROLES.map((r) => {
278
+ const hex = token(`--chart-${r}`);
279
+ return [r, hex ? paint(hex) : derived.roles[r] ? { scheme: derived.roles[r] } : null];
280
+ }));
281
+ const series = declared.length ? declared : derived.series.map((n) => ({ scheme: n }));
282
+ const quietDeclared = [];
283
+ for (let i = 1; token(`--chart-quiet-${i}`); i++) quietDeclared.push(paint(token(`--chart-quiet-${i}`)));
284
+ const quiet = quietDeclared.length ? quietDeclared : (derived.quiet ?? []).map((n) => ({ scheme: n }));
285
+ return series.length ? { series, roles, quiet: quiet.length ? quiet : roles.context ? [roles.context] : [] } : null;
286
+ }
287
+
288
+ // ---- Perceptual colour distance.
289
+ // CIELAB lightness/opponent axes from sRGB (D65).
290
+ export function labOf(rgb) {
291
+ const lin = (v) => { const c = v / 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
292
+ const [R, G, B] = rgb.map(lin);
293
+ const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
294
+ const x = f((R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047);
295
+ const y = f(R * 0.2126 + G * 0.7152 + B * 0.0722);
296
+ const z = f((R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883);
297
+ return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
298
+ }
299
+
300
+ /**
301
+ * CIEDE2000 between two colours: which design colour a mockup colour IS,
302
+ * perceptually — its green is the design's green, its very dark navy is the
303
+ * design's ink, not its bright blue, its pale grey card stays a pale surface.
304
+ * Null when either colour does not parse.
305
+ */
306
+ export function deltaE(hexA, hexB, { kL = 1 } = {}) {
307
+ const A = hexToRgb(hexA), B = hexToRgb(hexB);
308
+ if (!A || !B) return null;
309
+ const [L1, a1, b1] = labOf(A), [L2, a2, b2] = labOf(B);
310
+ const rad = Math.PI / 180;
311
+ const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2), Cb = (C1 + C2) / 2;
312
+ const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
313
+ const a1p = (1 + G) * a1, a2p = (1 + G) * a2;
314
+ const C1p = Math.hypot(a1p, b1), C2p = Math.hypot(a2p, b2);
315
+ const hue = (b, a) => { if (!b && !a) return 0; const t = Math.atan2(b, a) / rad; return t < 0 ? t + 360 : t; };
316
+ const h1p = hue(b1, a1p), h2p = hue(b2, a2p);
317
+ const dLp = L2 - L1, dCp = C2p - C1p;
318
+ let dhp = 0;
319
+ if (C1p * C2p) { dhp = h2p - h1p; if (dhp > 180) dhp -= 360; else if (dhp < -180) dhp += 360; }
320
+ const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * rad);
321
+ const Lbp = (L1 + L2) / 2, Cbp = (C1p + C2p) / 2;
322
+ let hbp = h1p + h2p;
323
+ if (C1p * C2p) hbp = Math.abs(h1p - h2p) > 180 ? (h1p + h2p + (h1p + h2p < 360 ? 360 : -360)) / 2 : (h1p + h2p) / 2;
324
+ const T = 1 - 0.17 * Math.cos((hbp - 30) * rad) + 0.24 * Math.cos(2 * hbp * rad) + 0.32 * Math.cos((3 * hbp + 6) * rad) - 0.2 * Math.cos((4 * hbp - 63) * rad);
325
+ const dth = 30 * Math.exp(-(((hbp - 275) / 25) ** 2));
326
+ const Rc = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
327
+ const Sl = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2), Sc = 1 + 0.045 * Cbp, Sh = 1 + 0.015 * Cbp * T;
328
+ const Rt = -Math.sin(2 * dth * rad) * Rc;
329
+ return Math.sqrt((dLp / (kL * Sl)) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2 + Rt * (dCp / Sc) * (dHp / Sh));
330
+ }
331
+
332
+ /**
333
+ * Every token, nearest first by `deltaE` to an observed colour. Two tokens
334
+ * that paint the same colour tie; `prefer` (a name pattern) breaks the tie
335
+ * toward the role the colour is used in (a white fill is a background, a
336
+ * white text is foreground).
337
+ */
338
+ export function rankTokens(hex, tokens, { prefer = null } = {}) {
339
+ const bias = (t) => (prefer && !prefer.test(t.name) ? 0.5 : 0);
340
+ return tokens.map((t) => ({ t, d: deltaE(hex, t.hex) })).filter((v) => v.d != null).sort((a, b) => a.d + bias(a.t) - (b.d + bias(b.t)));
341
+ }
342
+
343
+ /** CIELAB lightness (0–100) of a hex colour, or null. */
344
+ export function lightnessOf(hex) {
345
+ const rgb = hexToRgb(hex);
346
+ return rgb ? labOf(rgb)[0] : null;
347
+ }