@ponchia/ui 0.11.0 → 0.13.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.
- package/CHANGELOG.md +98 -0
- package/MIGRATIONS.json +24 -0
- package/README.md +5 -5
- package/behaviors/forms.d.ts +1 -1
- package/behaviors/internal.d.ts +1 -1
- package/classes/classes.json +3 -1
- package/classes/index.d.ts +2 -0
- package/classes/index.js +3 -1
- package/classes/vscode.css-custom-data.json +64 -0
- package/css/base.css +2 -2
- package/css/blocknote.css +61 -0
- package/css/clamp.css +2 -2
- package/css/content.css +126 -0
- package/css/dataviz.css +117 -64
- package/css/disclosure.css +10 -10
- package/css/forms.css +8 -8
- package/css/legend.css +1 -2
- package/css/primitives.css +5 -6
- package/css/row.css +2 -2
- package/css/term.css +2 -2
- package/css/textref.css +2 -2
- package/css/toc.css +2 -2
- package/css/tokens.css +43 -0
- package/css/workbench.css +2 -2
- package/dist/bronto.css +1 -1
- package/dist/css/analytical.css +1 -1
- package/dist/css/base.css +1 -1
- package/dist/css/blocknote.css +1 -0
- package/dist/css/clamp.css +1 -1
- package/dist/css/content.css +1 -1
- package/dist/css/dataviz.css +1 -1
- package/dist/css/disclosure.css +1 -1
- package/dist/css/forms.css +1 -1
- package/dist/css/legend.css +1 -1
- package/dist/css/report-kit.css +1 -1
- package/dist/css/row.css +1 -1
- package/dist/css/term.css +1 -1
- package/dist/css/textref.css +1 -1
- package/dist/css/toc.css +1 -1
- package/dist/css/tokens.css +1 -1
- package/dist/css/workbench.css +1 -1
- package/docs/adr/0001-color-system.md +29 -0
- package/docs/architecture.md +1 -1
- package/docs/compositions.md +2 -2
- package/docs/contrast.md +24 -24
- package/docs/frontier-primitives.md +5 -0
- package/docs/interop/blocknote.md +60 -0
- package/docs/mermaid.md +1 -1
- package/docs/migrations/0.11-to-0.12.md +47 -0
- package/docs/package-contract.md +12 -2
- package/docs/reference.md +18 -1
- package/docs/renderer.md +100 -0
- package/docs/reporting.md +9 -9
- package/docs/stability.md +4 -2
- package/docs/theming.md +77 -20
- package/docs/usage.md +12 -8
- package/docs/vega.md +24 -23
- package/llms.txt +8 -4
- package/package.json +24 -12
- package/renderer/index.d.ts +203 -0
- package/renderer/index.d.ts.map +1 -0
- package/renderer/index.js +650 -0
- package/tokens/charts.d.ts +16 -10
- package/tokens/charts.js +65 -49
- package/tokens/charts.json +77 -29
- package/tokens/figma.variables.json +240 -0
- package/tokens/index.d.ts +2 -2
- package/tokens/index.js +30 -2
- package/tokens/index.json +32 -0
- package/tokens/mermaid.js +56 -56
- package/tokens/mermaid.json +56 -56
- package/tokens/resolved.json +17 -1
- package/tokens/tokens.dtcg.json +190 -0
- package/tokens/vega.d.ts +3 -3
- package/tokens/vega.js +111 -72
- 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
|
+
}
|