@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 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 { effectiveBackground, parseCssColor, oklabLerp } from "./effective-bg.js";
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 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);
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
- const recheckSamples = (fgs, samples) => {
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], fgs, theme);
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
- // `applyTheme`'s clear-then-write, and non-eased color roles keep their
256
- // canonical oklch form. `overlay` carries only in-flight color roles as hex.
257
- const applyHexes = (overlay) => applyTheme(target, { vars: { ...baseVars, ...overlay } });
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) easing.set(r.cssVar, { from, to: r.hex, held: 0 });
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 = relativeLuminanceHex(lerpHex(seg.from, seg.to, blend));
288
- return bgLums.every((L) => wcagRatio(lum, L) >= floor);
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
- const stepEase = (now, samples) => {
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.map(relativeLuminanceHex) : null;
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] = lerpHex(seg.from, seg.to, blend);
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.map(relativeLuminanceHex) : null;
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] = lerpHex(seg.from, seg.to, blend);
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.6.2",
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",
@@ -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
- /** Опускается в тонком конфиге (задан `preset`); иначе полный словарь ролей. */
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
- __wbg_new_da52cf8fe3429cb2: function() {
187
- const ret = new Object();
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
  },
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;