diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 9ab2aa59..c1c16c91 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -203,39 +203,46 @@ impl Engine { Err(BindingError::ConfigRequired) } - /// Recheck the contrasts a set of foreground colours achieve against a - /// (possibly changed) `bg_hex` under `theme` — the cheap per-frame primitive - /// of the reactive runtime. One CAM16 forward for the background plus one per + /// Mint the numeric theme handle for a client theme key: the slot of the + /// key in the loaded config's theme dictionary. This is the cold-edge + /// string→number lowering (F1/F2): the controller resolves a theme name to + /// its handle ONCE at a solve boundary, then addresses it numerically in the + /// per-frame recheck loop — so the hot path never re-scans the dictionary by + /// string. Recheck without a loaded config is impossible (no dictionary), and + /// an unknown key is a typed [`BindingError::UnknownTheme`]. + pub fn theme_handle(&self, theme_key: &str) -> Result { + let named = self.named.as_ref().ok_or(BindingError::ConfigRequired)?; + let (slot, _) = + named + .theme_binding(theme_key) + .ok_or_else(|| BindingError::UnknownTheme { + requested: theme_key.to_string(), + })?; + Ok(slot) + } + + /// Recheck the contrasts a set of packed `0x00RRGGBB` foreground colours + /// achieve against a (possibly changed) packed `bg` background under the + /// theme addressed by `theme_handle` — the cheap per-frame primitive of the + /// reactive runtime. One display-forward for the background plus one per /// foreground, **no solve**: the controller keeps current colours while they /// still pass and re-solves only the rare role that stably fails. /// + /// The packed input is one contiguous typed-array copy into linear memory: + /// zero hex parse, zero `String`/`Cow` per foreground. The reserved high byte + /// of every word is required-zero and validated once, without allocation. /// Returns a flat, interleaved buffer `[lc0, wcag0, lc1, wcag1, …]` (mapped to - /// 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 + /// a JS `Float64Array`) — the same output layout the string boundary emitted, + /// byte for byte. Values equal what the solver measured, so a freshly-resolved /// set rechecks to its own reported contrasts. - pub fn recheck( + pub fn recheck_u32( &self, - bg_hex: &str, - fg_hexes: &[String], - theme_key: &str, + bg: u32, + fgs: &[u32], + theme_handle: u32, ) -> Result, BindingError> { - let vc = self.recheck_vc(theme_key)?; - // 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 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() - .map(|h| hex_for_recheck(h)) - .collect::>()?; - let refs: Vec<&str> = fg_cows.iter().map(Cow::as_ref).collect(); - let pairs = labcolors_core::recheck_against(bg.as_ref(), &refs, &vc) + let vc = self.recheck_vc_by_handle(theme_handle)?; + let pairs = labcolors_core::recheck_against_u32(bg, fgs, &vc) .map_err(|reason| BindingError::InvalidBackground { reason })?; let mut out = Vec::with_capacity(pairs.len() * 2); for (lc, wcag) in pairs { @@ -245,47 +252,41 @@ impl Engine { Ok(out) } - /// Recheck one foreground set against MANY background samples in a single - /// call, sharing each foreground's CAM16 forward across all samples. - /// Byte-identical, pair for pair, to N separate [`recheck`](Self::recheck) - /// calls; see [`recheck_against_multi`]. Exported to JS as - /// `recheckContrastMulti` and used by the `adaptTheme` controller's - /// multi-sample worst-case backdrop loop. - pub fn recheck_multi( + /// Recheck one packed foreground set against MANY packed background samples in + /// a single call, sharing each foreground's display-forward across all + /// samples. Byte-identical, entry for entry, to N separate + /// [`recheck_u32`](Self::recheck_u32) calls; see [`recheck_against_multi_u32`]. + /// Exported to JS as `recheckContrastMulti` and used by the `adaptTheme` + /// controller's multi-sample worst-case backdrop loop. The flat output is + /// background-major: sample `s`, foreground `i` sits at `(s*fgs.len()+i)*2`. + pub fn recheck_multi_u32( &self, - bg_hexes: &[String], - fg_hexes: &[String], - theme_key: &str, + bgs: &[u32], + fgs: &[u32], + theme_handle: u32, ) -> Result, BindingError> { - let vc = self.recheck_vc(theme_key)?; - let bg_cows: Vec> = bg_hexes - .iter() - .map(|h| hex_for_recheck(h)) - .collect::>()?; - let bg_refs: Vec<&str> = bg_cows.iter().map(Cow::as_ref).collect(); - let fg_cows: Vec> = fg_hexes - .iter() - .map(|h| hex_for_recheck(h)) - .collect::>()?; - let fg_refs: Vec<&str> = fg_cows.iter().map(Cow::as_ref).collect(); - labcolors_core::recheck_against_multi(&bg_refs, &fg_refs, &vc) + let vc = self.recheck_vc_by_handle(theme_handle)?; + labcolors_core::recheck_against_multi_u32(bgs, fgs, &vc) .map_err(|reason| BindingError::InvalidBackground { reason }) } - /// Условия просмотра для recheck-пути: ТОТ ЖЕ канонический словарь, что у - /// [`resolve_theme`](Self::resolve_theme) — recheck без загруженного - /// конфига невозможен (нет словаря ключей), неизвестный ключ типизирован. - fn recheck_vc( + /// Условия просмотра для recheck-пути по numeric handle: слот прямо индексирует + /// канонический словарь тем загруженного конфига — тот же словарь, что у + /// [`resolve_theme`](Self::resolve_theme), но адресуемый численно, без + /// строкового пере-сканирования на каждом кадре. Recheck без загруженного + /// конфига невозможен (нет словаря), а handle вне диапазона типизирован. + fn recheck_vc_by_handle( &self, - theme_key: &str, + theme_handle: u32, ) -> Result { let named = self.named.as_ref().ok_or(BindingError::ConfigRequired)?; - let (_, preset) = - named - .theme_binding(theme_key) - .ok_or_else(|| BindingError::UnknownTheme { - requested: theme_key.to_string(), - })?; + let preset = named + .themes + .get(theme_handle as usize) + .map(|(_, preset)| *preset) + .ok_or_else(|| BindingError::UnknownTheme { + requested: format!("theme handle {theme_handle}"), + })?; Ok(preset.viewing_conditions()) } } @@ -552,13 +553,20 @@ mod tests { assert!(keys.contains(&"none")); } + /// Parse an engine-emitted `#RRGGBB` into its packed `0x00RRGGBB` word — + /// the boundary transport the packed recheck path consumes. + fn pack_hex(hex: &str) -> u32 { + u32::from_str_radix(hex.trim_start_matches('#'), 16).expect("engine hex is #RRGGBB") + } + #[test] fn recheck_matches_resolve_theme_reported_contrasts() { // The WASM recheck end-to-end: resolve a set, then recheck each solved - // colour against its OWN background — the returned interleaved (lc, wcag) - // pairs must equal exactly what `resolve_theme` reported. This is the - // identity the reactive controller stands on: "still passes?" means the - // same thing as the original solve. + // colour (packed to `0x00RRGGBB`) against its OWN background under the + // minted theme handle — the returned interleaved (lc, wcag) pairs must + // equal exactly what `resolve_theme` reported. This is the identity the + // reactive controller stands on: "still passes?" means the same thing as + // the original solve. let engine = engine_with_labui(); for (bg, theme) in [ ("#FFFFFF", "light"), @@ -566,15 +574,16 @@ mod tests { ("#1C1C1E", "dark"), ] { let result = engine.resolve_theme(bg, theme).unwrap(); + let handle = engine.theme_handle(theme).unwrap(); let mut fgs = Vec::new(); let mut want = Vec::new(); for r in &result.roles { if let RoleOutcome::Color(c) = &r.outcome { - fgs.push(c.hex.clone()); + fgs.push(pack_hex(&c.hex)); want.push((c.lc, c.wcag_ratio)); } } - let flat = engine.recheck(bg, &fgs, theme).unwrap(); + let flat = engine.recheck_u32(pack_hex(bg), &fgs, handle).unwrap(); assert_eq!(flat.len(), want.len() * 2); for (i, (lc, wcag)) in want.iter().enumerate() { assert!((flat[2 * i] - lc).abs() < 1e-9, "{bg}: role {i} lc drift"); @@ -584,38 +593,70 @@ mod tests { ); } } - // Invalid foreground hex surfaces a structured error, not a panic — - // проверяется С ЗАГРУЖЕННЫМ конфигом, иначе первым сработал бы - // ConfigRequired и hex-путь остался бы вакуумным (C5.1: recheck - // требует словарь тем). + // A word with a non-zero reserved high byte (an RGBA/ARGB leak) surfaces a + // structured error, not a panic — проверяется С ЗАГРУЖЕННЫМ конфигом, + // иначе первым сработал бы ConfigRequired (C5.1: recheck требует словарь). + let handle = engine_with_labui().theme_handle("light").unwrap(); + assert!(matches!( + engine_with_labui().recheck_u32(0xFF00_0000, &[0x000000], handle), + Err(BindingError::InvalidBackground { .. }) + )); assert!(matches!( - engine_with_labui().recheck("#FFFFFF", &["nothex".to_string()], "light"), + engine_with_labui().recheck_u32(0x000000, &[0x0100_0000], handle), Err(BindingError::InvalidBackground { .. }) )); } #[test] - fn recheck_accepts_the_same_hex_forms_as_resolve_theme() { - // The three entry points share one hex contract: `#RGB` shorthand, a - // missing `#`, and mixed case are all accepted by recheck exactly as by - // resolve — and every spelling of a colour rechecks bit-identically. - // `#123` and `#112233` are the SAME colour (each nibble is doubled), and - // `#fff` is `#FFFFFF`, so all of these must agree with the canonical form. - // C5.1: recheck требует конфиг (словарь тем клиентский) — путь тот же, - // что у resolve. + fn recheck_multi_is_byte_identical_to_per_sample_packed_recheck() { + // C2 at the engine layer: the background-major multi buffer equals N + // per-sample packed recheck calls exactly — the byte-identity the + // controller's batch path stands on. let engine = engine_with_labui(); - let canonical = engine - .recheck("#FFFFFF", &["#112233".to_string()], "light") - .unwrap(); - for bg in ["#fff", "FFFFFF", "#FFFFFF"] { - for fg in ["#123", "112233", "#112233"] { - let got = engine.recheck(bg, &[fg.to_string()], "light").unwrap(); - assert_eq!(got.len(), 2, "{bg}/{fg}: one (lc, wcag) pair"); - assert_eq!(got, canonical, "{bg}/{fg}: must match the canonical form"); + let handle = engine.theme_handle("dark").unwrap(); + let result = engine.resolve_theme("#3A3A3C", "dark").unwrap(); + let fgs: Vec = result + .roles + .iter() + .filter_map(|r| match &r.outcome { + RoleOutcome::Color(c) => Some(pack_hex(&c.hex)), + _ => None, + }) + .collect(); + let bgs = [ + pack_hex("#38383A"), + pack_hex("#404042"), + pack_hex("#2E2E30"), + ]; + let multi = engine.recheck_multi_u32(&bgs, &fgs, handle).unwrap(); + assert_eq!(multi.len(), bgs.len() * fgs.len() * 2); + for (s, &bg) in bgs.iter().enumerate() { + let per = engine.recheck_u32(bg, &fgs, handle).unwrap(); + let base = s * fgs.len() * 2; + for (i, value) in per.iter().enumerate() { + assert_eq!(multi[base + i], *value, "sample {s} index {i} drift"); } } } + #[test] + fn theme_handle_addresses_the_dictionary_numerically() { + // The numeric handle is the dictionary slot; an unknown key is typed, and + // a handle out of range routes through the same typed rejection. + let engine = engine_with_labui(); + let light = engine.theme_handle("light").unwrap(); + let dark = engine.theme_handle("dark").unwrap(); + assert_ne!(light, dark, "distinct themes mint distinct handles"); + assert!(matches!( + engine.theme_handle("no-such-theme"), + Err(BindingError::UnknownTheme { .. }) + )); + assert!(matches!( + engine.recheck_u32(0x000000, &[0x000000], u32::MAX), + Err(BindingError::UnknownTheme { .. }) + )); + } + /// Реальный mixed-набор с конфликтами в начале, середине и конце. На /// `#808080` Core доказывает недостижимость всех трёх Lc-целей; на белом те /// же декларации законно решаются. Имена намеренно не лексикографические, @@ -1070,11 +1111,15 @@ mod tests { fn recheck_without_config_is_config_required() { let engine = Engine::new(); assert!(matches!( - engine.recheck("#FFFFFF", &["#112233".to_string()], "light"), + engine.recheck_u32(0xFFFFFF, &[0x112233], 0), + Err(BindingError::ConfigRequired) + )); + assert!(matches!( + engine.recheck_multi_u32(&[0xFFFFFF], &[0x112233], 0), Err(BindingError::ConfigRequired) )); assert!(matches!( - engine.recheck_multi(&["#FFFFFF".to_string()], &["#112233".to_string()], "light"), + engine.theme_handle("light"), Err(BindingError::ConfigRequired) )); } diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 2e7ced83..45c562a1 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -716,26 +716,41 @@ impl LabColors { Ok(format!("{fp:016x}")) } - /// Повторно проверяет контрасты `fgHexes` к `bgHex` в теме `theme`. + /// Минтит numeric handle темы `theme` — слот клиентского ключа в словаре + /// `themes` загруженного конфига. Это холодное string→number понижение: рантайм + /// разрешает имя темы в handle ОДИН раз на границе solve, затем адресует его + /// численно в покадровом recheck-цикле, без пере-сканирования словаря строкой. + /// Recheck без загруженного конфига невозможен; неизвестный ключ — обычный JS + /// `Error` со стабильным префиксом `": "`. + #[wasm_bindgen(js_name = themeHandle)] + pub fn theme_handle(&self, theme: &str) -> Result { + self.inner.theme_handle(theme).map_err(to_js_error) + } + + /// Повторно проверяет контрасты packed foreground-слов `fgs` к packed фону + /// `bg` (оба `0x00RRGGBB`, старший байт зарезервирован и обязан быть нулём) в + /// теме, адресованной numeric `theme` handle из [`themeHandle`](Self::theme_handle). /// Реактивный runtime вызывает этот дешёвый примитив покадрово и запускает /// новый solve лишь после устойчивого провала уже решённых цветов. Полного /// solve нет: одна оценка замороженной кривой для фона и по одной для foreground. /// - /// Возвращает `Float64Array` чередующихся пар `[lc, wcagRatio]` в порядке - /// `fgHexes`: `2*i` — знаковая candidate-координата Ys foreground `i` из - /// замороженной SAPC-shaped кривой, а не вердикт LPC/читаемости; `2*i+1` — - /// отношение WCAG. Невалидный hex или неизвестная тема дают обычный JS - /// `Error` со стабильным префиксом `": "`. + /// Вход — один смежный typed-array copy в линейную память: ноль hex-парсинга, + /// ноль `String`/`Cow` на foreground; зарезервированный старший байт каждого + /// слова валидируется один раз без аллокации. Возвращает `Float64Array` + /// чередующихся пар `[lc, wcagRatio]` в порядке `fgs` — тот же выходной layout, + /// что у прежней строковой границы, побайтно: `2*i` — знаковая + /// candidate-координата Ys foreground `i` замороженной SAPC-shaped кривой, а не + /// вердикт LPC/читаемости; `2*i+1` — отношение WCAG. Слово с ненулевым старшим + /// байтом или неизвестный handle дают обычный JS `Error` со стабильным + /// префиксом `": "`. #[wasm_bindgen(js_name = recheckContrast)] pub fn recheck_contrast( &self, - bg_hex: &str, - fg_hexes: Vec, - theme: &str, + bg: u32, + fgs: Vec, + theme: u32, ) -> Result, JsError> { - self.inner - .recheck(bg_hex, &fg_hexes, theme) - .map_err(to_js_error) + self.inner.recheck_u32(bg, &fgs, theme).map_err(to_js_error) } /// Точная runtime-перепроверка stable Glow. Возвращает, является ли точечный @@ -756,24 +771,26 @@ impl LabColors { .map_err(|reason| to_js_error(stable_glow_recheck_core_error(reason))) } - /// Одним вызовом проверяет набор foreground против многих образцов фона. - /// Контроллер использует это для меняющегося backdrop: gradient, image, - /// bg-blur или glass. Каждый foreground декодируется и квантуется один раз; - /// его display-relative luminance переиспользуется для всех образцов. + /// Одним вызовом проверяет набор packed foreground-слов `fgs` против многих + /// packed образцов фона `bgs` (все `0x00RRGGBB`, старший байт required-zero) в + /// теме, адресованной numeric `theme` handle. Контроллер использует это для + /// меняющегося backdrop: gradient, image, bg-blur или glass. Каждый foreground + /// декодируется один раз; его display-relative luminance переиспользуется для + /// всех образцов. /// /// Возвращает плоский background-major `Float64Array`: для образца `s` и - /// foreground `i` индекс `(s * fgHexes.length + i) * 2` содержит `lc`, а + /// foreground `i` индекс `(s * fgs.length + i) * 2` содержит `lc`, а /// следующий — `wcagRatio`. Значения побайтно совпадают с отдельным вызовом - /// `recheckContrast(bgHexes[s], fgHexes, theme)` для каждого `s`. + /// `recheckContrast(bgs[s], fgs, theme)` для каждого `s`. #[wasm_bindgen(js_name = recheckContrastMulti)] pub fn recheck_contrast_multi( &self, - bg_hexes: Vec, - fg_hexes: Vec, - theme: &str, + bgs: Vec, + fgs: Vec, + theme: u32, ) -> Result, JsError> { self.inner - .recheck_multi(&bg_hexes, &fg_hexes, theme) + .recheck_multi_u32(&bgs, &fgs, theme) .map_err(to_js_error) } } diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 1534e32b..97cfe868 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -450,11 +450,18 @@ fn vars_mirror_reachable_roles_in_oklch() { ); } -/// `recheckContrast` across the wasm boundary: the returned `Float64Array` -/// reproduces the core `resolve_named_set`'s own `(lc, wcag)` per role, accepts -/// the same shorthand hex forms as `resolveTheme`, and rejects a bad foreground. +/// Parse an engine-emitted `#RRGGBB` into the packed `0x00RRGGBB` boundary word. +fn pack_hex(hex: &str) -> u32 { + u32::from_str_radix(hex.trim_start_matches('#'), 16).expect("engine hex is #RRGGBB") +} + +/// Packed `recheckContrast` across the wasm boundary (C8d): the returned +/// `Float64Array` reproduces the core `resolve_named_set`'s own `(lc, wcag)` per +/// role from packed `0x00RRGGBB` words + a numeric theme handle, and a word with +/// a non-zero reserved high byte rejects with the stable code. The string +/// overload is hard-cut. #[wasm_bindgen_test] -fn recheck_contrast_boundary_matches_resolve_and_shares_hex_contract() { +fn recheck_contrast_boundary_matches_resolve_over_packed_words() { let bg = "#FFFFFF"; let core_resolved = resolve_named_set( &BgInput::solid(bg).expect("white is valid"), @@ -462,18 +469,19 @@ fn recheck_contrast_boundary_matches_resolve_and_shares_hex_contract() { &ViewingConditions::srgb(), ) .expect("valid recheck parity table resolves atomically"); - let mut fgs: Vec = Vec::new(); + let mut fgs: Vec = Vec::new(); let mut want: Vec<(f64, f64)> = Vec::new(); for (_name, resolved) in &core_resolved { if let Resolved::Color { solved, .. } = resolved { - fgs.push(solved.hex().to_string()); + fgs.push(pack_hex(solved.hex())); want.push((solved.lc(), solved.wcag_ratio())); } } let engine = boundary_with_labui(); + let handle = engine.theme_handle("light").expect("light mints a handle"); let flat = engine - .recheck_contrast(bg, fgs.clone(), "light") + .recheck_contrast(pack_hex(bg), fgs.clone(), handle) .expect("rechecks"); assert_eq!( flat.len(), @@ -491,28 +499,36 @@ fn recheck_contrast_boundary_matches_resolve_and_shares_hex_contract() { ); } - // Shorthand / missing-`#` foregrounds are accepted, identical to canonical — - // the same hex contract `resolveTheme` honours (`#123` == `#112233`). - // C5.1: recheck идёт через словарь тем загруженного конфига — engine здесь - // уже несёт labui-паспорт. - let canonical = engine - .recheck_contrast(bg, vec!["#112233".to_string()], "light") - .expect("canonical rechecks"); - for fg in ["#123", "112233"] { - let got = engine - .recheck_contrast(bg, vec![fg.to_string()], "light") - .expect("shorthand rechecks"); - assert_eq!(got, canonical, "{fg}: must match the canonical spelling"); + // The multi call is background-major byte-identical to per-sample calls. + let bgs = [ + pack_hex("#F2F2F7"), + pack_hex("#FFFFFF"), + pack_hex("#101012"), + ]; + let multi = engine + .recheck_contrast_multi(bgs.to_vec(), fgs.clone(), handle) + .expect("multi rechecks"); + assert_eq!(multi.len(), bgs.len() * fgs.len() * 2); + for (s, &sample) in bgs.iter().enumerate() { + let per = engine + .recheck_contrast(sample, fgs.clone(), handle) + .expect("per-sample rechecks"); + let base = s * fgs.len() * 2; + for (i, value) in per.iter().enumerate() { + assert_eq!(multi[base + i], *value, "multi sample {s} index {i} drift"); + } } - // A malformed foreground rejects with the stable code, never a panic. + // A word with a non-zero reserved high byte (an RGBA/ARGB leak) rejects with + // the stable code, never a panic. C5.1: recheck идёт через словарь тем + // загруженного конфига — engine здесь уже несёт labui-паспорт. let err = engine - .recheck_contrast(bg, vec!["zzz".to_string()], "light") + .recheck_contrast(pack_hex(bg), vec![0xFF00_0000], handle) .map(|_| ()) - .expect_err("garbage foreground rejects"); + .expect_err("high-byte-set foreground word rejects"); assert!( error_message(err).contains("invalid_background"), - "bad foreground must carry the stable code" + "bad foreground word must carry the stable code" ); } diff --git a/packages/colors/README.md b/packages/colors/README.md index 308f3366..9849cd33 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -339,21 +339,43 @@ Ys candidate score `lc` и диагностический `wcagRatio` не мо --- -### `engine.recheckContrast(bgHex, fgHexes, theme): Float64Array` +### `engine.themeHandle(theme): number` -Дешёвая покадровая проверка: какие Ys candidate score и WCAG ratio дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва. Требует загруженный конфиг — `theme` ищется в его словаре (`config_required` без конфига, `unknown_theme` для необъявленного ключа), как и `resolveTheme`. Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковая кандидатная оценка по `Ys` из frozen SAPC-shaped curve, `2·i+1` — WCAG-отношение. `lc` не является LPC/readability verdict; runtime использует его только как координату текущего transitional solver-а. +Минтит числовой хэндл темы для горячего update-loop-а. `theme` ищется в словаре +загруженного конфига (`config_required` без конфига, `unknown_theme` для +необъявленного ключа), как и в `resolveTheme`. Строку темы разрешают ОДИН раз на +холодном крае, затем в каждом кадре передают числовой хэндл в `recheckContrast`/ +`recheckContrastMulti` — так словарь темы не пересканируется по строке на каждом +тике. --- -### `engine.recheckContrastMulti(bgHexes, fgHexes, theme): Float64Array` +### `engine.recheckContrast(bg, fgs, themeHandle): Float64Array` + +Дешёвая покадровая проверка: какие Ys candidate score и WCAG ratio дают цвета +`fgs` на фоне `bg` под темой `themeHandle`, без полного резолва. `bg` — упакованное +слово `0x00RRGGBB` (u32, старший байт зарезервирован и обязан быть 0); `fgs` — +`Uint32Array` таких же упакованных слов; `themeHandle` — число из +`engine.themeHandle(theme)`. Один contiguous-копи в линейную память, ноль +hex-parse и строковых аллокаций на update-пути. Возвращает `Float64Array` пар +`[lc, wcagRatio]` в порядке `fgs`: индекс `2·i` — знаковая кандидатная оценка по +`Ys` из frozen SAPC-shaped curve, `2·i+1` — WCAG-отношение. `lc` не является +LPC/readability verdict; runtime использует его только как координату текущего +transitional solver-а. + +--- + +### `engine.recheckContrastMulti(bgs, fgs, themeHandle): Float64Array` Батч-вариант `recheckContrast` для конечного набора образцов меняющегося фона -(градиент / картинка / bg-blur / стекло): проверяет один набор `fgHexes` сразу -против нескольких `bgHexes`, разделяя прямой ход модели каждого переднего плана -между всеми образцами (он от фона не зависит). Результат байт-в-байт равен N -отдельным вызовам `recheckContrast`, пара за парой — это закреплено parity-тестом -границы. Возвращает плоский background-major `Float64Array`: образец `s`, цвет -`i` лежит в `(s · fgHexes.length + i) · 2` (`lc`) и `+1` (`wcagRatio`). +(градиент / картинка / bg-blur / стекло): проверяет один набор `fgs` сразу +против нескольких `bgs`, разделяя прямой ход модели каждого переднего плана +между всеми образцами (он от фона не зависит). `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 и интеграции и без отдельного воспроизводимого гейта не заявляется. Для одного образца контроллер остаётся на diff --git a/packages/colors/adapt-theme.d.ts b/packages/colors/adapt-theme.d.ts index dcab1f8f..c7d17195 100644 --- a/packages/colors/adapt-theme.d.ts +++ b/packages/colors/adapt-theme.d.ts @@ -4,13 +4,15 @@ import type { LabColors, ThemeName } from "./index.js"; export interface AdaptThemeOptions { /** - * An initialised engine — needs resolve + contrast recheck. The exact + * An initialised engine — needs resolve + packed contrast recheck. The exact * `isStableGlowPointNoop` capability is conditionally required when a result * contains a stable Glow role. `recheckContrastMulti` is optional and batches * a finite explicit sample set without changing its point-wise semantics. + * `themeHandle` is optional: when present, the theme key is lowered to its + * numeric handle once per theme and addressed numerically in the recheck loop. */ colors: Pick & - Partial>; + Partial>; theme: ThemeName; /** * Explicit point evidence, overriding computed-CSS observation. A string array diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index e26993eb..54a28d1a 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -19,6 +19,34 @@ 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; + +/** Pack a `#RRGGBB` (or `#RGB` shorthand / bare / any-case) colour string into + * the recheck boundary's `0x00RRGGBB` word — the packed transport the WASM + * `recheckContrast`/`recheckContrastMulti` now consume instead of hex strings. + * The reserved high byte is zero. This is the cold-edge hex→u32 lowering: it + * runs at the rare recheck seam, not as a per-frame parse of committed state (a + * later packed-byte cache hoists it out of the loop entirely). A non-hex input + * throws loudly rather than feeding the boundary a garbage word. */ +function packRgb24Hex(hex) { + if (typeof hex !== "string") { + throw new TypeError("adaptTheme: colour sample must be a string"); + } + const body = hex.charCodeAt(0) === 35 /* '#' */ ? hex.slice(1) : hex; + let six; + if (body.length === 3) { + six = body[0] + body[0] + body[1] + body[1] + body[2] + body[2]; + } else if (body.length === 6) { + six = body; + } else { + throw new TypeError(`adaptTheme: expected #RGB or #RRGGBB, got '${hex}'`); + } + if (!HEX6.test(six)) { + throw new TypeError(`adaptTheme: non-hex colour '${hex}'`); + } + return Number.parseInt(six, 16) >>> 0; +} + /** Cubic ease-out: fast start, gentle settle, no overshoot. A non-finite `t` * (e.g. a NaN clock making `(now - easeStart) / easeMs` NaN) is treated as a * completed ease (1), so the crossfade can never emit `#NANNANNAN` CSS. */ @@ -61,7 +89,7 @@ function segHex(seg, t) { * * @param {*} element * @param {object} options - * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:string,fgs:string[],theme:string)=>ArrayLike, isStableGlowPointNoop?:(tint:string,bg:string)=>boolean }} options.colors + * @param {{ resolveTheme: (bg:string,theme:string)=>any, recheckContrast:(bg:number,fgs:Uint32Array,theme:number)=>ArrayLike, recheckContrastMulti?:(bgs:Uint32Array,fgs:Uint32Array,theme:number)=>ArrayLike, themeHandle?:(theme:string)=>number, isStableGlowPointNoop?:(tint:string,bg:string)=>boolean }} options.colors * @param {string} options.theme * @param {string | string[] | (() => string | string[])} [options.background] * explicit background evidence. An ARRAY (or a function returning one) is a @@ -120,6 +148,17 @@ export function adaptTheme(element, options) { typeof stableGlowPointNoopCapability === "function" ? stableGlowPointNoopCapability.bind(colors) : null; + // Optional numeric theme handle (like recheckContrastMulti, it is an engine + // capability the controller uses when offered). When present, a theme key is + // lowered to its numeric handle ONCE per distinct theme at a cold recheck + // edge, then addressed numerically — the hot loop never re-scans the theme + // dictionary by string. Engines without it keep the string theme key. + const themeHandleCapability = colors.themeHandle; + if (themeHandleCapability !== undefined && typeof themeHandleCapability !== "function") { + throw new TypeError("adaptTheme: themeHandle must be a function"); + } + const mintThemeHandle = + typeof themeHandleCapability === "function" ? themeHandleCapability.bind(colors) : null; const target = options.target ?? element; const canvas = options.canvas; const backgroundSource = options.background; @@ -268,6 +307,21 @@ export function adaptTheme(element, options) { // below is bit-for-bit the same as the fallback loop. const canBatch = typeof recheckContrastMulti === "function"; + // Numeric theme-handle memo. Mint at most once per distinct theme key; the + // recheck loop then passes the numeric handle (or the raw key, when the engine + // exposes no themeHandle capability). + let themeArgKey = null; + let themeArgValue = null; + const themeArgFor = (themeName, owner) => { + if (!mintThemeHandle) return themeName; + if (themeName !== themeArgKey) { + themeArgValue = mintThemeHandle(themeName); + checkpoint(owner); + themeArgKey = themeName; + } + return themeArgValue; + }; + const recheckSamples = ( samples, roleSet = roles, @@ -279,10 +333,14 @@ export function adaptTheme(element, options) { 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 themeArg = themeArgFor(themeName, owner); + const packedFgs = Uint32Array.from(foregrounds, packRgb24Hex); const useBatch = canBatch && samples.length > 1; let batch = null; if (useBatch) { - batch = recheckContrastMulti(samples, foregrounds, themeName); + batch = recheckContrastMulti(Uint32Array.from(samples, packRgb24Hex), packedFgs, themeArg); checkpoint(owner); const batchLength = batch?.length ?? -1; checkpoint(owner); @@ -299,7 +357,7 @@ export function adaptTheme(element, options) { // Per-sample flat buffer, or a background-major window into the batch one. let flat = null; if (!useBatch) { - flat = recheckContrast(samples[s], foregrounds, themeName); + flat = recheckContrast(packRgb24Hex(samples[s]), packedFgs, themeArg); checkpoint(owner); } const flatLength = useBatch ? null : (flat?.length ?? -1); diff --git a/packages/colors/bench/hotpath.bench.mjs b/packages/colors/bench/hotpath.bench.mjs deleted file mode 100644 index bff363d4..00000000 --- a/packages/colors/bench/hotpath.bench.mjs +++ /dev/null @@ -1,280 +0,0 @@ -// Deterministic hot-path benchmark for the @labpics/colors runtime. -// -// Measures the per-frame cost of `adaptTheme` (the rAF-driven controller) and -// its supporting primitives (`oklabLerp`, `parseCssColor`, -// `effectiveBackground`) on a manual clock. Controller scenarios use a stub -// engine and isolate JS overhead; the effective-background microbenchmark also -// includes the allocation-free JS↔WASM point-compositor boundary it executes. -// -// Every scenario is fully deterministic: same schedule, same colours, same -// breach timing. Besides timing, each scenario reports a BEHAVIOUR FINGERPRINT -// (FNV-1a over the post-tick applied variable state of every frame) plus -// solve/recheck/style-op counters. An optimisation of the hot path must keep -// `fingerprint`, `solves` and `rechecks` IDENTICAL (byte-identical applied -// state); `styleSets`/`styleRemoves` may go DOWN (fewer redundant DOM writes) -// but never change the fingerprint. -// -// Run: node bench/hotpath.bench.mjs - -import { readFileSync } from "node:fs"; -import { performance } from "node:perf_hooks"; -import { initSync } from "../index.js"; -import { adaptTheme } from "../adapt-theme.js"; -import { oklabLerp, parseCssColor, effectiveBackground } from "../effective-bg.js"; - -initSync({ - module: readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url)), -}); - -const FRAME_MS = 1000 / 60; -const WARMUP_FRAMES = 300; -const MEASURE_FRAMES = 3000; -const ROLE_COUNT = 24; -const TRANSLUCENT_COUNT = 8; - -// ── helpers ───────────────────────────────────────────────────────────────── - -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); - -function fnv1a(hash, str) { - let h = hash >>> 0; - for (let i = 0; i < str.length; i++) { - h ^= str.charCodeAt(i); - h = Math.imul(h, 0x01000193) >>> 0; - } - return h >>> 0; -} - -/** Minimal CSSOM-like inline style: ordered names + value map + op counters. */ -function makeElement() { - const names = []; - const values = new Map(); - const counts = { set: 0, remove: 0 }; - return { - counts, - values, - style: { - setProperty(name, value) { - counts.set++; - if (!values.has(name)) names.push(name); - values.set(name, value); - }, - removeProperty(name) { - counts.remove++; - if (values.delete(name)) { - const i = names.indexOf(name); - if (i >= 0) names.splice(i, 1); - } - }, - item(i) { - return names[i] ?? ""; - }, - get length() { - return names.length; - }, - }, - }; -} - -/** Deterministic stand-in for the WASM engine. `resolveTheme` derives role - * colours from the background tone; `recheckContrast` reports a breach (Lc 40 - * vs target 60) iff the tone drifted ≥ 64 away from the last solved tone. */ -function makeStubEngine() { - const stub = { - solves: 0, - rechecks: 0, - lastSolvedTone: -1, - resolveTheme(bg) { - stub.solves++; - const tone = bgTone(bg); - stub.lastSolvedTone = tone; - const vars = {}; - const roles = {}; - for (let i = 0; i < ROLE_COUNT; i++) { - const cssVar = `--lab-role-${i}`; - vars[cssVar] = `oklch(${(40 + ((tone + i) % 50)).toFixed(1)}% 0.1200 ${(i * 13) % 360})`; - roles[`role${i}`] = { - kind: "color", - cssVar, - lc: 60, - hex: toneHex(tone + i * 9), - legalFloor: i % 3 === 0 ? 3 : null, - }; - } - for (let i = 0; i < TRANSLUCENT_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 }; - } - return { vars, roles }; - }, - recheckContrast(bg, fgs) { - stub.rechecks++; - const drift = Math.abs(bgTone(bg) - stub.lastSolvedTone); - const lc = drift >= 64 ? 40 : 60; - const out = new Float64Array(fgs.length * 2); - for (let i = 0; i < fgs.length; i++) out[2 * i] = lc; - return out; - }, - }; - return stub; -} - -// ── scenario driver ───────────────────────────────────────────────────────── - -/** - * @param {string} name - * @param {(frame:number)=>string|string[]} bgAt deterministic background schedule - * @param {{fingerprint?: boolean}} [mode] - */ -function runScenario(name, bgAt, mode = {}) { - const el = makeElement(); - const stub = makeStubEngine(); - let now = 0; - let frame = 0; - const ctrl = adaptTheme(el, { - colors: stub, - theme: "light", - background: () => bgAt(frame), - now: () => now, - win: undefined, - }); - - let fp = 0x811c9dc5; - const snapshot = () => { - for (const [k, v] of el.values) fp = fnv1a(fnv1a(fp, k), v); - }; - - for (frame = 1; frame <= WARMUP_FRAMES; frame++) { - now = frame * FRAME_MS; - ctrl.tick(now); - } - el.counts.set = 0; - el.counts.remove = 0; - const rechecks0 = stub.rechecks; - const solves0 = stub.solves; - - const t0 = performance.now(); - for (; frame <= WARMUP_FRAMES + MEASURE_FRAMES; frame++) { - now = frame * FRAME_MS; - ctrl.tick(now); - if (mode.fingerprint) snapshot(); - } - const t1 = performance.now(); - ctrl.stop(); - - return { - name, - totalMs: t1 - t0, - usPerFrame: ((t1 - t0) / MEASURE_FRAMES) * 1000, - styleSets: el.counts.set, - styleRemoves: el.counts.remove, - solves: stub.solves - solves0, - rechecks: stub.rechecks - rechecks0, - fingerprint: mode.fingerprint ? fp.toString(16).padStart(8, "0") : "-", - }; -} - -// Schedules. Tones are integers; toneHex() maps them onto #RRGGBB. -const SOLVED0 = 0x80; -const steadyBg = () => toneHex(SOLVED0); -// ±32 sine drift around the solved tone: key changes every frame, never breaches. -const driftBg = (f) => toneHex(SOLVED0 + Math.round(32 * Math.sin((2 * Math.PI * f) / 240))); -// Every 90 frames jump 96 tones away and hold 45 frames: sustained breach → -// 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)); -// Three-sample varying backdrop with the same breach schedule. -const breachBg3 = (f) => { - const base = breachBg(f); - const t = bgTone(base); - return [base, toneHex(t + 8), toneHex(t + 16)]; -}; - -// ── micro benches ─────────────────────────────────────────────────────────── - -function micro(name, iters, fn) { - // warmup - for (let i = 0; i < Math.min(iters, 2e4); i++) fn(i); - const t0 = performance.now(); - let sink = 0; - for (let i = 0; i < iters; i++) sink ^= fn(i).length ?? 0; - const t1 = performance.now(); - return { name, iters, nsPerOp: ((t1 - t0) / iters) * 1e6, sink }; -} - -const PARSE_FORMS = [ - "#1a2b3c", - "#f0e1d2cc", - "rgb(18, 52, 86)", - "rgba(240, 225, 210, 0.8)", - "rgb(18 52 86 / 0.5)", - "oklch(62.8% 0.2577 29.2)", - "oklch(0.628 0.2577 29.2 / 0.9)", - "transparent", -]; - -function fakeChain(depth) { - // depth translucent rgba layers over an opaque root — the worst honest case - // for the ancestor walk. - let leaf = { css: "rgb(240, 240, 240)", parent: null }; - for (let i = depth - 1; i >= 0; i--) { - leaf = { - css: `rgba(${20 + i * 7}, ${30 + i * 5}, ${40 + i * 3}, 0.35)`, - parent: leaf, - }; - } - return leaf; -} - -// ── run ───────────────────────────────────────────────────────────────────── - -const args = new Set(process.argv.slice(2)); -const fingerprint = args.has("--fingerprint"); - -console.log(`node ${process.version} | frames=${MEASURE_FRAMES} roles=${ROLE_COUNT}+${TRANSLUCENT_COUNT}tl | fingerprint=${fingerprint}`); -console.log(""); -console.log("scenario µs/frame styleSet styleRem solves rechecks fingerprint"); - -const scenarios = [ - 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( - `${s.name.padEnd(18)} ${s.usPerFrame.toFixed(2).padStart(9)} ${String(s.styleSets).padStart(10)} ${String(s.styleRemoves).padStart(9)} ${String(s.solves).padStart(7)} ${String(s.rechecks).padStart(9)} ${s.fingerprint}`, - ); -} - -console.log(""); -console.log("micro ns/op"); -const chain = fakeChain(8); -let probeReads = 0; -const probe = effectiveBackground(chain, { - getStyle: (el) => { - probeReads++; - return { getPropertyValue: () => el.css }; - }, - parentOf: (el) => el.parent, -}); -if (probeReads !== 9 || probe === "#F0F0F0") { - throw new Error("effectiveBackground benchmark did not traverse its translucent stack"); -} -const micros = [ - micro("oklabLerp hex→hex", 2e5, (i) => oklabLerp("#1A2B3C", "#F0E1D2", (i % 100) / 100)), - micro("oklabLerp oklch→hex", 1e5, (i) => oklabLerp("oklch(62.8% 0.2577 29.2)", "#F0E1D2", (i % 100) / 100)), - micro("parseCssColor mixed", 2e5, (i) => parseCssColor(PARSE_FORMS[i & 7]) ?? ""), - micro("effectiveBackground d8", 5e4, () => - effectiveBackground(chain, { - getStyle: (el) => ({ getPropertyValue: () => el.css }), - parentOf: (el) => el.parent, - }), - ), -]; -for (const m of micros) { - console.log(`${m.name.padEnd(24)} ${m.nsPerOp.toFixed(0).padStart(7)}`); -} diff --git a/packages/colors/bench/wasm-boundary.bench.mjs b/packages/colors/bench/wasm-boundary.bench.mjs index 8f5d5ff4..62de15b4 100644 --- a/packages/colors/bench/wasm-boundary.bench.mjs +++ b/packages/colors/bench/wasm-boundary.bench.mjs @@ -1,8 +1,7 @@ // JS↔WASM boundary benchmark for the @labpics/colors REAL engine. // -// The sibling `hotpath.bench.mjs` measures the pure-JS controller (`adaptTheme`) -// against a STUB engine, isolating JS overhead. This one is its pair: it drives -// the REAL wasm-bindgen boundary — `recheckContrast` (the per-frame primitive) +// This benchmark drives the REAL wasm-bindgen boundary — `recheckContrast` (the +// per-frame primitive) // and `resolveTheme` (the on-breach re-solve) — so we can see what a call across // the JS↔wasm line actually costs, and prove an optimisation keeps the results // byte-identical. @@ -50,6 +49,21 @@ const FGS = Object.values(resolved.roles) // Three worst-case samples of a varying backdrop (what strict/gradient mode feeds). const SAMPLES = ["#38383A", "#404042", "#2E2E30"]; +// C8d packed boundary: recheckContrast/recheckContrastMulti take packed +// `0x00RRGGBB` words + a `Uint32Array` of foregrounds + a numeric theme handle. +// Mirror the controller's `packRgb24Hex` (incl. #RGB shorthand expansion) and +// mint the theme handle ONCE, so the bench drives the real packed ABI — not +// hex strings silently coerced to 0. `resolveTheme` keeps the string theme (a +// cold authoring edge, unchanged by the hard-cut). +const pk = (hex) => { + const b = hex.charCodeAt(0) === 35 /* '#' */ ? hex.slice(1) : hex; + const six = b.length === 3 ? b[0] + b[0] + b[1] + b[1] + b[2] + b[2] : b; + return Number.parseInt(six, 16) >>> 0; +}; +const THEME_HANDLE = engine.themeHandle(THEME); +const FGSW = Uint32Array.from(FGS, pk); +const SAMPLESW = Uint32Array.from(SAMPLES, pk); + // ── timing core ───────────────────────────────────────────────────────────── /** Median ns/call: run `fn` in `batches` batches of `inner` calls, take the @@ -94,17 +108,17 @@ 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(SAMPLES[0], FGS, THEME); +const recheck1 = () => engine.recheckContrast(SAMPLESW[0], FGSW, THEME_HANDLE); // One frame with a 3-sample varying backdrop = THREE rechecks (worst-case loop). const recheck3 = () => { let last; - for (let s = 0; s < 3; s++) last = engine.recheckContrast(SAMPLES[s], FGS, THEME); + for (let s = 0; s < 3; s++) last = engine.recheckContrast(SAMPLESW[s], FGSW, 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(SAMPLES, FGS, THEME); +const recheckMulti3 = () => engine.recheckContrastMulti(SAMPLESW, FGSW, THEME_HANDLE); // 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 @@ -156,7 +170,7 @@ function fnv1aF64(hash, arr) { return h >>> 0; } let fp = 0x811c9dc5; -for (const s of SAMPLES) fp = fnv1aF64(fp, engine.recheckContrast(s, FGS, THEME)); +for (const s of SAMPLESW) fp = fnv1aF64(fp, engine.recheckContrast(s, FGSW, THEME_HANDLE)); // Include a resolveTheme vars fingerprint too (the projection is under test). const vfp = (() => { let h = 0x811c9dc5; diff --git a/packages/colors/bench/wasm.json b/packages/colors/bench/wasm.json index 12088d8d..cad716d6 100644 --- a/packages/colors/bench/wasm.json +++ b/packages/colors/bench/wasm.json @@ -19,13 +19,13 @@ "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked" }, "measurement": { - "source": "github-actions-run-29831804171", + "source": "github-actions-run-29844610967", "platform": "linux-x64", - "rawBytes": 421895 + "rawBytes": 419300 }, "policy": { - "maxRawBytes": 421895, - "basis": "c8d-1-packed-u32-core", + "maxRawBytes": 419300, + "basis": "c8d-4-packed-boundary", "gzip": "diagnostic-only" } } diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 01c81480..d43a66ea 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -13,9 +13,16 @@ initSync({ module: new WebAssembly.Module(readFileSync(new URL("../pkg/labcolors_bg.wasm", import.meta.url))), }); +// Packed boundary transport (C8d): recheckContrast/recheckContrastMulti now take +// packed `0x00RRGGBB` words and a `Uint32Array` of foregrounds, not hex strings. +// `pk`/`unpk` mirror the controller's `packRgb24Hex` so fakes can key on samples. +const pk = (hex) => Number.parseInt(hex.replace(/^#/u, ""), 16) >>> 0; +const unpk = (word) => `#${word.toString(16).padStart(6, "0").toUpperCase()}`; + // A fake LabColors engine. `resolveTheme` returns a controllable role set; // `recheckContrast` returns controllable signed Lc per role (interleaved with a -// dummy wcag). Records call counts. +// dummy wcag). Records call counts. `bg` arrives as a packed word, so the +// per-sample Lc map is looked up through `unpk`. function fakeColors(initial) { let resolveCount = 0; let resolve = initial; @@ -46,7 +53,7 @@ function fakeColors(initial) { return resolve; }, recheckContrast(bg) { - const lcs = recheckByBg ? (recheckByBg[bg] ?? recheckLc) : recheckLc; + const lcs = recheckByBg ? (recheckByBg[unpk(bg)] ?? recheckLc) : recheckLc; const out = []; for (const lc of lcs) { out.push(lc); @@ -265,7 +272,7 @@ test("a second-sample output conflict leaves DOM/state unchanged and remains ret }, recheckContrast(bg, _foregrounds, theme) { recheckThemes.push(theme); - return [bg === "#000000" ? 0 : 100, 10]; + return [bg === pk("#000000") ? 0 : 100, 10]; }, }; const ctrl = adaptTheme(el, { @@ -404,7 +411,7 @@ test("a stable-Glow reconciliation conflict cannot commit its class transition", return determinate; }, recheckContrast(background) { - return [background === "#000000" ? 0 : 100, 1]; + return [background === pk("#000000") ? 0 : 100, 1]; }, isStableGlowPointNoop(_source, background) { return background === "#FFFFFF"; @@ -1558,7 +1565,7 @@ test("owner loss in one adaptive recheck cancels the remaining samples", () => { }, recheckContrast(background, _foregrounds, theme) { calls.push(`${theme}:${background}`); - if (armed && theme === "A" && background === "#FFFFFF") { + if (armed && theme === "A" && background === pk("#FFFFFF")) { ctrl.setTheme("B"); } else if (armed && theme === "A") { ctrl.setTheme("C"); @@ -1581,7 +1588,7 @@ test("owner loss in one adaptive recheck cancels the remaining samples", () => { assert.equal(ctrl.current()["--lab-a"], "B"); assert.deepEqual( calls.filter((call) => call.startsWith("A:")), - ["A:#FFFFFF"], + [`A:${pk("#FFFFFF")}`], "the first revoked recheck must cancel the rest of A's sample loop", ); }); @@ -2867,7 +2874,7 @@ test("a color re-solve cannot reintroduce stable Glow vars unsafe for another sa }; }, recheckContrast(background) { - return [failing && background === "#FFFFFF" ? 10 : 100, 10]; + return [failing && background === pk("#FFFFFF") ? 10 : 100, 10]; }, isStableGlowPointNoop(_source, background) { return background === "#FFFFFF"; @@ -3376,6 +3383,166 @@ test("batch engine still uses the per-sample path for a single-sample backdrop", ); }); +// ── C8d packed recheck boundary (F1/F2) ────────────────────────────────────── +// The controller now speaks the packed wire to the engine: recheckContrast/ +// recheckContrastMulti receive packed `0x00RRGGBB` words (a `number` bg, a +// `Uint32Array` of foregrounds) and a NUMERIC theme handle minted once at the +// cold recheck edge. The old string overload (hex string bg, string[] fgs, +// string theme) is gone from the update path. This is the RED-then-GREEN +// JS-boundary lock for the packed surface, runnable without pkg/. + +test("packed recheck boundary: multi-sample tick passes Uint32Array words and a numeric theme handle, never a string", () => { + const el = fakeElement(); + const seen = { single: [], multi: [], handleThemes: [] }; + let samples = ["#FFFFFF", "#EEEEEE", "#DDDDDD"]; + let now = 1000; + const colors = { + resolveTheme() { + return oneRole("#000000", 100); + }, + themeHandle(theme) { + // The string key is lowered to a handle only at this cold edge. + assert.equal(typeof theme, "string", "themeHandle mints from the string key"); + seen.handleThemes.push(theme); + return theme === "light" ? 7 : 0; + }, + recheckContrast(bg, fgs, theme) { + seen.single.push({ bg, fgs, theme }); + return [...fgs].flatMap(() => [100, 10]); + }, + recheckContrastMulti(bgs, fgs, theme) { + seen.multi.push({ bgs, fgs, theme }); + const out = []; + for (let s = 0; s < bgs.length; s++) for (let i = 0; i < fgs.length; i++) out.push(100, 10); + return out; + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => samples, + target: el, + now: () => now, + win: {}, + sustainMs: 0, + dwellMs: 0, + }); + // Change the backdrop to force a >1-sample recheck through the batch call. + samples = ["#123456", "#654321", "#ABCDEF"]; + now += 10; + ctrl.tick(); + + assert.ok(seen.multi.length > 0, "a multi-sample recheck must run through the batch call"); + const m = seen.multi.at(-1); + assert.ok(m.bgs instanceof Uint32Array, "recheckContrastMulti bgs is a Uint32Array of packed words"); + assert.ok(m.fgs instanceof Uint32Array, "recheckContrastMulti fgs is a Uint32Array of packed words"); + assert.deepEqual([...m.bgs], [0x123456, 0x654321, 0xabcdef], "bgs decode to the packed samples"); + assert.deepEqual([...m.fgs], [0x000000], "fgs are the packed color-role hexes"); + assert.equal(m.theme, 7, "recheck is addressed by the numeric theme handle, not the key"); + + for (const call of seen.multi) { + assert.notEqual(typeof call.bgs, "string", "no hex-string sample ever reaches the boundary"); + assert.equal(typeof call.theme, "number", "the hot path passes a numeric handle"); + } + for (const call of seen.single) { + assert.equal(typeof call.bg, "number", "single-sample bg is a packed word, never a hex string"); + assert.ok(call.fgs instanceof Uint32Array, "single-sample fgs is a Uint32Array"); + assert.equal(typeof call.theme, "number", "single-sample theme is a numeric handle"); + } + // The handle is minted at a cold edge and memoised: a steady same-theme run + // must not re-scan the dictionary by string every frame. + now += 10; + ctrl.tick(); + assert.deepEqual(seen.handleThemes, ["light"], "theme handle minted once for one theme, then reused"); +}); + +test("packed recheck boundary: single-sample path also uses packed words and a numeric handle", () => { + const el = fakeElement(); + const seen = []; + let sample = "#FFFFFF"; + let now = 1000; + const colors = { + resolveTheme() { + return oneRole("#000000", 100); + }, + themeHandle() { + return 3; + }, + recheckContrast(bg, fgs, theme) { + seen.push({ bg, fgs, theme }); + return [...fgs].flatMap(() => [100, 10]); + }, + // No recheckContrastMulti: force the per-sample path. + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => sample, + target: el, + now: () => now, + win: {}, + sustainMs: 0, + dwellMs: 0, + }); + sample = "#202020"; + now += 10; + ctrl.tick(); + assert.ok(seen.length > 0, "single-sample recheck ran"); + const last = seen.at(-1); + assert.equal(last.bg, 0x202020, "bg is the packed word"); + assert.ok(last.fgs instanceof Uint32Array, "fgs is a Uint32Array"); + assert.equal(last.theme, 3, "theme is the numeric handle"); +}); + +test("packed recheck boundary: without a themeHandle capability the theme key stays a string", () => { + const el = fakeElement(); + const seen = []; + let sample = "#FFFFFF"; + let now = 1000; + const colors = { + resolveTheme() { + return oneRole("#000000", 100); + }, + recheckContrast(bg, fgs, theme) { + seen.push({ bg, fgs, theme }); + return [...fgs].flatMap(() => [100, 10]); + }, + }; + const ctrl = adaptTheme(el, { + colors, + theme: "light", + background: () => sample, + target: el, + now: () => now, + win: {}, + sustainMs: 0, + dwellMs: 0, + }); + sample = "#202020"; + now += 10; + ctrl.tick(); + const last = seen.at(-1); + assert.equal(typeof last.bg, "number", "bg is packed even without a theme handle"); + assert.equal(last.theme, "light", "no themeHandle capability → the string key is passed through"); +}); + +test("adaptTheme rejects a non-function themeHandle capability", () => { + assert.throws( + () => + adaptTheme(fakeElement(), { + colors: { + resolveTheme: () => oneRole("#000000", 100), + recheckContrast: () => [100, 10], + themeHandle: 42, + }, + theme: "light", + background: "#FFFFFF", + win: {}, + }), + /themeHandle must be a function/u, + ); +}); + // ── Допущенный Unresolved сквозь рантайм-цикл ──────────────────────────────── // Смешанный набор: живой цвет + допущенный Unresolved (var НЕ эмитится, @@ -3420,7 +3587,7 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( const el = fakeElement(); let bg = "#FFFFFF"; let now = 1000; - const seenRecheckHex = []; + const seenRecheckWords = []; const colors = { resolveCount: 0, resolveTheme(b) { @@ -3428,11 +3595,16 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( return mixedWithUnresolved(this.resolveCount === 1 ? "#000000" : "#111111", 100); }, recheckContrast(b, fgs) { - seenRecheckHex.push(...fgs); + seenRecheckWords.push(...fgs); for (const f of fgs) { - assert.match(f, /^#[0-9A-Fa-f]{6}$/, "recheck must only ever see color hexes"); + // Packed boundary: recheck now sees only packed `0x00RRGGBB` color words + // (high byte zero) — never a hex string, never a translucent role. + assert.ok( + Number.isInteger(f) && f >= 0 && f <= 0x00ffffff, + "recheck must only ever see packed color words (0x00RRGGBB)", + ); } - return fgs.flatMap(() => [10, 1.5]); // пробой: цикл обязан пере-решить + return [...fgs].flatMap(() => [10, 1.5]); // пробой: цикл обязан пере-решить }, }; const ctrl = adaptTheme(el, { @@ -3462,7 +3634,7 @@ test("admitted Unresolved stays inert through init, breach re-solve and ease", ( now += 200; ctrl.tick(); assert.ok(colors.resolveCount >= 2, "breach must re-solve"); - assert.ok(seenRecheckHex.length > 0, "recheck actually ran"); + assert.ok(seenRecheckWords.length > 0, "recheck actually ran"); assert.equal(el.props.get("--lab-impossible"), undefined, "failure stays var-less across re-solve"); assert.ok(el.props.get("--lab-label-primary"), "surviving color stays painted"); @@ -3481,8 +3653,8 @@ test("corrupted recheck buffer fails loud instead of silently keeping stale colo resolveTheme: () => oneRole("#000000", 100), recheckContrast(b, fgs) { if (recheckMode === "short") return [10]; // битая длина - if (recheckMode === "nan") return fgs.flatMap(() => [Number.NaN, 1.5]); - return fgs.flatMap(() => [100, 10]); + if (recheckMode === "nan") return [...fgs].flatMap(() => [Number.NaN, 1.5]); + return [...fgs].flatMap(() => [100, 10]); }, }; let now = 1000; @@ -3513,7 +3685,7 @@ test("corrupted resolve result throws instead of wiping vars with an empty snaps let bg = "#FFFFFF"; const colors = { resolveTheme: () => (corrupt ? { roles: {} } : oneRole("#000000", 100)), - recheckContrast: (b, fgs) => fgs.flatMap(() => [1, 1.5]), // пробой → re-solve + recheckContrast: (b, fgs) => [...fgs].flatMap(() => [1, 1.5]), // пробой → re-solve }; let now = 1000; const ctrl = adaptTheme(el, { diff --git a/packages/colors/test/chain-invariants.test.mjs b/packages/colors/test/chain-invariants.test.mjs index 1374e272..fa553912 100644 --- a/packages/colors/test/chain-invariants.test.mjs +++ b/packages/colors/test/chain-invariants.test.mjs @@ -59,6 +59,12 @@ function engine() { return e; } +// C8d packed recheck boundary: recheckContrast takes a packed `0x00RRGGBB` +// background word, a `Uint32Array` of foregrounds, and a numeric theme handle. +const pk = (hex) => Number.parseInt(hex.replace(/^#/u, ""), 16) >>> 0; +const recheck1 = (e, bg, fgHex, theme) => + e.recheckContrast(pk(bg), Uint32Array.of(pk(fgHex)), e.themeHandle(theme)); + // [r,g,b] из parseCssColor-результата (отбрасываем α). const rgb = (parsed) => [parsed[0], parsed[1], parsed[2]]; const packRgb24 = (parsed) => @@ -101,7 +107,7 @@ test("legality survives serialization: each solid role's emitted var reparses to // Контраст РЕПАРСНУТОГО цвета, замеренный тем же движком, воспроизводит // обещанный — и, для floored-ролей, всё ещё держит пол. - const flat = e.recheckContrast(bg, [paintedHex], theme); + const flat = recheck1(e, bg, paintedHex, theme); const [lc, wcag] = [flat[0], flat[1]]; assert.ok( Math.abs(wcag - role.wcagRatio) < 1e-9, @@ -188,7 +194,7 @@ test("translucent serialization fidelity: emitted tint+alpha, reference composit // Самосогласованность движка: перепроверка ЕГО ЖЕ compositeHex // воспроизводит отданные метрики композита (не зависит от находки выше). - const flat = e.recheckContrast(bg, [role.compositeHex], theme); + const flat = recheck1(e, bg, role.compositeHex, theme); assert.ok( Math.abs(flat[1] - role.compositeWcag) < 1e-9, `${theme}/${bg}/${key}: recheck(compositeHex) WCAG ${flat[1]} != reported ${role.compositeWcag}`, diff --git a/packages/colors/test/hotpath-parity.test.mjs b/packages/colors/test/hotpath-parity.test.mjs index 63f588a6..e4fbdc7f 100644 --- a/packages/colors/test/hotpath-parity.test.mjs +++ b/packages/colors/test/hotpath-parity.test.mjs @@ -25,7 +25,7 @@ import * as ebg from "../effective-bg.js"; const { oklabLerp } = ebg; -// ── deterministic mini-harness (mirrors bench/hotpath.bench.mjs, smaller) ──── +// ── deterministic mini-harness (small, self-contained hot-path replay) ─────── const FRAME_MS = 1000 / 60; const FRAMES = 700; @@ -97,7 +97,11 @@ function makeStubEngine() { return { vars, roles }; }, recheckContrast(bg, fgs) { - const drift = Math.abs(bgTone(bg) - stub.lastSolvedTone); + // Packed boundary (C8d): `bg` is a `0x00RRGGBB` word. Its R byte is the + // same tone `bgTone` extracts from the hex spelling, so the drift — and + // thus every applied-state fingerprint — is byte-identical to the pre-pack + // string path. + const drift = Math.abs(((bg >> 16) & 0xff) - stub.lastSolvedTone); const lc = drift >= 64 ? 40 : 60; const out = new Float64Array(fgs.length * 2); for (let i = 0; i < fgs.length; i++) out[2 * i] = lc; diff --git a/packages/colors/test/wasm-boundary-parity.test.mjs b/packages/colors/test/wasm-boundary-parity.test.mjs index d5486060..3b330c51 100644 --- a/packages/colors/test/wasm-boundary-parity.test.mjs +++ b/packages/colors/test/wasm-boundary-parity.test.mjs @@ -49,9 +49,37 @@ test("wasm recheck boundary is byte-identical to the pre-optimisation golden", a const engine = new LabColors(); engine.loadConfig(CONFIG); - // (1) Every recheck case: exact f64 equality (Object.is catches ±0 / NaN too). + // C8d packed boundary: recheckContrast/recheckContrastMulti take packed + // `0x00RRGGBB` words + a `Uint32Array` of foregrounds + a numeric theme handle + // minted by `themeHandle`. The string overloads are hard-cut. `pk` mirrors the + // controller's `packRgb24Hex`; the FROZEN golden (captured on the hex path) + // must still hold byte-for-byte, proving the packed transport changed nothing + // but the encoding. + // Mirror the controller's `packRgb24Hex` EXACTLY, including #RGB shorthand + // expansion (#fff → #FFFFFF, #123 → #112233). The frozen golden was captured + // on the string boundary, which normalised shorthand before measuring; the + // packed boundary does pure shifts with no expansion, so the test must expand + // here to keep byte-identity against the golden's shorthand fixtures. + const pk = (hex) => { + const body = hex.charCodeAt(0) === 35 /* '#' */ ? hex.slice(1) : hex; + const six = + body.length === 3 + ? body[0] + body[0] + body[1] + body[1] + body[2] + body[2] + : body; + return Number.parseInt(six, 16) >>> 0; + }; + const words = (fgs) => Uint32Array.from(fgs, pk); + assert.equal( + typeof engine.themeHandle, + "function", + "engine must expose the numeric themeHandle mint", + ); + + // (C1) Every recheck case, packed input: exact f64 equality to the golden the + // string boundary produced (Object.is catches ±0 / NaN too). for (const { theme, bg, fgs, flat } of golden.recheck) { - const got = engine.recheckContrast(bg, fgs, theme); + const handle = engine.themeHandle(theme); + const got = engine.recheckContrast(pk(bg), words(fgs), handle); assert.equal(got.length, flat.length, `${theme} ${bg}: length`); for (let i = 0; i < flat.length; i++) { assert.ok( @@ -61,25 +89,26 @@ test("wasm recheck boundary is byte-identical to the pre-optimisation golden", a } } - // (2) The public `recheckContrastMulti` batch call must equal per-sample - // `recheckContrast`, byte-for-byte, over a 3-sample backdrop of the resolved - // role set. This is the byte-identity the controller's batch path depends on, - // so it is a hard assertion (not an `if present` skip) — the promoted method - // is part of the public engine surface. + // (C2) The public `recheckContrastMulti` batch call must equal per-sample + // `recheckContrast`, byte-for-byte, over a 3-sample backdrop of the resolved + // role set — the background-major layout the controller's batch path depends + // on. Hard assertion (not an `if present` skip): the method is public surface. + const darkHandle = engine.themeHandle("dark"); const res = engine.resolveTheme("#3A3A3C", "dark"); const fgSet = Object.values(res.roles) .filter((r) => r.kind === "color") .map((r) => r.hex); + const packedFgs = words(fgSet); const samples = ["#38383A", "#404042", "#2E2E30"]; assert.equal( typeof engine.recheckContrastMulti, "function", "engine must expose the public recheckContrastMulti batch method", ); - const multi = engine.recheckContrastMulti(samples, fgSet, "dark"); + const multi = engine.recheckContrastMulti(words(samples), packedFgs, darkHandle); assert.equal(multi.length, samples.length * fgSet.length * 2); for (let s = 0; s < samples.length; s++) { - const per = engine.recheckContrast(samples[s], fgSet, "dark"); + const per = engine.recheckContrast(pk(samples[s]), packedFgs, darkHandle); for (let i = 0; i < per.length; i++) { const base = s * fgSet.length * 2 + i; assert.ok( diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 053ab262..df3f3946 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -14,7 +14,7 @@ export const DEFAULT_BUDGET = resolve( "packages/colors/bench/wasm.json", ); export const WASM_BUDGET_FILE_SHA256 = - "ac0ae363b74f2485ed6fb6ffea34f56e3b46ce5f3650e0b84c1ba21241d00293"; + "4ec83fbd8c2395b95cb25b6b1fc7b54d3c21d6efb91190f362c6c58aa9ced365"; const SCHEMA_VERSION = 1; const CANONICAL_ARTIFACT = "packages/colors/pkg/labcolors_bg.wasm";