@labpics/colors 0.1.0 → 0.3.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/adapt-theme.js CHANGED
@@ -1,431 +1,431 @@
1
- // Adaptive theme controller — the lazy debounced-re-solve runtime. Zero dependencies.
2
- //
3
- // `watchTheme` re-resolves the whole set whenever the background changes. For a
4
- // CONTINUOUSLY changing background (animation/scroll/blur) that is both expensive
5
- // (a full solve every frame) and jittery (colours twitch frame to frame).
6
- //
7
- // `adaptTheme` is the elegant alternative, and the way real systems behave: it
8
- // does NOT re-solve per frame. Each frame it cheaply RE-CHECKS whether the
9
- // current colours still pass their contrast against the (new) background — one
10
- // perceptual-model forward for the background plus one per role, no solve. While they pass
11
- // it does nothing (no churn, no jitter). Only when a role's perceptual contrast
12
- // stays below target for a sustained moment does it re-solve and **ease** to the
13
- // fresh colours over a short transition. The result: fewer computations, no
14
- // flicker, and a smooth, calm adaptation.
15
- //
16
- // Control law (principled defaults; all tunable):
17
- // * Margin threshold — re-solve only when a role's achieved |Lc| falls below
18
- // `(1 - dropFraction)` of its target |Lc| (a `dropFraction` margin under the
19
- // target), not merely when it touches the line. This is a SINGLE-threshold
20
- // detector: the same level gates entering and leaving the breach state — NOT a
21
- // Schmitt trigger, which would need two distinct levels. The margin keeps a
22
- // background sitting right at the target from counting as a breach, but it
23
- // does NOT by itself stop chatter for values straddling the threshold.
24
- // * Debounce — the breach must persist `sustainMs` before acting, so a dark
25
- // object scrolling past for a couple of frames never triggers. Together with
26
- // min-dwell this is what actually prevents oscillation near the threshold.
27
- // * Min dwell — at least `dwellMs` between re-solves, capping the effective
28
- // transition rate well under the flash threshold.
29
- // * Ease — a non-overshooting ease-out crossfade of `easeMs`; under
30
- // `prefers-reduced-motion` a gentle short fade (NOT a jarring snap — an
31
- // instant state change is more stressful than a soft one, and a colour
32
- // crossfade is not "motion").
33
- // * Theme switches are a deliberate INTENT, not a drift: applied instantly
34
- // (a single quick crossfade), never run through the debounce/dwell machinery.
35
- //
36
- // Floor-clamp modes:
37
- // * Default (free ease): the crossfade does not floor-clamp each frame.
38
- // Reading comprehension is far slower than a ~300ms transition and surfaces
39
- // usually sit on a substrate, so a brief dip of the aesthetic *surplus*
40
- // during the ease is imperceptible while the freshly-solved destination is
41
- // always legal.
42
- // * Strict (`strict: true`): the WCAG legal floor is HELD every frame. For
43
- // text directly on animated content or under `prefers-contrast`, an
44
- // intermediate colour is only shown while it still clears the role's
45
- // `legalFloor` against the live background; a role whose eased intermediate
46
- // would dip below its floor is advanced (monotonically) to the least blend
47
- // that stays legal — never below the line, never a backwards flicker. Roles
48
- // with no legal floor (decorative) ease freely either way.
49
-
50
- import { applyTheme } from "./apply-theme.js";
51
- import { effectiveBackground, parseCssColor, oklabLerp } from "./effective-bg.js";
52
-
53
- /** Cubic ease-out: fast start, gentle settle, no overshoot. A non-finite `t`
54
- * (e.g. a NaN clock making `(now - easeStart) / easeMs` NaN) is treated as a
55
- * completed ease (1), so the crossfade can never emit `#NANNANNAN` CSS. */
56
- function easeOut(t) {
57
- const clamped = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 1;
58
- const u = 1 - clamped;
59
- return 1 - u * u * u;
60
- }
61
-
62
- /** WCAG 2.1 relative luminance of `#RRGGBB` — a faithful transcription of the
63
- * normative definition (0.03928 split, 2.4 exponent), so the strict floor-clamp
64
- * agrees byte-for-byte with the core's `legalFloor` semantics. */
65
- function relativeLuminanceHex(hex) {
66
- const rgb = parseCssColor(hex) ?? [0, 0, 0, 1];
67
- const lin = (c) => {
68
- const s = c / 255;
69
- return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
70
- };
71
- return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]);
72
- }
73
-
74
- /** WCAG contrast ratio from two relative luminances: `(L+0.05)/(L+0.05)`. */
75
- function wcagRatio(lumA, lumB) {
76
- const hi = Math.max(lumA, lumB);
77
- const lo = Math.min(lumA, lumB);
78
- return (hi + 0.05) / (lo + 0.05);
79
- }
80
-
81
- /** Interpolate two `#RRGGBB` hexes at `t ∈ [0,1]` in Oklab, so the crossfade is
82
- * perceptually even (no lingering-bright sRGB midpoint, no muddy chroma path). */
83
- function lerpHex(from, to, t) {
84
- return oklabLerp(from, to, t);
85
- }
86
-
87
- /**
88
- * @typedef {object} AdaptController
89
- * @property {(now?: number) => void} tick Drive one step (call from rAF, or let
90
- * `start()` do it). Cheap: a re-check; a re-solve only on a sustained breach.
91
- * @property {(theme: string) => void} setTheme Switch theme INSTANTLY (intent,
92
- * not drift) — re-resolve and apply, bypassing the debounce/dwell machinery.
93
- * @property {() => void} start Begin an internal requestAnimationFrame loop.
94
- * @property {() => void} stop Stop the loop and disconnect.
95
- * @property {() => Record<string,string>} current The currently-applied vars.
96
- */
97
-
98
- /**
99
- * Keep an element's `--lab-*` variables adapting to its (changing) background
100
- * lazily and smoothly. Applies the resolved set immediately, then holds it while
101
- * it still passes, re-solving + easing only when contrast stably degrades.
102
- *
103
- * @param {*} element
104
- * @param {object} options
105
- * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:string,fgs:string[],theme:string)=>ArrayLike<number> }} options.colors
106
- * @param {string} options.theme
107
- * @param {string | string[] | (() => string | string[])} [options.background]
108
- * explicit effective background. An ARRAY (or a function returning one) is a
109
- * set of worst-case samples of a varying backdrop (gradient / image): the
110
- * colours are held legible against the hardest sample. The caller does the
111
- * pixel sampling; this consumes the samples worst-case.
112
- * @param {*} [options.target=element] element to write vars onto
113
- * @param {string} [options.fallback="#FFFFFF"]
114
- * @param {number} [options.dropFraction=0.2] surplus fraction lost before re-solve
115
- * @param {number} [options.sustainMs=120] breach must persist this long
116
- * @param {number} [options.dwellMs=250] minimum between re-solves
117
- * @param {number} [options.easeMs=280] crossfade duration
118
- * @param {boolean} [options.strict=false] hold each role's WCAG legal floor
119
- * every frame of the ease (for text on animated content / `prefers-contrast`)
120
- * @param {boolean} [options.reducedMotion] override; default reads matchMedia
121
- * @param {() => number} [options.now] clock (default performance.now/Date.now)
122
- * @param {*} [options.win=globalThis]
123
- * @param {(el:*)=>*} [options.getStyle] effectiveBackground seam (testing)
124
- * @param {(el:*)=>*} [options.parentOf] effectiveBackground seam (testing)
125
- * @returns {AdaptController}
126
- */
127
- export function adaptTheme(element, options) {
128
- if (
129
- !options ||
130
- typeof options.colors?.resolveTheme !== "function" ||
131
- typeof options.colors?.recheckContrast !== "function"
132
- ) {
133
- throw new TypeError("adaptTheme: options.colors needs resolveTheme + recheckContrast");
134
- }
135
- const colors = options.colors;
136
- const target = options.target ?? element;
137
- const fallback = options.fallback ?? "#FFFFFF";
138
- const dropFraction = options.dropFraction ?? 0.2;
139
- const sustainMs = options.sustainMs ?? 120;
140
- const dwellMs = options.dwellMs ?? 250;
141
- const strict = options.strict ?? false;
142
- const win = options.win ?? (typeof globalThis !== "undefined" ? globalThis : undefined);
143
- const reducedMotion =
144
- options.reducedMotion ??
145
- (win?.matchMedia ? win.matchMedia("(prefers-reduced-motion: reduce)").matches : false);
146
- // Reduced motion → a gentle SHORT fade, never a hard snap.
147
- const easeMs = reducedMotion ? Math.min(options.easeMs ?? 280, 80) : (options.easeMs ?? 280);
148
- const clock = options.now ?? (() => (win?.performance?.now ? win.performance.now() : Date.now()));
149
-
150
- let theme = options.theme;
151
- /** @type {{ cssVar: string, key: string, lc: number, hex: string, legalFloor: number|null }[]} stable role order */
152
- let roles = [];
153
- /** @type {Map<string,{from:string,to:string}>} in-flight ease per cssVar */
154
- let easing = new Map();
155
- let easeStart = 0;
156
- let breachSince = null;
157
- let lastSolveAt = -Infinity;
158
- let lastKey = null;
159
-
160
- const readBackground = () => {
161
- const b = options.background;
162
- if (typeof b === "function") return b();
163
- if (typeof b === "string" || Array.isArray(b)) return b;
164
- return effectiveBackground(element, {
165
- fallback,
166
- getStyle: options.getStyle,
167
- parentOf: options.parentOf,
168
- });
169
- };
170
-
171
- // The background is a SET of samples. A solid surface is one sample; a varying
172
- // backdrop (gradient / image / video the caller sampled) is several. Every
173
- // decision is worst-case over the set: a role "passes" only if it passes
174
- // against EVERY sample, and we re-solve against the HARDEST sample. With one
175
- // sample this collapses to plain single-background behaviour, bit-for-bit.
176
- const readSamples = () => {
177
- const v = readBackground();
178
- const arr = Array.isArray(v) ? v.filter((s) => typeof s === "string" && s.length > 0) : [v];
179
- return arr.length > 0 ? arr : [fallback];
180
- };
181
-
182
- // Recheck the current colours against every sample. A role breaches if its
183
- // achieved |Lc| drops below its single-threshold margin (`|lc| * (1 -
184
- // dropFraction)`) against ANY sample. `worstIdx` is the sample with the least
185
- // set-wide margin — the one to re-solve against, so the constraint we solve to
186
- // is the same constraint we check hardest.
187
- const recheckSamples = (fgs, samples) => {
188
- let breached = false;
189
- let worstIdx = 0;
190
- let worstMargin = Infinity;
191
- for (let s = 0; s < samples.length; s++) {
192
- const flat = colors.recheckContrast(samples[s], fgs, theme);
193
- let sampleMargin = Infinity;
194
- for (let i = 0; i < roles.length; i++) {
195
- const want = Math.abs(roles[i].lc) * (1 - dropFraction);
196
- const lcNow = Math.abs(flat[2 * i]);
197
- if (lcNow < want) breached = true;
198
- const margin = want > 0 ? lcNow / want : Infinity;
199
- if (margin < sampleMargin) sampleMargin = margin;
200
- }
201
- if (sampleMargin < worstMargin) {
202
- worstMargin = sampleMargin;
203
- worstIdx = s;
204
- }
205
- }
206
- return { breached, worstIdx };
207
- };
208
-
209
- // Resolve a fresh set and adopt it as the current colours (no ease).
210
- const solveAndAdopt = (bg, now) => {
211
- const result = colors.resolveTheme(bg, theme);
212
- roles = Object.entries(result.roles)
213
- .filter(([, r]) => r && r.kind === "color")
214
- .map(([key, r]) => ({
215
- cssVar: r.cssVar,
216
- key,
217
- lc: r.lc,
218
- hex: r.hex,
219
- legalFloor: typeof r.legalFloor === "number" ? r.legalFloor : null,
220
- }));
221
- lastSolveAt = now;
222
- breachSince = null;
223
- return result;
224
- };
225
-
226
- // Solve+adopt against the hardest of `samples`. With one sample this is a
227
- // single solve; with several it does a provisional solve to learn the role
228
- // colours, picks the worst sample for them, and re-solves against it if that
229
- // is not the one already chosen — so the adopted set is the strongest the
230
- // backdrop demands. Used where there is no current set to recheck (initial
231
- // apply, theme switch); the tick path already knows the worst sample from its
232
- // own recheck and calls `solveAndAdopt` directly.
233
- const solveAndAdoptWorst = (samples, now) => {
234
- solveAndAdopt(samples[0], now);
235
- if (samples.length > 1) {
236
- const { worstIdx } = recheckSamples(
237
- roles.map((r) => r.hex),
238
- samples,
239
- );
240
- if (worstIdx !== 0) solveAndAdopt(samples[worstIdx], now);
241
- }
242
- };
243
-
244
- const applyHexes = (hexByVar) => applyTheme(target, { vars: hexByVar });
245
-
246
- const applyRolesDirect = () => {
247
- const vars = {};
248
- for (const r of roles) vars[r.cssVar] = r.hex;
249
- applyHexes(vars);
250
- };
251
-
252
- // Begin an ease from the currently-applied colours toward the role colours.
253
- // `held` latches the per-role displayed blend so it only ever advances toward
254
- // the destination (strict mode) — see `stepEase`.
255
- const beginEase = (fromByVar, now) => {
256
- easing = new Map();
257
- for (const r of roles) {
258
- const from = fromByVar[r.cssVar] ?? r.hex;
259
- if (from !== r.hex) easing.set(r.cssVar, { from, to: r.hex, held: 0 });
260
- }
261
- easeStart = now;
262
- if (easing.size === 0) applyRolesDirect();
263
- };
264
-
265
- // Strict mode: the least blend in [e, 1] whose interpolated colour clears
266
- // `floor` against EVERY background sample in `bgLums`. The destination (`to`,
267
- // blend 1) is a freshly-solved legal colour, so it anchors the search; we
268
- // bisect toward it from the natural ease value `e`. Returns `e` unchanged when
269
- // the eased colour is already legal everywhere (the common case — no
270
- // intervention). The returned blend is always floor-legal against the worst
271
- // sample, except in the unavoidable case where even `to` is illegal against a
272
- // sample that drifted further this frame — then it returns 1 (the most-legal
273
- // colour we have) and the recheck loop re-solves.
274
- const floorBlend = (seg, e, bgLums, floor) => {
275
- const legalAt = (blend) => {
276
- const lum = relativeLuminanceHex(lerpHex(seg.from, seg.to, blend));
277
- return bgLums.every((L) => wcagRatio(lum, L) >= floor);
278
- };
279
- if (legalAt(e)) return e;
280
- let lo = e;
281
- let hi = 1;
282
- for (let k = 0; k < 14; k++) {
283
- const mid = (lo + hi) / 2;
284
- if (legalAt(mid)) hi = mid;
285
- else lo = mid;
286
- }
287
- return hi; // hi is always legal (or blend 1, the most-legal we have)
288
- };
289
-
290
- const stepEase = (now, samples) => {
291
- const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
292
- const e = easeOut(t);
293
- const bgLums = strict ? samples.map(relativeLuminanceHex) : null;
294
- const vars = {};
295
- for (const r of roles) {
296
- const seg = easing.get(r.cssVar);
297
- if (!seg) {
298
- vars[r.cssVar] = r.hex;
299
- continue;
300
- }
301
- let blend = e;
302
- if (strict && r.legalFloor != null) {
303
- // Hold the floor (against the worst sample), then LATCH: the displayed
304
- // blend may only advance toward the destination, never retreat.
305
- // `floorBlend` is stateless and depends on the live (drifting) samples,
306
- // so on a frame where they drift favourably it could return a *lower*
307
- // blend than last frame — a backwards step toward the old colour, the
308
- // precise jarring reversal this mode exists to avoid. `held` clamps that
309
- // out: the colour progresses monotonically from→to and never below the
310
- // legal line on any sample.
311
- blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held);
312
- seg.held = blend;
313
- }
314
- vars[r.cssVar] = lerpHex(seg.from, seg.to, blend);
315
- }
316
- applyHexes(vars);
317
- if (t >= 1) easing = new Map();
318
- };
319
-
320
- const currentApplied = () => {
321
- const vars = {};
322
- for (const r of roles) {
323
- const seg = easing.get(r.cssVar);
324
- vars[r.cssVar] = seg ? seg.to : r.hex; // logical target during/after ease
325
- }
326
- return vars;
327
- };
328
-
329
- // The colour each role is PAINTED right now — exactly what `stepEase` writes
330
- // this frame: an in-flight segment sampled at `now` (with the SAME strict
331
- // floor-hold + latch against the worst sample when `strict`), else the static
332
- // hex. Mirrors `stepEase`'s blend math byte-for-byte so the begin-from value
333
- // equals what is on screen, including the strict-mode `held` clamp — otherwise
334
- // an overlapping re-solve in strict mode would start one frame BELOW the
335
- // painted (floored) colour.
336
- const paintedNow = (now, samples) => {
337
- const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
338
- const e = easeOut(t);
339
- const bgLums = strict ? samples.map(relativeLuminanceHex) : null;
340
- const vars = {};
341
- for (const r of roles) {
342
- const seg = easing.get(r.cssVar);
343
- if (!seg) {
344
- vars[r.cssVar] = r.hex;
345
- continue;
346
- }
347
- const blend =
348
- strict && r.legalFloor != null
349
- ? Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held)
350
- : e;
351
- vars[r.cssVar] = lerpHex(seg.from, seg.to, blend);
352
- }
353
- return vars;
354
- };
355
-
356
- const tick = (nowArg) => {
357
- const now = nowArg ?? clock();
358
- const samples = readSamples();
359
- // Advance any in-flight ease first (against the live samples, so strict mode
360
- // holds the legal floor every frame as the backdrop keeps drifting under it).
361
- if (easing.size > 0) stepEase(now, samples);
362
-
363
- // Steady state: a static backdrop with no in-flight ease and no pending
364
- // breach needs no work. A PENDING breach keeps us live even on a static
365
- // backdrop, so the sustain timer can fire on one that changed once to a
366
- // failing value and then held.
367
- const key = samples.join("|");
368
- if (key === lastKey && easing.size === 0 && breachSince === null) return;
369
- lastKey = key;
370
- if (roles.length === 0) return;
371
-
372
- // Cheap worst-case re-check: do the current colours still pass against every
373
- // sample? `worstIdx` is the hardest sample, the one to re-solve against.
374
- const { breached, worstIdx } = recheckSamples(
375
- roles.map((r) => r.hex),
376
- samples,
377
- );
378
-
379
- if (!breached) {
380
- breachSince = null;
381
- return; // hold — the common case for a slowly-drifting backdrop
382
- }
383
- if (breachSince === null) breachSince = now;
384
- if (now - breachSince < sustainMs || now - lastSolveAt < dwellMs) return; // debounce / dwell
385
-
386
- // Sustained breach: re-solve against the worst sample and ease toward fresh,
387
- // starting from the colour each role is PAINTED right now (the in-flight ease
388
- // sampled at `now`) — never the in-flight TARGET. Starting from the target
389
- // would SNAP the element to the old target for one frame before easing,
390
- // reintroducing flicker when a re-solve overlaps a previous ease.
391
- const fromByVar = paintedNow(now, samples);
392
- solveAndAdopt(samples[worstIdx], now);
393
- beginEase(fromByVar, now);
394
- stepEase(now, samples);
395
- };
396
-
397
- let rafId = null;
398
- const loop = () => {
399
- tick();
400
- if (win?.requestAnimationFrame) rafId = win.requestAnimationFrame(loop);
401
- };
402
-
403
- // Apply the initial set immediately (against the worst sample of the backdrop).
404
- {
405
- const samples = readSamples();
406
- lastKey = samples.join("|");
407
- solveAndAdoptWorst(samples, clock());
408
- applyRolesDirect();
409
- }
410
-
411
- return {
412
- tick,
413
- setTheme(next) {
414
- theme = next;
415
- const samples = readSamples();
416
- lastKey = samples.join("|");
417
- easing = new Map();
418
- solveAndAdoptWorst(samples, clock());
419
- applyRolesDirect(); // instant — a theme switch is intent, not drift
420
- },
421
- start() {
422
- if (rafId == null && win?.requestAnimationFrame) rafId = win.requestAnimationFrame(loop);
423
- },
424
- stop() {
425
- if (rafId != null && win?.cancelAnimationFrame) win.cancelAnimationFrame(rafId);
426
- rafId = null;
427
- easing = new Map();
428
- },
429
- current: currentApplied,
430
- };
431
- }
1
+ // Adaptive theme controller — the lazy debounced-re-solve runtime. Zero dependencies.
2
+ //
3
+ // `watchTheme` re-resolves the whole set whenever the background changes. For a
4
+ // CONTINUOUSLY changing background (animation/scroll/blur) that is both expensive
5
+ // (a full solve every frame) and jittery (colours twitch frame to frame).
6
+ //
7
+ // `adaptTheme` is the elegant alternative, and the way real systems behave: it
8
+ // does NOT re-solve per frame. Each frame it cheaply RE-CHECKS whether the
9
+ // current colours still pass their contrast against the (new) background — one
10
+ // perceptual-model forward for the background plus one per role, no solve. While they pass
11
+ // it does nothing (no churn, no jitter). Only when a role's perceptual contrast
12
+ // stays below target for a sustained moment does it re-solve and **ease** to the
13
+ // fresh colours over a short transition. The result: fewer computations, no
14
+ // flicker, and a smooth, calm adaptation.
15
+ //
16
+ // Control law (principled defaults; all tunable):
17
+ // * Margin threshold — re-solve only when a role's achieved |Lc| falls below
18
+ // `(1 - dropFraction)` of its target |Lc| (a `dropFraction` margin under the
19
+ // target), not merely when it touches the line. This is a SINGLE-threshold
20
+ // detector: the same level gates entering and leaving the breach state — NOT a
21
+ // Schmitt trigger, which would need two distinct levels. The margin keeps a
22
+ // background sitting right at the target from counting as a breach, but it
23
+ // does NOT by itself stop chatter for values straddling the threshold.
24
+ // * Debounce — the breach must persist `sustainMs` before acting, so a dark
25
+ // object scrolling past for a couple of frames never triggers. Together with
26
+ // min-dwell this is what actually prevents oscillation near the threshold.
27
+ // * Min dwell — at least `dwellMs` between re-solves, capping the effective
28
+ // transition rate well under the flash threshold.
29
+ // * Ease — a non-overshooting ease-out crossfade of `easeMs`; under
30
+ // `prefers-reduced-motion` a gentle short fade (NOT a jarring snap — an
31
+ // instant state change is more stressful than a soft one, and a colour
32
+ // crossfade is not "motion").
33
+ // * Theme switches are a deliberate INTENT, not a drift: applied instantly
34
+ // (a single quick crossfade), never run through the debounce/dwell machinery.
35
+ //
36
+ // Floor-clamp modes:
37
+ // * Default (free ease): the crossfade does not floor-clamp each frame.
38
+ // Reading comprehension is far slower than a ~300ms transition and surfaces
39
+ // usually sit on a substrate, so a brief dip of the aesthetic *surplus*
40
+ // during the ease is imperceptible while the freshly-solved destination is
41
+ // always legal.
42
+ // * Strict (`strict: true`): the WCAG legal floor is HELD every frame. For
43
+ // text directly on animated content or under `prefers-contrast`, an
44
+ // intermediate colour is only shown while it still clears the role's
45
+ // `legalFloor` against the live background; a role whose eased intermediate
46
+ // would dip below its floor is advanced (monotonically) to the least blend
47
+ // that stays legal — never below the line, never a backwards flicker. Roles
48
+ // with no legal floor (decorative) ease freely either way.
49
+
50
+ import { applyTheme } from "./apply-theme.js";
51
+ import { effectiveBackground, parseCssColor, oklabLerp } from "./effective-bg.js";
52
+
53
+ /** Cubic ease-out: fast start, gentle settle, no overshoot. A non-finite `t`
54
+ * (e.g. a NaN clock making `(now - easeStart) / easeMs` NaN) is treated as a
55
+ * completed ease (1), so the crossfade can never emit `#NANNANNAN` CSS. */
56
+ function easeOut(t) {
57
+ const clamped = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 1;
58
+ const u = 1 - clamped;
59
+ return 1 - u * u * u;
60
+ }
61
+
62
+ /** WCAG 2.1 relative luminance of `#RRGGBB` — a faithful transcription of the
63
+ * normative definition (0.03928 split, 2.4 exponent), so the strict floor-clamp
64
+ * agrees byte-for-byte with the core's `legalFloor` semantics. */
65
+ function relativeLuminanceHex(hex) {
66
+ const rgb = parseCssColor(hex) ?? [0, 0, 0, 1];
67
+ const lin = (c) => {
68
+ const s = c / 255;
69
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
70
+ };
71
+ return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]);
72
+ }
73
+
74
+ /** WCAG contrast ratio from two relative luminances: `(L+0.05)/(L+0.05)`. */
75
+ function wcagRatio(lumA, lumB) {
76
+ const hi = Math.max(lumA, lumB);
77
+ const lo = Math.min(lumA, lumB);
78
+ return (hi + 0.05) / (lo + 0.05);
79
+ }
80
+
81
+ /** Interpolate two `#RRGGBB` hexes at `t ∈ [0,1]` in Oklab, so the crossfade is
82
+ * perceptually even (no lingering-bright sRGB midpoint, no muddy chroma path). */
83
+ function lerpHex(from, to, t) {
84
+ return oklabLerp(from, to, t);
85
+ }
86
+
87
+ /**
88
+ * @typedef {object} AdaptController
89
+ * @property {(now?: number) => void} tick Drive one step (call from rAF, or let
90
+ * `start()` do it). Cheap: a re-check; a re-solve only on a sustained breach.
91
+ * @property {(theme: string) => void} setTheme Switch theme INSTANTLY (intent,
92
+ * not drift) — re-resolve and apply, bypassing the debounce/dwell machinery.
93
+ * @property {() => void} start Begin an internal requestAnimationFrame loop.
94
+ * @property {() => void} stop Stop the loop and disconnect.
95
+ * @property {() => Record<string,string>} current The currently-applied vars.
96
+ */
97
+
98
+ /**
99
+ * Keep an element's `--lab-*` variables adapting to its (changing) background
100
+ * lazily and smoothly. Applies the resolved set immediately, then holds it while
101
+ * it still passes, re-solving + easing only when contrast stably degrades.
102
+ *
103
+ * @param {*} element
104
+ * @param {object} options
105
+ * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:string,fgs:string[],theme:string)=>ArrayLike<number> }} options.colors
106
+ * @param {string} options.theme
107
+ * @param {string | string[] | (() => string | string[])} [options.background]
108
+ * explicit effective background. An ARRAY (or a function returning one) is a
109
+ * set of worst-case samples of a varying backdrop (gradient / image): the
110
+ * colours are held legible against the hardest sample. The caller does the
111
+ * pixel sampling; this consumes the samples worst-case.
112
+ * @param {*} [options.target=element] element to write vars onto
113
+ * @param {string} [options.fallback="#FFFFFF"]
114
+ * @param {number} [options.dropFraction=0.2] surplus fraction lost before re-solve
115
+ * @param {number} [options.sustainMs=120] breach must persist this long
116
+ * @param {number} [options.dwellMs=250] minimum between re-solves
117
+ * @param {number} [options.easeMs=280] crossfade duration
118
+ * @param {boolean} [options.strict=false] hold each role's WCAG legal floor
119
+ * every frame of the ease (for text on animated content / `prefers-contrast`)
120
+ * @param {boolean} [options.reducedMotion] override; default reads matchMedia
121
+ * @param {() => number} [options.now] clock (default performance.now/Date.now)
122
+ * @param {*} [options.win=globalThis]
123
+ * @param {(el:*)=>*} [options.getStyle] effectiveBackground seam (testing)
124
+ * @param {(el:*)=>*} [options.parentOf] effectiveBackground seam (testing)
125
+ * @returns {AdaptController}
126
+ */
127
+ export function adaptTheme(element, options) {
128
+ if (
129
+ !options ||
130
+ typeof options.colors?.resolveTheme !== "function" ||
131
+ typeof options.colors?.recheckContrast !== "function"
132
+ ) {
133
+ throw new TypeError("adaptTheme: options.colors needs resolveTheme + recheckContrast");
134
+ }
135
+ const colors = options.colors;
136
+ const target = options.target ?? element;
137
+ const fallback = options.fallback ?? "#FFFFFF";
138
+ const dropFraction = options.dropFraction ?? 0.2;
139
+ const sustainMs = options.sustainMs ?? 120;
140
+ const dwellMs = options.dwellMs ?? 250;
141
+ const strict = options.strict ?? false;
142
+ const win = options.win ?? (typeof globalThis !== "undefined" ? globalThis : undefined);
143
+ const reducedMotion =
144
+ options.reducedMotion ??
145
+ (win?.matchMedia ? win.matchMedia("(prefers-reduced-motion: reduce)").matches : false);
146
+ // Reduced motion → a gentle SHORT fade, never a hard snap.
147
+ const easeMs = reducedMotion ? Math.min(options.easeMs ?? 280, 80) : (options.easeMs ?? 280);
148
+ const clock = options.now ?? (() => (win?.performance?.now ? win.performance.now() : Date.now()));
149
+
150
+ let theme = options.theme;
151
+ /** @type {{ cssVar: string, key: string, lc: number, hex: string, legalFloor: number|null }[]} stable role order */
152
+ let roles = [];
153
+ /** @type {Map<string,{from:string,to:string}>} in-flight ease per cssVar */
154
+ let easing = new Map();
155
+ let easeStart = 0;
156
+ let breachSince = null;
157
+ let lastSolveAt = -Infinity;
158
+ let lastKey = null;
159
+
160
+ const readBackground = () => {
161
+ const b = options.background;
162
+ if (typeof b === "function") return b();
163
+ if (typeof b === "string" || Array.isArray(b)) return b;
164
+ return effectiveBackground(element, {
165
+ fallback,
166
+ getStyle: options.getStyle,
167
+ parentOf: options.parentOf,
168
+ });
169
+ };
170
+
171
+ // The background is a SET of samples. A solid surface is one sample; a varying
172
+ // backdrop (gradient / image / video the caller sampled) is several. Every
173
+ // decision is worst-case over the set: a role "passes" only if it passes
174
+ // against EVERY sample, and we re-solve against the HARDEST sample. With one
175
+ // sample this collapses to plain single-background behaviour, bit-for-bit.
176
+ const readSamples = () => {
177
+ const v = readBackground();
178
+ const arr = Array.isArray(v) ? v.filter((s) => typeof s === "string" && s.length > 0) : [v];
179
+ return arr.length > 0 ? arr : [fallback];
180
+ };
181
+
182
+ // Recheck the current colours against every sample. A role breaches if its
183
+ // achieved |Lc| drops below its single-threshold margin (`|lc| * (1 -
184
+ // dropFraction)`) against ANY sample. `worstIdx` is the sample with the least
185
+ // set-wide margin — the one to re-solve against, so the constraint we solve to
186
+ // is the same constraint we check hardest.
187
+ const recheckSamples = (fgs, samples) => {
188
+ let breached = false;
189
+ let worstIdx = 0;
190
+ let worstMargin = Infinity;
191
+ for (let s = 0; s < samples.length; s++) {
192
+ const flat = colors.recheckContrast(samples[s], fgs, theme);
193
+ let sampleMargin = Infinity;
194
+ for (let i = 0; i < roles.length; i++) {
195
+ const want = Math.abs(roles[i].lc) * (1 - dropFraction);
196
+ const lcNow = Math.abs(flat[2 * i]);
197
+ if (lcNow < want) breached = true;
198
+ const margin = want > 0 ? lcNow / want : Infinity;
199
+ if (margin < sampleMargin) sampleMargin = margin;
200
+ }
201
+ if (sampleMargin < worstMargin) {
202
+ worstMargin = sampleMargin;
203
+ worstIdx = s;
204
+ }
205
+ }
206
+ return { breached, worstIdx };
207
+ };
208
+
209
+ // Resolve a fresh set and adopt it as the current colours (no ease).
210
+ const solveAndAdopt = (bg, now) => {
211
+ const result = colors.resolveTheme(bg, theme);
212
+ roles = Object.entries(result.roles)
213
+ .filter(([, r]) => r && r.kind === "color")
214
+ .map(([key, r]) => ({
215
+ cssVar: r.cssVar,
216
+ key,
217
+ lc: r.lc,
218
+ hex: r.hex,
219
+ legalFloor: typeof r.legalFloor === "number" ? r.legalFloor : null,
220
+ }));
221
+ lastSolveAt = now;
222
+ breachSince = null;
223
+ return result;
224
+ };
225
+
226
+ // Solve+adopt against the hardest of `samples`. With one sample this is a
227
+ // single solve; with several it does a provisional solve to learn the role
228
+ // colours, picks the worst sample for them, and re-solves against it if that
229
+ // is not the one already chosen — so the adopted set is the strongest the
230
+ // backdrop demands. Used where there is no current set to recheck (initial
231
+ // apply, theme switch); the tick path already knows the worst sample from its
232
+ // own recheck and calls `solveAndAdopt` directly.
233
+ const solveAndAdoptWorst = (samples, now) => {
234
+ solveAndAdopt(samples[0], now);
235
+ if (samples.length > 1) {
236
+ const { worstIdx } = recheckSamples(
237
+ roles.map((r) => r.hex),
238
+ samples,
239
+ );
240
+ if (worstIdx !== 0) solveAndAdopt(samples[worstIdx], now);
241
+ }
242
+ };
243
+
244
+ const applyHexes = (hexByVar) => applyTheme(target, { vars: hexByVar });
245
+
246
+ const applyRolesDirect = () => {
247
+ const vars = {};
248
+ for (const r of roles) vars[r.cssVar] = r.hex;
249
+ applyHexes(vars);
250
+ };
251
+
252
+ // Begin an ease from the currently-applied colours toward the role colours.
253
+ // `held` latches the per-role displayed blend so it only ever advances toward
254
+ // the destination (strict mode) — see `stepEase`.
255
+ const beginEase = (fromByVar, now) => {
256
+ easing = new Map();
257
+ for (const r of roles) {
258
+ const from = fromByVar[r.cssVar] ?? r.hex;
259
+ if (from !== r.hex) easing.set(r.cssVar, { from, to: r.hex, held: 0 });
260
+ }
261
+ easeStart = now;
262
+ if (easing.size === 0) applyRolesDirect();
263
+ };
264
+
265
+ // Strict mode: the least blend in [e, 1] whose interpolated colour clears
266
+ // `floor` against EVERY background sample in `bgLums`. The destination (`to`,
267
+ // blend 1) is a freshly-solved legal colour, so it anchors the search; we
268
+ // bisect toward it from the natural ease value `e`. Returns `e` unchanged when
269
+ // the eased colour is already legal everywhere (the common case — no
270
+ // intervention). The returned blend is always floor-legal against the worst
271
+ // sample, except in the unavoidable case where even `to` is illegal against a
272
+ // sample that drifted further this frame — then it returns 1 (the most-legal
273
+ // colour we have) and the recheck loop re-solves.
274
+ const floorBlend = (seg, e, bgLums, floor) => {
275
+ const legalAt = (blend) => {
276
+ const lum = relativeLuminanceHex(lerpHex(seg.from, seg.to, blend));
277
+ return bgLums.every((L) => wcagRatio(lum, L) >= floor);
278
+ };
279
+ if (legalAt(e)) return e;
280
+ let lo = e;
281
+ let hi = 1;
282
+ for (let k = 0; k < 14; k++) {
283
+ const mid = (lo + hi) / 2;
284
+ if (legalAt(mid)) hi = mid;
285
+ else lo = mid;
286
+ }
287
+ return hi; // hi is always legal (or blend 1, the most-legal we have)
288
+ };
289
+
290
+ const stepEase = (now, samples) => {
291
+ const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
292
+ const e = easeOut(t);
293
+ const bgLums = strict ? samples.map(relativeLuminanceHex) : null;
294
+ const vars = {};
295
+ for (const r of roles) {
296
+ const seg = easing.get(r.cssVar);
297
+ if (!seg) {
298
+ vars[r.cssVar] = r.hex;
299
+ continue;
300
+ }
301
+ let blend = e;
302
+ if (strict && r.legalFloor != null) {
303
+ // Hold the floor (against the worst sample), then LATCH: the displayed
304
+ // blend may only advance toward the destination, never retreat.
305
+ // `floorBlend` is stateless and depends on the live (drifting) samples,
306
+ // so on a frame where they drift favourably it could return a *lower*
307
+ // blend than last frame — a backwards step toward the old colour, the
308
+ // precise jarring reversal this mode exists to avoid. `held` clamps that
309
+ // out: the colour progresses monotonically from→to and never below the
310
+ // legal line on any sample.
311
+ blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held);
312
+ seg.held = blend;
313
+ }
314
+ vars[r.cssVar] = lerpHex(seg.from, seg.to, blend);
315
+ }
316
+ applyHexes(vars);
317
+ if (t >= 1) easing = new Map();
318
+ };
319
+
320
+ const currentApplied = () => {
321
+ const vars = {};
322
+ for (const r of roles) {
323
+ const seg = easing.get(r.cssVar);
324
+ vars[r.cssVar] = seg ? seg.to : r.hex; // logical target during/after ease
325
+ }
326
+ return vars;
327
+ };
328
+
329
+ // The colour each role is PAINTED right now — exactly what `stepEase` writes
330
+ // this frame: an in-flight segment sampled at `now` (with the SAME strict
331
+ // floor-hold + latch against the worst sample when `strict`), else the static
332
+ // hex. Mirrors `stepEase`'s blend math byte-for-byte so the begin-from value
333
+ // equals what is on screen, including the strict-mode `held` clamp — otherwise
334
+ // an overlapping re-solve in strict mode would start one frame BELOW the
335
+ // painted (floored) colour.
336
+ const paintedNow = (now, samples) => {
337
+ const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
338
+ const e = easeOut(t);
339
+ const bgLums = strict ? samples.map(relativeLuminanceHex) : null;
340
+ const vars = {};
341
+ for (const r of roles) {
342
+ const seg = easing.get(r.cssVar);
343
+ if (!seg) {
344
+ vars[r.cssVar] = r.hex;
345
+ continue;
346
+ }
347
+ const blend =
348
+ strict && r.legalFloor != null
349
+ ? Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held)
350
+ : e;
351
+ vars[r.cssVar] = lerpHex(seg.from, seg.to, blend);
352
+ }
353
+ return vars;
354
+ };
355
+
356
+ const tick = (nowArg) => {
357
+ const now = nowArg ?? clock();
358
+ const samples = readSamples();
359
+ // Advance any in-flight ease first (against the live samples, so strict mode
360
+ // holds the legal floor every frame as the backdrop keeps drifting under it).
361
+ if (easing.size > 0) stepEase(now, samples);
362
+
363
+ // Steady state: a static backdrop with no in-flight ease and no pending
364
+ // breach needs no work. A PENDING breach keeps us live even on a static
365
+ // backdrop, so the sustain timer can fire on one that changed once to a
366
+ // failing value and then held.
367
+ const key = samples.join("|");
368
+ if (key === lastKey && easing.size === 0 && breachSince === null) return;
369
+ lastKey = key;
370
+ if (roles.length === 0) return;
371
+
372
+ // Cheap worst-case re-check: do the current colours still pass against every
373
+ // sample? `worstIdx` is the hardest sample, the one to re-solve against.
374
+ const { breached, worstIdx } = recheckSamples(
375
+ roles.map((r) => r.hex),
376
+ samples,
377
+ );
378
+
379
+ if (!breached) {
380
+ breachSince = null;
381
+ return; // hold — the common case for a slowly-drifting backdrop
382
+ }
383
+ if (breachSince === null) breachSince = now;
384
+ if (now - breachSince < sustainMs || now - lastSolveAt < dwellMs) return; // debounce / dwell
385
+
386
+ // Sustained breach: re-solve against the worst sample and ease toward fresh,
387
+ // starting from the colour each role is PAINTED right now (the in-flight ease
388
+ // sampled at `now`) — never the in-flight TARGET. Starting from the target
389
+ // would SNAP the element to the old target for one frame before easing,
390
+ // reintroducing flicker when a re-solve overlaps a previous ease.
391
+ const fromByVar = paintedNow(now, samples);
392
+ solveAndAdopt(samples[worstIdx], now);
393
+ beginEase(fromByVar, now);
394
+ stepEase(now, samples);
395
+ };
396
+
397
+ let rafId = null;
398
+ const loop = () => {
399
+ tick();
400
+ if (win?.requestAnimationFrame) rafId = win.requestAnimationFrame(loop);
401
+ };
402
+
403
+ // Apply the initial set immediately (against the worst sample of the backdrop).
404
+ {
405
+ const samples = readSamples();
406
+ lastKey = samples.join("|");
407
+ solveAndAdoptWorst(samples, clock());
408
+ applyRolesDirect();
409
+ }
410
+
411
+ return {
412
+ tick,
413
+ setTheme(next) {
414
+ theme = next;
415
+ const samples = readSamples();
416
+ lastKey = samples.join("|");
417
+ easing = new Map();
418
+ solveAndAdoptWorst(samples, clock());
419
+ applyRolesDirect(); // instant — a theme switch is intent, not drift
420
+ },
421
+ start() {
422
+ if (rafId == null && win?.requestAnimationFrame) rafId = win.requestAnimationFrame(loop);
423
+ },
424
+ stop() {
425
+ if (rafId != null && win?.cancelAnimationFrame) win.cancelAnimationFrame(rafId);
426
+ rafId = null;
427
+ easing = new Map();
428
+ },
429
+ current: currentApplied,
430
+ };
431
+ }