diff --git a/crates/labcolors-core/src/hash.rs b/crates/labcolors-core/src/hash.rs index 3ebf4e76..e5008963 100644 --- a/crates/labcolors-core/src/hash.rs +++ b/crates/labcolors-core/src/hash.rs @@ -7,12 +7,9 @@ //! //! # Почему FNV-1a //! -//! Нужен маленький, детерминированный, кросс-рантаймовый (JS↔Rust) хеш без -//! зависимостей, дающий побайтово идентичный результат в обоих рантаймах. -//! FNV-1a — это ~10 строк целочисленной арифметики без таблиц, что делает -//! JS-зеркало тривиально верифицируемым против этой реализации -//! (`packages/colors/fnv1a.js`, дифференциальный тест -//! `tests/fnv1a_differential.rs`). +//! Нужен маленький детерминированный хеш без зависимостей. FNV-1a — это +//! несколько строк целочисленной арифметики без таблиц; опубликованные векторы +//! и воспроизводимые hostile-входы проверяются в `tests/fnv1a_vectors.rs`. //! //! # Провенанс констант //! @@ -31,9 +28,8 @@ const FNV1A_32_PRIME: u32 = 16777619; // 0x01000193 /// FNV-1a 32-битный хеш произвольной последовательности байт. /// -/// Детерминирован и портируем: даёт побайтово идентичный беззнаковый `u32` в -/// Rust и в JS-зеркале (`packages/colors/fnv1a.js`) на одном и том же входе. -/// Вся арифметика — обёрточная (`wrapping_*`), поэтому 32-битное переполнение +/// Детерминирован и портируем в объявленном байтовом domain. Вся арифметика — +/// обёрточная (`wrapping_*`), поэтому 32-битное переполнение /// корректно заворачивается по модулю 2^32 и НЕ паникует даже в debug-сборке. /// /// Вызывающий сам кодирует вход в байты (напр. `s.as_bytes()` для UTF-8) — diff --git a/crates/labcolors-core/tests/data/fnv1a-vectors.txt b/crates/labcolors-core/tests/data/fnv1a-vectors.txt index c73f3cf3..74bde686 100644 --- a/crates/labcolors-core/tests/data/fnv1a-vectors.txt +++ b/crates/labcolors-core/tests/data/fnv1a-vectors.txt @@ -1,10 +1,11 @@ -# FNV-1a 32-bit shared vectors. Single source of truth for the JS<->Rust -# differential test. Constants: offset_basis=2166136261 (0x811c9dc5), +# FNV-1a 32-bit published anchors + frozen Rust characterization. +# Constants: offset_basis=2166136261 (0x811c9dc5), # prime=16777619 (0x01000193). Spec + published vectors: # http://www.isthe.com/chongo/tech/comp/fnv/ # columns: groupnamekindpayloadexpected(decimal u32) # anchors carry the PUBLISHED reference expecteds (external ground truth); -# adversarial/fuzz expecteds are the cross-runtime oracle. +# adversarial/fuzz expecteds have no preserved independent provenance and are +# regression fixtures, not an external oracle. anchor empty text 2166136261 anchor a text a 3826002220 anchor foobar text foobar 3214735720 diff --git a/crates/labcolors-core/tests/fnv1a_differential.rs b/crates/labcolors-core/tests/fnv1a_vectors.rs similarity index 81% rename from crates/labcolors-core/tests/fnv1a_differential.rs rename to crates/labcolors-core/tests/fnv1a_vectors.rs index 39568b13..8c1e6e4d 100644 --- a/crates/labcolors-core/tests/fnv1a_differential.rs +++ b/crates/labcolors-core/tests/fnv1a_vectors.rs @@ -1,17 +1,15 @@ -//! Differential + anchor test for the portable FNV-1a 32-bit core primitive. +//! Published anchors and frozen characterization for the FNV-1a 32-bit core primitive. //! -//! One source of truth for vectors: `tests/data/fnv1a-vectors.txt` (LF-pinned -//! TSV), shared byte-for-byte with the JS mirror test -//! (`packages/colors/test/fnv1a-differential.test.mjs`). Both sides recompute -//! every vector and assert equality against the committed expected (unsigned -//! decimal u32). Green on both = byte-identical JS==Rust output on every vector: -//! empty string, Cyrillic, emoji, high-bit bytes, an overflow-length key, and a -//! 500-vector randomized fuzz corpus. +//! `tests/data/fnv1a-vectors.txt` is an LF-pinned TSV. Every row is recomputed +//! against its committed unsigned `u32`: empty string, Cyrillic, emoji, +//! high-bit bytes, an overflow-length key, and a 500-vector randomized corpus. //! //! `anchor` rows carry the CANONICAL published FNV-1a values (external ground //! truth, ) so correctness is -//! grounded in the spec, not self-blessed. `text` rows are stored as literal -//! strings so this runtime exercises its OWN UTF-8 encoding path. +//! grounded in the spec, not self-blessed. The remaining committed expected +//! values have no preserved independent provenance and therefore protect only +//! frozen behaviour. `text` rows are literal strings so Rust exercises its own +//! UTF-8 encoding path. use labcolors_core::fnv1a_32; @@ -73,7 +71,7 @@ fn anchors_match_canonical_published_vectors() { } #[test] -fn adversarial_emoji_cyrillic_highbit_overflow_match_oracle() { +fn adversarial_emoji_cyrillic_highbit_overflow_match_frozen_characterization() { let adv: Vec<_> = load() .into_iter() .filter(|v| v.group == "adversarial") @@ -90,7 +88,7 @@ fn adversarial_emoji_cyrillic_highbit_overflow_match_oracle() { } #[test] -fn fuzz_500_frozen_vectors_match_oracle_cross_runtime() { +fn fuzz_500_vectors_match_frozen_characterization() { let fuzz: Vec<_> = load().into_iter().filter(|v| v.group == "fuzz").collect(); assert!( fuzz.len() >= 500, diff --git a/crates/labcolors-wasm/Cargo.toml b/crates/labcolors-wasm/Cargo.toml index 3532e899..877021ec 100644 --- a/crates/labcolors-wasm/Cargo.toml +++ b/crates/labcolors-wasm/Cargo.toml @@ -15,8 +15,8 @@ description = "WASM bindings for the labcolors-core contrast engine, packaged as crate-type = ["cdylib", "rlib"] [dependencies] -# Runtime owns only point evaluation and the adaptive theme engine. Offline -# compiler operations have a separate Cargo root and physical WASM artifact. +# WASM-граница владеет JSON-конфигом и browser bindings для point-evaluator-ов; +# `labcolors-core` остаётся без runtime-зависимостей. labcolors-core = { path = "../labcolors-core", default-features = false } wasm-bindgen = { workspace = true } # js-sys ships with the wasm-bindgen toolchain (no new third-party tree). It is @@ -25,9 +25,8 @@ js-sys = "0.3" # Derive-only: thiserror is a proc-macro with no runtime code, so it adds # nothing to the WASM bundle while giving matchable, well-described errors. thiserror = "2" -# Граница конфига: JSON живёт ТОЛЬКО в этом крейте (ядро — ноль -# runtime-зависимостей). Размер бандла отслеживается CI-шагом report bundle -# size (информационный до perf-bench). +# JSON-декодирование принадлежит только WASM-границе; Core не получает serde. +# Raw release-WASM закреплён exact size ratchet в `packages/colors/bench/wasm.json`. serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index 5872486d..6a714262 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -212,8 +212,7 @@ pub struct SolvedColor { pub floor_override: bool, /// Минимальное отношение WCAG из контракта роли (`AaText` → 4.5, /// `AaUi` → 3.0) либо `None`, если пола нет. Solve проверяет финальную - /// эмитированную пару. Default runtime не удерживает пол на каждом - /// промежуточном кадре; `strict` использует охарактеризованный clamp, но не - /// является сертификатом. + /// эмитированную пару. Runtime-переход — только способ показа и не + /// сертифицирует этот пол на промежуточных кадрах. pub legal_floor: Option, } diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index c7f85294..9ab2aa59 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -210,9 +210,10 @@ impl Engine { /// still pass and re-solves only the rare role that stably fails. /// /// Returns a flat, interleaved buffer `[lc0, wcag0, lc1, wcag1, …]` (mapped to - /// a JS `Float64Array`) — no per-call object allocation on the hot path. The - /// values equal what the solver measured, so a freshly-resolved set rechecks - /// to its own reported contrasts. + /// a JS `Float64Array`) instead of a per-result object graph. The current + /// string ABI and implementation still allocate boundary and work buffers + /// per call. Values equal what the solver measured, so a freshly-resolved + /// set rechecks to its own reported contrasts. pub fn recheck( &self, bg_hex: &str, @@ -223,10 +224,11 @@ impl Engine { // Accept the same hex forms as the background and `resolveTheme` (`#RGB` // shorthand, missing `#`, any case) — but on this per-frame primitive, // BORROW the input when it is already a valid 6-hex-digit colour so the - // common case (already-canonical `#RRGGBB` role hexes) allocates nothing. - // Only `#RGB` shorthand (or an otherwise-non-canonical form) allocates a - // normalised `String`. `srgb_from_hex` parses case- and `#`-insensitively, - // so a borrowed lower/upper/bare form yields the byte-identical colour. + // common case avoids a normalisation `String`. Boundary vectors, + // references, pairs and the flat output still allocate. `#RGB` shorthand + // (or another non-canonical form) additionally allocates a normalised + // `String`. `srgb_from_hex` parses case- and `#`-insensitively, so a + // borrowed lower/upper/bare form yields the byte-identical colour. let bg = hex_for_recheck(bg_hex)?; let fg_cows: Vec> = fg_hexes .iter() diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 2690c8c1..0e484649 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -66,8 +66,8 @@ export interface SolvedColor { /** * Минимальное отношение WCAG из контракта роли: 4.5 для AA-текста, 3.0 для * AA-UI или `null`, если пола нет. Solve проверяет финальную эмитированную - * пару. Default runtime не удерживает пол на каждом промежуточном кадре; - * `strict` использует охарактеризованный clamp, но не является сертификатом. + * пару. Runtime-переход — только способ показа и не сертифицирует этот пол + * на промежуточных кадрах. */ readonly legalFloor: number | null; } diff --git a/packages/colors/README.md b/packages/colors/README.md index 1ddb86d8..edc9efa5 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -65,6 +65,7 @@ applyTheme(document.documentElement, result); // записать все --lab ```ts import init, { LabColors, watchTheme } from "@labpics/colors"; +import dsConfig from "./theme.config.json"; await init(); const colors = new LabColors(); @@ -95,35 +96,31 @@ watcher.stop(); // отключить наблюдателя отслеживаемых метрик. ```ts -import init, { LabColors, adaptTheme, effectiveBackground } from "@labpics/colors"; +import init, { LabColors, adaptTheme } from "@labpics/colors"; +import dsConfig from "./theme.config.json"; await init(); const colors = new LabColors(); colors.loadConfig(JSON.stringify(dsConfig)); // конфиг дизайн-системы (см. квик-старт) const surface = document.querySelector(".hero") as HTMLElement; +let samples = ["#101012"]; const adaptive = adaptTheme(surface, { colors, theme: "light", - background: () => effectiveBackground(surface, { fallback: "#101012" }), + background: () => samples, }); adaptive.start(); // запустить внутренний requestAnimationFrame-цикл +samples = ["#101012", "#202024"]; // интеграция обновила конечные образцы подложки +adaptive.tick(); // явно обработать новое наблюдение adaptive.setTheme("dark"); // смена темы применяется мгновенно adaptive.stop(); // остановить цикл ``` -Для градиента, изображения или видео интеграция может передать конечный набор -самостоятельно полученных образцов. Контроллер проверяет только переданные точки: -он не наблюдает всё поле и не переносит результат на промежутки между образцами. - -```ts -adaptTheme(hero, { - colors, - theme: "light", - background: () => sampleBackdrop(hero), // например ["#0B0B0E", "#3A3A40"] -}); -``` +Для градиента, изображения или видео интеграция может передать конечный набор образцов, +полученных самостоятельно. Контроллер проверяет только переданные точки: он не +наблюдает всё поле и не переносит результат на промежутки между образцами. ### Инициализация в Node @@ -436,7 +433,6 @@ interface AdaptThemeOptions { sustainMs?: number; // минимальное время удержания нарушения (по умолчанию 120) dwellMs?: number; // минимальный интервал между пересчётами (по умолчанию 250) easeMs?: number; // длительность перехода (по умолчанию 280; уменьшается при reduced-motion) - strict?: boolean; // legacy characterized clamp; не universal floor certificate (по умолчанию false) reducedMotion?: boolean; // переопределить системную настройку } @@ -449,11 +445,6 @@ interface AdaptController { } ``` -`strict` сохраняет прежнее runtime-поведение, но не является доказательством -минимального или проходящего состояния на каждом кадре: путь -Oklab→gamut clip→sRGB8 немонотонен. Включайте его только явно для воспроизведения -этого legacy clamp, а не как режим корректности или читаемости. - Объявленный набор `background` обязан быть непустым и содержать только непустые строки. Невалидный явный образец отклоняется до resolver без coercion и без подмены fallback-цветом. @@ -474,34 +465,6 @@ Glow-свидетельств и подготовка перехода обра --- -### `effectiveBackground(element, options?): string` - -Возвращает непрозрачную опорную оценку `#RRGGBB` для поддерживаемой цепочки -сплошных и полупрозрачных DOM `background-color`. Это не browser pixel capture -и не сертификат цвета, который реально видит наблюдатель. Helper обходит цепочку -предков и композитит распознанные слои поверх `fallback` (по умолчанию белый). - -```ts -const bg = effectiveBackground(panel); // например "#0F1014" -const bg2 = effectiveBackground(panel, { fallback: "#101012" }); -``` - -**Честное ограничение:** работает только с поддерживаемыми сплошными и -полупрозрачными `background-color`; неподдерживаемый CSS, неполная прозрачная -цепочка, `background-image`, градиент, blur и video не дают полного наблюдения. -Текущий compatibility helper ещё может отбросить неподдерживаемый слой или -использовать fallback. Если такой контент влияет на решение, интеграция должна -передать собственный конечный набор образцов в `adaptTheme`; он расширяет только -набор проверенных точек и не превращается в наблюдение всего поля. - -Дополнительно экспортируются вспомогательные функции для работы со слоями: -`parseCssColor`, `compositeOver`, `compositeStackToHex`, `toHex` и `oklabLerp` -(линейная интерполяция координат Oklab между двумя цветами с последующим -преобразованием в непрозрачный `#RRGGBB`). Alpha входов при этом отбрасывается, -а точность endpoints относится только к непрозрачным RGB-байтам. - ---- - ## Размер бандла Raw-размер WASM — hard gate. SSOT текущего exact Linux-x64 size-бюджета @@ -522,7 +485,7 @@ release artifact. Будет ли runtime-загрузка критическим путём первого рендера, определяет интеграция: до первого `resolveTheme` инициализация обязана завершиться. -JS-хелперы (`applyTheme`, `watchTheme`, `adaptTheme`, `effectiveBackground`) +JS-хелперы (`applyTheme`, `watchTheme`, `adaptTheme`) имеют именованные экспорты и допускают tree-shaking, но их размер также следует мерить сборкой, а не описывать приблизительно. diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index 310f1b0d..26184ad3 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -40,14 +40,6 @@ export interface AdaptThemeOptions { dwellMs?: number; /** Crossfade duration in ms. Default `280` (capped to a short fade under reduced motion). */ easeMs?: number; - /** - * Enable the legacy characterized per-frame clamp. The current - * Oklab→clip→sRGB8 path is not globally monotone, so this option is not a - * universal floor/least-blend or legibility certificate. Use it only when an - * integration explicitly needs the characterized legacy clamp. Default - * `false`. - */ - strict?: boolean; /** Override reduced-motion detection (default reads `matchMedia`). */ reducedMotion?: boolean; /** Clock injection (default `performance.now`/`Date.now`). */ diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index fc53d45e..f7518a05 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -9,18 +9,14 @@ // These are runtime mechanics, not a whole-field or human-readability proof. // `dropFraction`, `sustainMs`, `dwellMs`, `easeMs`, and the shorter transition // selected for the host motion preference are compatibility parameters, not -// standard-derived thresholds. Default easing does not verify a floor on every -// frame. `strict: true` enables the characterized per-frame clamp, whose current -// Oklab→clip→sRGB8 path is not globally monotone and is not a floor certificate. +// standard-derived thresholds. Coordinate interpolation is presentation only; +// it does not verify a constraint on every intermediate frame. import { effectiveBackground, - parseCssColor, oklabLerp, compileLerpPair, lerpPairHex, - lerpPairLuminance, - wcagLuminanceCached, } from "./effective-bg.js"; import { admitSnapshot, writeVars } from "./snapshot.js"; @@ -36,25 +32,6 @@ function easeOut(t) { return 1 - u * u * u; } -/** Relative luminance of `#RRGGBB` in the frozen original WCAG 2.1 (2018) - * profile (0.03928 split, 2.4 exponent), so the strict floor-clamp agrees - * byte-for-byte with the core's versioned `legalFloor` semantics. */ -function relativeLuminanceHex(hex) { - const rgb = parseCssColor(hex) ?? [0, 0, 0, 1]; - const lin = (c) => { - const s = c / 255; - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); -} - -/** WCAG contrast ratio from two relative luminances: `(L+0.05)/(L+0.05)`. */ -function wcagRatio(lumA, lumB) { - const hi = Math.max(lumA, lumB); - const lo = Math.min(lumA, lumB); - return (hi + 0.05) / (lo + 0.05); -} - /** Linearly interpolate an ease segment's Oklab coordinates at `t ∈ [0,1]`. * Segments carry a compiled pair (`compileLerpPair`) when both endpoints * parse — the always-case in practice, both being engine-emitted `#RRGGBB` — @@ -66,13 +43,6 @@ function segHex(seg, t) { return seg.pair ? lerpPairHex(seg.pair, t) : oklabLerp(seg.from, seg.to, t); } -/** WCAG relative luminance of `segHex(seg, t)` — numeric fast path on the - * compiled pair (no `#RRGGBB` round-trip), string path otherwise. Strict - * mode's `floorBlend` bisection calls this up to 14× per role per frame. */ -function segLum(seg, t) { - return seg.pair ? lerpPairLuminance(seg.pair, t) : relativeLuminanceHex(segHex(seg, t)); -} - /** * @typedef {object} AdaptController * @property {(now?: number) => void} tick Drive one step (call from rAF, or let @@ -110,9 +80,6 @@ function segLum(seg, t) { * @param {number} [options.sustainMs=120] breach must persist this long * @param {number} [options.dwellMs=250] minimum between re-solves * @param {number} [options.easeMs=280] crossfade duration - * @param {boolean} [options.strict=false] enable the legacy characterized - * per-frame clamp; the current non-monotone interpolation path is not a - * universal floor certificate * @param {boolean} [options.reducedMotion] override; default reads matchMedia * @param {() => number} [options.now] clock (default performance.now/Date.now) * @param {*} [options.win=globalThis] @@ -165,7 +132,6 @@ export function adaptTheme(element, options) { const dropFraction = options.dropFraction ?? 0.2; const sustainMs = options.sustainMs ?? 120; const dwellMs = options.dwellMs ?? 250; - const strict = options.strict ?? false; const win = options.win ?? (typeof globalThis !== "undefined" ? globalThis : undefined); const requestFrameCapability = win?.requestAnimationFrame; const requestFrame = @@ -199,7 +165,7 @@ export function adaptTheme(element, options) { }; let theme = options.theme; - /** @type {{ cssVar: string, key: string, lc: number, hex: string, legalFloor: number|null }[]} stable role order */ + /** @type {{ cssVar: string, key: string, lc: number, hex: string }[]} stable role order */ let roles = []; /** Stable Glow roles need an exact class recheck in addition to color * contrast rechecks. The only determinate stable state is the core-certified @@ -211,7 +177,7 @@ export function adaptTheme(element, options) { * composes `{...baseVars, ...easedColorOverlay}`, so translucent roles are * never dropped by clear-then-write. Only `kind === "color"` roles ease. */ let baseVars = {}; - /** @type {Map} in-flight ease per cssVar */ + /** @type {Map} in-flight ease per cssVar */ let easing = new Map(); let easeStart = 0; let breachSince = null; @@ -481,7 +447,6 @@ export function adaptTheme(element, options) { key, lc: r.lc, hex: r.hex, - legalFloor: typeof r.legalFloor === "number" ? r.legalFloor : null, })); return { result: snapshot, @@ -745,8 +710,6 @@ export function adaptTheme(element, options) { }; // Begin an ease from the currently-applied colours toward the role colours. - // `held` latches the per-role displayed blend so it only ever advances toward - // the destination (strict mode) — see `stepEase`. const prepareEase = (roleSet, fromByVar, now) => { const nextEasing = new Map(); for (const r of roleSet) { @@ -755,7 +718,6 @@ export function adaptTheme(element, options) { nextEasing.set(r.cssVar, { from, to: r.hex, - held: 0, pair: compileLerpPair(from, r.hex), }); } @@ -770,46 +732,7 @@ export function adaptTheme(element, options) { return easing.size === 0 ? applyRolesDirect(owner) : true; }; - // Legacy strict clamp: fixed-step bisection from the natural ease value `e` - // toward the freshly-solved destination. Oklab→clip→sRGB8 legality is not - // globally monotone, so this is a characterized compatibility selector, not - // a proof of the least or universally legal blend. If even `to` fails after - // background drift, the selector returns 1 and the recheck loop requests - // another solve. - const floorBlend = (seg, e, bgLums, floor) => { - const legalAt = (blend) => { - const lum = segLum(seg, blend); - for (let i = 0; i < bgLums.length; i++) { - if (wcagRatio(lum, bgLums[i]) < floor) return false; - } - return true; - }; - if (legalAt(e)) return e; - let lo = e; - let hi = 1; - for (let k = 0; k < 14; k++) { - const mid = (lo + hi) / 2; - if (legalAt(mid)) hi = mid; - else lo = mid; - } - return hi; // upper search bound, or 1 when the destination also fails - }; - - // Per-key memo of the samples' WCAG luminances. Strict mode reads them in - // both `stepEase` and `paintedNow` within a tick, and across consecutive - // frames of a static backdrop mid-ease; the tick already computes the - // samples key, so this costs one map per DISTINCT backdrop, not per call. - let lumsKey = null; - let lums = null; - const bgLumsFor = (samples, key) => { - if (key !== lumsKey) { - lums = samples.map(wcagLuminanceCached); - lumsKey = key; - } - return lums; - }; - - const stepEase = (now, samples, key, owner) => { + const stepEase = (now, owner) => { if (!ownsOperation(owner)) return false; const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs; // Terminate the ease when it is done (`t >= 1`) OR when the clock went @@ -826,7 +749,6 @@ export function adaptTheme(element, options) { return applyRolesDirect(owner); } const e = easeOut(t); - const bgLums = strict ? bgLumsFor(samples, key) : null; // Overlay carries ONLY in-flight color roles (as interpolated hex); every // other role — non-eased color and all translucent — keeps its canonical // `baseVars` value under the merge in `applyHexes`. @@ -834,20 +756,7 @@ export function adaptTheme(element, options) { for (const r of roles) { const seg = easing.get(r.cssVar); if (!seg) continue; - let blend = e; - if (strict && r.legalFloor != null) { - // Hold the floor (against the worst sample), then LATCH: the displayed - // blend may only advance toward the destination, never retreat. - // `floorBlend` is stateless and depends on the live (drifting) samples, - // so on a frame where they drift favourably it could return a *lower* - // blend than last frame — a backwards step toward the old colour, the - // precise jarring reversal this mode exists to avoid. `held` clamps that - // out: the scalar blend parameter never retreats. This latch alone is - // not a proof that the quantized colour stays above every floor. - blend = Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held); - seg.held = blend; - } - overlay[r.cssVar] = segHex(seg, blend); + overlay[r.cssVar] = segHex(seg, e); } return applyHexes(overlay, owner); }; @@ -869,16 +778,11 @@ export function adaptTheme(element, options) { }; // The colour each role is PAINTED right now — exactly what `stepEase` writes - // this frame: an in-flight segment sampled at `now` (with the SAME strict - // floor-hold + latch against the worst sample when `strict`), else the static - // hex. Mirrors `stepEase`'s blend math byte-for-byte so the begin-from value - // equals what is on screen, including the strict-mode `held` clamp — otherwise - // an overlapping re-solve in strict mode would start one frame BELOW the - // painted (floored) colour. - const paintedNow = (now, samples, key) => { + // this frame: an in-flight segment sampled at `now`, else the static hex. + // Matching the same blend keeps an overlapping re-solve continuous. + const paintedNow = (now) => { const t = easeMs <= 0 ? 1 : (now - easeStart) / easeMs; const e = easeOut(t); - const bgLums = strict ? bgLumsFor(samples, key) : null; const vars = {}; for (const r of roles) { const seg = easing.get(r.cssVar); @@ -886,11 +790,7 @@ export function adaptTheme(element, options) { vars[r.cssVar] = r.hex; continue; } - const blend = - strict && r.legalFloor != null - ? Math.max(floorBlend(seg, e, bgLums, r.legalFloor), seg.held) - : e; - vars[r.cssVar] = segHex(seg, blend); + vars[r.cssVar] = segHex(seg, e); } return vars; }; @@ -912,7 +812,7 @@ export function adaptTheme(element, options) { breachSince === null && (!hasEase || easeCompletesAt(now)) ) { - if (hasEase) stepEase(now, samples, key, owner); + if (hasEase) stepEase(now, owner); else if (written === null) applyRolesDirect(owner); return; } @@ -933,7 +833,7 @@ export function adaptTheme(element, options) { if (!ownsOperation(owner)) return; if (!commitStableGlowReconciliation(preparedGlow, owner)) return; lastKey = key; - if (hasEase) stepEase(now, samples, key, owner); + if (hasEase) stepEase(now, owner); else if (written === null) applyRolesDirect(owner); return; } @@ -979,7 +879,7 @@ export function adaptTheme(element, options) { if (!commitStableGlowReconciliation(preparedGlow, owner)) return; lastKey = key; breachSince = nextBreachSince; - if (hasEase) stepEase(now, samples, key, owner); + if (hasEase) stepEase(now, owner); else if (written === null) applyRolesDirect(owner); return; } @@ -989,7 +889,7 @@ export function adaptTheme(element, options) { // sampled at `now`) — never the in-flight TARGET. Starting from the target // would SNAP the element to the old target for one frame before easing, // reintroducing flicker when a re-solve overlaps a previous ease. - const fromByVar = paintedNow(now, samples, key); + const fromByVar = paintedNow(now); let candidate = solveCandidate(samples[worstIdx], now, theme, owner); if (!ownsOperation(owner)) return; candidate = withStableGlowReconciliation( @@ -1006,7 +906,7 @@ export function adaptTheme(element, options) { if (!commitResolved(candidate, owner)) return; if (!commitEase(preparedEase, owner)) return; lastKey = key; - stepEase(now, samples, key, owner); + stepEase(now, owner); }; const runTick = (nowArg) => { diff --git a/packages/colors/bench/hotpath.bench.mjs b/packages/colors/bench/hotpath.bench.mjs index 650dd136..4900211b 100644 --- a/packages/colors/bench/hotpath.bench.mjs +++ b/packages/colors/bench/hotpath.bench.mjs @@ -121,10 +121,9 @@ function makeStubEngine() { /** * @param {string} name * @param {(frame:number)=>string|string[]} bgAt deterministic background schedule - * @param {boolean} strict * @param {{fingerprint?: boolean}} [mode] */ -function runScenario(name, bgAt, strict, mode = {}) { +function runScenario(name, bgAt, mode = {}) { const el = makeElement(); const stub = makeStubEngine(); let now = 0; @@ -134,7 +133,6 @@ function runScenario(name, bgAt, strict, mode = {}) { theme: "light", background: () => bgAt(frame), now: () => now, - strict, win: undefined, }); @@ -182,7 +180,7 @@ const driftBg = (f) => toneHex(SOLVED0 + Math.round(32 * Math.sin((2 * Math.PI * // re-solve → 280ms ease, then back near the new tone (no breach) — a steady // mix of recheck / solve / ease frames. const breachBg = (f) => toneHex(SOLVED0 + (Math.floor(f / 90) % 2 === 1 ? 96 : 0) + (f % 3)); -// Strict mode over a 3-sample varying backdrop with the same breach schedule. +// Three-sample varying backdrop with the same breach schedule. const breachBg3 = (f) => { const base = breachBg(f); const t = bgTone(base); @@ -238,10 +236,10 @@ console.log(""); console.log("scenario µs/frame styleSet styleRem solves rechecks fingerprint"); const scenarios = [ - runScenario("steady", steadyBg, false, { fingerprint }), - runScenario("drift-nobreach", driftBg, false, { fingerprint }), - runScenario("ease-default", breachBg, false, { fingerprint }), - runScenario("ease-strict-3bg", breachBg3, true, { fingerprint }), + runScenario("steady", steadyBg, { fingerprint }), + runScenario("drift-nobreach", driftBg, { fingerprint }), + runScenario("ease-default", breachBg, { fingerprint }), + runScenario("ease-3bg", breachBg3, { fingerprint }), ]; for (const s of scenarios) { console.log( diff --git a/packages/colors/effective-bg.d.ts b/packages/colors/effective-bg.d.ts deleted file mode 100644 index b63bec82..00000000 --- a/packages/colors/effective-bg.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -// Public types for the effective-background resolver. - -/** `[r, g, b, a]` — r,g,b in 0..255, a in 0..1. */ -export type Rgba = [number, number, number, number]; - -/** A computed-style-like accessor: only `getPropertyValue` is used. */ -export interface StyleLike { - getPropertyValue(property: string): string; -} - -export interface EffectiveBackgroundOptions { - /** Base colour when the ancestor chain never reaches an opaque layer. Default `"#FFFFFF"`. */ - fallback?: string; - /** Injection seam for the computed style of an element. Defaults to `getComputedStyle`. */ - getStyle?: (element: unknown) => StyleLike; - /** Injection seam for an element's parent. Defaults to `el.parentElement`. */ - parentOf?: (element: unknown) => unknown; - /** Guard against detached/cyclic chains. Default `64`. */ - maxDepth?: number; -} - -/** Parse a CSS colour string into `[r,g,b,a]`, or `null` if unrecognised. */ -export declare function parseCssColor(css: string): Rgba | null; - -/** Porter-Duff source-over composite of `top` onto `bottom`. */ -export declare function compositeOver(top: Rgba, bottom: Rgba): Rgba; - -/** `[r,g,b]` (0..255) → `#RRGGBB`. */ -export declare function toHex(rgb: Rgba | [number, number, number]): string; - -/** - * Linearly interpolate the Oklab coordinates of two colours at `t ∈ [0,1]` → - * `#RRGGBB`. `from`/`to` may be any string `parseCssColor` accepts (`#hex`, - * `rgb()`/`rgba()`, `oklch()`, `transparent`), not only `#RRGGBB`. The output is - * always opaque: input alpha is discarded, endpoint RGB bytes are normalized - * through `toHex`, and out-of-gamut intermediate channels are clamped. - */ -export declare function oklabLerp(from: string, to: string, t: number): string; - -/** Composite an ordered front-to-back layer stack over an opaque base → `#RRGGBB`. */ -export declare function compositeStackToHex(layersFrontToBack: Rgba[], opaqueBase: Rgba): string; - -/** - * Opaque reference estimate for the supported solid/translucent ancestor - * `background-color` chain, composited over the declared fallback. This is not - * a browser pixel observation and does not account for images, gradients, - * filters, video, or other unsupported layers. - */ -export declare function effectiveBackground( - element: unknown, - opts?: EffectiveBackgroundOptions, -): string; diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index d7db68ae..1970e490 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -291,16 +291,12 @@ export function oklabLerp(from, to, t) { // --- Compiled hot-path forms (package-internal) ----------------------------- // -// `adaptTheme` interpolates the SAME from/to pair on every frame of an ease, -// and strict mode re-derives WCAG luminance from the interpolated colour up to -// repeatedly per floored role (`floorBlend`'s bisection). The string API would -// re-parse both endpoints and round-trip through `#RRGGBB` on every call. These -// helpers compile a pair once and then produce results BYTE-IDENTICAL to their -// string-path equivalents: +// `adaptTheme` interpolates the SAME from/to pair on every frame of an ease. +// The string API would re-parse both endpoints on every call. These helpers +// compile a pair once and then produce results BYTE-IDENTICAL to the string +// path: // -// · `lerpPairHex(pair, t)` ≡ `oklabLerp(from, to, t)` -// · `lerpPairLuminance(pair, t)` ≡ WCAG luminance of `oklabLerp(from, to, t)` -// · `wcagLuminanceCached(css)` ≡ luminance of `parseCssColor(css) ?? black` +// · `lerpPairHex(pair, t)` ≡ `oklabLerp(from, to, t)` // // (locked by test/hotpath-parity.test.mjs on randomised inputs). They are // consumed by `adapt-theme.js` and are NOT part of the public package surface @@ -313,10 +309,10 @@ const parseCache = new Map(); * it recurring strings (computed-style values, backdrop samples, ease * endpoints). The cap is a blunt bound, not an LRU: a full cache is simply * cleared and refills within a frame — cheaper than eviction bookkeeping for - * a working set that is a handful of strings. The cached arrays are SHARED — - * package-internal callers must treat them as read-only. (The public - * `parseCssColor` stays unmemoized and returns a fresh array per call.) */ -export function parseCssColorCached(css) { + * a working set that is a handful of strings. Запись кэша не покидает модуль: + * `compileLerpPair` сразу преобразует её в новый объект с собственными + * массивами, поэтому cache hit не требует защитной аллокации. */ +function parseCssColorCached(css) { let hit = parseCache.get(css); if (hit === undefined) { hit = parseCssColor(css); @@ -326,42 +322,6 @@ export function parseCssColorCached(css) { return hit; } -/** Relative luminance of r,g,b channels (0..255) in the frozen original WCAG - * 2.1 (2018) profile: 0.03928 / 12.92 / 2.4, matching `adapt-theme`. */ -function wcagLumChannels(r, g, b) { - const lin = (c) => { - const s = c / 255; - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); -} - -const LUM_CACHE_CAP = 256; -const lumCache = new Map(); - -/** WCAG relative luminance of any colour string, memoised. Byte-equal to the - * string path `adapt-theme` uses for each sample during a performed metric - * recheck: luminance of - * `parseCssColor(css) ?? [0,0,0,1]` — i.e. unparseable input yields the - * luminance of black, preserving the historical fallback. */ -export function wcagLuminanceCached(css) { - let lum = lumCache.get(css); - if (lum === undefined) { - const c = parseCssColorCached(css) ?? [0, 0, 0, 1]; - lum = wcagLumChannels(c[0], c[1], c[2]); - if (lumCache.size >= LUM_CACHE_CAP) lumCache.clear(); - lumCache.set(css, lum); - } - return lum; -} - -/** Round a channel to the exact byte `toHex` would emit (non-finite → 0, - * clamp, round) — keeps the numeric luminance path quantisation-identical to - * the `#RRGGBB` round-trip it replaces. */ -function hexByte(v) { - return Math.round(clamp255(Number.isFinite(v) ? v : 0)); -} - /** * Compile a from/to colour pair for repeated interpolation. Returns `null` * when either endpoint fails to parse — callers fall back to `oklabLerp`, @@ -371,7 +331,7 @@ function hexByte(v) { * * @param {string} from any colour string `parseCssColor` accepts * @param {string} to any colour string `parseCssColor` accepts - * @returns {{la:number[],lb:number[],aHex:string,bHex:string,aBytes:number[],bBytes:number[]} | null} + * @returns {{la:number[],lb:number[],aHex:string,bHex:string} | null} */ export function compileLerpPair(from, to) { const a = parseCssColorCached(from); @@ -382,8 +342,6 @@ export function compileLerpPair(from, to) { lb: linearRgbToOklab(srgbToLinear(b[0] / 255), srgbToLinear(b[1] / 255), srgbToLinear(b[2] / 255)), aHex: toHex(a), bHex: toHex(b), - aBytes: [hexByte(a[0]), hexByte(a[1]), hexByte(a[2])], - bBytes: [hexByte(b[0]), hexByte(b[1]), hexByte(b[2])], }; } @@ -403,31 +361,10 @@ export function lerpPairHex(pair, t) { return toHex([linearToSrgb(lin[0]) * 255, linearToSrgb(lin[1]) * 255, linearToSrgb(lin[2]) * 255]); } -/** Relative luminance of `lerpPairHex(pair, t)` in the frozen legacy WCAG 2.1 - * (2018) profile, WITHOUT the `#RRGGBB` round-trip: channels are quantised to - * the exact bytes `toHex` would emit, then fed to the same profile formula. - * This preserves value parity; it does not prove bisection monotonicity. */ -export function lerpPairLuminance(pair, t) { - if (t <= 0) return wcagLumChannels(pair.aBytes[0], pair.aBytes[1], pair.aBytes[2]); - if (t >= 1) return wcagLumChannels(pair.bBytes[0], pair.bBytes[1], pair.bBytes[2]); - const la = pair.la; - const lb = pair.lb; - const lin = oklabToLinearRgb( - la[0] + (lb[0] - la[0]) * t, - la[1] + (lb[1] - la[1]) * t, - la[2] + (lb[2] - la[2]) * t, - ); - return wcagLumChannels( - hexByte(linearToSrgb(lin[0]) * 255), - hexByte(linearToSrgb(lin[1]) * 255), - hexByte(linearToSrgb(lin[2]) * 255), - ); -} - /** * Compose an ordered stack of colour layers (front-to-back) over an opaque base - * into a single opaque `#RRGGBB`. Pure — no DOM. Exposed for testing and for - * callers that sample their own layers. + * into a single opaque `#RRGGBB`. Pure — no DOM; package-internal until the + * occurrence observer replaces this compatibility estimate. * * @param {Rgba[]} layersFrontToBack index 0 is the topmost layer * @param {Rgba} opaqueBase must have alpha 1 diff --git a/packages/colors/fnv1a.d.ts b/packages/colors/fnv1a.d.ts deleted file mode 100644 index 0445cfa3..00000000 --- a/packages/colors/fnv1a.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * FNV-1a 32-bit hash of a raw byte sequence — JS mirror of the Rust core - * primitive `labcolors_core::fnv1a_32`. Deterministic and byte-identical across - * runtimes. Returns an unsigned 32-bit integer (0..=4294967295). The caller - * encodes to bytes (e.g. `new TextEncoder().encode(str)` for UTF-8). - */ -export function fnv1a32(bytes: Uint8Array | number[]): number; diff --git a/packages/colors/fnv1a.js b/packages/colors/fnv1a.js deleted file mode 100644 index 52a1f332..00000000 --- a/packages/colors/fnv1a.js +++ /dev/null @@ -1,44 +0,0 @@ -// Portable non-cryptographic hash — JS mirror of the Rust core primitive -// `labcolors_core::fnv1a_32` (crates/labcolors-core/src/hash.rs). -// -// Agnostic: knows nothing about hues, accents, or roles — bytes in, u32 out. -// The deterministic auto-accent selection that will sit on top of this lives in -// the consumer (labui), not here. -// -// Provenance: Fowler–Noll–Vo, variant 1a, 32-bit. offset_basis = 2166136261 -// (0x811c9dc5), prime = 16777619 (0x01000193). Canonical spec + published test -// vectors: http://www.isthe.com/chongo/tech/comp/fnv/. Byte-identical to the -// Rust impl on every shared vector (see the JS<->Rust differential test -// packages/colors/test/fnv1a-differential.test.mjs). - -const FNV1A_32_OFFSET_BASIS = 2166136261; // 0x811c9dc5 -const FNV1A_32_PRIME = 16777619; // 0x01000193 - -/** - * FNV-1a 32-bit hash of an arbitrary byte sequence. - * - * Deterministic and portable: returns the same unsigned 32-bit integer as the - * Rust `fnv1a_32` on identical input. The caller encodes to bytes (e.g. - * `new TextEncoder().encode(str)` for UTF-8) — the hash is byte-oriented and - * knows no string semantics. - * - * Overflow discipline: JS multiplication overflows the 2^53 safe-integer range - * for the 32×32-bit product, so we split the multiply into 16-bit halves and - * mask with `>>> 0` at each step to stay byte-identical to Rust's wrapping u32 - * arithmetic. The result is always an unsigned 32-bit integer (0..=0xffffffff). - * - * @param {Uint8Array | number[]} bytes raw bytes to hash - * @returns {number} unsigned 32-bit hash (0..=4294967295) - */ -export function fnv1a32(bytes) { - let hash = FNV1A_32_OFFSET_BASIS >>> 0; - for (let i = 0; i < bytes.length; i++) { - hash ^= bytes[i] & 0xff; - // 32-bit multiply without precision loss: (hash * prime) mod 2^32. - // Split hash into low/high 16 bits so every partial product stays < 2^53. - const lo = (hash & 0xffff) * FNV1A_32_PRIME; - const hi = ((hash >>> 16) * FNV1A_32_PRIME) & 0xffff; - hash = ((((hi << 16) >>> 0) + lo) & 0xffffffff) >>> 0; - } - return hash >>> 0; -} diff --git a/packages/colors/index.d.ts b/packages/colors/index.d.ts index 6688e94f..75ccd697 100644 --- a/packages/colors/index.d.ts +++ b/packages/colors/index.d.ts @@ -81,12 +81,3 @@ export { watchTheme } from "./watch-theme.js"; export type { WatchThemeOptions, WatchController } from "./watch-theme.js"; export { adaptTheme } from "./adapt-theme.js"; export type { AdaptThemeOptions, AdaptController } from "./adapt-theme.js"; -export { - effectiveBackground, - parseCssColor, - compositeOver, - compositeStackToHex, - toHex, - oklabLerp, -} from "./effective-bg.js"; -export type { Rgba, EffectiveBackgroundOptions, StyleLike } from "./effective-bg.js"; diff --git a/packages/colors/index.js b/packages/colors/index.js index 988cb22e..f0e7ccf5 100644 --- a/packages/colors/index.js +++ b/packages/colors/index.js @@ -2,8 +2,8 @@ // // Re-exports the wasm-bindgen surface (the default `init` loader, `initSync`, // and the `LabColors` engine class) plus the vanilla DOM runtime helpers: -// `applyTheme` (one-shot apply), `watchTheme` (reactive sync), and the -// effective-background resolver. +// `applyTheme` (one-shot apply), `watchTheme` (reactive sync), and +// `adaptTheme` (sample-driven adaptation). export { default, @@ -17,11 +17,3 @@ export { export { applyTheme } from "./apply-theme.js"; export { watchTheme } from "./watch-theme.js"; export { adaptTheme } from "./adapt-theme.js"; -export { - effectiveBackground, - parseCssColor, - compositeOver, - compositeStackToHex, - toHex, - oklabLerp, -} from "./effective-bg.js"; diff --git a/packages/colors/package.json b/packages/colors/package.json index f97e9a93..0c18c38f 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -31,10 +31,6 @@ "types": "./adapt-theme.d.ts", "default": "./adapt-theme.js" }, - "./effective-bg": { - "types": "./effective-bg.d.ts", - "default": "./effective-bg.js" - }, "./pkg/labcolors_bg.wasm": "./pkg/labcolors_bg.wasm", "./build-metadata.json": "./build-metadata.json", "./package.json": "./package.json" @@ -53,7 +49,6 @@ "adapt-theme.js", "adapt-theme.d.ts", "effective-bg.js", - "effective-bg.d.ts", "evidence/wcag22-srgb8-v1.json", "evidence/wcag22-srgb8-q55-v1.bin", "evidence/wcag22-srgb8-q55-proof-v1.json", diff --git a/packages/colors/smoke.consumer.ts b/packages/colors/smoke.consumer.ts index af2d4e77..c476d33e 100644 --- a/packages/colors/smoke.consumer.ts +++ b/packages/colors/smoke.consumer.ts @@ -7,8 +7,6 @@ import init, { applyTheme, watchTheme, adaptTheme, - effectiveBackground, - oklabLerp, } from "./index.js"; import type { FailureCategory, @@ -267,21 +265,12 @@ async function consume(clientConfigJson: string): Promise { applyTheme(document.documentElement, result); - // Current effectiveBackground returns the legacy solid reference estimate, - // not evidence of the browser's actually rendered pixel. - const effBg: string = effectiveBackground(document.documentElement); - void effBg; - - // The interpolation helper is an explicit Oklab construction primitive. - const blended: string = oklabLerp("#101012", effBg, 0.5); - void blended; - // The reactive runtime keeps an element in sync; the controller is typed. const surface = document.querySelector(".surface") as HTMLElement; const controller = watchTheme(surface, { colors: engine, theme, - background: () => effectiveBackground(surface, { fallback: "#101012" }), + background: "#101012", onError(error: unknown) { void error; }, @@ -307,10 +296,9 @@ async function consume(clientConfigJson: string): Promise { const adaptive = adaptTheme(surface, { colors: engine, theme, - background: () => effectiveBackground(surface, { fallback: "#101012" }), + background: "#101012", easeMs: 280, dropFraction: 0.2, - strict: true, }); adaptive.start(); adaptive.tick(); @@ -323,8 +311,7 @@ async function consume(clientConfigJson: string): Promise { const adaptiveBackdrop = adaptTheme(surface, { colors: engine, theme, - background: (): string[] => ["#101012", effectiveBackground(surface), "#202024"], - strict: true, + background: (): string[] => ["#101012", "#202024"], }); adaptiveBackdrop.stop(); } diff --git a/packages/colors/test/adapt-theme-translucent.test.mjs b/packages/colors/test/adapt-theme-translucent.test.mjs index b3ecc56a..4c4fdb7d 100644 --- a/packages/colors/test/adapt-theme-translucent.test.mjs +++ b/packages/colors/test/adapt-theme-translucent.test.mjs @@ -210,60 +210,3 @@ test("a later solve that ADDS a role writes its new var", () => { ctrl.setTheme("dark"); assert.equal(el.props.get("--lab-panel"), "oklch(30.000% 0.02 260 / 0.6)", "added role's var must appear"); }); - -// Differential lock for the OTHER color-string consumer in this file: the -// strict floor-clamp reads each background sample through `relativeLuminanceHex` -// → `parseCssColor`. An explicit oklch background must drive the clamp EXACTLY -// like its hex equivalent; if oklch were unparsed (→ null → black luminance), -// the floor math would use the wrong luminance and the eased frames diverge. -// -// The background alternates between two dark colours so the key changes each -// tick (defeating the steady-state early-out); `bgHex`/`bgOklch` are the SAME -// two colours in each representation, so a correct parse makes the two runs -// bit-identical. Two solid fixtures, live-emitted: #1A1A1A / #000000. -const BG_HEX = ["#1A1A1A", "#000000"]; -const BG_OKLCH = ["oklch(21.77865% 0.000000 89.876)", "oklch(0.00000% 0.000000 0.000)"]; - -function strictEasePaints(seq) { - const colors = fakeColors(makeResult("#000000", "oklch(0.000% 0 0)", "oklch(96.000% 0.01 260 / 0.6)", 4.5)); - const el = fakeElement(); - let now = 2000; - let i = 0; // constructor reads seq[0]; each tick advances first - const ctrl = adaptTheme(el, { - colors, - theme: "light", - background: () => seq[i % seq.length], - target: el, - now: () => now, - win: {}, - strict: true, - easeMs: 100, - sustainMs: 120, - dwellMs: 250, - }); - colors.setRecheckLc([10]); // black fails on the dark bg → will breach - colors.setResolve(makeResult("#FFFFFF", "oklch(100.000% 0 0)", "oklch(30.000% 0.02 260 / 0.6)", 4.5)); - const tickAt = (t) => { - i++; - now = t; - ctrl.tick(); - }; - tickAt(2130); // arm breach (key changed) - tickAt(2260); // breach sustained (130≥120) + dwell met (260≥250) → re-solve + ease - colors.setRecheckLc([100]); // destination passes → no re-arm; pure ease henceforth - const paints = [el.props.get("--lab-label")]; - for (const t of [2270, 2285, 2310, 2335]) { - tickAt(t); - paints.push(el.props.get("--lab-label")); - } - return paints; -} - -test("strict floor-clamp reads an oklch background sample identically to its hex equivalent", () => { - const hexPaints = strictEasePaints(BG_HEX); - const oklchPaints = strictEasePaints(BG_OKLCH); - // Guard: every captured frame is a mid-ease hex → the clamp is genuinely - // active (not a trivial no-ease case that would pass vacuously). - for (const p of hexPaints) assert.match(p, /^#[0-9A-Fa-f]{6}$/); - assert.deepEqual(oklchPaints, hexPaints, "oklch background must drive the strict clamp like its hex form"); -}); diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 59b9deb7..3229b6af 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -117,8 +117,7 @@ function captureOutputConflict(fn, expectedRoles = ["impossible"]) { return error; } -// A role set that carries an explicit `legalFloor` (4.5 / 3.0 / null), the field -// the strict floor-clamp reads. +// A role set that carries the resolver's endpoint `legalFloor` evidence. const floorRole = (hex, lc, legalFloor) => ({ vars: { "--lab-label-primary": hex }, roles: { @@ -3001,8 +3000,8 @@ test("a background that changes once to a failing value still re-solves (stable- // Drive a dark-background breach that re-solves a black role to white, then ease // across the (polarity-crossing) blend, sampling the applied colour each frame. // Returns the contrast each frame achieved against the dark background. -function easeContrasts({ strict }) { - const h = harness({ strict, easeMs: 100 }); +function easeContrasts() { + const h = harness({ easeMs: 100 }); h.colors.setRecheckLc([10]); // current #000000 fails on the dark bg h.colors.setResolve(floorRole("#FFFFFF", 100, 4.5)); // re-solve → legal white h.setBg("#101010"); @@ -3023,81 +3022,14 @@ function easeContrasts({ strict }) { return { h, out }; } -test("legacy strict clamp holds the floor on the canonical polarity-crossing fixture", () => { - const { h, out } = easeContrasts({ strict: true }); - assert.equal(h.colors.resolveCount(), 2); - for (const c of out) { - assert.ok(c >= 4.5 - 0.05, `every eased frame must clear 4.5:1, saw ${c.toFixed(2)}`); - } - // And it still arrives exactly at the freshly-solved destination. - assert.equal(h.el.props.get("--lab-label-primary"), "#FFFFFF"); -}); - -test("legacy strict fixture has non-regressing sampled contrast", () => { - const { out } = easeContrasts({ strict: true }); - for (let i = 1; i < out.length; i++) { - assert.ok( - out[i] >= out[i - 1] - 0.05, - `contrast must not regress mid-ease: ${out[i - 1].toFixed(2)} → ${out[i].toFixed(2)}`, - ); - } -}); - -test("held latch never reverses the scalar blend when the background drifts favourably", () => { - // The structural guarantee is only on the scalar blend parameter: it advances - // from→to even when a favourably-drifting (darkening) background would - // let the stateless floor solver pick a LOWER blend frame to frame. Without the - // `held` latch the grey value would step back down; with it, it is monotone. - const h = harness({ strict: true, easeMs: 400 }); // long ease so bg drift dominates - h.colors.setRecheckLc([10]); - h.colors.setResolve(floorRole("#FFFFFF", 100, 4.5)); - h.setBg("#303030"); // moderate dark at re-solve → forces a mid blend up front - h.setNow(2000); - h.ctrl.tick(); // arm breach - const t0 = 2130; - h.setNow(t0); - h.setBg("#2F2F2F"); - h.ctrl.tick(); // re-solve + begin ease (first eased frame on a dark bg) - h.colors.setRecheckLc([100]); - const grey = () => parseInt(h.el.props.get("--lab-label-primary").slice(1, 3), 16); - let prev = grey(); - // Drift the background DARKER mid-ease: the legal floor gets *easier*, so the - // stateless solver would choose a smaller blend — the latch must hold the line. - const bgs = ["#202020", "#141414", "#0C0C0C", "#060606", "#000000"]; - for (let i = 0; i < bgs.length; i++) { - h.setNow(t0 + 20 + i * 20); - h.setBg(bgs[i]); - h.ctrl.tick(); - const g = grey(); - assert.ok(g >= prev - 1, `colour must not retreat toward the origin: ${prev} → ${g}`); - prev = g; - } -}); - -test("the default ease dips below the floor on the canonical strict comparison fixture", () => { - const { out } = easeContrasts({ strict: false }); +test("coordinate easing does not claim a constraint for intermediate frames", () => { + const { out } = easeContrasts(); assert.ok( out.some((c) => c < 4.5), - "without strict, an early polarity-crossing frame is expected below 4.5:1", + "an early polarity-crossing frame is presentation, not certified output", ); }); -test("strict mode leaves floorless (decorative) roles to ease freely", () => { - // legalFloor null → the clamp is a no-op; the role crosses low contrast freely. - const h = harness({ strict: true, easeMs: 100 }); - h.colors.setRecheckLc([10]); - h.colors.setResolve(floorRole("#FFFFFF", 100, null)); // no legal floor - h.setBg("#101010"); - h.setNow(2000); - h.ctrl.tick(); // arm breach - h.setNow(2130); - h.setBg("#101011"); - h.ctrl.tick(); // re-solve + begin ease (first eased frame at t=0 → #000000 end) - h.colors.setRecheckLc([100]); - const c0 = wcagContrast(h.el.props.get("--lab-label-primary"), "#101011"); - assert.ok(c0 < 4.5, `a floorless role must ease freely (low contrast allowed), saw ${c0.toFixed(2)}`); -}); - test("worst-case recheck breaches when any sample fails (even if another passes)", () => { let samples = ["#FFFFFF", "#FAFAFA"]; // both pass at construction const h = harness({ background: () => samples }); @@ -3151,27 +3083,6 @@ test("initial apply re-solves against the worst sample of a varying backdrop", ( assert.equal(colors.lastResolveBg(), "#202020"); }); -test("legacy strict clamp holds the canonical hardest-sample fixture", () => { - let samples = ["#0A0A0A"]; // passing at construction - const h = harness({ strict: true, easeMs: 100, background: () => samples }); - h.colors.setResolve(floorRole("#FFFFFF", 100, 4.5)); - h.colors.setRecheckByBg({ "#0A0A0A": [100], "#1A1A1A": [10], "#101010": [10] }); - samples = ["#1A1A1A", "#101010"]; // dark backdrop; #1A1A1A is the hardest (lightest) - h.ctrl.tick(); // arm breach - h.setNow(1300); - h.ctrl.tick(); // re-solve against the worst sample + begin ease - assert.equal(h.colors.lastResolveBg(), "#1A1A1A"); - for (const dt of [0, 25, 50, 75, 100]) { - h.setNow(1300 + dt); - h.ctrl.tick(); - const hex = h.el.props.get("--lab-label-primary"); - assert.ok( - wcagContrast(hex, "#1A1A1A") >= 4.5 - 0.05, - `frame ${dt}: ${hex} below the floor against the worst sample`, - ); - } -}); - test("a single-sample array behaves like a solid background (holds, no churn)", () => { const h = harness({ background: () => ["#FFFFFF"] }); h.colors.setRecheckLc([100]); @@ -3515,7 +3426,6 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( sustainMs: 0, dwellMs: 0, easeMs: 100, - strict: true, }); // 1. Init: отказная роль не окрашена и не выдумана. diff --git a/packages/colors/test/fnv1a-differential.test.mjs b/packages/colors/test/fnv1a-differential.test.mjs deleted file mode 100644 index 362be739..00000000 --- a/packages/colors/test/fnv1a-differential.test.mjs +++ /dev/null @@ -1,86 +0,0 @@ -// Differential + anchor test for the FNV-1a 32-bit JS mirror. -// -// One source of truth for vectors: crates/labcolors-core/tests/data/fnv1a-vectors.txt -// (LF-pinned TSV), shared byte-for-byte with the Rust integration test -// (crates/labcolors-core/tests/fnv1a_differential.rs). Both sides recompute -// every vector and assert equality against the committed expected (unsigned -// decimal u32). Green on both = byte-identical JS==Rust output on every vector: -// empty string, Cyrillic, emoji, high-bit bytes, an overflow-length key, and a -// 500-vector randomized fuzz corpus. -// -// anchors carry the CANONICAL published FNV-1a values (external ground truth, -// http://www.isthe.com/chongo/tech/comp/fnv/) so correctness is grounded in the -// spec, not self-blessed. `text` vectors are stored as literal strings so this -// runtime exercises its OWN UTF-8 encoding path (the real cross-runtime risk). -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { fnv1a32 } from '../fnv1a.js'; - -const here = dirname(fileURLToPath(import.meta.url)); -const raw = readFileSync( - join(here, '../../../crates/labcolors-core/tests/data/fnv1a-vectors.txt'), - 'utf8', -); - -const enc = new TextEncoder(); -function bytesOf(kind, payload) { - if (kind === 'text') return enc.encode(payload); - if (kind === 'bytes') { - const out = new Uint8Array(payload.length / 2); - for (let i = 0; i < out.length; i++) out[i] = parseInt(payload.slice(i * 2, i * 2 + 2), 16); - return out; - } - if (kind === 'repeat') { - const [hex, count] = payload.split(':'); - return new Uint8Array(Number(count)).fill(parseInt(hex, 16)); - } - throw new Error(`unknown kind: ${kind}`); -} - -const vectors = raw - .split(/\r?\n/) - .filter((l) => l.length > 0 && !l.startsWith('#')) - .map((l) => { - const [group, name, kind, payload, expected] = l.split('\t'); - return { group, name, kind, payload: payload ?? '', expected: Number(expected) }; - }); -const byGroup = (g) => vectors.filter((v) => v.group === g); - -test('anchors: mirror matches canonical published FNV-1a vectors', () => { - const anchors = byGroup('anchor'); - assert.ok(anchors.length >= 3, 'expected >=3 published anchors'); - for (const v of anchors) - assert.strictEqual(fnv1a32(bytesOf(v.kind, v.payload)), v.expected, `anchor ${v.name}`); - const empty = anchors.find((v) => v.name === 'empty'); - assert.strictEqual(empty.expected, 2166136261, 'empty == offset basis'); -}); - -test('adversarial: emoji / cyrillic / high-bit / overflow all match oracle', () => { - const adv = byGroup('adversarial'); - const names = new Set(adv.map((v) => v.name)); - for (const req of ['cyrillic', 'emoji', 'high-bit-bytes', 'overflow-long-10000']) - assert.ok(names.has(req), `missing required adversarial vector: ${req}`); - for (const v of adv) - assert.strictEqual(fnv1a32(bytesOf(v.kind, v.payload)), v.expected, `adversarial ${v.name}`); -}); - -test('fuzz: >=500 frozen random vectors match oracle (cross-runtime differential)', () => { - const fuzz = byGroup('fuzz'); - assert.ok(fuzz.length >= 500, `expected >=500 fuzz vectors, got ${fuzz.length}`); - for (const v of fuzz) - assert.strictEqual(fnv1a32(bytesOf(v.kind, v.payload)), v.expected, `fuzz ${v.name}`); -}); - -test('live property: unsigned u32 range + determinism on random input', () => { - for (let i = 0; i < 2000; i++) { - const len = 1 + Math.floor(Math.random() * 40); - const arr = new Uint8Array(len); - for (let j = 0; j < len; j++) arr[j] = Math.floor(Math.random() * 256); - const a = fnv1a32(arr); - assert.strictEqual(a, fnv1a32(arr), 'deterministic'); - assert.ok(Number.isInteger(a) && a >= 0 && a <= 0xffffffff, `u32 range: ${a}`); - } -}); diff --git a/packages/colors/test/hotpath-parity.test.mjs b/packages/colors/test/hotpath-parity.test.mjs index afd4db55..63f588a6 100644 --- a/packages/colors/test/hotpath-parity.test.mjs +++ b/packages/colors/test/hotpath-parity.test.mjs @@ -1,7 +1,7 @@ // Hot-path parity locks for the adapt-theme runtime. // // The perf pass (perf/js-runtime-hotpath) rewrites the per-frame internals — -// compiled lerp pairs, numeric luminance, diff-writes — under one invariant: +// compiled lerp pairs and diff-writes — under one invariant: // the APPLIED VARIABLE STATE of every frame is byte-identical to the original // parse-per-frame implementation. This file locks that invariant two ways: // @@ -23,7 +23,7 @@ import assert from "node:assert/strict"; import { adaptTheme } from "../adapt-theme.js"; import * as ebg from "../effective-bg.js"; -const { oklabLerp, parseCssColor } = ebg; +const { oklabLerp } = ebg; // ── deterministic mini-harness (mirrors bench/hotpath.bench.mjs, smaller) ──── @@ -107,7 +107,7 @@ function makeStubEngine() { return stub; } -function runFingerprint(bgAt, strict) { +function runFingerprint(bgAt) { const el = makeElement(); let now = 0; let frame = 0; @@ -116,7 +116,6 @@ function runFingerprint(bgAt, strict) { theme: "light", background: () => bgAt(frame), now: () => now, - strict, win: undefined, }); let fp = 0x811c9dc5; @@ -133,12 +132,6 @@ const SOLVED0 = 0x80; const steadyBg = () => toneHex(SOLVED0); const driftBg = (f) => toneHex(SOLVED0 + Math.round(32 * Math.sin((2 * Math.PI * f) / 240))); const breachBg = (f) => toneHex(SOLVED0 + (Math.floor(f / 90) % 2 === 1 ? 96 : 0) + (f % 3)); -const breachBg3 = (f) => { - const base = breachBg(f); - const t = bgTone(base); - return [base, toneHex(t + 8), toneHex(t + 16)]; -}; - // Captured on the pre-optimisation implementation — see header for the rules. // (steady === drift is expected: while rechecks pass, the applied state never // changes, so both hash the same repeated post-solve snapshot.) @@ -146,26 +139,24 @@ const GOLDEN = { steady: "99d7af7d", drift: "99d7af7d", ease: "c996bd0b", - easeStrict3: "a04804c5", }; const CASES = [ - ["steady", steadyBg, false], - ["drift", driftBg, false], - ["ease", breachBg, false], - ["easeStrict3", breachBg3, true], + ["steady", steadyBg], + ["drift", driftBg], + ["ease", breachBg], ]; if (process.env.PRINT_FP) { test("print golden fingerprints (capture mode)", () => { - for (const [name, bg, strict] of CASES) { - console.log(`GOLDEN ${name}: "${runFingerprint(bg, strict)}"`); + for (const [name, bg] of CASES) { + console.log(`GOLDEN ${name}: "${runFingerprint(bg)}"`); } }); } else { - for (const [name, bg, strict] of CASES) { + for (const [name, bg] of CASES) { test(`golden fingerprint: ${name}`, () => { - assert.equal(runFingerprint(bg, strict), GOLDEN[name]); + assert.equal(runFingerprint(bg), GOLDEN[name]); }); } } @@ -174,20 +165,7 @@ if (process.env.PRINT_FP) { const hasCompiled = typeof ebg.compileLerpPair === "function" && - typeof ebg.lerpPairHex === "function" && - typeof ebg.lerpPairLuminance === "function"; - -/** Reference relative luminance — a verbatim copy of the string path the - * runtime used pre-optimisation (frozen WCAG 2.1 2018 profile: - * 0.03928/12.92/2.4). */ -function refLuminance(css) { - const rgb = parseCssColor(css) ?? [0, 0, 0, 1]; - const lin = (c) => { - const s = c / 255; - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; - }; - return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); -} + typeof ebg.lerpPairHex === "function"; // Mulberry32 — tiny seeded PRNG, reproducible across runs. function mulberry32(seed) { @@ -213,7 +191,7 @@ function randColor(rnd) { const T_EDGES = [-0.5, 0, 1e-9, 0.25, 0.5, 0.75, 1 - 1e-9, 1, 1.5]; -test("compiled pair ≡ string path (lerp + luminance), 500 random pairs", { skip: !hasCompiled }, () => { +test("compiled pair ≡ string path, 500 random pairs", { skip: !hasCompiled }, () => { const rnd = mulberry32(0xc0ffee); for (let i = 0; i < 500; i++) { const from = randColor(rnd); @@ -223,11 +201,6 @@ test("compiled pair ≡ string path (lerp + luminance), 500 random pairs", { ski for (const t of T_EDGES.concat(rnd(), rnd())) { const viaString = oklabLerp(from, to, t); assert.equal(ebg.lerpPairHex(pair, t), viaString, `lerp mismatch @t=${t}: ${from} → ${to}`); - assert.equal( - ebg.lerpPairLuminance(pair, t), - refLuminance(viaString), - `luminance mismatch @t=${t}: ${from} → ${to}`, - ); } } }); @@ -236,15 +209,3 @@ 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("wcagLuminanceCached ≡ reference (incl. unparseable → black)", { skip: !hasCompiled || typeof ebg.wcagLuminanceCached !== "function" }, () => { - const rnd = mulberry32(0xbadc0de); - const inputs = []; - for (let i = 0; i < 200; i++) inputs.push(randColor(rnd)); - inputs.push("transparent", "blah", "color-mix(in srgb, red, blue)"); - for (const css of inputs) { - assert.equal(ebg.wcagLuminanceCached(css), refLuminance(css), `luminance cache mismatch: ${css}`); - // second read exercises the cache hit path - assert.equal(ebg.wcagLuminanceCached(css), refLuminance(css)); - } -}); diff --git a/packages/colors/test/public-api-cleanup.test.mjs b/packages/colors/test/public-api-cleanup.test.mjs new file mode 100644 index 00000000..51a34c81 --- /dev/null +++ b/packages/colors/test/public-api-cleanup.test.mjs @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { test } from "node:test"; + +const ROOT = resolve(import.meta.dirname, "../../.."); +const read = (...parts) => readFileSync(join(ROOT, ...parts), "utf8"); + +test("effective-background math stays internal to the browser shell", () => { + const manifest = JSON.parse(read("packages", "colors", "package.json")); + const rootRuntime = read("packages", "colors", "index.js"); + const rootTypes = read("packages", "colors", "index.d.ts"); + const releaseVerifier = read("scripts", "verify-package-release.mjs"); + + assert.equal(manifest.exports["./effective-bg"], undefined); + assert.doesNotMatch(releaseVerifier, /from "@labpics\/colors\/effective-bg"/u); + assert.match(releaseVerifier, /import\("@labpics\/colors\/effective-bg"\)/u); + for (const name of [ + "effectiveBackground", + "parseCssColor", + "compositeOver", + "compositeStackToHex", + "toHex", + "oklabLerp", + ]) { + assert.doesNotMatch(rootRuntime, new RegExp(`\\b${name}\\b`, "u")); + assert.doesNotMatch(rootTypes, new RegExp(`\\b${name}\\b`, "u")); + } + assert.ok( + manifest.files.includes("effective-bg.js"), + "watch/adapt still need the internal estimate until occurrence cutover", + ); +}); + +test("the parse memo never exposes its shared cache entry", async () => { + const backdrop = await import("../effective-bg.js"); + assert.equal(backdrop.parseCssColorCached, undefined); +}); + +test("the unsupported strict transition recipe cannot re-enter source or shipped declarations", () => { + const runtime = read("packages", "colors", "adapt-theme.js"); + const declarations = read("packages", "colors", "adapt-theme.d.ts"); + const consumer = read("packages", "colors", "smoke.consumer.ts"); + const docs = read("packages", "colors", "README.md"); + const sourceClaims = [ + read("crates", "labcolors-wasm", "src", "lib.rs"), + read("crates", "labcolors-wasm", "src", "dto.rs"), + read("packages", "colors", "pkg", "labcolors.d.ts"), + ]; + + for (const source of [runtime, declarations, consumer, docs]) { + assert.doesNotMatch(source, /\bstrict\s*\??\s*:/u); + } + for (const source of sourceClaims) { + assert.doesNotMatch(source, /`strict`/u); + } + assert.doesNotMatch(runtime, /floorBlend|lerpPairLuminance|wcagLuminanceCached/u); +}); + +test("the unshipped JavaScript FNV mirror stays deleted", () => { + for (const path of [ + ["packages", "colors", "fnv1a.js"], + ["packages", "colors", "fnv1a.d.ts"], + ["packages", "colors", "test", "fnv1a-differential.test.mjs"], + ]) { + assert.equal(existsSync(join(ROOT, ...path)), false, path.join("/")); + } + assert.doesNotMatch(read("crates", "labcolors-core", "src", "hash.rs"), /packages\/colors\/fnv1a/u); +}); diff --git a/packages/colors/test/public-claims.test.mjs b/packages/colors/test/public-claims.test.mjs index 8fc9da2b..3e0fc714 100644 --- a/packages/colors/test/public-claims.test.mjs +++ b/packages/colors/test/public-claims.test.mjs @@ -1188,10 +1188,6 @@ test("runtime docs scope background evidence to estimates and finite samples", ( join(ROOT, "packages/colors/adapt-theme.d.ts"), "utf8", ); - const backgroundTypes = readFileSync( - join(ROOT, "packages/colors/effective-bg.d.ts"), - "utf8", - ); const watchSource = readFileSync( join(ROOT, "packages/colors/watch-theme.js"), "utf8", @@ -1205,11 +1201,6 @@ test("runtime docs scope background evidence to estimates and finite samples", ( assert.match(readme, /только[^\n]*переданн[^\n]*точ/u); assert.match(adaptTypes, /finite, caller-supplied sample set/iu); assert.match(adaptTypes, /does not infer[^\n]*between samples/iu); - assert.match(backgroundTypes, /reference estimate/iu); - assert.match(backgroundTypes, /solid\/translucent ancestor/iu); - assert.match(backgroundTypes, /`background-color` chain/iu); - assert.match(backgroundTypes, /not[\s*]+a browser pixel observation/iu); - assert.match(backgroundTypes, /alpha[^\n]*discarded/iu); assert.match(adaptTypes, /Канонические логические цели/u); assert.match(watchSource, /reference estimate/iu); assert.match(readme, /изменения атрибутов `style`\/`class`/iu); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index 3b0d2e43..e3b33db8 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -1736,13 +1736,22 @@ test("runtime declarations expose one curated type surface", () => { } const verifier = read("scripts", "verify-package-release.mjs"); - for (const subpath of ["apply-theme", "watch-theme", "adapt-theme", "effective-bg"]) { + for (const subpath of ["apply-theme", "watch-theme", "adapt-theme"]) { assert.match( verifier, new RegExp(`@labpics/colors/${subpath}`, "u"), `clean-consumer type smoke must compile the ${subpath} public subpath`, ); } + assert.doesNotMatch(verifier, /from "@labpics\/colors\/effective-bg"/u); + assert.match(verifier, /ERR_PACKAGE_PATH_NOT_EXPORTED/u); + + const packageJson = JSON.parse(read("packages", "colors", "package.json")); + assert.equal( + packageJson.exports["./effective-bg"], + undefined, + "low-level effective-background math must not be a package subpath", + ); const rootTypes = rootDeclarations.match( /export type \{([\s\S]*?)\} from "\.\/pkg\/labcolors\.js";/u, @@ -1802,7 +1811,6 @@ test("public declarations compile at the documented minimum TypeScript version", "apply-theme.d.ts", "watch-theme.d.ts", "adapt-theme.d.ts", - "effective-bg.d.ts", ], { cwd: join(root, "packages", "colors"), stdio: ["ignore", "pipe", "pipe"], diff --git a/packages/colors/tsconfig.json b/packages/colors/tsconfig.json index 733b4f56..5d1c6a4e 100644 --- a/packages/colors/tsconfig.json +++ b/packages/colors/tsconfig.json @@ -14,7 +14,6 @@ "apply-theme.d.ts", "watch-theme.d.ts", "adapt-theme.d.ts", - "effective-bg.d.ts", "smoke.consumer.ts" ] } diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index c9869cb2..561edd11 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -715,11 +715,28 @@ import { createRequire } from "node:module"; import init, { LabColors, + adaptTheme, evaluateWcag22, numericalCapabilityManifest, + watchTheme, } from "@labpics/colors"; +import * as colorsApi from "@labpics/colors"; const require = createRequire(import.meta.url); +for (const name of [ + "effectiveBackground", + "parseCssColor", + "compositeOver", + "compositeStackToHex", + "toHex", + "oklabLerp", +]) { + assert.equal(name in colorsApi, false, name + " must not be a root export"); +} +await assert.rejects( + import("@labpics/colors/effective-bg"), + (error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED", +); const wasmPath = require.resolve("@labpics/colors/pkg/labcolors_bg.wasm"); const metadataPath = require.resolve("@labpics/colors/build-metadata.json"); const packagePath = require.resolve("@labpics/colors/package.json"); @@ -824,6 +841,52 @@ const background = "#000000"; const resolved = engine.resolveTheme(background, "light"); assert.deepEqual(Object.keys(resolved.roles).sort(), ["token-7f3a", "token-92be", "token-a11c"]); +const runtimeTarget = () => { + const names = []; + const values = new Map(); + return { + values, + style: { + setProperty(name, value) { + if (!values.has(name)) names.push(name); + values.set(name, value); + }, + removeProperty(name) { + values.delete(name); + const index = names.indexOf(name); + if (index >= 0) names.splice(index, 1); + }, + item(index) { return names[index] ?? ""; }, + get length() { return names.length; }, + }, + }; +}; +const watchedTarget = runtimeTarget(); +const watcher = watchTheme(watchedTarget, { + colors: engine, + theme: "light", + background, + observe: false, + win: {}, +}); +assert.equal(watcher.background(), background); +assert.equal(typeof watchedTarget.values.get("--lab-token-7f3a"), "string"); +watcher.refresh(); +watcher.stop(); + +const adaptedTarget = runtimeTarget(); +const adaptive = adaptTheme(adaptedTarget, { + colors: engine, + theme: "light", + background, + target: adaptedTarget, + now: () => 0, + win: {}, +}); +adaptive.tick(0); +assert.equal(typeof adaptive.current()["--lab-token-7f3a"], "string"); +adaptive.stop(); + const alpha = resolved.roles["token-7f3a"]; assert.equal(alpha.kind, "translucent"); assert.match(alpha.tintHex, /^#[0-9A-F]{6}$/u); @@ -947,27 +1010,31 @@ import { type AdaptController, type AdaptThemeOptions, } from "@labpics/colors/adapt-theme"; -import { - effectiveBackground, - type EffectiveBackgroundOptions, - type Rgba, -} from "@labpics/colors/effective-bg"; const initialise: typeof init = init; const apply: typeof applyTheme = applyTheme; const watch: typeof watchTheme = watchTheme; const adapt: typeof adaptTheme = adaptTheme; -const effective: typeof effectiveBackground = effectiveBackground; type PublicSubpathTypes = | WatchController | WatchThemeOptions | AdaptController - | AdaptThemeOptions - | EffectiveBackgroundOptions - | Rgba; + | AdaptThemeOptions; declare const publicSubpathType: PublicSubpathTypes; -void [apply, watch, adapt, effective, publicSubpathType]; +void [apply, watch, adapt, publicSubpathType]; +declare const rootApi: typeof import("@labpics/colors"); +// @ts-expect-error low-level browser-shell colour math is not public API. +rootApi.parseCssColor; +// @ts-expect-error the compatibility background estimate is package-internal. +rootApi.effectiveBackground; const engine = new LabColors(); +const removedStrict: AdaptThemeOptions = { + colors: engine, + theme: "light", + // @ts-expect-error the unverified legacy transition clamp was removed. + strict: true, +}; +void removedStrict; const fingerprint: string = engine.loadConfig("{}"); const resolved: ResolvedTheme = engine.resolveTheme("#000000", "light"); const capability: NumericalCapabilityManifestV2 = numericalCapabilityManifest();