@ponchia/ui 0.10.0 → 0.12.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 (62) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/MIGRATIONS.json +52 -9
  3. package/README.md +3 -3
  4. package/behaviors/forms.d.ts +1 -1
  5. package/behaviors/internal.d.ts +1 -1
  6. package/classes/classes.json +31 -2
  7. package/classes/index.d.ts +12 -0
  8. package/classes/index.js +13 -0
  9. package/css/annotations.css +2 -2
  10. package/css/app.css +3 -1
  11. package/css/command.css +4 -4
  12. package/css/dataviz.css +117 -64
  13. package/css/discussion.css +150 -0
  14. package/css/figure.css +6 -4
  15. package/css/generated.css +19 -12
  16. package/css/legend.css +14 -7
  17. package/css/report.css +25 -11
  18. package/css/sources.css +1 -1
  19. package/dist/bronto.css +1 -1
  20. package/dist/css/analytical.css +1 -1
  21. package/dist/css/annotations.css +1 -1
  22. package/dist/css/app.css +1 -1
  23. package/dist/css/command.css +1 -1
  24. package/dist/css/dataviz.css +1 -1
  25. package/dist/css/discussion.css +1 -0
  26. package/dist/css/figure.css +1 -1
  27. package/dist/css/generated.css +1 -1
  28. package/dist/css/legend.css +1 -1
  29. package/dist/css/report-kit.css +1 -1
  30. package/dist/css/report.css +1 -1
  31. package/dist/css/sources.css +1 -1
  32. package/docs/adr/0001-color-system.md +29 -0
  33. package/docs/architecture.md +1 -1
  34. package/docs/compositions.md +23 -2
  35. package/docs/contrast.md +24 -24
  36. package/docs/discussion.md +64 -0
  37. package/docs/figure.md +10 -1
  38. package/docs/frontier-primitives.md +5 -0
  39. package/docs/mermaid.md +1 -1
  40. package/docs/migrations/0.10-to-0.11.md +61 -0
  41. package/docs/migrations/0.11-to-0.12.md +47 -0
  42. package/docs/package-contract.md +14 -2
  43. package/docs/reference.md +18 -1
  44. package/docs/renderer.md +100 -0
  45. package/docs/reporting.md +9 -9
  46. package/docs/stability.md +4 -2
  47. package/docs/theming.md +29 -19
  48. package/docs/usage.md +12 -8
  49. package/docs/vega.md +24 -23
  50. package/llms.txt +8 -4
  51. package/package.json +18 -3
  52. package/renderer/index.d.ts +203 -0
  53. package/renderer/index.d.ts.map +1 -0
  54. package/renderer/index.js +650 -0
  55. package/tokens/charts.d.ts +16 -10
  56. package/tokens/charts.js +65 -49
  57. package/tokens/charts.json +77 -29
  58. package/tokens/mermaid.js +56 -56
  59. package/tokens/mermaid.json +56 -56
  60. package/tokens/vega.d.ts +3 -3
  61. package/tokens/vega.js +111 -72
  62. package/tokens/vega.json +198 -126
@@ -0,0 +1,650 @@
1
+ /**
2
+ * @ponchia/ui/renderer — the live theme, resolved for renderers that cannot
3
+ * read CSS.
4
+ *
5
+ * A canvas, WebGL or SVG-baking renderer (Vega, xterm, a graph or map engine,
6
+ * a 3D scene) is handed literal colours, so a page that switches theme, skin,
7
+ * contrast or surface at runtime has to resolve its tokens before every paint
8
+ * that matters. The static exports (`charts.json`, `tokens/vega.js`) cannot
9
+ * follow a skin or the OLED preset; this module reads the page instead.
10
+ *
11
+ * - `readTokens()` resolves the bronto roles a renderer needs to sRGB literals.
12
+ * - `observeTokens()` calls back when anything that moves them changes.
13
+ * - `vegaConfig()` and `xtermTheme()` map those tokens to two renderers'
14
+ * configuration shapes. They are pure: no renderer is imported or run.
15
+ * - `parseColor()` / `formatColor()` / `resolveColor()` are the conversions
16
+ * underneath, for any other target.
17
+ *
18
+ * SSR-safe: nothing touches the DOM until a function that needs it is called.
19
+ */
20
+ import { charts } from '../tokens/charts.js';
21
+ import { cssVars } from '../tokens/index.js';
22
+
23
+ /**
24
+ * A colour as sRGB channels (0–255) and alpha (0–1).
25
+ * @typedef {{ r: number, g: number, b: number, alpha: number }} Rgba
26
+ */
27
+
28
+ /**
29
+ * The bronto roles a renderer draws with, resolved to literals: opaque colours
30
+ * as `#rrggbb`, translucent ones as `rgba(r, g, b, a)`.
31
+ * @typedef {object} RendererTokens
32
+ * @property {'light' | 'dark'} scheme The resolved scheme of the page background.
33
+ * @property {string} bg Page background (`--bg`).
34
+ * @property {string} bgElevated A lifted page background (`--bg-elevated`).
35
+ * @property {string} panel The surface a renderer usually draws on (`--panel`).
36
+ * @property {string} panelStrong A raised surface (`--panel-strong`).
37
+ * @property {string} text Primary ink (`--text`).
38
+ * @property {string} textSoft Secondary ink (`--text-soft`).
39
+ * @property {string} textDim Tertiary ink (`--text-dim`).
40
+ * @property {string} line Hairline and grid (`--line`).
41
+ * @property {string} lineStrong Axis, domain and rule (`--line-strong`).
42
+ * @property {string} accent The one accent (`--accent`).
43
+ * @property {string} accentText Accent as text on the page (`--accent-text`).
44
+ * @property {string} onAccent Ink on an accent fill (`--on-accent`).
45
+ * @property {string} focus Focus ring (`--focus-ring`).
46
+ * @property {string} selection A translucent selection wash over `panel`.
47
+ * @property {string} success Status: success.
48
+ * @property {string} warning Status: warning.
49
+ * @property {string} danger Status: danger.
50
+ * @property {string} info Status: info.
51
+ * @property {string} sans The sans font stack.
52
+ * @property {string} mono The monospace font stack.
53
+ * @property {string[]} categorical Eight categorical hues, fixed order.
54
+ * @property {string[]} categoricalTint Each hue's wash over `panel`.
55
+ * @property {string[]} categoricalInk Each hue as text on `panel` and its tint.
56
+ * @property {string[]} sequential One-hue ramp; step 1 nearest the surface.
57
+ * @property {string[]} diverging Negative … neutral … positive ramp.
58
+ */
59
+
60
+ const CATEGORICAL = 8;
61
+
62
+ // --- Parsing --------------------------------------------------------------------
63
+
64
+ const clamp01 = (x) => Math.min(1, Math.max(0, x));
65
+ const toLinear = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
66
+ const fromLinear = (x) => (x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055);
67
+
68
+ /** @param {number[]} m @param {number[]} v */
69
+ const mul = (m, v) =>
70
+ [0, 1, 2].map((i) => m[i * 3] * v[0] + m[i * 3 + 1] * v[1] + m[i * 3 + 2] * v[2]);
71
+
72
+ // CSS Color 4 matrices.
73
+ const XYZ65_TO_LSRGB = [
74
+ 3.2409699419045226, -1.537383177570094, -0.4986107602930034, -0.9692436362808796,
75
+ 1.8759675015077202, 0.04155505740717559, 0.05563007969699366, -0.20397695888897652,
76
+ 1.0569715142428786,
77
+ ];
78
+ const LP3_TO_XYZ65 = [
79
+ 0.4865709486482162, 0.26566769316909306, 0.1982172852343625, 0.2289745640697488,
80
+ 0.6917385218365064, 0.079286914093745, 0, 0.04511338185890264, 1.043944368900976,
81
+ ];
82
+ const XYZ50_TO_XYZ65 = [
83
+ 0.955473421488075, -0.02309845494876471, 0.06325924320057072, -0.0283697093338637,
84
+ 1.0099953980813041, 0.021041441191917323, 0.012314014864481998, -0.020507649298898964,
85
+ 1.330365926242124,
86
+ ];
87
+ const LSRGB_TO_LMS = [
88
+ 0.4122214708, 0.5363325363, 0.0514459929, 0.2119034982, 0.6806995451, 0.1073969566, 0.0883024619,
89
+ 0.2817188376, 0.6299787005,
90
+ ];
91
+ const LMS_TO_OKLAB = [
92
+ 0.2104542553, 0.793617785, -0.0040720468, 1.9779984951, -2.428592205, 0.4505937099, 0.0259040371,
93
+ 0.7827717662, -0.808675766,
94
+ ];
95
+
96
+ /** @param {number} L @param {number} a @param {number} b */
97
+ function oklabToLinear(L, a, b) {
98
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
99
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
100
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
101
+ return [
102
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
103
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
104
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
105
+ ];
106
+ }
107
+
108
+ /** @param {number} L @param {number} a @param {number} b CIE Lab (D50). */
109
+ function labToLinear(L, a, b) {
110
+ const k = 24389 / 27;
111
+ const e = 216 / 24389;
112
+ const fy = (L + 16) / 116;
113
+ const fx = fy + a / 500;
114
+ const fz = fy - b / 200;
115
+ const xyz50 = [
116
+ ((fx ** 3 > e ? fx ** 3 : (116 * fx - 16) / k) * 0.3457) / 0.3585,
117
+ L > k * e ? fy ** 3 : L / k,
118
+ ((fz ** 3 > e ? fz ** 3 : (116 * fz - 16) / k) * (1 - 0.3457 - 0.3585)) / 0.3585,
119
+ ];
120
+ return mul(XYZ65_TO_LSRGB, mul(XYZ50_TO_XYZ65, xyz50));
121
+ }
122
+
123
+ /** @param {number[]} linear @param {number} alpha @returns {Rgba} */
124
+ const fromLinearRgb = (linear, alpha) => {
125
+ const [r, g, b] = linear.map((x) => Math.round(clamp01(fromLinear(x)) * 255));
126
+ return { r, g, b, alpha: clamp01(alpha) };
127
+ };
128
+
129
+ /**
130
+ * One channel token: `none`, a number, a percentage (scaled by `pct`), or an
131
+ * angle (degrees, `rad`, `grad`, `turn`).
132
+ * @param {string} token @param {number} pct
133
+ */
134
+ function channel(token, pct = 1) {
135
+ if (token === 'none') return 0;
136
+ const n = Number.parseFloat(token);
137
+ if (!Number.isFinite(n)) return Number.NaN;
138
+ if (token.endsWith('%')) return (n / 100) * pct;
139
+ if (token.endsWith('rad')) return (n * 180) / Math.PI;
140
+ if (token.endsWith('grad')) return n * 0.9;
141
+ if (token.endsWith('turn')) return n * 360;
142
+ return n;
143
+ }
144
+
145
+ /** Channels and alpha; a comma list (legacy `rgba()`/`hsla()`) carries alpha
146
+ * as its fourth item. @param {string} body */
147
+ function split(body) {
148
+ const [main, alpha] = body.split('/');
149
+ const legacy = main.includes(',');
150
+ const parts = main.replace(/,/g, ' ').trim().split(/\s+/);
151
+ const legacyAlpha = legacy && alpha === undefined && parts.length === 4 ? parts.pop() : undefined;
152
+ const a = alpha?.trim() ?? legacyAlpha;
153
+ return { parts, alpha: a === undefined ? 1 : channel(a, 1) };
154
+ }
155
+
156
+ /** @param {string} h @returns {Rgba | null} */
157
+ function parseHex(h) {
158
+ const full = h.length <= 4 ? [...h].map((c) => c + c).join('') : h;
159
+ if (full.length !== 6 && full.length !== 8) return null;
160
+ const n = (i) => Number.parseInt(full.slice(i, i + 2), 16);
161
+ return { r: n(0), g: n(2), b: n(4), alpha: full.length === 8 ? n(6) / 255 : 1 };
162
+ }
163
+
164
+ /** @param {number} h @param {number} sat @param {number} light @param {number} alpha */
165
+ function hslToRgba(h, sat, light, alpha) {
166
+ const f = (n) => {
167
+ const k = (n + h / 30) % 12;
168
+ return light - sat * Math.min(light, 1 - light) * Math.max(-1, Math.min(k - 3, 9 - k, 1));
169
+ };
170
+ return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255), alpha };
171
+ }
172
+
173
+ /** Polar (L, C, H°) to rectangular (L, a, b). @param {string[]} p @param {number} lScale @param {number} cScale */
174
+ const polar = (p, lScale, cScale) => {
175
+ const C = channel(p[1], cScale);
176
+ const H = (channel(p[2]) * Math.PI) / 180;
177
+ return [channel(p[0], lScale), C * Math.cos(H), C * Math.sin(H)];
178
+ };
179
+
180
+ /** `color()` spaces, each to linear sRGB. @type {Record<string, (v: number[]) => number[]>} */
181
+ const COLOR_SPACES = {
182
+ srgb: (v) => v.map(toLinear),
183
+ 'srgb-linear': (v) => v,
184
+ 'display-p3': (v) => mul(XYZ65_TO_LSRGB, mul(LP3_TO_XYZ65, v.map(toLinear))),
185
+ xyz: (v) => mul(XYZ65_TO_LSRGB, v),
186
+ 'xyz-d65': (v) => mul(XYZ65_TO_LSRGB, v),
187
+ 'xyz-d50': (v) => mul(XYZ65_TO_LSRGB, mul(XYZ50_TO_XYZ65, v)),
188
+ };
189
+
190
+ /** Functional notations. @type {Record<string, (p: string[], alpha: number) => Rgba | null>} */
191
+ const FUNCTIONS = {
192
+ rgb: (p, alpha) => {
193
+ const [r, g, b] = p.map((x) => Math.round(channel(x, 255)));
194
+ return { r, g, b, alpha };
195
+ },
196
+ hsl: (p, alpha) =>
197
+ hslToRgba(channel(p[0]), channel(p[1], 100) / 100, channel(p[2], 100) / 100, alpha),
198
+ oklab: (p, alpha) =>
199
+ fromLinearRgb(oklabToLinear(channel(p[0], 1), channel(p[1], 0.4), channel(p[2], 0.4)), alpha),
200
+ oklch: (p, alpha) => {
201
+ const [L, a, b] = polar(p, 1, 0.4);
202
+ return fromLinearRgb(oklabToLinear(L, a, b), alpha);
203
+ },
204
+ lab: (p, alpha) =>
205
+ fromLinearRgb(labToLinear(channel(p[0], 100), channel(p[1], 125), channel(p[2], 125)), alpha),
206
+ lch: (p, alpha) => {
207
+ const [L, a, b] = polar(p, 100, 150);
208
+ return fromLinearRgb(labToLinear(L, a, b), alpha);
209
+ },
210
+ color: ([space, ...rest], alpha) => {
211
+ const toLinearRgb = COLOR_SPACES[space];
212
+ return toLinearRgb
213
+ ? fromLinearRgb(toLinearRgb(rest.slice(0, 3).map((x) => channel(x, 1))), alpha)
214
+ : null;
215
+ },
216
+ };
217
+ FUNCTIONS.rgba = FUNCTIONS.rgb;
218
+ FUNCTIONS.hsla = FUNCTIONS.hsl;
219
+
220
+ /** @param {Rgba | null} c @returns {Rgba | null} */
221
+ function finite(c) {
222
+ if (!c || ![c.r, c.g, c.b, c.alpha].every(Number.isFinite)) return null;
223
+ const byte = (x) => Math.min(255, Math.max(0, x));
224
+ return { r: byte(c.r), g: byte(c.g), b: byte(c.b), alpha: clamp01(c.alpha) };
225
+ }
226
+
227
+ /**
228
+ * Parse a resolved CSS colour to sRGB, gamut-clipped: hex, `rgb()`, `hsl()`,
229
+ * `oklch()`, `oklab()`, `lab()`, `lch()`, `color(srgb | srgb-linear |
230
+ * display-p3 | xyz | xyz-d65 | xyz-d50 …)` and `transparent`. Anything else —
231
+ * `var()`, `color-mix()`, `light-dark()`, a named colour — returns null; use
232
+ * `resolveColor()` to have the browser compute it first.
233
+ * @param {string} value
234
+ * @returns {Rgba | null}
235
+ */
236
+ export function parseColor(value) {
237
+ const s = String(value ?? '')
238
+ .trim()
239
+ .toLowerCase();
240
+ if (s === 'transparent') return { r: 0, g: 0, b: 0, alpha: 0 };
241
+ const hex = /^#([0-9a-f]{3,8})$/.exec(s);
242
+ if (hex) return parseHex(hex[1]);
243
+ const fn = /^([a-z-]+)\((.*)\)$/.exec(s);
244
+ const parse = fn && Object.hasOwn(FUNCTIONS, fn[1]) ? FUNCTIONS[fn[1]] : null;
245
+ if (!parse) return null;
246
+ const { parts, alpha } = split(fn[2]);
247
+ return finite(parse(parts, alpha));
248
+ }
249
+
250
+ /**
251
+ * Serialize for a renderer: `#rrggbb` when opaque, `rgba(r, g, b, a)` when
252
+ * not — the two forms canvas, SVG, d3-color, xterm, three.js and MapLibre
253
+ * all accept.
254
+ * @param {Rgba} color
255
+ */
256
+ export function formatColor({ r, g, b, alpha }) {
257
+ if (alpha >= 1)
258
+ return `#${[r, g, b].map((c) => Math.round(c).toString(16).padStart(2, '0')).join('')}`;
259
+ return `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${Math.round(alpha * 1000) / 1000})`;
260
+ }
261
+
262
+ /**
263
+ * Resolve any CSS colour expression to a renderer literal, using the browser
264
+ * for what `parseColor()` cannot compute (`var()`, `color-mix()`,
265
+ * `light-dark()`, named colours). Returns null for an invalid colour or when no
266
+ * document is available.
267
+ * @param {string} value
268
+ * @param {{ element?: Element }} [options] Context for `var()` and `light-dark()`.
269
+ */
270
+ export function resolveColor(value, options = {}) {
271
+ const direct = parseColor(value);
272
+ if (direct) return formatColor(direct);
273
+ const doc = options.element?.ownerDocument ?? globalThis.document;
274
+ if (!doc?.documentElement) return null;
275
+ const host = options.element ?? doc.documentElement;
276
+ const probe = doc.createElement('span');
277
+ probe.style.color = value;
278
+ if (!probe.style.color) return null;
279
+ probe.style.position = 'fixed';
280
+ probe.style.visibility = 'hidden';
281
+ probe.style.pointerEvents = 'none';
282
+ probe.style.colorScheme = getComputedStyle(host).colorScheme;
283
+ // var() resolves against the probe's own ancestors, so it sits in the host.
284
+ host.append(probe);
285
+ const computed = getComputedStyle(probe).color;
286
+ probe.remove();
287
+ const parsed = parseColor(computed);
288
+ return parsed ? formatColor(parsed) : null;
289
+ }
290
+
291
+ // --- Reading the page -------------------------------------------------------------
292
+
293
+ /** @param {Rgba} c */
294
+ const luminance = ({ r, g, b }) =>
295
+ 0.2126 * toLinear(r / 255) + 0.7152 * toLinear(g / 255) + 0.0722 * toLinear(b / 255);
296
+
297
+ /** @param {Rgba} c */
298
+ function toOklch({ r, g, b }) {
299
+ const lms = mul(
300
+ LSRGB_TO_LMS,
301
+ [r, g, b].map((x) => toLinear(x / 255)),
302
+ ).map(Math.cbrt);
303
+ const [L, A, B] = mul(LMS_TO_OKLAB, lms);
304
+ return { L, C: Math.hypot(A, B), H: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
305
+ }
306
+
307
+ /** `color-mix(in oklch, a share, b)` for opaque colours. @param {Rgba} a @param {Rgba} b @param {number} share */
308
+ function mixOklch(a, b, share) {
309
+ const x = toOklch(a);
310
+ const y = toOklch(b);
311
+ const hueOf = (c, other) => (c.C < 0.02 ? other.H : c.H);
312
+ const hx = hueOf(x, y);
313
+ const hy = hueOf(y, x);
314
+ let d = hy - hx;
315
+ if (d > 180) d -= 360;
316
+ else if (d < -180) d += 360;
317
+ const L = x.L * share + y.L * (1 - share);
318
+ const C = x.C * share + y.C * (1 - share);
319
+ const H = ((hx + d * (1 - share)) * Math.PI) / 180;
320
+ return fromLinearRgb(oklabToLinear(L, C * Math.cos(H), C * Math.sin(H)), 1);
321
+ }
322
+
323
+ /**
324
+ * Resolve the page's bronto tokens for a renderer. Reads custom properties as
325
+ * computed on `element` (default: the root), so a subtree that re-points them
326
+ * is honoured. When the categorical leaf (`css/dataviz.css`) is not loaded,
327
+ * the categorical, sequential and diverging sets fall back to the packaged
328
+ * palette for the resolved scheme. Without a DOM it returns the packaged light
329
+ * (or `options.scheme`) values.
330
+ * @param {Element} [element]
331
+ * @param {{ scheme?: 'light' | 'dark' }} [options] Force the scheme instead of reading the background.
332
+ * @returns {RendererTokens}
333
+ */
334
+ export function readTokens(element, options = {}) {
335
+ const doc = element?.ownerDocument ?? globalThis.document;
336
+ const target = element ?? doc?.documentElement;
337
+ const style = target && typeof getComputedStyle === 'function' ? getComputedStyle(target) : null;
338
+ const raw = (name) => style?.getPropertyValue(name).trim() ?? '';
339
+ const color = (name, fallback) => {
340
+ const value = raw(name);
341
+ return (value && resolveColor(value, { element: target })) || fallback;
342
+ };
343
+ const bgValue = color('--bg', '');
344
+ const scheme =
345
+ options.scheme ??
346
+ (bgValue
347
+ ? luminance(/** @type {Rgba} */ (parseColor(bgValue))) < 0.2
348
+ ? 'dark'
349
+ : 'light'
350
+ : 'light');
351
+ // The packaged value for the scheme, where the token source holds a literal.
352
+ const packaged = (name) => {
353
+ const parsed = parseColor(cssVars[scheme]?.[name] ?? '');
354
+ return parsed ? formatColor(parsed) : '';
355
+ };
356
+ const token = (name, fallback) => color(name, packaged(name) || fallback);
357
+ const bg = bgValue || packaged('--bg');
358
+ const panel = token('--panel', bg);
359
+ const text = token('--text', scheme === 'dark' ? '#ffffff' : '#000000');
360
+ const accent = token('--accent', text);
361
+ const line = token('--line', text);
362
+ const list = (prefix, count, fallback) => {
363
+ const values = Array.from({ length: count }, (_, i) => color(`${prefix}${i + 1}`, ''));
364
+ // A partial set would silently mix the page's hues with the packaged ones.
365
+ return values.every(Boolean)
366
+ ? values
367
+ : fallback.map((v) => formatColor(/** @type {Rgba} */ (parseColor(v))));
368
+ };
369
+ const palette = charts[scheme];
370
+ const categorical = list('--cat-', CATEGORICAL, palette.categorical);
371
+ const panelRgba = /** @type {Rgba} */ (parseColor(panel));
372
+ const tints = categorical.map((hue, i) => {
373
+ const live = color(`--cat-${i + 1}-tint`, '');
374
+ return live || formatColor(mixOklch(/** @type {Rgba} */ (parseColor(hue)), panelRgba, 0.16));
375
+ });
376
+ const inks = categorical.map((hue, i) => color(`--cat-${i + 1}-ink`, '') || hue);
377
+ const sequentialCount = Math.max(palette.sequential.length, countDeclared(raw, '--chart-seq-'));
378
+ const divergingCount = Math.max(palette.diverging.length, countDeclared(raw, '--chart-div-'));
379
+ return {
380
+ scheme,
381
+ bg,
382
+ bgElevated: token('--bg-elevated', bg),
383
+ panel,
384
+ panelStrong: token('--panel-strong', panel),
385
+ text,
386
+ textSoft: token('--text-soft', text),
387
+ textDim: token('--text-dim', text),
388
+ line,
389
+ lineStrong: token('--line-strong', line),
390
+ accent,
391
+ accentText: token('--accent-text', accent),
392
+ onAccent: token('--on-accent', scheme === 'dark' ? '#000000' : '#ffffff'),
393
+ focus: token('--focus-ring', accent),
394
+ selection:
395
+ resolveColor(`color-mix(in srgb, ${accent} 27%, transparent)`, { element: target }) ??
396
+ formatColor({ .../** @type {Rgba} */ (parseColor(accent)), alpha: 0.27 }),
397
+ success: token('--success', accent),
398
+ warning: token('--warning', accent),
399
+ danger: token('--danger', accent),
400
+ info: token('--info', accent),
401
+ sans: raw('--sans') || cssVars.global['--sans'],
402
+ mono: raw('--mono') || cssVars.global['--mono'],
403
+ categorical,
404
+ categoricalTint: tints,
405
+ categoricalInk: inks,
406
+ sequential: list('--chart-seq-', sequentialCount, palette.sequential),
407
+ diverging: list('--chart-div-', divergingCount, palette.diverging),
408
+ };
409
+ }
410
+
411
+ /** @param {(name: string) => string} raw @param {string} prefix */
412
+ function countDeclared(raw, prefix) {
413
+ let n = 0;
414
+ while (n < 16 && raw(`${prefix}${n + 1}`)) n += 1;
415
+ return n;
416
+ }
417
+
418
+ const FINGERPRINTED = [
419
+ '--bg',
420
+ '--bg-elevated',
421
+ '--panel',
422
+ '--panel-strong',
423
+ '--text',
424
+ '--text-soft',
425
+ '--text-dim',
426
+ '--line',
427
+ '--line-strong',
428
+ '--accent',
429
+ '--accent-text',
430
+ '--on-accent',
431
+ '--focus-ring',
432
+ '--success',
433
+ '--warning',
434
+ '--danger',
435
+ '--info',
436
+ '--sans',
437
+ '--mono',
438
+ ];
439
+
440
+ /**
441
+ * The unresolved strings `readTokens` starts from. Equal strings resolve to
442
+ * equal tokens, so comparing them costs no probe element.
443
+ * @param {Element} target
444
+ */
445
+ function fingerprint(target) {
446
+ const style = getComputedStyle(target);
447
+ const raw = (name) => style.getPropertyValue(name).trim();
448
+ const series = (prefix) =>
449
+ Array.from({ length: countDeclared(raw, prefix) }, (_, i) => raw(`${prefix}${i + 1}`));
450
+ return [
451
+ ...FINGERPRINTED.map(raw),
452
+ ...series('--cat-'),
453
+ ...Array.from(
454
+ { length: CATEGORICAL },
455
+ (_, i) => `${raw(`--cat-${i + 1}-tint`)}|${raw(`--cat-${i + 1}-ink`)}`,
456
+ ),
457
+ ...series('--chart-seq-'),
458
+ ...series('--chart-div-'),
459
+ ].join('\n');
460
+ }
461
+
462
+ /**
463
+ * Call `callback` with fresh tokens whenever they change: after an attribute
464
+ * on the root changes (`data-theme`, `data-bronto-skin`, `data-contrast`,
465
+ * `data-surface`, `data-density`, `class`, `style`) and a token's value moved
466
+ * with it, or when the system colour scheme or contrast preference changes.
467
+ * A host that writes unrelated inline styles on the root every frame costs one
468
+ * computed-style read per frame, not a re-resolution. Calls are coalesced to
469
+ * one per animation frame. Returns a function that stops observing.
470
+ * @param {(tokens: RendererTokens) => void} callback
471
+ * @param {{ element?: Element, signal?: AbortSignal }} [options]
472
+ * @returns {() => void}
473
+ */
474
+ export function observeTokens(callback, options = {}) {
475
+ const doc = options.element?.ownerDocument ?? globalThis.document;
476
+ if (!doc?.documentElement) return () => {};
477
+ const view = doc.defaultView ?? globalThis;
478
+ const target = options.element ?? doc.documentElement;
479
+ let seen = fingerprint(target);
480
+ let frame = 0;
481
+ let forced = false;
482
+ let stopped = false;
483
+ const schedule = (force) => {
484
+ if (stopped) return;
485
+ forced ||= force;
486
+ if (frame) return;
487
+ const run = () => {
488
+ frame = 0;
489
+ if (stopped) return;
490
+ const next = fingerprint(target);
491
+ // A media change can move a computed colour under an unchanged string.
492
+ if (next === seen && !forced) return;
493
+ seen = next;
494
+ forced = false;
495
+ callback(readTokens(options.element));
496
+ };
497
+ frame = view.requestAnimationFrame ? view.requestAnimationFrame(run) : (setTimeout(run, 16), 1);
498
+ };
499
+ const observer = new MutationObserver(() => schedule(false));
500
+ observer.observe(doc.documentElement, {
501
+ attributes: true,
502
+ attributeFilter: [
503
+ 'data-theme',
504
+ 'data-bronto-skin',
505
+ 'data-contrast',
506
+ 'data-surface',
507
+ 'data-density',
508
+ 'class',
509
+ 'style',
510
+ ],
511
+ });
512
+ const queries = ['(prefers-color-scheme: dark)', '(prefers-contrast: more)']
513
+ .map((q) => view.matchMedia?.(q))
514
+ .filter(Boolean);
515
+ const media = () => schedule(true);
516
+ for (const q of queries) q.addEventListener('change', media);
517
+ const stop = () => {
518
+ stopped = true;
519
+ observer.disconnect();
520
+ for (const q of queries) q.removeEventListener('change', media);
521
+ if (frame && view.cancelAnimationFrame) view.cancelAnimationFrame(frame);
522
+ };
523
+ options.signal?.addEventListener('abort', stop, { once: true });
524
+ return stop;
525
+ }
526
+
527
+ // --- Renderer mappings ------------------------------------------------------------
528
+
529
+ /**
530
+ * A Vega-Lite (default) or Vega `config` from resolved tokens: quiet chrome in
531
+ * the bronto inks, the categorical palette as `range.category` (a single
532
+ * series takes its first hue), the sequential ramp for ordinal/ramp/heatmap
533
+ * scales and the diverging ramp for diverging ones. A spec's own `config`
534
+ * still wins where Vega merges it over this one.
535
+ * @param {RendererTokens} tokens
536
+ * @param {{ mode?: 'vega-lite' | 'vega', narrow?: boolean, background?: string }} [options]
537
+ * `narrow` moves the legend under the plot and thins ticks for a small
538
+ * container; `background` defaults to transparent so the host surface shows.
539
+ * @returns {Record<string, any>}
540
+ */
541
+ export function vegaConfig(tokens, options = {}) {
542
+ const { mode = 'vega-lite', narrow = false, background = 'transparent' } = options;
543
+ const font = tokens.sans;
544
+ const shared = {
545
+ background,
546
+ font,
547
+ range: {
548
+ category: [...tokens.categorical],
549
+ ordinal: [...tokens.sequential],
550
+ ramp: [...tokens.sequential],
551
+ heatmap: [...tokens.sequential],
552
+ diverging: [...tokens.diverging],
553
+ },
554
+ axis: {
555
+ domainColor: tokens.lineStrong,
556
+ gridColor: tokens.line,
557
+ tickColor: tokens.lineStrong,
558
+ labelColor: tokens.textSoft,
559
+ labelFont: font,
560
+ labelFontSize: 12,
561
+ titleColor: tokens.text,
562
+ titleFont: font,
563
+ titleFontSize: 13,
564
+ titleFontWeight: 600,
565
+ ...(narrow ? { tickCount: 4 } : {}),
566
+ },
567
+ legend: {
568
+ labelColor: tokens.textSoft,
569
+ labelFont: font,
570
+ labelFontSize: 12,
571
+ titleColor: tokens.text,
572
+ titleFont: font,
573
+ titleFontSize: 13,
574
+ titleFontWeight: 600,
575
+ symbolType: 'circle',
576
+ symbolSize: 100,
577
+ ...(narrow ? { orient: 'bottom', direction: 'horizontal', columns: 2 } : {}),
578
+ },
579
+ header: {
580
+ labelColor: tokens.textSoft,
581
+ labelFont: font,
582
+ titleColor: tokens.text,
583
+ titleFont: font,
584
+ },
585
+ title: {
586
+ color: tokens.text,
587
+ font,
588
+ fontSize: 15,
589
+ fontWeight: 600,
590
+ subtitleColor: tokens.textDim,
591
+ subtitleFont: font,
592
+ subtitleFontSize: 13,
593
+ ...(narrow ? { limit: 280 } : {}),
594
+ },
595
+ };
596
+ if (mode === 'vega')
597
+ // Raw grammars style their own marks; these are the defaults they inherit.
598
+ return { ...shared, text: { fill: tokens.text, font }, rule: { stroke: tokens.lineStrong } };
599
+ return {
600
+ ...shared,
601
+ mark: { color: tokens.categorical[0] },
602
+ view: { stroke: null },
603
+ // Discrete x labels read horizontally; overlapping ones are dropped, not rotated.
604
+ axisXDiscrete: { labelAngle: 0, labelOverlap: 'greedy', labelLimit: 120 },
605
+ // A Vega-Lite text mark is coloured; its fill would outrank a colour encoding.
606
+ text: { color: tokens.text, font },
607
+ rule: { color: tokens.lineStrong },
608
+ line: { strokeWidth: 2 },
609
+ point: { size: 64, filled: true },
610
+ bar: { cornerRadiusEnd: 4 },
611
+ // Adjacent fills keep a surface-coloured gap between them.
612
+ rect: { stroke: tokens.panel, strokeWidth: 2 },
613
+ arc: { stroke: tokens.panel, strokeWidth: 2 },
614
+ area: { stroke: tokens.panel, strokeWidth: 1 },
615
+ };
616
+ }
617
+
618
+ /**
619
+ * An xterm.js `ITheme` from resolved tokens: page ink on the page background,
620
+ * the accent as the cursor, status colours for red/green/yellow/blue and the
621
+ * categorical magenta and aqua for magenta/cyan, so all six ANSI hues differ.
622
+ * @param {RendererTokens} tokens
623
+ * @returns {Record<string, string>}
624
+ */
625
+ export function xtermTheme(tokens) {
626
+ const dark = tokens.scheme === 'dark';
627
+ return {
628
+ background: tokens.bg,
629
+ foreground: tokens.text,
630
+ cursor: tokens.accent,
631
+ cursorAccent: tokens.bg,
632
+ selectionBackground: tokens.selection,
633
+ black: dark ? tokens.textDim : tokens.text,
634
+ brightBlack: dark ? tokens.textSoft : tokens.textDim,
635
+ red: tokens.danger,
636
+ brightRed: tokens.danger,
637
+ green: tokens.success,
638
+ brightGreen: tokens.success,
639
+ yellow: tokens.warning,
640
+ brightYellow: tokens.warning,
641
+ blue: tokens.info,
642
+ brightBlue: tokens.info,
643
+ magenta: tokens.categoricalInk[4],
644
+ brightMagenta: tokens.categoricalInk[4],
645
+ cyan: tokens.categoricalInk[2],
646
+ brightCyan: tokens.categoricalInk[2],
647
+ white: tokens.textSoft,
648
+ brightWhite: tokens.text,
649
+ };
650
+ }