@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/README.md +248 -242
- package/adapt-theme.d.ts +67 -67
- package/adapt-theme.js +431 -431
- package/apply-theme.d.ts +16 -16
- package/apply-theme.js +44 -44
- package/effective-bg.d.ts +48 -48
- package/effective-bg.js +255 -255
- package/index.d.ts +36 -36
- package/index.js +21 -21
- package/package.json +65 -65
- package/pkg/labcolors.js +5 -5
- package/pkg/labcolors_bg.wasm +0 -0
- package/watch-theme.d.ts +57 -57
- package/watch-theme.js +144 -144
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
|
+
}
|