diff --git a/packages/colors/README.md b/packages/colors/README.md index 9849cd33..6bc382be 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -362,24 +362,31 @@ hex-parse и строковых аллокаций на update-пути. Воз `Ys` из frozen SAPC-shaped curve, `2·i+1` — WCAG-отношение. `lc` не является LPC/readability verdict; runtime использует его только как координату текущего transitional solver-а. +Массив `fgs` передаётся как синхронно заимствованный scratch-буфер: реализация +обязана полностью прочитать его до возврата из вызова и не может удерживать, +кэшировать или читать его асинхронно после возврата. --- ### `engine.recheckContrastMulti(bgs, fgs, themeHandle): Float64Array` -Батч-вариант `recheckContrast` для конечного набора образцов меняющегося фона -(градиент / картинка / bg-blur / стекло): проверяет один набор `fgs` сразу -против нескольких `bgs`, разделяя прямой ход модели каждого переднего плана -между всеми образцами (он от фона не зависит). `bgs` и `fgs` — `Uint32Array` +Батч-вариант `recheckContrast` для конечного объявленного набора образцов +меняющегося фона: проверяет один набор `fgs` сразу против нескольких `bgs`, +разделяя прямой ход модели каждого переднего плана между всеми образцами (он от +фона не зависит). Это finite-sample API, не доказательство всего поля градиента, +картинки, blur или стекла. `bgs` и `fgs` — `Uint32Array` упакованных `0x00RRGGBB` слов, `themeHandle` — число из `engine.themeHandle(theme)`. Результат байт-в-байт равен N отдельным вызовам `recheckContrast`, пара за парой — это закреплено parity-тестом границы. Возвращает плоский background-major `Float64Array`: образец `s`, цвет `i` лежит в `(s · fgs.length + i) · 2` (`lc`) и `+1` (`wcagRatio`). -`adaptTheme` использует один батч-вызов вместо N отдельных пересечений границы; -эффект на производительность зависит от host и интеграции и без отдельного -воспроизводимого гейта не заявляется. Для одного образца контроллер остаётся на -`recheckContrast`. +`adaptTheme` использует один батч-вызов, только когда все foreground occurrences +не зависят от фона (`opacity = 1`). При `opacity < 1` контроллер сначала заново +композитит source на каждом текущем sample, затем вызывает `recheckContrast` для +этого sample: общий foreground-ряд здесь физически неверен. Эффект на +производительность зависит от host и интеграции и без отдельного +воспроизводимого гейта не заявляется. Для одного образца контроллер также +остаётся на `recheckContrast`. --- @@ -410,7 +417,7 @@ interface WatchThemeOptions { theme: ThemeName; background?: string | (() => string); // явный фон (если автоматический невозможен) target?: HTMLElement; // куда писать переменные (по умолчанию: element) - fallback?: string; // непрозрачная поддерживаемая база (по умолчанию "#FFFFFF") + canvas?: string; // явный непрозрачный canvas для прозрачного корня observe?: boolean; // авто-обновление при style/class в поддереве (по умолчанию true) onError?: (error: unknown) => void; // ошибки автоматического observer-refresh root?: Node; // корень MutationObserver (по умолчанию: documentElement) @@ -428,7 +435,9 @@ interface WatchController { видит поле — передайте явный reference-образец `background` (hex-строку или функцию, возвращающую hex). Один образец не является доказательством всего поля. Явный вход обязан быть непустой строкой: невалидное значение отклоняется и не -трактуется как отсутствие `background` с переходом на fallback. +трактуется как отсутствие `background`. Если поддерживаемая цепочка полностью +прозрачна и `canvas` не объявлен, наблюдение имеет тип `Unknown`; белый цвет не +подставляется. До захвата `MutationObserver` невалидный вход или initial resolve бросает синхронно и не создаёт долгоживущий ресурс. После захвата observer функция сначала возвращает контроллер-владелец. Если затем упал `observe`, первый CSS @@ -463,7 +472,7 @@ interface AdaptThemeOptions { background?: string | string[] | (() => string | string[]); // один hex или несколько образцов фона (наихудший учитывается) target?: HTMLElement; // куда писать переменные (по умолчанию: element) - fallback?: string; // непрозрачная поддерживаемая база (по умолчанию "#FFFFFF") + canvas?: string; // явный непрозрачный canvas для прозрачного корня dropFraction?: number; // запас контраста до пересчёта (по умолчанию 0.2) sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120) dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250) @@ -482,7 +491,8 @@ interface AdaptController { Объявленный набор `background` обязан быть непустым и содержать только непустые строки. Невалидный явный образец отклоняется до resolver без coercion и без -подмены fallback-цветом. +подмены выдуманным цветом. Без объявленного `canvas` полностью прозрачная +поддерживаемая цепочка даёт `Unknown` и не запускает resolver. Управляйте через `start()` (внутренний rAF-цикл) или вызывайте `tick()` из собственного цикла. Смена темы применяется мгновенно — это осознанное намерение, а не дрейф. Перед применением результата контроллер проверяет provenance как stable-, так и diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index 6278676e..5586de13 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -14,12 +14,14 @@ import { oklabLerp, compileLerpPair, lerpPairHex } from "./effective-bg.js"; import { observePointBackground } from "./background-observation.js"; +import { __over } from "./pkg/labcolors.js"; import { admitSnapshot, writeVars } from "./snapshot.js"; const CANCELLED = Symbol("adaptTheme.cancelled"); const NO_FRAME = Symbol("adaptTheme.noFrame"); const HEX6 = /^[0-9a-fA-F]{6}$/u; +const INVALID_RGB24 = 0xFFFFFFFF; /** Pack a `#RRGGBB` (or `#RGB` shorthand / bare / any-case) colour string into * the recheck boundary's `0x00RRGGBB` word — the packed transport the WASM @@ -329,16 +331,13 @@ export function adaptTheme(element, options) { // dropFraction)`) against ANY sample. `worstIdx` is the sample with the least // set-wide margin — the one to re-solve against, so the constraint we solve to // is the same constraint we check hardest. - // Recheck lanes — SEPARATE from the color-only ease set above. Every - // contrast-bearing role rides a recheck lane: color roles by their hex/lc, - // translucent roles by their occurrence descriptor (compositeHex as the - // foreground word, |compositeLc| as the drift floor). Kept parallel and 1:1 by - // construction (built from one projection → recheckRoles.length === - // recheckFgs.length always), so the length-parity/stride invariants hold. The - // ease/overlay loops NEVER read these, so a translucent role stays rechecked - // but never eased — its live oklch(tint/alpha) var is left intact (C8d E1). - let recheckRoles = []; - let recheckFgs = []; + // Recheck occurrences are separate from the color-only ease set above. Solid + // and translucent output share one physical descriptor: emitted source bytes, + // admitted opacity and the solve-time metric used only as a drift baseline. + // The visible foreground is materialized later on EACH current backdrop; + // solve-time `compositeHex` is never a runtime input. The ease/overlay loops + // never read this set, so translucent CSS remains tint+alpha (C8d E1). + let recheckOccurrences = []; // Batch path (many samples): collapse the shared per-foreground CAM16 forward // across every sample into ONE engine call. `recheckContrastMulti` returns a @@ -366,8 +365,7 @@ export function adaptTheme(element, options) { const recheckSamples = ( samples, - roleSet = recheckRoles, - foregrounds = recheckFgs, + occurrenceSet = recheckOccurrences, themeName = theme, owner, ) => { @@ -375,15 +373,31 @@ export function adaptTheme(element, options) { let breachCount = 0; let worstIdx = 0; let worstMargin = Infinity; - const stride = foregrounds.length; - // Cold-edge packing: the theme key is lowered to its numeric handle and the - // foreground hexes to one packed `Uint32Array` — the boundary transport. + const stride = occurrenceSet.length; + // The theme key is lowered to its numeric handle once. Source words were + // already parsed at solve admission; one row is reused across samples. const themeArg = themeArgFor(themeName, owner); - const packedFgs = Uint32Array.from(foregrounds, packRgb24Hex); - const useBatch = canBatch && samples.length > 1; + const foregroundRow = new Uint32Array(stride); + let hasBackdropDependentOccurrence = false; + for (let i = 0; i < stride; i++) { + const occurrence = occurrenceSet[i]; + foregroundRow[i] = occurrence.sourceRgb24; + if (occurrence.opacity !== 1) hasBackdropDependentOccurrence = true; + } + // One shared foreground row exists only for opaque occurrences. For alpha, + // foreground is a function of the particular backdrop, so the rectangular + // batch API would encode the wrong physics. + const useBatch = + canBatch && samples.length > 1 && !hasBackdropDependentOccurrence; let batch = null; + let packedBackdrops = null; if (useBatch) { - batch = recheckContrastMulti(Uint32Array.from(samples, packRgb24Hex), packedFgs, themeArg); + packedBackdrops = Uint32Array.from(samples, packRgb24Hex); + batch = recheckContrastMulti( + packedBackdrops, + foregroundRow, + themeArg, + ); checkpoint(owner); const batchLength = batch?.length ?? -1; checkpoint(owner); @@ -397,27 +411,51 @@ export function adaptTheme(element, options) { } } for (let s = 0; s < samples.length; s++) { + const backdrop = useBatch + ? packedBackdrops[s] + : packRgb24Hex(samples[s]); // Per-sample flat buffer, or a background-major window into the batch one. let flat = null; if (!useBatch) { - flat = recheckContrast(packRgb24Hex(samples[s]), packedFgs, themeArg); + for (let i = 0; i < stride; i++) { + const occurrence = occurrenceSet[i]; + if (occurrence.opacity === 1) { + foregroundRow[i] = occurrence.sourceRgb24; + continue; + } + const visible = __over( + occurrence.sourceRgb24, + occurrence.opacity, + backdrop, + ); + if (visible === INVALID_RGB24) { + throw new RangeError( + `adaptTheme: Core rejected admitted opacity for '${occurrence.key}'`, + ); + } + foregroundRow[i] = visible; + } + // This scratch row is overwritten for the next backdrop. The engine + // must consume it synchronously and must not retain, cache or inspect + // the Uint32Array after recheckContrast returns. + flat = recheckContrast(backdrop, foregroundRow, themeArg); checkpoint(owner); } const flatLength = useBatch ? null : (flat?.length ?? -1); checkpoint(owner); - if (!useBatch && flatLength !== roleSet.length * 2) { + if (!useBatch && flatLength !== occurrenceSet.length * 2) { const received = typeof flatLength === "number" ? String(flatLength) : typeof flatLength; throw new RangeError( "adaptTheme: recheckContrast вернул буфер неверной длины " + - `(${received} вместо ${roleSet.length * 2}) — ` + + `(${received} вместо ${occurrenceSet.length * 2}) — ` + "битый результат нельзя молча принять за отсутствие пробоя", ); } const base = s * stride; let sampleMargin = Infinity; let sampleBreached = false; - for (let i = 0; i < roleSet.length; i++) { - const want = Math.abs(roleSet[i].lc) * (1 - dropFraction); + for (let i = 0; i < occurrenceSet.length; i++) { + const want = Math.abs(occurrenceSet[i].lc) * (1 - dropFraction); const lcRaw = useBatch ? batch[(base + i) * 2] : flat[2 * i]; checkpoint(owner); if (!Number.isFinite(lcRaw)) { @@ -554,33 +592,47 @@ export function adaptTheme(element, options) { lc: r.lc, hex: r.hex, })); - // Recheck lanes: color roles PLUS translucent roles that carry a usable - // occurrence descriptor. The defensive `typeof/Number.isFinite` guard makes - // this a clean no-op for stubs/engines that emit no composites (the real - // engine always emits compositeHex/compositeLc for translucent roles), and - // prevents packRgb24Hex from throwing on an undefined foreground. - const nextRecheck = Object.entries(snapshot.roles) - .filter( - ([, r]) => - r && - (r.kind === "color" || - (r.kind === "translucent" && - typeof r.compositeHex === "string" && - Number.isFinite(r.compositeLc))), - ) - .map(([key, r]) => ({ - cssVar: r.cssVar, + const nextRecheckOccurrences = []; + for (const [key, role] of Object.entries(snapshot.roles)) { + if (!role || (role.kind !== "color" && role.kind !== "translucent")) continue; + const translucent = role.kind === "translucent"; + const sourceField = translucent ? "tintHex" : "hex"; + const lcField = translucent ? "compositeLc" : "lc"; + if (typeof role[sourceField] !== "string") { + throw new TypeError( + `adaptTheme: ${role.kind} role '${key}' requires string ${sourceField}`, + ); + } + if (!Number.isFinite(role[lcField])) { + throw new TypeError( + `adaptTheme: ${role.kind} role '${key}' requires finite ${lcField}`, + ); + } + const opacity = translucent ? role.alpha : 1; + if (typeof opacity !== "number") { + throw new TypeError( + `adaptTheme: ${role.kind} role '${key}' requires numeric opacity`, + ); + } + if (!Number.isFinite(opacity) || opacity <= 0 || opacity > 1) { + throw new RangeError( + `adaptTheme: ${role.kind} role '${key}' requires opacity in (0,1]`, + ); + } + nextRecheckOccurrences.push({ + cssVar: role.cssVar, key, - lc: r.kind === "color" ? r.lc : r.compositeLc, - hex: r.kind === "color" ? r.hex : r.compositeHex, - })); + lc: role[lcField], + sourceRgb24: packRgb24Hex(role[sourceField]), + opacity, + }); + } return { result: snapshot, baseVars: nextBaseVars, roles: nextRoles, stableGlows: nextStableGlows, - recheckRoles: nextRecheck, - recheckFgs: nextRecheck.map((r) => r.hex), + recheckOccurrences: nextRecheckOccurrences, lastSolveAt: now, breachSince: null, }; @@ -591,8 +643,7 @@ export function adaptTheme(element, options) { baseVars = candidate.baseVars; roles = candidate.roles; stableGlows = candidate.stableGlows; - recheckRoles = candidate.recheckRoles; - recheckFgs = candidate.recheckFgs; + recheckOccurrences = candidate.recheckOccurrences; lastSolveAt = candidate.lastSolveAt; breachSince = candidate.breachSince; // Пере-решённый кандидат может сменить набор ключей: следующая запись @@ -645,8 +696,7 @@ export function adaptTheme(element, options) { } const { breached, worstIdx: nextWorst, breachCount } = recheckSamples( samples, - candidate.recheckRoles, - candidate.recheckFgs, + candidate.recheckOccurrences, themeName, owner, ); @@ -1009,9 +1059,9 @@ export function adaptTheme(element, options) { // Glow-only набор всё равно реагирует на смену подложки. Готовим точный // class-переход до публикации и ключа, и CSS-состояния. Ключуемся по НАБОРУ // recheck-полос, а не по ease-набору: translucent-only набор (без color-роли, - // roles.length===0, но recheckRoles.length>0) обязан пройти recheck/re-solve, + // roles.length===0, но recheckOccurrences.length>0) обязан пройти recheck/re-solve, // а не уйти в glow-ветку (C8d E1). - if (recheckRoles.length === 0) { + if (recheckOccurrences.length === 0) { const preparedGlow = key === lastKey ? null @@ -1034,8 +1084,7 @@ export function adaptTheme(element, options) { // lowest-metric sample, the one used for the next resolve. const { breached, worstIdx } = recheckSamples( samples, - recheckRoles, - recheckFgs, + recheckOccurrences, theme, owner, ); diff --git a/packages/colors/bench/misses.mjs b/packages/colors/bench/misses.mjs new file mode 100644 index 00000000..b675ddbb --- /dev/null +++ b/packages/colors/bench/misses.mjs @@ -0,0 +1,56 @@ +const CHANNEL_MAX = 0xff; +const RGB24_MAX = 0xffffff; + +export function rustCacheCapacity(source) { + const match = source.match( + /^\s*(?:pub(?:\([^)]*\))?\s+)?const\s+CACHE_CAPACITY\s*:\s*usize\s*=\s*([\d_]+)(?:usize)?\s*;/mu, + ); + const literal = match?.[1].replaceAll("_", ""); + const capacity = Number(literal); + if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity >= RGB24_MAX) { + throw new Error("Rust cache capacity is absent or outside the finite RGB24 domain"); + } + return capacity; +} + +export function buildMissRing(centerRgb24, capacity) { + if (!Number.isInteger(centerRgb24) || centerRgb24 < 0 || centerRgb24 > RGB24_MAX) { + throw new TypeError("centerRgb24 must be one encoded-sRGB8 word"); + } + if (!Number.isSafeInteger(capacity) || capacity < 1 || capacity >= RGB24_MAX) { + throw new RangeError("capacity must leave one non-centre RGB24 cache-miss key"); + } + + const center = [ + centerRgb24 >>> 16, + (centerRgb24 >>> 8) & CHANNEL_MAX, + centerRgb24 & CHANNEL_MAX, + ]; + const required = capacity + 1; + const backgrounds = []; + // Chebyshev shells enumerate each neighbouring RGB point once and keep the + // successful benchmark fixture as local to the solve background as possible. + for (let radius = 1; radius <= CHANNEL_MAX && backgrounds.length < required; radius++) { + for (let dr = -radius; dr <= radius && backgrounds.length < required; dr++) { + for (let dg = -radius; dg <= radius && backgrounds.length < required; dg++) { + for (let db = -radius; db <= radius && backgrounds.length < required; db++) { + if (Math.max(Math.abs(dr), Math.abs(dg), Math.abs(db)) !== radius) continue; + const [r, g, b] = [center[0] + dr, center[1] + dg, center[2] + db]; + if ( + r < 0 || r > CHANNEL_MAX || + g < 0 || g > CHANNEL_MAX || + b < 0 || b > CHANNEL_MAX + ) { + continue; + } + const word = (r << 16) | (g << 8) | b; + backgrounds.push(`#${word.toString(16).padStart(6, "0").toUpperCase()}`); + } + } + } + } + if (backgrounds.length !== required) { + throw new Error("RGB24 domain cannot provide a cache-capacity-plus-one ring"); + } + return backgrounds; +} diff --git a/packages/colors/bench/occurrences.mjs b/packages/colors/bench/occurrences.mjs new file mode 100644 index 00000000..26c28a99 --- /dev/null +++ b/packages/colors/bench/occurrences.mjs @@ -0,0 +1,45 @@ +const INVALID_RGB24 = 0xFFFFFFFF; + +/** Lower the real resolver projection into the physical occurrence shape that + * the boundary benchmark measures. Unsupported diagnostic roles are not + * emitted occurrences and therefore do not enter the measured row. */ +export function benchmarkOccurrencesFromRoles(roles, packRgb24) { + return Object.values(roles).flatMap((role) => { + if (role.kind === "color") { + return [{ sourceRgb24: packRgb24(role.hex), opacity: 1 }]; + } + if (role.kind === "translucent") { + return [{ sourceRgb24: packRgb24(role.tintHex), opacity: role.alpha }]; + } + return []; + }); +} + +/** Materialize each occurrence on one current backdrop into caller-owned + * scratch. `composite` stays injected so the benchmark helper remains a pure + * behavioral oracle over the same Core operation used by the controller. */ +export function materializeOccurrences(occurrences, backdrop, row, composite) { + if (row.length !== occurrences.length) { + throw new RangeError("benchmark occurrence row must match the occurrence set"); + } + + for (let i = 0; i < occurrences.length; i++) { + const occurrence = occurrences[i]; + if (occurrence.opacity === 1) { + row[i] = occurrence.sourceRgb24; + continue; + } + const visible = composite( + occurrence.sourceRgb24, + occurrence.opacity, + backdrop, + ); + if (visible === INVALID_RGB24) { + throw new RangeError( + `benchmark: Core rejected admitted opacity ${occurrence.opacity}`, + ); + } + row[i] = visible; + } + return row; +} diff --git a/packages/colors/bench/wasm-boundary.bench.mjs b/packages/colors/bench/wasm-boundary.bench.mjs index 62de15b4..78a8d104 100644 --- a/packages/colors/bench/wasm-boundary.bench.mjs +++ b/packages/colors/bench/wasm-boundary.bench.mjs @@ -7,8 +7,10 @@ // byte-identical. // // Fixture: the frozen canonical labui passport, resolved on a representative -// background, giving the true ~28-role foreground set the runtime rechecks each -// frame (the controller passes `colorRoles.map(r => r.hex)` to recheckContrast). +// background. The shipping measurement uses every solid and translucent +// occurrence: alpha sources are composited again on each current backdrop +// before the packed contrast call, exactly like `adaptTheme`. The opaque-only +// batch remains a lower-level capability measurement, not a controller claim. // // Reports median ns/call after warmup (medians resist GC/JIT jitter better than // means). With `--expose-gc` it also reports a heap-allocation proxy: bytes of JS @@ -21,7 +23,12 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, resolve } from "node:path"; import { performance } from "node:perf_hooks"; -import { initSync, LabColors } from "../pkg/labcolors.js"; +import { buildMissRing, rustCacheCapacity } from "./misses.mjs"; +import { + benchmarkOccurrencesFromRoles, + materializeOccurrences, +} from "./occurrences.mjs"; +import { __over, initSync, LabColors } from "../pkg/labcolors.js"; const here = dirname(fileURLToPath(import.meta.url)); initSync({ module: readFileSync(resolve(here, "../pkg/labcolors_bg.wasm")) }); @@ -42,9 +49,6 @@ engine.loadConfig(CONFIG); const SOLVE_BG = "#3A3A3C"; // a representative surface (mid dark) const THEME = "dark"; const resolved = engine.resolveTheme(SOLVE_BG, THEME); -const FGS = Object.values(resolved.roles) - .filter((r) => r.kind === "color") - .map((r) => r.hex); // Three worst-case samples of a varying backdrop (what strict/gradient mode feeds). const SAMPLES = ["#38383A", "#404042", "#2E2E30"]; @@ -61,7 +65,11 @@ const pk = (hex) => { return Number.parseInt(six, 16) >>> 0; }; const THEME_HANDLE = engine.themeHandle(THEME); -const FGSW = Uint32Array.from(FGS, pk); +const OCCURRENCES = benchmarkOccurrencesFromRoles(resolved.roles, pk); +const OPAQUE_FGSW = Uint32Array.from( + OCCURRENCES.filter(({ opacity }) => opacity === 1), + ({ sourceRgb24 }) => sourceRgb24, +); const SAMPLESW = Uint32Array.from(SAMPLES, pk); // ── timing core ───────────────────────────────────────────────────────────── @@ -107,43 +115,63 @@ function allocPerCall(fn, n) { // ── the calls under test ──────────────────────────────────────────────────── -// One frame with a solid background = ONE recheck of the whole fg set. -const recheck1 = () => engine.recheckContrast(SAMPLESW[0], FGSW, THEME_HANDLE); -// One frame with a 3-sample varying backdrop = THREE rechecks (worst-case loop). -const recheck3 = () => { +// Shipping frame with one current backdrop: allocate one row, materialize every +// occurrence on that backdrop, then cross the packed recheck boundary once. +const recheckMixed1 = () => { + const row = new Uint32Array(OCCURRENCES.length); + materializeOccurrences(OCCURRENCES, SAMPLESW[0], row, __over); + return engine.recheckContrast(SAMPLESW[0], row, THEME_HANDLE); +}; +// Shipping frame with a finite three-sample support. The row allocation is +// shared across samples, while alpha occurrences are rematerialized per sample. +const recheckMixed3 = () => { + const row = new Uint32Array(OCCURRENCES.length); let last; - for (let s = 0; s < 3; s++) last = engine.recheckContrast(SAMPLESW[s], FGSW, THEME_HANDLE); + for (let s = 0; s < SAMPLESW.length; s++) { + materializeOccurrences(OCCURRENCES, SAMPLESW[s], row, __over); + last = engine.recheckContrast(SAMPLESW[s], row, THEME_HANDLE); + } return last; }; -// The same 3-sample frame as `recheck3`, but batched into ONE call so each -// foreground's CAM16 forward is computed once and shared across samples. -// Byte-identical to `recheck3`; the public batch API the controller now uses. -const recheckMulti3 = () => engine.recheckContrastMulti(SAMPLESW, FGSW, THEME_HANDLE); +// Capability microbench: valid only for an all-opaque occurrence set, where one +// foreground row is physically shared by every backdrop. +const recheckOpaqueMulti3 = OPAQUE_FGSW.length > 0 + ? () => engine.recheckContrastMulti(SAMPLESW, OPAQUE_FGSW, THEME_HANDLE) + : null; // Re-solve, cache HIT (same bg repeatedly): pays only the JS-object projection. const resolveHit = () => engine.resolveTheme(SOLVE_BG, THEME); -// Re-solve, cache MISS (distinct bg each call): full solve + projection. Sweep -// 4096 distinct backgrounds so we never repeat within the cache window. -let missTone = 0; +// Re-solve, cache MISS (distinct bg each call): full solve + projection. A ring +// one element larger than the Rust cache cannot reach a still-resident key. +// Nearby encoded colours avoid mixing legitimate contract conflicts into a +// successful-resolve latency sample; the test suite exhaustively admits them. +const cacheCapacity = rustCacheCapacity( + readFileSync(resolve(here, "../../../crates/labcolors-wasm/src/engine.rs"), "utf8"), +); +const cacheMissBackgrounds = buildMissRing(pk(SOLVE_BG), cacheCapacity); +let missIndex = 0; const resolveMiss = () => { - const t = missTone++ & 0xfff; - const hex = `#${(0x100000 + t * 17).toString(16).slice(-6).toUpperCase()}`; - return engine.resolveTheme(hex, THEME); + const background = cacheMissBackgrounds[missIndex++ % cacheMissBackgrounds.length]; + return engine.resolveTheme(background, THEME); }; // ── run ───────────────────────────────────────────────────────────────────── const gcOn = typeof globalThis.gc === "function"; console.log( - `node ${process.version} | roles=${FGS.length} color fgs | theme=${THEME} | alloc-proxy=${gcOn}`, + `node ${process.version} | occurrences=${OCCURRENCES.length} ` + + `(opaque=${OPAQUE_FGSW.length}, alpha=${OCCURRENCES.length - OPAQUE_FGSW.length}) | ` + + `theme=${THEME} | alloc-proxy=${gcOn}`, ); console.log(""); console.log("call median ns min ns ns/role alloc B/call"); // [label, fn, batches, inner, allocN, perRoleDivisor] const plan = [ - ["recheckContrast ×1 (28 roles)", recheck1, 40, 20000, 40000, FGS.length], - ["recheckContrast ×3 (28 roles)", recheck3, 40, 7000, 15000, FGS.length * 3], - ["recheckContrastMulti 3bg", recheckMulti3, 40, 7000, 15000, FGS.length * 3], + ["mixed occurrence ×1", recheckMixed1, 40, 10000, 20000, OCCURRENCES.length], + ["mixed occurrence ×3", recheckMixed3, 40, 3000, 7000, OCCURRENCES.length * 3], + ...(recheckOpaqueMulti3 + ? [["opaque batch capability ×3", recheckOpaqueMulti3, 40, 7000, 15000, OPAQUE_FGSW.length * 3]] + : []), ["resolveTheme cache-hit", resolveHit, 25, 4000, 8000, 0], ["resolveTheme cache-miss", resolveMiss, 20, 1500, 4000, 0], ]; @@ -170,7 +198,11 @@ function fnv1aF64(hash, arr) { return h >>> 0; } let fp = 0x811c9dc5; -for (const s of SAMPLESW) fp = fnv1aF64(fp, engine.recheckContrast(s, FGSW, THEME_HANDLE)); +const fingerprintRow = new Uint32Array(OCCURRENCES.length); +for (const sample of SAMPLESW) { + materializeOccurrences(OCCURRENCES, sample, fingerprintRow, __over); + fp = fnv1aF64(fp, engine.recheckContrast(sample, fingerprintRow, THEME_HANDLE)); +} // Include a resolveTheme vars fingerprint too (the projection is under test). const vfp = (() => { let h = 0x811c9dc5; diff --git a/packages/colors/test/adapt-theme-translucent.test.mjs b/packages/colors/test/adapt-theme-translucent.test.mjs index 4c4fdb7d..05d664b5 100644 --- a/packages/colors/test/adapt-theme-translucent.test.mjs +++ b/packages/colors/test/adapt-theme-translucent.test.mjs @@ -13,8 +13,14 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { adaptTheme } from "../adapt-theme.js"; +import { initSync } from "../pkg/labcolors.js"; + +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); function fakeElement() { const props = new Map(); @@ -39,7 +45,14 @@ const makeResult = (hex, labelVar, panelVar, legalFloor = null) => ({ vars: { "--lab-label": labelVar, "--lab-panel": panelVar }, roles: { label: { kind: "color", cssVar: "--lab-label", hex, lc: 100, legalFloor }, - panel: { kind: "translucent", cssVar: "--lab-panel" }, + panel: { + kind: "translucent", + cssVar: "--lab-panel", + tintHex: "#808080", + alpha: 0.6, + compositeHex: "#B3B3B3", + compositeLc: 60, + }, }, }); @@ -48,7 +61,7 @@ const makeResult = (hex, labelVar, panelVar, legalFloor = null) => ({ function fakeColors(initial) { let resolveCount = 0; let current = initial; - let recheckLc = [100]; // one color role, passing by default + let recheckLc = [100]; return { resolveCount: () => resolveCount, setResolve: (r) => (current = r), @@ -57,9 +70,10 @@ function fakeColors(initial) { resolveCount++; return current; }, - recheckContrast() { + recheckContrast(_background, foregrounds) { const out = []; - for (const lc of recheckLc) { + for (let index = 0; index < foregrounds.length; index++) { + const lc = recheckLc[index] ?? recheckLc[0]; out.push(lc); out.push(10); } @@ -171,15 +185,36 @@ const THREE = { }, roles: { label: { kind: "color", cssVar: "--lab-label", hex: "#1A1A1A", lc: 100, legalFloor: null }, - panel: { kind: "translucent", cssVar: "--lab-panel" }, - extra: { kind: "translucent", cssVar: "--lab-extra" }, + panel: { + kind: "translucent", + cssVar: "--lab-panel", + tintHex: "#808080", + alpha: 0.6, + compositeHex: "#B3B3B3", + compositeLc: 60, + }, + extra: { + kind: "translucent", + cssVar: "--lab-extra", + tintHex: "#408020", + alpha: 0.4, + compositeHex: "#B3CCA6", + compositeLc: 50, + }, }, }; const TWO = { vars: { "--lab-label": "oklch(90.000% 0 0)", "--lab-panel": "oklch(30.000% 0.02 260 / 0.6)" }, roles: { label: { kind: "color", cssVar: "--lab-label", hex: "#E5E5E5", lc: 100, legalFloor: null }, - panel: { kind: "translucent", cssVar: "--lab-panel" }, + panel: { + kind: "translucent", + cssVar: "--lab-panel", + tintHex: "#808080", + alpha: 0.6, + compositeHex: "#B3B3B3", + compositeLc: 60, + }, }, }; const ONE = { diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index eb88e2ec..873f349d 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -3763,10 +3763,9 @@ function mixedWithUnresolved(hex, lc) { cssVar: "--lab-veil", tintHex: "#808080", alpha: 0.5, - // Occurrence descriptor emitted by the real engine (core→dto→projection) - // for a translucent role: the encoded-sRGB8 reference composite over the - // resolve background and its signed composite contrast estimate. These - // are what let the translucent role ride a recheck lane (C8d E1). + // Occurrence descriptor emitted by the real engine (core→dto→projection). + // The runtime must retain source+alpha; the reference composite is only + // solve-time evidence and must never stand in for a later occurrence. compositeHex: "#55757F", compositeLc: -41.5, }, @@ -3774,6 +3773,329 @@ function mixedWithUnresolved(hex, lc) { }; } +const translucentOnly = ({ + tintHex = "#C0B2FA", + alpha = 0.122, + compositeHex = "#F7F6FE", + compositeLc = -40, +} = {}) => ({ + vars: { "--lab-veil": `rgba(192 178 250 / ${alpha})` }, + roles: { + veil: { + kind: "translucent", + cssVar: "--lab-veil", + tintHex, + alpha, + compositeHex, + compositeLc, + }, + }, +}); + +test("translucent recheck materializes the emitted source over the current backdrop", () => { + const el = fakeElement(); + let bg = "#FFFFFF"; + const seen = []; + const colors = { + resolveTheme: () => translucentOnly(), + recheckContrast(backdrop, foregrounds) { + seen.push({ backdrop, foregrounds: [...foregrounds] }); + return [...foregrounds].flatMap(() => [100, 10]); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => bg, + target: el, + now: () => 1, + win: {}, + }); + + bg = "#000000"; + ctrl.tick(); + + const recheck = seen.at(-1); + assert.equal(recheck.backdrop, pk("#000000")); + assert.deepEqual( + recheck.foregrounds, + [pk("#17161F")], + "the visible occurrence must be recomposited from tint+alpha on the current backdrop", + ); + assert.notDeepEqual( + recheck.foregrounds, + [pk("#F7F6FE")], + "the solve-time composite is stale after the backdrop changes", + ); + ctrl.stop(); +}); + +test("translucent multi-sample recheck builds one foreground row per backdrop", () => { + const el = fakeElement(); + let samples = ["#FFFFFF", "#FFFFFF"]; + const single = []; + let multiCalls = 0; + const colors = { + resolveTheme: () => translucentOnly(), + recheckContrast(backdrop, foregrounds) { + single.push({ backdrop, foregrounds: [...foregrounds] }); + return [...foregrounds].flatMap(() => [100, 10]); + }, + recheckContrastMulti(backdrops, foregrounds) { + multiCalls++; + return [...backdrops].flatMap(() => + [...foregrounds].flatMap(() => [100, 10])); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => samples, + target: el, + now: () => 1, + win: {}, + }); + const singleAtStart = single.length; + const multiAtStart = multiCalls; + + samples = ["#000000", "#FFFFFF"]; + ctrl.tick(); + + assert.equal( + multiCalls, + multiAtStart, + "a shared-foreground batch cannot represent a backdrop-dependent alpha occurrence", + ); + assert.deepEqual(single.slice(singleAtStart), [ + { backdrop: pk("#000000"), foregrounds: [pk("#17161F")] }, + { backdrop: pk("#FFFFFF"), foregrounds: [pk("#F7F6FE")] }, + ]); + ctrl.stop(); +}); + +const mixedOccurrenceSet = () => ({ + vars: { + "--lab-label": "#010203", + "--lab-veil": "rgba(192 178 250 / 0.122)", + }, + roles: { + label: { + kind: "color", + cssVar: "--lab-label", + hex: "#010203", + lc: 100, + }, + veil: { + kind: "translucent", + cssVar: "--lab-veil", + tintHex: "#C0B2FA", + alpha: 0.122, + compositeHex: "#F7F6FE", + compositeLc: 100, + }, + }, +}); + +function replayMixedOccurrenceSamples(nextSamples) { + const el = fakeElement(); + let samples = ["#FFFFFF"]; + let now = 1; + const resolves = []; + const rechecks = []; + const colors = { + resolveTheme(backdrop) { + resolves.push(backdrop); + return mixedOccurrenceSet(); + }, + recheckContrast(backdrop, foregrounds) { + const row = [...foregrounds]; + rechecks.push({ backdrop, row }); + const translucentLc = backdrop === pk("#000000") ? 0 : 100; + return [100, 10, translucentLc, 10]; + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => samples, + target: el, + now: () => now, + win: {}, + sustainMs: 0, + dwellMs: 0, + }); + resolves.length = 0; + rechecks.length = 0; + + samples = nextSamples; + now++; + ctrl.tick(); + ctrl.stop(); + return { resolves, rechecks }; +} + +test("mixed occurrence rows preserve order and materialize alpha per backdrop", () => { + const replay = replayMixedOccurrenceSamples(["#000000", "#FFFFFF"]); + const expected = new Map([ + [pk("#000000"), [pk("#010203"), pk("#17161F")]], + [pk("#FFFFFF"), [pk("#010203"), pk("#F7F6FE")]], + ]); + + for (const { backdrop, row } of replay.rechecks) { + assert.deepEqual(row, expected.get(backdrop)); + } + assert.deepEqual( + replay.rechecks.map(({ backdrop }) => backdrop), + [pk("#000000"), pk("#FFFFFF"), pk("#000000"), pk("#FFFFFF")], + "current and candidate-admission passes must each recheck every sample in declared order", + ); + assert.deepEqual(replay.resolves, ["#000000"], "the sole breaching sample seeds re-solve"); +}); + +test("sample permutation preserves occurrence mapping and breach witness", () => { + const forward = replayMixedOccurrenceSamples(["#000000", "#FFFFFF"]); + const reverse = replayMixedOccurrenceSamples(["#FFFFFF", "#000000"]); + const canonical = (calls) => + [...new Map(calls.map(({ backdrop, row }) => [backdrop, row])).entries()] + .sort(([left], [right]) => left - right); + + assert.deepEqual(canonical(forward.rechecks), canonical(reverse.rechecks)); + assert.deepEqual(forward.resolves, ["#000000"]); + assert.deepEqual(reverse.resolves, ["#000000"]); +}); + +test("alpha one is the opaque identity and retains the packed batch path", () => { + const el = fakeElement(); + let samples = ["#FFFFFF", "#FFFFFF"]; + const batches = []; + let now = 1; + const colors = { + resolveTheme: () => translucentOnly({ alpha: 1 }), + recheckContrast: () => { + throw new Error("opaque identity must retain the batch path"); + }, + recheckContrastMulti(backdrops, foregrounds) { + batches.push({ backdrops: [...backdrops], foregrounds: [...foregrounds] }); + return [...backdrops].flatMap(() => + [...foregrounds].flatMap(() => [100, 10])); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => samples, + target: el, + now: () => now, + win: {}, + }); + batches.length = 0; + + samples = ["#000000", "#FFFFFF"]; + now++; + ctrl.tick(); + + assert.deepEqual(batches, [{ + backdrops: [pk("#000000"), pk("#FFFFFF")], + foregrounds: [pk("#C0B2FA")], + }]); + ctrl.stop(); +}); + +test("malformed translucent occurrence fails before the first DOM mutation", () => { + const el = fakeElement(); + assert.throws( + () => + adaptTheme(el, { + colors: { + resolveTheme: () => translucentOnly({ tintHex: null }), + recheckContrast: () => [], + }, + theme: "light", + background: "#FFFFFF", + target: el, + now: () => 1, + win: {}, + }), + /translucent.*tintHex/u, + ); + assert.deepEqual(el.mutations, []); + + assert.throws( + () => + adaptTheme(el, { + colors: { + resolveTheme: () => translucentOnly({ alpha: "0.5" }), + recheckContrast: () => [], + }, + theme: "light", + background: "#FFFFFF", + target: el, + now: () => 1, + win: {}, + }), + /requires numeric opacity/u, + ); + assert.deepEqual(el.mutations, []); + + assert.throws( + () => + adaptTheme(el, { + colors: { + resolveTheme: () => translucentOnly({ alpha: 1.1 }), + recheckContrast: () => [], + }, + theme: "light", + background: "#FFFFFF", + target: el, + now: () => 1, + win: {}, + }), + /opacity in \(0,1\]/u, + ); + assert.deepEqual(el.mutations, []); +}); + +test("malformed translucent re-solve preserves the committed DOM and state", () => { + const el = fakeElement(); + let bg = "#FFFFFF"; + let now = 1; + let resolveCount = 0; + let breach = false; + const colors = { + resolveTheme() { + resolveCount++; + return resolveCount === 1 + ? translucentOnly() + : translucentOnly({ alpha: 0 }); + }, + recheckContrast(_backdrop, foregrounds) { + return [...foregrounds].flatMap(() => [breach ? 0 : 100, 10]); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => bg, + target: el, + now: () => now, + win: {}, + sustainMs: 0, + dwellMs: 0, + }); + const committedCurrent = ctrl.current(); + const committedProps = [...el.props]; + const committedMutationCount = el.mutations.length; + + breach = true; + bg = "#000000"; + now++; + assert.throws(() => ctrl.tick(), /opacity in \(0,1\]/u); + assert.deepEqual(ctrl.current(), committedCurrent); + assert.deepEqual([...el.props], committedProps); + assert.equal(el.mutations.length, committedMutationCount); + ctrl.stop(); +}); + test("admitted Unresolved stays inert through init, breach re-solve and ease", () => { const h = harness(); h.colors.setResolve(mixedWithUnresolved("#000000", 100)); @@ -3791,8 +4113,8 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( recheckContrast(b, fgs) { seenRecheckWords.push(...fgs); for (const f of fgs) { - // Packed boundary: recheck now sees only packed `0x00RRGGBB` color words - // (high byte zero) — never a hex string, never a translucent role. + // Packed boundary: recheck sees only materialized `0x00RRGGBB` + // occurrences (high byte zero), never role metadata or hex strings. assert.ok( Number.isInteger(f) && f >= 0 && f <= 0x00ffffff, "recheck must only ever see packed color words (0x00RRGGBB)", @@ -3839,17 +4161,17 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( // ── C8d E1: translucent recheck lanes (X2 alpha-only adapts) ──────────────── // -// Translucent roles carry an occurrence descriptor {compositeHex, compositeLc} -// — the reference composite over the resolve background. Dropping the -// kind==="color" filter on the RECHECK path lets a translucent role ride a -// recheck lane (compositeHex as the foreground word, |compositeLc| as the drift -// floor) WITHOUT joining the ease set, so its live oklch(tint/alpha) var stays -// intact. A contrast-drifting alpha-only theme must then re-adapt through the -// same graph path (C8 exit X2). +// Solid and translucent outputs share one physical recheck descriptor. For a +// translucent output, the foreground occurrence is recomposited from tint and +// alpha on every current backdrop; compositeLc remains the solve-time drift +// baseline. The role does not join the ease set, so its live tint/alpha CSS var +// stays intact. A contrast-drifting alpha-only theme must still re-adapt through +// the same graph path (C8 exit X2). test("alpha_only_theme_readapts: a translucent-only contrast drop re-solves through the same graph", () => { const el = fakeElement(); - const VEIL_COMPOSITE = "#55757F"; // matches the enriched veil stub + const SOLVE_TIME_COMPOSITE = "#55757F"; + const CURRENT_OCCURRENCE = "#B7B7B7"; // #808080 at 0.5 over #EEEEEE let bg = "#FFFFFF"; let now = 1000; const seenRecheckWords = []; @@ -3868,10 +4190,10 @@ test("alpha_only_theme_readapts: a translucent-only contrast drop re-solves thro Number.isInteger(f) && f >= 0 && f <= 0x00ffffff, "recheck must only ever see packed color words (0x00RRGGBB)", ); - // ONLY the translucent lane's composite drops below its |compositeLc| * + // ONLY the translucent lane's current occurrence drops below its |compositeLc| * // (1 - dropFraction) floor (41.5 * 0.8 = 33.2); the color role passes. // No color-role drift at all — the change is alpha-only. - out.push(f === pk(VEIL_COMPOSITE) ? 5 : 100); + out.push(f === pk(CURRENT_OCCURRENCE) ? 5 : 100); out.push(10); } return out; @@ -3900,8 +4222,13 @@ test("alpha_only_theme_readapts: a translucent-only contrast drop re-solves thro ctrl.tick(); assert.ok( - seenRecheckWords.includes(pk(VEIL_COMPOSITE)), - "translucent compositeHex must reach the recheck lane", + seenRecheckWords.includes(pk(CURRENT_OCCURRENCE)), + "tint+alpha must materialize the current occurrence before recheck", + ); + assert.equal( + seenRecheckWords.includes(pk(SOLVE_TIME_COMPOSITE)), + false, + "the solve-time composite must not survive a backdrop change", ); assert.ok( colors.resolveCount >= 2, @@ -3912,12 +4239,13 @@ test("alpha_only_theme_readapts: a translucent-only contrast drop re-solves thro test("translucent_lanes_participate: translucent rides a recheck lane 1:1 but is never eased", () => { // (1) MIXED set: a color role AND a translucent role. The recheck foreground - // words must include BOTH the color hex and the translucent compositeHex, and + // words must include BOTH the color hex and the materialized alpha occurrence, and // every recheck call is 1:1 (one foreground word per role in the set). The // color role breaches → an ease actually runs on its var, while the veil's // live oklch(tint/alpha) var is never overwritten with a hex. const el = fakeElement(); - const VEIL_COMPOSITE = "#55757F"; + const SOLVE_TIME_COMPOSITE = "#55757F"; + const CURRENT_OCCURRENCE = "#B7B7B7"; // #808080 at 0.5 over #EEEEEE let bg = "#FFFFFF"; let now = 1000; const perCallFgCounts = []; @@ -3935,7 +4263,7 @@ test("translucent_lanes_participate: translucent rides a recheck lane 1:1 but is const out = []; for (const f of fgs) { // Color role breaches (10 < 80); translucent passes (100 ≥ 33.2). - out.push(f === pk(VEIL_COMPOSITE) ? 100 : 10); + out.push(f === pk(CURRENT_OCCURRENCE) ? 100 : 10); out.push(10); } return out; @@ -3967,8 +4295,13 @@ test("translucent_lanes_participate: translucent rides a recheck lane 1:1 but is "color role hex present among recheck foreground words", ); assert.ok( - seenRecheckWords.includes(pk(VEIL_COMPOSITE)), - "translucent compositeHex present among recheck foreground words (lane participation)", + seenRecheckWords.includes(pk(CURRENT_OCCURRENCE)), + "materialized alpha occurrence participates in the recheck row", + ); + assert.equal( + seenRecheckWords.includes(pk(SOLVE_TIME_COMPOSITE)), + false, + "the recheck row must not reuse solve-time compositeHex", ); assert.ok( perCallFgCounts.every((n) => n === 2), @@ -3984,7 +4317,7 @@ test("translucent_lanes_participate: translucent rides a recheck lane 1:1 but is ); // (2) TRANSLUCENT-ONLY set: no color role at all. The glow-only fast-path - // guard must key off the RECHECK set (recheckRoles.length), not the ease set + // guard must key off the RECHECK set (recheckOccurrences.length), not the ease set // (roles.length) — otherwise a translucent-only set routes to the glow branch // and is never rechecked/re-solved. const el2 = fakeElement(); @@ -3998,7 +4331,7 @@ test("translucent_lanes_participate: translucent rides a recheck lane 1:1 but is cssVar: "--lab-veil", tintHex: "#808080", alpha: 0.5, - compositeHex: VEIL_COMPOSITE, + compositeHex: SOLVE_TIME_COMPOSITE, compositeLc: -41.5, }, }, diff --git a/packages/colors/test/hotpath-parity.test.mjs b/packages/colors/test/hotpath-parity.test.mjs index e4fbdc7f..16a477ab 100644 --- a/packages/colors/test/hotpath-parity.test.mjs +++ b/packages/colors/test/hotpath-parity.test.mjs @@ -20,11 +20,22 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { adaptTheme } from "../adapt-theme.js"; import * as ebg from "../effective-bg.js"; +import { buildMissRing, rustCacheCapacity } from "../bench/misses.mjs"; +import { + benchmarkOccurrencesFromRoles, + materializeOccurrences, +} from "../bench/occurrences.mjs"; +import { __over, initSync, LabColors } from "../pkg/labcolors.js"; const { oklabLerp } = ebg; +initSync({ + module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), +}); + // ── deterministic mini-harness (small, self-contained hot-path replay) ─────── const FRAME_MS = 1000 / 60; @@ -35,6 +46,8 @@ const TL_COUNT = 4; const hex2 = (n) => n.toString(16).padStart(2, "0"); const toneHex = (t) => `#${hex2(t & 0xff)}${hex2((t * 3) & 0xff)}${hex2((t * 7) & 0xff)}`.toUpperCase(); const bgTone = (bg) => parseInt(bg.slice(1, 3), 16); +const pack = (hex) => Number.parseInt(hex.slice(1), 16) >>> 0; +const unpack = (word) => `#${word.toString(16).padStart(6, "0").toUpperCase()}`; const fnv1a = (h, str) => { for (let i = 0; i < str.length; i++) { @@ -91,8 +104,18 @@ function makeStubEngine() { } for (let i = 0; i < TL_COUNT; i++) { const cssVar = `--lab-tl-${i}`; - vars[cssVar] = `oklch(80.0% 0.0200 ${(i * 31) % 360} / 0.6)`; - roles[`tl${i}`] = { kind: "translucent", cssVar }; + const css = `oklch(80.0% 0.0200 ${(i * 31) % 360} / 0.6)`; + const tintHex = ebg.toHex(ebg.parseCssColor(css)); + const alpha = 0.6; + vars[cssVar] = css; + roles[`tl${i}`] = { + kind: "translucent", + cssVar, + tintHex, + alpha, + compositeHex: unpack(__over(pack(tintHex), alpha, pack(bg))), + compositeLc: 60, + }; } return { vars, roles }; }, @@ -213,3 +236,98 @@ test("compileLerpPair falls back (null) on unparseable endpoints", { skip: !hasC assert.equal(ebg.compileLerpPair("blah", "#112233"), null); assert.equal(ebg.compileLerpPair("#112233", "hsl(1,2%,3%)"), null); }); + +test("the boundary benchmark materializes opaque and alpha occurrences behaviorally", () => { + const occurrences = benchmarkOccurrencesFromRoles( + { + label: { kind: "color", hex: "#010203" }, + veil: { kind: "translucent", tintHex: "#C0B2FA", alpha: 0.122 }, + unresolved: { kind: "failure" }, + }, + pack, + ); + const black = materializeOccurrences( + occurrences, + pack("#000000"), + new Uint32Array(occurrences.length), + __over, + ); + const white = materializeOccurrences( + occurrences, + pack("#FFFFFF"), + new Uint32Array(occurrences.length), + __over, + ); + + assert.deepEqual([...black], [pack("#010203"), pack("#17161F")]); + assert.deepEqual([...white], [pack("#010203"), pack("#F7F6FE")]); + assert.equal(black[0], white[0], "opaque occurrence must be backdrop-independent"); + assert.notEqual(black[1], white[1], "alpha occurrence must be rematerialized per backdrop"); + assert.throws( + () => materializeOccurrences( + [occurrences[1]], + pack("#000000"), + new Uint32Array(1), + () => 0xFFFFFFFF, + ), + { name: "RangeError", message: /Core rejected admitted opacity/u }, + ); +}); + +test("rustCacheCapacity accepts formatting-preserving Rust literal variants", () => { + for (const source of [ + "const CACHE_CAPACITY: usize = 4096;", + " pub const CACHE_CAPACITY : usize = 4_096 ; // policy", + "pub(crate)\tconst CACHE_CAPACITY:\tusize=4_096usize; /* policy */", + ]) { + assert.equal(rustCacheCapacity(source), 4096, source); + } + assert.throws( + () => rustCacheCapacity("const CACHE_CAPACITY: usize = 1 << 12;"), + /capacity is absent or outside/u, + ); +}); + +test("the cache-miss benchmark corpus is admissible and never masks conflict", () => { + const engineSource = readFileSync( + new URL("../../../crates/labcolors-wasm/src/engine.rs", import.meta.url), + "utf8", + ); + const capacity = rustCacheCapacity(engineSource); + const solveBackground = "#3A3A3C"; + const ring = buildMissRing(pack(solveBackground), capacity); + + assert.equal(ring.length, capacity + 1); + assert.equal(new Set(ring).size, ring.length); + assert.equal(ring.includes(solveBackground), false); + + const colors = new LabColors(); + colors.loadConfig( + readFileSync( + new URL("../../../crates/labcolors-wasm/tests/data/labui.config.json", import.meta.url), + "utf8", + ), + ); + for (const background of ring) { + assert.doesNotThrow( + () => colors.resolveTheme(background, "dark"), + `cache-miss background ${background} must admit a successful latency sample`, + ); + } + + let conflict; + try { + colors.resolveTheme("#1099FF", "dark"); + } catch (error) { + conflict = error; + } + assert.ok(conflict instanceof Error, "the historical arbitrary sweep witness must conflict"); + assert.equal(conflict.code, "output_conflict"); + assert.deepEqual( + conflict.conflicts.map(({ role, code }) => ({ role, code })), + [ + { role: "border-warning-strong", code: "floor_unreachable" }, + { role: "border-success-strong", code: "floor_unreachable" }, + ], + ); +}); diff --git a/packages/colors/test/public-api-cleanup.test.mjs b/packages/colors/test/public-api-cleanup.test.mjs index 19b7340b..3738abaa 100644 --- a/packages/colors/test/public-api-cleanup.test.mjs +++ b/packages/colors/test/public-api-cleanup.test.mjs @@ -14,6 +14,7 @@ test("effective-background math stays internal to the browser shell", () => { const rootTypes = read("packages", "colors", "index.d.ts"); const backdropRuntime = read("packages", "colors", "effective-bg.js"); const observationRuntime = read("packages", "colors", "background-observation.js"); + const adaptRuntime = read("packages", "colors", "adapt-theme.js"); const releaseVerifier = read("scripts", "verify-package-release.mjs"); assert.equal(manifest.exports["./effective-bg"], undefined); @@ -44,6 +45,8 @@ test("effective-background math stays internal to the browser shell", () => { assert.equal(manifest.exports["./pkg/labcolors.js"], undefined); assert.doesNotMatch(backdropRuntime, /__over|effectiveBackground/u); assert.match(observationRuntime, /__over/u); + assert.match(adaptRuntime, /import\s*\{\s*__over\s*\}/u); + assert.doesNotMatch(adaptRuntime, /\.compositeHex\b|\["compositeHex"\]/u); assert.match(observationRuntime, /export function observePointBackground/u); assert.doesNotMatch( backdropRuntime, @@ -87,6 +90,13 @@ test("the unsupported strict transition recipe cannot re-enter source or shipped assert.doesNotMatch(runtime, /floorBlend|lerpPairLuminance|wcagLuminanceCached/u); }); +test("runtime documentation names the declared canvas instead of the removed fallback option", () => { + const docs = read("packages", "colors", "README.md"); + + assert.doesNotMatch(docs, /^\s*fallback\??\s*:/mu); + assert.match(docs, /^\s*canvas\??\s*:/mu); +}); + test("the unshipped JavaScript FNV mirror stays deleted", () => { for (const path of [ ["packages", "colors", "fnv1a.js"],