@labpics/colors 0.1.0 → 0.2.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/effective-bg.js CHANGED
@@ -1,255 +1,255 @@
1
- // Effective background resolution — zero dependencies.
2
- //
3
- // `labcolors` resolves roles against a *solid* background. A real UI surface is
4
- // often translucent (a panel at `rgba(…, .8)` over its parents) or has no
5
- // background of its own (inheriting whatever is behind it). To resolve such a
6
- // surface honestly you need the **effective** background: the opaque colour a
7
- // viewer actually sees behind the element's own content.
8
- //
9
- // This module computes it by walking the ancestor chain and **alpha-compositing**
10
- // each element's `background-color` (front-to-back) until the stack is opaque,
11
- // over an opaque fallback (white by default). It is the bridge from the DOM's
12
- // layered, translucent reality to the single solid hex the WASM core consumes.
13
- //
14
- // HONEST LIMIT: this composites solid/translucent `background-color` layers only.
15
- // It does NOT sample `background-image`s, gradients, blurred backdrops, video, or
16
- // content showing through — those have no single colour to read from computed
17
- // style. For those, the caller supplies the effective background explicitly (the
18
- // `background` option of `watchTheme`, or a sampled average). What it does cover —
19
- // translucent panels over solid parents — is the common case and is composited
20
- // *correctly* (true source-over alpha), not approximated.
21
-
22
- /** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */
23
-
24
- /**
25
- * Parse a CSS colour string into `[r, g, b, a]`, or `null` if unrecognised.
26
- *
27
- * Handles the forms computed style actually yields (`rgb(r, g, b)`,
28
- * `rgba(r, g, b, a)`, the modern `rgb(r g b / a)`) plus `#rgb`/`#rrggbb` and the
29
- * `transparent` keyword. Unknown keywords return `null` (treated as "no layer").
30
- *
31
- * @param {string} css
32
- * @returns {Rgba | null}
33
- */
34
- export function parseCssColor(css) {
35
- if (typeof css !== "string") return null;
36
- const s = css.trim().toLowerCase();
37
- if (s === "transparent") return [0, 0, 0, 0];
38
-
39
- if (s[0] === "#") {
40
- const h = s.slice(1);
41
- if (h.length === 3 || h.length === 4) {
42
- const r = parseInt(h[0] + h[0], 16);
43
- const g = parseInt(h[1] + h[1], 16);
44
- const b = parseInt(h[2] + h[2], 16);
45
- const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
46
- return [r, g, b, a].some(Number.isNaN) ? null : [r, g, b, a];
47
- }
48
- if (h.length === 6 || h.length === 8) {
49
- const r = parseInt(h.slice(0, 2), 16);
50
- const g = parseInt(h.slice(2, 4), 16);
51
- const b = parseInt(h.slice(4, 6), 16);
52
- const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
53
- return [r, g, b, a].some(Number.isNaN) ? null : [r, g, b, a];
54
- }
55
- return null;
56
- }
57
-
58
- const m = s.match(/^rgba?\(([^)]+)\)$/);
59
- if (!m) return null;
60
- // Split on commas or whitespace and an optional "/" alpha separator.
61
- const parts = m[1].split(/[,\s/]+/).filter((p) => p.length > 0);
62
- if (parts.length < 3) return null;
63
- const chan = (p) => (p.endsWith("%") ? (parseFloat(p) / 100) * 255 : parseFloat(p));
64
- const r = chan(parts[0]);
65
- const g = chan(parts[1]);
66
- const b = chan(parts[2]);
67
- const a = parts.length >= 4 ? (parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3])) : 1;
68
- if ([r, g, b, a].some((v) => Number.isNaN(v))) return null;
69
- return [clamp255(r), clamp255(g), clamp255(b), Math.min(1, Math.max(0, a))];
70
- }
71
-
72
- function clamp255(v) {
73
- return Math.min(255, Math.max(0, v));
74
- }
75
-
76
- /**
77
- * Source-over composite of `top` onto `bottom` (Porter-Duff "over").
78
- *
79
- * @param {Rgba} top
80
- * @param {Rgba} bottom
81
- * @returns {Rgba}
82
- */
83
- export function compositeOver(top, bottom) {
84
- const at = top[3];
85
- const ab = bottom[3];
86
- const a = at + ab * (1 - at);
87
- if (a === 0) return [0, 0, 0, 0];
88
- const c = (i) => (top[i] * at + bottom[i] * ab * (1 - at)) / a;
89
- return [c(0), c(1), c(2), a];
90
- }
91
-
92
- /**
93
- * `[r, g, b]` (0..255) → `#RRGGBB`, channels rounded and clamped.
94
- *
95
- * @param {Rgba | [number, number, number]} rgb
96
- * @returns {string}
97
- */
98
- export function toHex(rgb) {
99
- // Coerce non-finite channels (NaN/±Infinity, e.g. from a malformed `Rgba`
100
- // passed by a caller) to 0, so a bad input yields a valid `#RRGGBB` rather
101
- // than an invalid CSS string like `"#NAN0000"`.
102
- const h = (v) => {
103
- const n = Math.round(clamp255(Number.isFinite(v) ? v : 0));
104
- return n.toString(16).padStart(2, "0");
105
- };
106
- return `#${h(rgb[0])}${h(rgb[1])}${h(rgb[2])}`.toUpperCase();
107
- }
108
-
109
- // --- Perceptual interpolation (Oklab) -------------------------------------
110
- //
111
- // A crossfade should feel even: equal progress should be equal *perceived*
112
- // change. A straight sRGB-channel blend is not that — it lingers in the brighter
113
- // half (black→white at t=0.5 is #808080, but the perceived midpoint grey is
114
- // ~#606060). Interpolating in Oklab — a perceptually-uniform space — fixes the
115
- // timing and, for chromatic endpoints, avoids the muddy desaturated midpoint a
116
- // raw RGB blend produces. Björn Ottosson's sRGB↔Oklab transform, self-contained.
117
-
118
- /** sRGB gamma transfer (IEC 61966-2-1): encoded channel 0..1 → linear 0..1. */
119
- function srgbToLinear(c) {
120
- return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
121
- }
122
-
123
- /** Inverse sRGB transfer: linear 0..1 → encoded 0..1. */
124
- function linearToSrgb(c) {
125
- return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
126
- }
127
-
128
- /** Linear-light sRGB `[r,g,b]` (0..1) → Oklab `[L, a, b]`. */
129
- function linearRgbToOklab(r, g, b) {
130
- const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
131
- const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
132
- const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
133
- const l_ = Math.cbrt(l);
134
- const m_ = Math.cbrt(m);
135
- const s_ = Math.cbrt(s);
136
- return [
137
- 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
138
- 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
139
- 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
140
- ];
141
- }
142
-
143
- /** Oklab `[L, a, b]` → linear-light sRGB `[r,g,b]` (0..1, may be out of gamut). */
144
- function oklabToLinearRgb(L, A, B) {
145
- const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
146
- const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
147
- const s_ = L - 0.0894841775 * A - 1.291485548 * B;
148
- const l = l_ * l_ * l_;
149
- const m = m_ * m_ * m_;
150
- const s = s_ * s_ * s_;
151
- return [
152
- 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
153
- -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
154
- -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
155
- ];
156
- }
157
-
158
- /**
159
- * Interpolate two `#RRGGBB` colours in Oklab at `t ∈ [0,1]`, returning `#RRGGBB`.
160
- *
161
- * Perceptually uniform: equal steps in `t` are equal steps in perceived
162
- * lightness (and a straight, non-muddy path in hue/chroma), so a crossfade feels
163
- * even rather than lingering bright. Endpoints are returned exactly (`t ≤ 0` →
164
- * `from`, `t ≥ 1` → `to`, both re-normalised through `toHex`); out-of-gamut
165
- * intermediates are clamped per channel. Unparseable input falls back to the
166
- * nearer endpoint.
167
- *
168
- * @param {string} fromHex
169
- * @param {string} toHex_
170
- * @param {number} t
171
- * @returns {string}
172
- */
173
- export function oklabLerp(fromHex, toHex_, t) {
174
- const a = parseCssColor(fromHex);
175
- const b = parseCssColor(toHex_);
176
- if (!a || !b) return (b && t >= 0.5) || !a ? (b ? toHex(b) : "#000000") : toHex(a);
177
- if (t <= 0) return toHex(a);
178
- if (t >= 1) return toHex(b);
179
- const la = linearRgbToOklab(srgbToLinear(a[0] / 255), srgbToLinear(a[1] / 255), srgbToLinear(a[2] / 255));
180
- const lb = linearRgbToOklab(srgbToLinear(b[0] / 255), srgbToLinear(b[1] / 255), srgbToLinear(b[2] / 255));
181
- const lin = oklabToLinearRgb(
182
- la[0] + (lb[0] - la[0]) * t,
183
- la[1] + (lb[1] - la[1]) * t,
184
- la[2] + (lb[2] - la[2]) * t,
185
- );
186
- return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]);
187
- }
188
-
189
- /**
190
- * Compose an ordered stack of colour layers (front-to-back) over an opaque base
191
- * into a single opaque `#RRGGBB`. Pure — no DOM. Exposed for testing and for
192
- * callers that sample their own layers.
193
- *
194
- * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer
195
- * @param {Rgba} opaqueBase must have alpha 1
196
- * @returns {string}
197
- */
198
- export function compositeStackToHex(layersFrontToBack, opaqueBase) {
199
- let result = opaqueBase;
200
- // Apply from the back (closest to base) forward, so index 0 lands on top.
201
- for (let i = layersFrontToBack.length - 1; i >= 0; i--) {
202
- result = compositeOver(layersFrontToBack[i], result);
203
- }
204
- return toHex(result);
205
- }
206
-
207
- /**
208
- * The opaque effective background `#RRGGBB` visible behind `element`'s own
209
- * content, by walking ancestors and compositing their `background-color`s.
210
- *
211
- * Walks from `element` upward, collecting each `background-color` layer, and
212
- * stops at the first fully-opaque layer (which becomes the base). If the chain
213
- * reaches the root without an opaque layer, `fallback` (default white) is the
214
- * base — matching how a browser shows the page's default canvas.
215
- *
216
- * Pure and injectable: pass `getStyle` and `parentOf` to test without a DOM; in
217
- * the browser they default to `getComputedStyle` and `el.parentElement`.
218
- *
219
- * @param {*} element
220
- * @param {object} [opts]
221
- * @param {string} [opts.fallback="#FFFFFF"] base when the chain is fully translucent
222
- * @param {(el: *) => { getPropertyValue: (p: string) => string }} [opts.getStyle]
223
- * @param {(el: *) => *} [opts.parentOf]
224
- * @param {number} [opts.maxDepth=64] guard against detached/cyclic chains
225
- * @returns {string}
226
- */
227
- export function effectiveBackground(element, opts = {}) {
228
- const fallback = opts.fallback ?? "#FFFFFF";
229
- const getStyle =
230
- opts.getStyle ?? ((el) => (typeof getComputedStyle === "function" ? getComputedStyle(el) : { getPropertyValue: () => "" }));
231
- const parentOf = opts.parentOf ?? ((el) => el.parentElement);
232
- const maxDepth = opts.maxDepth ?? 64;
233
-
234
- /** @type {Rgba[]} */
235
- const layers = [];
236
- let el = element;
237
- let depth = 0;
238
- let base = parseCssColor(fallback) ?? [255, 255, 255, 1];
239
-
240
- while (el && depth < maxDepth) {
241
- const css = getStyle(el).getPropertyValue("background-color");
242
- const c = parseCssColor(css);
243
- if (c && c[3] > 0) {
244
- if (c[3] >= 1) {
245
- base = c; // first opaque layer is the base; nothing behind it shows
246
- break;
247
- }
248
- layers.push(c);
249
- }
250
- el = parentOf(el);
251
- depth++;
252
- }
253
-
254
- return compositeStackToHex(layers, base);
255
- }
1
+ // Effective background resolution — zero dependencies.
2
+ //
3
+ // `labcolors` resolves roles against a *solid* background. A real UI surface is
4
+ // often translucent (a panel at `rgba(…, .8)` over its parents) or has no
5
+ // background of its own (inheriting whatever is behind it). To resolve such a
6
+ // surface honestly you need the **effective** background: the opaque colour a
7
+ // viewer actually sees behind the element's own content.
8
+ //
9
+ // This module computes it by walking the ancestor chain and **alpha-compositing**
10
+ // each element's `background-color` (front-to-back) until the stack is opaque,
11
+ // over an opaque fallback (white by default). It is the bridge from the DOM's
12
+ // layered, translucent reality to the single solid hex the WASM core consumes.
13
+ //
14
+ // HONEST LIMIT: this composites solid/translucent `background-color` layers only.
15
+ // It does NOT sample `background-image`s, gradients, blurred backdrops, video, or
16
+ // content showing through — those have no single colour to read from computed
17
+ // style. For those, the caller supplies the effective background explicitly (the
18
+ // `background` option of `watchTheme`, or a sampled average). What it does cover —
19
+ // translucent panels over solid parents — is the common case and is composited
20
+ // *correctly* (true source-over alpha), not approximated.
21
+
22
+ /** @typedef {[number, number, number, number]} Rgba r,g,b in 0..255, a in 0..1 */
23
+
24
+ /**
25
+ * Parse a CSS colour string into `[r, g, b, a]`, or `null` if unrecognised.
26
+ *
27
+ * Handles the forms computed style actually yields (`rgb(r, g, b)`,
28
+ * `rgba(r, g, b, a)`, the modern `rgb(r g b / a)`) plus `#rgb`/`#rrggbb` and the
29
+ * `transparent` keyword. Unknown keywords return `null` (treated as "no layer").
30
+ *
31
+ * @param {string} css
32
+ * @returns {Rgba | null}
33
+ */
34
+ export function parseCssColor(css) {
35
+ if (typeof css !== "string") return null;
36
+ const s = css.trim().toLowerCase();
37
+ if (s === "transparent") return [0, 0, 0, 0];
38
+
39
+ if (s[0] === "#") {
40
+ const h = s.slice(1);
41
+ if (h.length === 3 || h.length === 4) {
42
+ const r = parseInt(h[0] + h[0], 16);
43
+ const g = parseInt(h[1] + h[1], 16);
44
+ const b = parseInt(h[2] + h[2], 16);
45
+ const a = h.length === 4 ? parseInt(h[3] + h[3], 16) / 255 : 1;
46
+ return [r, g, b, a].some(Number.isNaN) ? null : [r, g, b, a];
47
+ }
48
+ if (h.length === 6 || h.length === 8) {
49
+ const r = parseInt(h.slice(0, 2), 16);
50
+ const g = parseInt(h.slice(2, 4), 16);
51
+ const b = parseInt(h.slice(4, 6), 16);
52
+ const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
53
+ return [r, g, b, a].some(Number.isNaN) ? null : [r, g, b, a];
54
+ }
55
+ return null;
56
+ }
57
+
58
+ const m = s.match(/^rgba?\(([^)]+)\)$/);
59
+ if (!m) return null;
60
+ // Split on commas or whitespace and an optional "/" alpha separator.
61
+ const parts = m[1].split(/[,\s/]+/).filter((p) => p.length > 0);
62
+ if (parts.length < 3) return null;
63
+ const chan = (p) => (p.endsWith("%") ? (parseFloat(p) / 100) * 255 : parseFloat(p));
64
+ const r = chan(parts[0]);
65
+ const g = chan(parts[1]);
66
+ const b = chan(parts[2]);
67
+ const a = parts.length >= 4 ? (parts[3].endsWith("%") ? parseFloat(parts[3]) / 100 : parseFloat(parts[3])) : 1;
68
+ if ([r, g, b, a].some((v) => Number.isNaN(v))) return null;
69
+ return [clamp255(r), clamp255(g), clamp255(b), Math.min(1, Math.max(0, a))];
70
+ }
71
+
72
+ function clamp255(v) {
73
+ return Math.min(255, Math.max(0, v));
74
+ }
75
+
76
+ /**
77
+ * Source-over composite of `top` onto `bottom` (Porter-Duff "over").
78
+ *
79
+ * @param {Rgba} top
80
+ * @param {Rgba} bottom
81
+ * @returns {Rgba}
82
+ */
83
+ export function compositeOver(top, bottom) {
84
+ const at = top[3];
85
+ const ab = bottom[3];
86
+ const a = at + ab * (1 - at);
87
+ if (a === 0) return [0, 0, 0, 0];
88
+ const c = (i) => (top[i] * at + bottom[i] * ab * (1 - at)) / a;
89
+ return [c(0), c(1), c(2), a];
90
+ }
91
+
92
+ /**
93
+ * `[r, g, b]` (0..255) → `#RRGGBB`, channels rounded and clamped.
94
+ *
95
+ * @param {Rgba | [number, number, number]} rgb
96
+ * @returns {string}
97
+ */
98
+ export function toHex(rgb) {
99
+ // Coerce non-finite channels (NaN/±Infinity, e.g. from a malformed `Rgba`
100
+ // passed by a caller) to 0, so a bad input yields a valid `#RRGGBB` rather
101
+ // than an invalid CSS string like `"#NAN0000"`.
102
+ const h = (v) => {
103
+ const n = Math.round(clamp255(Number.isFinite(v) ? v : 0));
104
+ return n.toString(16).padStart(2, "0");
105
+ };
106
+ return `#${h(rgb[0])}${h(rgb[1])}${h(rgb[2])}`.toUpperCase();
107
+ }
108
+
109
+ // --- Perceptual interpolation (Oklab) -------------------------------------
110
+ //
111
+ // A crossfade should feel even: equal progress should be equal *perceived*
112
+ // change. A straight sRGB-channel blend is not that — it lingers in the brighter
113
+ // half (black→white at t=0.5 is #808080, but the perceived midpoint grey is
114
+ // ~#606060). Interpolating in Oklab — a perceptually-uniform space — fixes the
115
+ // timing and, for chromatic endpoints, avoids the muddy desaturated midpoint a
116
+ // raw RGB blend produces. Björn Ottosson's sRGB↔Oklab transform, self-contained.
117
+
118
+ /** sRGB gamma transfer (IEC 61966-2-1): encoded channel 0..1 → linear 0..1. */
119
+ function srgbToLinear(c) {
120
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
121
+ }
122
+
123
+ /** Inverse sRGB transfer: linear 0..1 → encoded 0..1. */
124
+ function linearToSrgb(c) {
125
+ return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
126
+ }
127
+
128
+ /** Linear-light sRGB `[r,g,b]` (0..1) → Oklab `[L, a, b]`. */
129
+ function linearRgbToOklab(r, g, b) {
130
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
131
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
132
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
133
+ const l_ = Math.cbrt(l);
134
+ const m_ = Math.cbrt(m);
135
+ const s_ = Math.cbrt(s);
136
+ return [
137
+ 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
138
+ 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
139
+ 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
140
+ ];
141
+ }
142
+
143
+ /** Oklab `[L, a, b]` → linear-light sRGB `[r,g,b]` (0..1, may be out of gamut). */
144
+ function oklabToLinearRgb(L, A, B) {
145
+ const l_ = L + 0.3963377774 * A + 0.2158037573 * B;
146
+ const m_ = L - 0.1055613458 * A - 0.0638541728 * B;
147
+ const s_ = L - 0.0894841775 * A - 1.291485548 * B;
148
+ const l = l_ * l_ * l_;
149
+ const m = m_ * m_ * m_;
150
+ const s = s_ * s_ * s_;
151
+ return [
152
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
153
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
154
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
155
+ ];
156
+ }
157
+
158
+ /**
159
+ * Interpolate two `#RRGGBB` colours in Oklab at `t ∈ [0,1]`, returning `#RRGGBB`.
160
+ *
161
+ * Perceptually uniform: equal steps in `t` are equal steps in perceived
162
+ * lightness (and a straight, non-muddy path in hue/chroma), so a crossfade feels
163
+ * even rather than lingering bright. Endpoints are returned exactly (`t ≤ 0` →
164
+ * `from`, `t ≥ 1` → `to`, both re-normalised through `toHex`); out-of-gamut
165
+ * intermediates are clamped per channel. Unparseable input falls back to the
166
+ * nearer endpoint.
167
+ *
168
+ * @param {string} fromHex
169
+ * @param {string} toHex_
170
+ * @param {number} t
171
+ * @returns {string}
172
+ */
173
+ export function oklabLerp(fromHex, toHex_, t) {
174
+ const a = parseCssColor(fromHex);
175
+ const b = parseCssColor(toHex_);
176
+ if (!a || !b) return (b && t >= 0.5) || !a ? (b ? toHex(b) : "#000000") : toHex(a);
177
+ if (t <= 0) return toHex(a);
178
+ if (t >= 1) return toHex(b);
179
+ const la = linearRgbToOklab(srgbToLinear(a[0] / 255), srgbToLinear(a[1] / 255), srgbToLinear(a[2] / 255));
180
+ const lb = linearRgbToOklab(srgbToLinear(b[0] / 255), srgbToLinear(b[1] / 255), srgbToLinear(b[2] / 255));
181
+ const lin = oklabToLinearRgb(
182
+ la[0] + (lb[0] - la[0]) * t,
183
+ la[1] + (lb[1] - la[1]) * t,
184
+ la[2] + (lb[2] - la[2]) * t,
185
+ );
186
+ return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]);
187
+ }
188
+
189
+ /**
190
+ * Compose an ordered stack of colour layers (front-to-back) over an opaque base
191
+ * into a single opaque `#RRGGBB`. Pure — no DOM. Exposed for testing and for
192
+ * callers that sample their own layers.
193
+ *
194
+ * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer
195
+ * @param {Rgba} opaqueBase must have alpha 1
196
+ * @returns {string}
197
+ */
198
+ export function compositeStackToHex(layersFrontToBack, opaqueBase) {
199
+ let result = opaqueBase;
200
+ // Apply from the back (closest to base) forward, so index 0 lands on top.
201
+ for (let i = layersFrontToBack.length - 1; i >= 0; i--) {
202
+ result = compositeOver(layersFrontToBack[i], result);
203
+ }
204
+ return toHex(result);
205
+ }
206
+
207
+ /**
208
+ * The opaque effective background `#RRGGBB` visible behind `element`'s own
209
+ * content, by walking ancestors and compositing their `background-color`s.
210
+ *
211
+ * Walks from `element` upward, collecting each `background-color` layer, and
212
+ * stops at the first fully-opaque layer (which becomes the base). If the chain
213
+ * reaches the root without an opaque layer, `fallback` (default white) is the
214
+ * base — matching how a browser shows the page's default canvas.
215
+ *
216
+ * Pure and injectable: pass `getStyle` and `parentOf` to test without a DOM; in
217
+ * the browser they default to `getComputedStyle` and `el.parentElement`.
218
+ *
219
+ * @param {*} element
220
+ * @param {object} [opts]
221
+ * @param {string} [opts.fallback="#FFFFFF"] base when the chain is fully translucent
222
+ * @param {(el: *) => { getPropertyValue: (p: string) => string }} [opts.getStyle]
223
+ * @param {(el: *) => *} [opts.parentOf]
224
+ * @param {number} [opts.maxDepth=64] guard against detached/cyclic chains
225
+ * @returns {string}
226
+ */
227
+ export function effectiveBackground(element, opts = {}) {
228
+ const fallback = opts.fallback ?? "#FFFFFF";
229
+ const getStyle =
230
+ opts.getStyle ?? ((el) => (typeof getComputedStyle === "function" ? getComputedStyle(el) : { getPropertyValue: () => "" }));
231
+ const parentOf = opts.parentOf ?? ((el) => el.parentElement);
232
+ const maxDepth = opts.maxDepth ?? 64;
233
+
234
+ /** @type {Rgba[]} */
235
+ const layers = [];
236
+ let el = element;
237
+ let depth = 0;
238
+ let base = parseCssColor(fallback) ?? [255, 255, 255, 1];
239
+
240
+ while (el && depth < maxDepth) {
241
+ const css = getStyle(el).getPropertyValue("background-color");
242
+ const c = parseCssColor(css);
243
+ if (c && c[3] > 0) {
244
+ if (c[3] >= 1) {
245
+ base = c; // first opaque layer is the base; nothing behind it shows
246
+ break;
247
+ }
248
+ layers.push(c);
249
+ }
250
+ el = parentOf(el);
251
+ depth++;
252
+ }
253
+
254
+ return compositeStackToHex(layers, base);
255
+ }
package/index.d.ts CHANGED
@@ -1,36 +1,36 @@
1
- // Public types for @labpics/colors.
2
- //
3
- // Re-exports the wasm-bindgen-generated types (the rich `ResolvedTheme` /
4
- // `RoleResult` union and the `LabColors` engine) and the vanilla `applyTheme`
5
- // helper, so a consumer gets full typing from the package root.
6
-
7
- export {
8
- default,
9
- default as init,
10
- initSync,
11
- LabColors,
12
- } from "./pkg/labcolors.js";
13
-
14
- export type {
15
- ThemeName,
16
- SolvedColor,
17
- NoneRole,
18
- UnreachableRole,
19
- RoleResult,
20
- ResolvedTheme,
21
- } from "./pkg/labcolors.js";
22
-
23
- export { applyTheme } from "./apply-theme.js";
24
- export { watchTheme } from "./watch-theme.js";
25
- export type { WatchThemeOptions, WatchController } from "./watch-theme.js";
26
- export { adaptTheme } from "./adapt-theme.js";
27
- export type { AdaptThemeOptions, AdaptController } from "./adapt-theme.js";
28
- export {
29
- effectiveBackground,
30
- parseCssColor,
31
- compositeOver,
32
- compositeStackToHex,
33
- toHex,
34
- oklabLerp,
35
- } from "./effective-bg.js";
36
- export type { Rgba, EffectiveBackgroundOptions, StyleLike } from "./effective-bg.js";
1
+ // Public types for @labpics/colors.
2
+ //
3
+ // Re-exports the wasm-bindgen-generated types (the rich `ResolvedTheme` /
4
+ // `RoleResult` union and the `LabColors` engine) and the vanilla `applyTheme`
5
+ // helper, so a consumer gets full typing from the package root.
6
+
7
+ export {
8
+ default,
9
+ default as init,
10
+ initSync,
11
+ LabColors,
12
+ } from "./pkg/labcolors.js";
13
+
14
+ export type {
15
+ ThemeName,
16
+ SolvedColor,
17
+ NoneRole,
18
+ UnreachableRole,
19
+ RoleResult,
20
+ ResolvedTheme,
21
+ } from "./pkg/labcolors.js";
22
+
23
+ export { applyTheme } from "./apply-theme.js";
24
+ export { watchTheme } from "./watch-theme.js";
25
+ export type { WatchThemeOptions, WatchController } from "./watch-theme.js";
26
+ export { adaptTheme } from "./adapt-theme.js";
27
+ export type { AdaptThemeOptions, AdaptController } from "./adapt-theme.js";
28
+ export {
29
+ effectiveBackground,
30
+ parseCssColor,
31
+ compositeOver,
32
+ compositeStackToHex,
33
+ toHex,
34
+ oklabLerp,
35
+ } from "./effective-bg.js";
36
+ export type { Rgba, EffectiveBackgroundOptions, StyleLike } from "./effective-bg.js";
package/index.js CHANGED
@@ -1,21 +1,21 @@
1
- // Public entry for @labpics/colors.
2
- //
3
- // Re-exports the wasm-bindgen surface (the default `init` loader, `initSync`,
4
- // and the `LabColors` engine class) plus the vanilla DOM runtime helpers:
5
- // `applyTheme` (one-shot apply), `watchTheme` (reactive sync), and the
6
- // effective-background resolver. The wasm glue is the generated `pkg/` artifact
7
- // (built by `npm run build`).
8
-
9
- export { default, default as init, initSync, LabColors } from "./pkg/labcolors.js";
10
-
11
- export { applyTheme } from "./apply-theme.js";
12
- export { watchTheme } from "./watch-theme.js";
13
- export { adaptTheme } from "./adapt-theme.js";
14
- export {
15
- effectiveBackground,
16
- parseCssColor,
17
- compositeOver,
18
- compositeStackToHex,
19
- toHex,
20
- oklabLerp,
21
- } from "./effective-bg.js";
1
+ // Public entry for @labpics/colors.
2
+ //
3
+ // Re-exports the wasm-bindgen surface (the default `init` loader, `initSync`,
4
+ // and the `LabColors` engine class) plus the vanilla DOM runtime helpers:
5
+ // `applyTheme` (one-shot apply), `watchTheme` (reactive sync), and the
6
+ // effective-background resolver. The wasm glue is the generated `pkg/` artifact
7
+ // (built by `npm run build`).
8
+
9
+ export { default, default as init, initSync, LabColors } from "./pkg/labcolors.js";
10
+
11
+ export { applyTheme } from "./apply-theme.js";
12
+ export { watchTheme } from "./watch-theme.js";
13
+ export { adaptTheme } from "./adapt-theme.js";
14
+ export {
15
+ effectiveBackground,
16
+ parseCssColor,
17
+ compositeOver,
18
+ compositeStackToHex,
19
+ toHex,
20
+ oklabLerp,
21
+ } from "./effective-bg.js";