@labpics/colors 0.6.2 → 0.7.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 +99 -32
- package/effective-bg.js +135 -0
- package/package.json +1 -1
- package/pkg/labcolors.d.ts +19 -11
- package/pkg/labcolors.js +45 -16
- package/pkg/labcolors_bg.wasm +0 -0
- package/pkg/labcolors_bg.wasm.d.ts +1 -0
package/adapt-theme.js
CHANGED
|
@@ -48,7 +48,15 @@
|
|
|
48
48
|
// with no legal floor (decorative) ease freely either way.
|
|
49
49
|
|
|
50
50
|
import { applyTheme } from "./apply-theme.js";
|
|
51
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
effectiveBackground,
|
|
53
|
+
parseCssColor,
|
|
54
|
+
oklabLerp,
|
|
55
|
+
compileLerpPair,
|
|
56
|
+
lerpPairHex,
|
|
57
|
+
lerpPairLuminance,
|
|
58
|
+
wcagLuminanceCached,
|
|
59
|
+
} from "./effective-bg.js";
|
|
52
60
|
|
|
53
61
|
/** Cubic ease-out: fast start, gentle settle, no overshoot. A non-finite `t`
|
|
54
62
|
* (e.g. a NaN clock making `(now - easeStart) / easeMs` NaN) is treated as a
|
|
@@ -78,10 +86,23 @@ function wcagRatio(lumA, lumB) {
|
|
|
78
86
|
return (hi + 0.05) / (lo + 0.05);
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
/** Interpolate
|
|
82
|
-
* perceptually even (no lingering-bright sRGB midpoint, no muddy chroma path).
|
|
83
|
-
|
|
84
|
-
|
|
89
|
+
/** Interpolate an ease segment at `t ∈ [0,1]` in Oklab, so the crossfade is
|
|
90
|
+
* perceptually even (no lingering-bright sRGB midpoint, no muddy chroma path).
|
|
91
|
+
* Segments carry a compiled pair (`compileLerpPair`) when both endpoints
|
|
92
|
+
* parse — the always-case in practice, both being engine-emitted `#RRGGBB` —
|
|
93
|
+
* so per-frame interpolation runs on pre-parsed Oklab coordinates instead of
|
|
94
|
+
* re-parsing both endpoint strings every frame. A `null` pair falls back to
|
|
95
|
+
* `oklabLerp`, which owns the unparseable-endpoint fallback semantics.
|
|
96
|
+
* Byte-identical either way (locked by test/hotpath-parity.test.mjs). */
|
|
97
|
+
function segHex(seg, t) {
|
|
98
|
+
return seg.pair ? lerpPairHex(seg.pair, t) : oklabLerp(seg.from, seg.to, t);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** WCAG relative luminance of `segHex(seg, t)` — numeric fast path on the
|
|
102
|
+
* compiled pair (no `#RRGGBB` round-trip), string path otherwise. Strict
|
|
103
|
+
* mode's `floorBlend` bisection calls this up to 14× per role per frame. */
|
|
104
|
+
function segLum(seg, t) {
|
|
105
|
+
return seg.pair ? lerpPairLuminance(seg.pair, t) : relativeLuminanceHex(segHex(seg, t));
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
/**
|
|
@@ -156,7 +177,7 @@ export function adaptTheme(element, options) {
|
|
|
156
177
|
* roles are never dropped by `applyTheme`'s clear-then-write and always carry
|
|
157
178
|
* the current theme's value. Only `kind === "color"` roles (in `roles`) ease. */
|
|
158
179
|
let baseVars = {};
|
|
159
|
-
/** @type {Map<string,{from:string,to:string}>} in-flight ease per cssVar */
|
|
180
|
+
/** @type {Map<string,{from:string,to:string,held:number,pair:object|null}>} in-flight ease per cssVar */
|
|
160
181
|
let easing = new Map();
|
|
161
182
|
let easeStart = 0;
|
|
162
183
|
let breachSince = null;
|
|
@@ -190,12 +211,16 @@ export function adaptTheme(element, options) {
|
|
|
190
211
|
// dropFraction)`) against ANY sample. `worstIdx` is the sample with the least
|
|
191
212
|
// set-wide margin — the one to re-solve against, so the constraint we solve to
|
|
192
213
|
// is the same constraint we check hardest.
|
|
193
|
-
|
|
214
|
+
// The current foreground hexes, refreshed on adopt. Rechecks run per changed
|
|
215
|
+
// frame and previously rebuilt this identical array from `roles` each time.
|
|
216
|
+
let fgsCache = [];
|
|
217
|
+
|
|
218
|
+
const recheckSamples = (samples) => {
|
|
194
219
|
let breached = false;
|
|
195
220
|
let worstIdx = 0;
|
|
196
221
|
let worstMargin = Infinity;
|
|
197
222
|
for (let s = 0; s < samples.length; s++) {
|
|
198
|
-
const flat = colors.recheckContrast(samples[s],
|
|
223
|
+
const flat = colors.recheckContrast(samples[s], fgsCache, theme);
|
|
199
224
|
let sampleMargin = Infinity;
|
|
200
225
|
for (let i = 0; i < roles.length; i++) {
|
|
201
226
|
const want = Math.abs(roles[i].lc) * (1 - dropFraction);
|
|
@@ -227,6 +252,10 @@ export function adaptTheme(element, options) {
|
|
|
227
252
|
hex: r.hex,
|
|
228
253
|
legalFloor: typeof r.legalFloor === "number" ? r.legalFloor : null,
|
|
229
254
|
}));
|
|
255
|
+
fgsCache = roles.map((r) => r.hex);
|
|
256
|
+
// The adopt may change the var/role KEY SET — force the next write through
|
|
257
|
+
// `applyTheme`'s full clear-then-write instead of the mid-ease diff path.
|
|
258
|
+
written = null;
|
|
230
259
|
lastSolveAt = now;
|
|
231
260
|
breachSince = null;
|
|
232
261
|
return result;
|
|
@@ -242,19 +271,41 @@ export function adaptTheme(element, options) {
|
|
|
242
271
|
const solveAndAdoptWorst = (samples, now) => {
|
|
243
272
|
solveAndAdopt(samples[0], now);
|
|
244
273
|
if (samples.length > 1) {
|
|
245
|
-
const { worstIdx } = recheckSamples(
|
|
246
|
-
roles.map((r) => r.hex),
|
|
247
|
-
samples,
|
|
248
|
-
);
|
|
274
|
+
const { worstIdx } = recheckSamples(samples);
|
|
249
275
|
if (worstIdx !== 0) solveAndAdopt(samples[worstIdx], now);
|
|
250
276
|
}
|
|
251
277
|
};
|
|
252
278
|
|
|
253
279
|
// Every write goes through the full canonical set with the (optional) eased
|
|
254
280
|
// color overlay on top — so translucent roles in `baseVars` persist through
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
|
|
281
|
+
// the apply, and non-eased color roles keep their canonical oklch form.
|
|
282
|
+
// `overlay` carries only in-flight color roles as hex.
|
|
283
|
+
//
|
|
284
|
+
// WRITE STRATEGY — full vs diff. `written` holds the vars of the last write;
|
|
285
|
+
// `null` forces the next write through `applyTheme`'s full clear-then-write.
|
|
286
|
+
// Between two adopts the composed key set is invariant (always `baseVars`'
|
|
287
|
+
// keys), so mid-ease frames DIFF against `written`: only values that changed
|
|
288
|
+
// this frame hit `setProperty` (≈ the roles actually easing), instead of
|
|
289
|
+
// remove+set of EVERY `--lab-*` var on every frame — the dominant DOM cost
|
|
290
|
+
// of an ease and pure churn for the style engine. The final style state is
|
|
291
|
+
// byte-identical to a full rewrite (locked by the golden fingerprints in
|
|
292
|
+
// test/hotpath-parity.test.mjs). Every adopt nulls `written`, so key-set
|
|
293
|
+
// changes and any external clobbering self-heal at the next solve — the same
|
|
294
|
+
// guarantee the always-full-rewrite gave, which also wrote nothing between
|
|
295
|
+
// eases while steady.
|
|
296
|
+
let written = null;
|
|
297
|
+
const applyHexes = (overlay) => {
|
|
298
|
+
const vars = { ...baseVars, ...overlay };
|
|
299
|
+
if (written === null) {
|
|
300
|
+
applyTheme(target, { vars });
|
|
301
|
+
} else {
|
|
302
|
+
for (const k in vars) {
|
|
303
|
+
const v = vars[k];
|
|
304
|
+
if (written[k] !== v) target.style.setProperty(k, v);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
written = vars;
|
|
308
|
+
};
|
|
258
309
|
|
|
259
310
|
// Apply the canonical set as-is (no ease in flight): color roles show their
|
|
260
311
|
// oklch form, translucent roles their tint+alpha.
|
|
@@ -267,7 +318,9 @@ export function adaptTheme(element, options) {
|
|
|
267
318
|
easing = new Map();
|
|
268
319
|
for (const r of roles) {
|
|
269
320
|
const from = fromByVar[r.cssVar] ?? r.hex;
|
|
270
|
-
if (from !== r.hex)
|
|
321
|
+
if (from !== r.hex) {
|
|
322
|
+
easing.set(r.cssVar, { from, to: r.hex, held: 0, pair: compileLerpPair(from, r.hex) });
|
|
323
|
+
}
|
|
271
324
|
}
|
|
272
325
|
easeStart = now;
|
|
273
326
|
if (easing.size === 0) applyRolesDirect();
|
|
@@ -284,8 +337,11 @@ export function adaptTheme(element, options) {
|
|
|
284
337
|
// colour we have) and the recheck loop re-solves.
|
|
285
338
|
const floorBlend = (seg, e, bgLums, floor) => {
|
|
286
339
|
const legalAt = (blend) => {
|
|
287
|
-
const lum =
|
|
288
|
-
|
|
340
|
+
const lum = segLum(seg, blend);
|
|
341
|
+
for (let i = 0; i < bgLums.length; i++) {
|
|
342
|
+
if (wcagRatio(lum, bgLums[i]) < floor) return false;
|
|
343
|
+
}
|
|
344
|
+
return true;
|
|
289
345
|
};
|
|
290
346
|
if (legalAt(e)) return e;
|
|
291
347
|
let lo = e;
|
|
@@ -298,7 +354,21 @@ export function adaptTheme(element, options) {
|
|
|
298
354
|
return hi; // hi is always legal (or blend 1, the most-legal we have)
|
|
299
355
|
};
|
|
300
356
|
|
|
301
|
-
|
|
357
|
+
// Per-key memo of the samples' WCAG luminances. Strict mode reads them in
|
|
358
|
+
// both `stepEase` and `paintedNow` within a tick, and across consecutive
|
|
359
|
+
// frames of a static backdrop mid-ease; the tick already computes the
|
|
360
|
+
// samples key, so this costs one map per DISTINCT backdrop, not per call.
|
|
361
|
+
let lumsKey = null;
|
|
362
|
+
let lums = null;
|
|
363
|
+
const bgLumsFor = (samples, key) => {
|
|
364
|
+
if (key !== lumsKey) {
|
|
365
|
+
lums = samples.map(wcagLuminanceCached);
|
|
366
|
+
lumsKey = key;
|
|
367
|
+
}
|
|
368
|
+
return lums;
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
const stepEase = (now, samples, key) => {
|
|
302
372
|
const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
|
|
303
373
|
// Terminate the ease when it is done (`t >= 1`) OR when the clock went
|
|
304
374
|
// non-finite (a NaN/±∞ `now` making `t` non-finite): drop the segments and
|
|
@@ -315,7 +385,7 @@ export function adaptTheme(element, options) {
|
|
|
315
385
|
return;
|
|
316
386
|
}
|
|
317
387
|
const e = easeOut(t);
|
|
318
|
-
const bgLums = strict ? samples
|
|
388
|
+
const bgLums = strict ? bgLumsFor(samples, key) : null;
|
|
319
389
|
// Overlay carries ONLY in-flight color roles (as interpolated hex); every
|
|
320
390
|
// other role — non-eased color and all translucent — keeps its canonical
|
|
321
391
|
// `baseVars` value under the merge in `applyHexes`.
|
|
@@ -336,7 +406,7 @@ export function adaptTheme(element, options) {
|
|
|
336
406
|
blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held);
|
|
337
407
|
seg.held = blend;
|
|
338
408
|
}
|
|
339
|
-
overlay[r.cssVar] =
|
|
409
|
+
overlay[r.cssVar] = segHex(seg, blend);
|
|
340
410
|
}
|
|
341
411
|
applyHexes(overlay);
|
|
342
412
|
};
|
|
@@ -360,10 +430,10 @@ export function adaptTheme(element, options) {
|
|
|
360
430
|
// equals what is on screen, including the strict-mode `held` clamp — otherwise
|
|
361
431
|
// an overlapping re-solve in strict mode would start one frame BELOW the
|
|
362
432
|
// painted (floored) colour.
|
|
363
|
-
const paintedNow = (now, samples) => {
|
|
433
|
+
const paintedNow = (now, samples, key) => {
|
|
364
434
|
const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs;
|
|
365
435
|
const e = easeOut(t);
|
|
366
|
-
const bgLums = strict ? samples
|
|
436
|
+
const bgLums = strict ? bgLumsFor(samples, key) : null;
|
|
367
437
|
const vars = {};
|
|
368
438
|
for (const r of roles) {
|
|
369
439
|
const seg = easing.get(r.cssVar);
|
|
@@ -375,7 +445,7 @@ export function adaptTheme(element, options) {
|
|
|
375
445
|
strict && r.legalFloor != null
|
|
376
446
|
? Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held)
|
|
377
447
|
: e;
|
|
378
|
-
vars[r.cssVar] =
|
|
448
|
+
vars[r.cssVar] = segHex(seg, blend);
|
|
379
449
|
}
|
|
380
450
|
return vars;
|
|
381
451
|
};
|
|
@@ -383,25 +453,22 @@ export function adaptTheme(element, options) {
|
|
|
383
453
|
const tick = (nowArg) => {
|
|
384
454
|
const now = nowArg ?? clock();
|
|
385
455
|
const samples = readSamples();
|
|
456
|
+
const key = samples.join("|");
|
|
386
457
|
// Advance any in-flight ease first (against the live samples, so strict mode
|
|
387
458
|
// holds the legal floor every frame as the backdrop keeps drifting under it).
|
|
388
|
-
if (easing.size > 0) stepEase(now, samples);
|
|
459
|
+
if (easing.size > 0) stepEase(now, samples, key);
|
|
389
460
|
|
|
390
461
|
// Steady state: a static backdrop with no in-flight ease and no pending
|
|
391
462
|
// breach needs no work. A PENDING breach keeps us live even on a static
|
|
392
463
|
// backdrop, so the sustain timer can fire on one that changed once to a
|
|
393
464
|
// failing value and then held.
|
|
394
|
-
const key = samples.join("|");
|
|
395
465
|
if (key === lastKey && easing.size === 0 && breachSince === null) return;
|
|
396
466
|
lastKey = key;
|
|
397
467
|
if (roles.length === 0) return;
|
|
398
468
|
|
|
399
469
|
// Cheap worst-case re-check: do the current colours still pass against every
|
|
400
470
|
// sample? `worstIdx` is the hardest sample, the one to re-solve against.
|
|
401
|
-
const { breached, worstIdx } = recheckSamples(
|
|
402
|
-
roles.map((r) => r.hex),
|
|
403
|
-
samples,
|
|
404
|
-
);
|
|
471
|
+
const { breached, worstIdx } = recheckSamples(samples);
|
|
405
472
|
|
|
406
473
|
if (!breached) {
|
|
407
474
|
breachSince = null;
|
|
@@ -415,10 +482,10 @@ export function adaptTheme(element, options) {
|
|
|
415
482
|
// sampled at `now`) — never the in-flight TARGET. Starting from the target
|
|
416
483
|
// would SNAP the element to the old target for one frame before easing,
|
|
417
484
|
// reintroducing flicker when a re-solve overlaps a previous ease.
|
|
418
|
-
const fromByVar = paintedNow(now, samples);
|
|
485
|
+
const fromByVar = paintedNow(now, samples, key);
|
|
419
486
|
solveAndAdopt(samples[worstIdx], now);
|
|
420
487
|
beginEase(fromByVar, now);
|
|
421
|
-
stepEase(now, samples);
|
|
488
|
+
stepEase(now, samples, key);
|
|
422
489
|
};
|
|
423
490
|
|
|
424
491
|
let rafId = null;
|
package/effective-bg.js
CHANGED
|
@@ -288,6 +288,141 @@ export function oklabLerp(from, to, t) {
|
|
|
288
288
|
return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]);
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// --- Compiled hot-path forms (package-internal) -----------------------------
|
|
292
|
+
//
|
|
293
|
+
// `adaptTheme` interpolates the SAME from/to pair on every frame of an ease,
|
|
294
|
+
// and strict mode re-derives WCAG luminance from the interpolated colour up to
|
|
295
|
+
// 14× per floored role per frame (`floorBlend`'s bisection). Doing that
|
|
296
|
+
// through the string API means re-parsing both endpoints and round-tripping
|
|
297
|
+
// through `#RRGGBB` text on every call — measured at ~85-90% of the ease-frame
|
|
298
|
+
// budget (bench/hotpath.bench.mjs). These helpers compile a pair once and then
|
|
299
|
+
// produce results BYTE-IDENTICAL to their string-path equivalents:
|
|
300
|
+
//
|
|
301
|
+
// · `lerpPairHex(pair, t)` ≡ `oklabLerp(from, to, t)`
|
|
302
|
+
// · `lerpPairLuminance(pair, t)` ≡ WCAG luminance of `oklabLerp(from, to, t)`
|
|
303
|
+
// · `wcagLuminanceCached(css)` ≡ luminance of `parseCssColor(css) ?? black`
|
|
304
|
+
//
|
|
305
|
+
// (locked by test/hotpath-parity.test.mjs on randomised inputs). They are
|
|
306
|
+
// consumed by `adapt-theme.js` and are NOT part of the public package surface
|
|
307
|
+
// (`index.js` does not re-export them).
|
|
308
|
+
|
|
309
|
+
const PARSE_CACHE_CAP = 256;
|
|
310
|
+
const parseCache = new Map();
|
|
311
|
+
|
|
312
|
+
/** `parseCssColor` behind a small bounded memo, for per-frame callers feeding
|
|
313
|
+
* it recurring strings (computed-style values, backdrop samples, ease
|
|
314
|
+
* endpoints). The cap is a blunt bound, not an LRU: a full cache is simply
|
|
315
|
+
* cleared and refills within a frame — cheaper than eviction bookkeeping for
|
|
316
|
+
* a working set that is a handful of strings. The cached arrays are SHARED —
|
|
317
|
+
* package-internal callers must treat them as immutable. (The public
|
|
318
|
+
* `parseCssColor` stays unmemoized and returns a fresh array per call.) */
|
|
319
|
+
export function parseCssColorCached(css) {
|
|
320
|
+
let hit = parseCache.get(css);
|
|
321
|
+
if (hit === undefined) {
|
|
322
|
+
hit = parseCssColor(css);
|
|
323
|
+
if (parseCache.size >= PARSE_CACHE_CAP) parseCache.clear();
|
|
324
|
+
parseCache.set(css, hit);
|
|
325
|
+
}
|
|
326
|
+
return hit;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** WCAG 2.1 relative luminance of r,g,b channels (0..255) — the normative
|
|
330
|
+
* 0.03928 / 12.92 / 2.4 constants, matching `adapt-theme`'s floor semantics. */
|
|
331
|
+
function wcagLumChannels(r, g, b) {
|
|
332
|
+
const lin = (c) => {
|
|
333
|
+
const s = c / 255;
|
|
334
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
335
|
+
};
|
|
336
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const LUM_CACHE_CAP = 256;
|
|
340
|
+
const lumCache = new Map();
|
|
341
|
+
|
|
342
|
+
/** WCAG relative luminance of any colour string, memoised. Byte-equal to the
|
|
343
|
+
* string path `adapt-theme` used per sample per frame: luminance of
|
|
344
|
+
* `parseCssColor(css) ?? [0,0,0,1]` — i.e. unparseable input yields the
|
|
345
|
+
* luminance of black, preserving the historical fallback. */
|
|
346
|
+
export function wcagLuminanceCached(css) {
|
|
347
|
+
let lum = lumCache.get(css);
|
|
348
|
+
if (lum === undefined) {
|
|
349
|
+
const c = parseCssColorCached(css) ?? [0, 0, 0, 1];
|
|
350
|
+
lum = wcagLumChannels(c[0], c[1], c[2]);
|
|
351
|
+
if (lumCache.size >= LUM_CACHE_CAP) lumCache.clear();
|
|
352
|
+
lumCache.set(css, lum);
|
|
353
|
+
}
|
|
354
|
+
return lum;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Round a channel to the exact byte `toHex` would emit (non-finite → 0,
|
|
358
|
+
* clamp, round) — keeps the numeric luminance path quantisation-identical to
|
|
359
|
+
* the `#RRGGBB` round-trip it replaces. */
|
|
360
|
+
function hexByte(v) {
|
|
361
|
+
return Math.round(clamp255(Number.isFinite(v) ? v : 0));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Compile a from/to colour pair for repeated interpolation. Returns `null`
|
|
366
|
+
* when either endpoint fails to parse — callers fall back to `oklabLerp`,
|
|
367
|
+
* which owns the unparseable-endpoint fallback semantics. The pair carries
|
|
368
|
+
* both endpoints' Oklab coordinates plus their exact `toHex` forms, so the
|
|
369
|
+
* per-frame work is one Oklab lerp + gamut map — no string parsing at all.
|
|
370
|
+
*
|
|
371
|
+
* @param {string} from any colour string `parseCssColor` accepts
|
|
372
|
+
* @param {string} to any colour string `parseCssColor` accepts
|
|
373
|
+
* @returns {{la:number[],lb:number[],aHex:string,bHex:string,aBytes:number[],bBytes:number[]} | null}
|
|
374
|
+
*/
|
|
375
|
+
export function compileLerpPair(from, to) {
|
|
376
|
+
const a = parseCssColorCached(from);
|
|
377
|
+
const b = parseCssColorCached(to);
|
|
378
|
+
if (!a || !b) return null;
|
|
379
|
+
return {
|
|
380
|
+
la: linearRgbToOklab(srgbToLinear(a[0] / 255), srgbToLinear(a[1] / 255), srgbToLinear(a[2] / 255)),
|
|
381
|
+
lb: linearRgbToOklab(srgbToLinear(b[0] / 255), srgbToLinear(b[1] / 255), srgbToLinear(b[2] / 255)),
|
|
382
|
+
aHex: toHex(a),
|
|
383
|
+
bHex: toHex(b),
|
|
384
|
+
aBytes: [hexByte(a[0]), hexByte(a[1]), hexByte(a[2])],
|
|
385
|
+
bBytes: [hexByte(b[0]), hexByte(b[1]), hexByte(b[2])],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Byte-identical to `oklabLerp(from, to, t)` for the pair's endpoints — the
|
|
390
|
+
* same double-precision Oklab lerp on the same parsed channels, minus the
|
|
391
|
+
* per-call re-parse. */
|
|
392
|
+
export function lerpPairHex(pair, t) {
|
|
393
|
+
if (t <= 0) return pair.aHex;
|
|
394
|
+
if (t >= 1) return pair.bHex;
|
|
395
|
+
const la = pair.la;
|
|
396
|
+
const lb = pair.lb;
|
|
397
|
+
const lin = oklabToLinearRgb(
|
|
398
|
+
la[0] + (lb[0] - la[0]) * t,
|
|
399
|
+
la[1] + (lb[1] - la[1]) * t,
|
|
400
|
+
la[2] + (lb[2] - la[2]) * t,
|
|
401
|
+
);
|
|
402
|
+
return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** WCAG relative luminance of `lerpPairHex(pair, t)` WITHOUT the `#RRGGBB`
|
|
406
|
+
* round-trip: channels are quantised to the exact bytes `toHex` would emit,
|
|
407
|
+
* then fed to the normative formula — so strict-mode bisection over `t` is
|
|
408
|
+
* ~20 flops instead of serialise + re-parse, at identical results. */
|
|
409
|
+
export function lerpPairLuminance(pair, t) {
|
|
410
|
+
if (t <= 0) return wcagLumChannels(pair.aBytes[0], pair.aBytes[1], pair.aBytes[2]);
|
|
411
|
+
if (t >= 1) return wcagLumChannels(pair.bBytes[0], pair.bBytes[1], pair.bBytes[2]);
|
|
412
|
+
const la = pair.la;
|
|
413
|
+
const lb = pair.lb;
|
|
414
|
+
const lin = oklabToLinearRgb(
|
|
415
|
+
la[0] + (lb[0] - la[0]) * t,
|
|
416
|
+
la[1] + (lb[1] - la[1]) * t,
|
|
417
|
+
la[2] + (lb[2] - la[2]) * t,
|
|
418
|
+
);
|
|
419
|
+
return wcagLumChannels(
|
|
420
|
+
hexByte(linearToSrgb(lin[0]) * 255),
|
|
421
|
+
hexByte(linearToSrgb(lin[1]) * 255),
|
|
422
|
+
hexByte(linearToSrgb(lin[2]) * 255),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
291
426
|
/**
|
|
292
427
|
* Compose an ordered stack of colour layers (front-to-back) over an opaque base
|
|
293
428
|
* into a single opaque `#RRGGBB`. Pure — no DOM. Exposed for testing and for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@labpics/colors",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Framework-agnostic contrast engine: resolve accessible, perceptually-anchored colour roles for any background. WASM core, zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/pkg/labcolors.d.ts
CHANGED
|
@@ -141,18 +141,8 @@ export type RoleRecipe =
|
|
|
141
141
|
| { kind: "alpha-analog"; of: LadderSource; alpha: number }
|
|
142
142
|
| { kind: "zero" };
|
|
143
143
|
|
|
144
|
-
/** Именованный пресет ролей. Тонкий конфиг несёт `preset` вместо простыни `roles`. */
|
|
145
|
-
export type RolePreset = "labui";
|
|
146
|
-
|
|
147
144
|
/** Полный конфиг дизайн-системы клиента — вход loadConfig (JSON.stringify(config)). */
|
|
148
145
|
export interface ThemeConfig {
|
|
149
|
-
/**
|
|
150
|
-
* Пресет ролей: наполняет словарь дизайн-системы целиком, чтобы клиент вносил
|
|
151
|
-
* ТОЛЬКО значения (якоря, ручки), не семантику. Тонкий конфиг задаёт `preset` и
|
|
152
|
-
* ОПУСКАЕТ `roles`/`aliases`. Задать `preset` вместе с непустыми `roles` —
|
|
153
|
-
* ошибка `invalid_config` (оверрайд отдельных ролей — не этот слой).
|
|
154
|
-
*/
|
|
155
|
-
readonly preset?: RolePreset;
|
|
156
146
|
readonly brand: ThemeAnchors;
|
|
157
147
|
readonly neutral: {
|
|
158
148
|
readonly anchors: { light: string; mid: string; dark: string };
|
|
@@ -177,7 +167,8 @@ export interface ThemeConfig {
|
|
|
177
167
|
readonly chroma_fraction: number;
|
|
178
168
|
};
|
|
179
169
|
readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>;
|
|
180
|
-
/**
|
|
170
|
+
/** Словарь ролей дизайн-системы. Конфиг обязан нести собственные роли; пустой
|
|
171
|
+
* контракт (без `roles` и `aliases`) отклоняется на загрузке. */
|
|
181
172
|
readonly roles?: ReadonlyArray<{ name: string; recipe: RoleRecipe }>;
|
|
182
173
|
readonly aliases?: ReadonlyArray<{ alias: string; target: string }>;
|
|
183
174
|
}
|
|
@@ -213,6 +204,22 @@ export interface ResolvedTheme {
|
|
|
213
204
|
export class LabColors {
|
|
214
205
|
free(): void;
|
|
215
206
|
[Symbol.dispose](): void;
|
|
207
|
+
/**
|
|
208
|
+
* PROTOTYPE — NOT part of the public API (unlisted, `_`-prefixed; may change
|
|
209
|
+
* or be removed without notice). Recheck one foreground set against MANY
|
|
210
|
+
* background samples in a single call. The reactive controller's worst-case
|
|
211
|
+
* loop rechecks the same foregrounds against every sample of a varying
|
|
212
|
+
* backdrop; the dominant per-foreground CAM16 forward is background-
|
|
213
|
+
* independent, so this shares it across all samples instead of recomputing it
|
|
214
|
+
* per `recheckContrast` call.
|
|
215
|
+
*
|
|
216
|
+
* Returns a flat, background-major `Float64Array`: sample `s`, foreground `i`
|
|
217
|
+
* is at `(s * fgHexes.length + i) * 2` (`lc`) and `+1` (`wcagRatio`). The
|
|
218
|
+
* values are byte-identical to calling `recheckContrast(bgHexes[s], fgHexes,
|
|
219
|
+
* theme)` for each `s`. Wiring the runtime to it is an OWNER decision (it
|
|
220
|
+
* changes the controller↔engine call shape); it is measured, not adopted.
|
|
221
|
+
*/
|
|
222
|
+
_recheckContrastMulti(bg_hexes: string[], fg_hexes: string[], theme: string): Float64Array;
|
|
216
223
|
/**
|
|
217
224
|
* Загрузить конфиг дизайн-системы (JSON по типу `ThemeConfig` из `.d.ts`).
|
|
218
225
|
*
|
|
@@ -268,6 +275,7 @@ export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembl
|
|
|
268
275
|
export interface InitOutput {
|
|
269
276
|
readonly memory: WebAssembly.Memory;
|
|
270
277
|
readonly __wbg_labcolors_free: (a: number, b: number) => void;
|
|
278
|
+
readonly labcolors__recheckContrastMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
|
|
271
279
|
readonly labcolors_loadConfig: (a: number, b: number, c: number, d: number) => void;
|
|
272
280
|
readonly labcolors_muddiness: (a: number, b: number, c: number, d: number) => void;
|
|
273
281
|
readonly labcolors_new: () => number;
|
package/pkg/labcolors.js
CHANGED
|
@@ -17,6 +17,49 @@ export class LabColors {
|
|
|
17
17
|
const ptr = this.__destroy_into_raw();
|
|
18
18
|
wasm.__wbg_labcolors_free(ptr, 0);
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* PROTOTYPE — NOT part of the public API (unlisted, `_`-prefixed; may change
|
|
22
|
+
* or be removed without notice). Recheck one foreground set against MANY
|
|
23
|
+
* background samples in a single call. The reactive controller's worst-case
|
|
24
|
+
* loop rechecks the same foregrounds against every sample of a varying
|
|
25
|
+
* backdrop; the dominant per-foreground CAM16 forward is background-
|
|
26
|
+
* independent, so this shares it across all samples instead of recomputing it
|
|
27
|
+
* per `recheckContrast` call.
|
|
28
|
+
*
|
|
29
|
+
* Returns a flat, background-major `Float64Array`: sample `s`, foreground `i`
|
|
30
|
+
* is at `(s * fgHexes.length + i) * 2` (`lc`) and `+1` (`wcagRatio`). The
|
|
31
|
+
* values are byte-identical to calling `recheckContrast(bgHexes[s], fgHexes,
|
|
32
|
+
* theme)` for each `s`. Wiring the runtime to it is an OWNER decision (it
|
|
33
|
+
* changes the controller↔engine call shape); it is measured, not adopted.
|
|
34
|
+
* @param {string[]} bg_hexes
|
|
35
|
+
* @param {string[]} fg_hexes
|
|
36
|
+
* @param {string} theme
|
|
37
|
+
* @returns {Float64Array}
|
|
38
|
+
*/
|
|
39
|
+
_recheckContrastMulti(bg_hexes, fg_hexes, theme) {
|
|
40
|
+
try {
|
|
41
|
+
const retptr = wasm.__wbindgen_add_to_stack_pointer(-16);
|
|
42
|
+
const ptr0 = passArrayJsValueToWasm0(bg_hexes, wasm.__wbindgen_export);
|
|
43
|
+
const len0 = WASM_VECTOR_LEN;
|
|
44
|
+
const ptr1 = passArrayJsValueToWasm0(fg_hexes, wasm.__wbindgen_export);
|
|
45
|
+
const len1 = WASM_VECTOR_LEN;
|
|
46
|
+
const ptr2 = passStringToWasm0(theme, wasm.__wbindgen_export, wasm.__wbindgen_export2);
|
|
47
|
+
const len2 = WASM_VECTOR_LEN;
|
|
48
|
+
wasm.labcolors__recheckContrastMulti(retptr, this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
|
|
49
|
+
var r0 = getDataViewMemory0().getInt32(retptr + 4 * 0, true);
|
|
50
|
+
var r1 = getDataViewMemory0().getInt32(retptr + 4 * 1, true);
|
|
51
|
+
var r2 = getDataViewMemory0().getInt32(retptr + 4 * 2, true);
|
|
52
|
+
var r3 = getDataViewMemory0().getInt32(retptr + 4 * 3, true);
|
|
53
|
+
if (r3) {
|
|
54
|
+
throw takeObject(r2);
|
|
55
|
+
}
|
|
56
|
+
var v4 = getArrayF64FromWasm0(r0, r1).slice();
|
|
57
|
+
wasm.__wbindgen_export4(r0, r1 * 8, 8);
|
|
58
|
+
return v4;
|
|
59
|
+
} finally {
|
|
60
|
+
wasm.__wbindgen_add_to_stack_pointer(16);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
20
63
|
/**
|
|
21
64
|
* Загрузить конфиг дизайн-системы (JSON по типу `ThemeConfig` из `.d.ts`).
|
|
22
65
|
*
|
|
@@ -183,24 +226,10 @@ function __wbg_get_imports() {
|
|
|
183
226
|
__wbg___wbindgen_throw_344f42d3211c4765: function(arg0, arg1) {
|
|
184
227
|
throw new Error(getStringFromWasm0(arg0, arg1));
|
|
185
228
|
},
|
|
186
|
-
|
|
187
|
-
const ret =
|
|
229
|
+
__wbg_parse_1c0d8a8656d7e016: function() { return handleError(function (arg0, arg1) {
|
|
230
|
+
const ret = JSON.parse(getStringFromWasm0(arg0, arg1));
|
|
188
231
|
return addHeapObject(ret);
|
|
189
|
-
},
|
|
190
|
-
__wbg_set_8535240470bf2500: function() { return handleError(function (arg0, arg1, arg2) {
|
|
191
|
-
const ret = Reflect.set(getObject(arg0), getObject(arg1), getObject(arg2));
|
|
192
|
-
return ret;
|
|
193
232
|
}, arguments); },
|
|
194
|
-
__wbindgen_cast_0000000000000001: function(arg0) {
|
|
195
|
-
// Cast intrinsic for `F64 -> Externref`.
|
|
196
|
-
const ret = arg0;
|
|
197
|
-
return addHeapObject(ret);
|
|
198
|
-
},
|
|
199
|
-
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
|
|
200
|
-
// Cast intrinsic for `Ref(String) -> Externref`.
|
|
201
|
-
const ret = getStringFromWasm0(arg0, arg1);
|
|
202
|
-
return addHeapObject(ret);
|
|
203
|
-
},
|
|
204
233
|
__wbindgen_object_drop_ref: function(arg0) {
|
|
205
234
|
takeObject(arg0);
|
|
206
235
|
},
|
package/pkg/labcolors_bg.wasm
CHANGED
|
Binary file
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/* eslint-disable */
|
|
3
3
|
export const memory: WebAssembly.Memory;
|
|
4
4
|
export const __wbg_labcolors_free: (a: number, b: number) => void;
|
|
5
|
+
export const labcolors__recheckContrastMulti: (a: number, b: number, c: number, d: number, e: number, f: number, g: number, h: number) => void;
|
|
5
6
|
export const labcolors_loadConfig: (a: number, b: number, c: number, d: number) => void;
|
|
6
7
|
export const labcolors_muddiness: (a: number, b: number, c: number, d: number) => void;
|
|
7
8
|
export const labcolors_new: () => number;
|