diff --git a/conformance/README.md b/conformance/README.md index 4bc6df50..811ff57d 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -83,7 +83,10 @@ adjacent bytes или нормативного отношения пересчи наблюдателях человеческий вердикт clean/dirty и не пригодность для production decision. Legacy-идентификаторы сохранены только для совместимости. -- `theme` — kebab-ключ: `light` \| `dark` \| `light-ic` \| `dark-ic`. +- `theme` — kebab-ключ ЛОКАЛЬНОГО fixture-словаря пака (совпадает со словарём + labui-паспорта): `light` \| `dark` \| `light-ic` \| `dark-ic`. Канонический + словарь тем принадлежит клиентскому конфигу (C5.1); ядро встроенных имён + не несёт. - `contract` (в `solve`): `{kind:"text", lc}` \| `{kind:"ui", lc}` \| `{kind:"range", floor, ceiling}`. - `outcome` (в `solve`): успех `{kind:"solved", hex, lc, wcagRatio, floorOverride}` diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index fa5fdca8..5c574656 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -48,8 +48,8 @@ use serde::{Deserialize, Serialize}; use labcolors_core::alpha::{composite_hex, min_alpha_hex}; use labcolors_core::cleanliness::muddiness_from_hex; use labcolors_core::{ - BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, Theme, ViewingConditions, - fnv1a_32, recheck_against, solve, + BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, ViewingConditions, fnv1a_32, + recheck_against, solve, }; /// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или @@ -71,12 +71,21 @@ pub fn core_version() -> &'static str { // словарём тем ("light" | "dark" | "light-ic" | "dark-ic"). // ───────────────────────────────────────────────────────────────────────────── -/// Условия просмотра для kebab-ключа темы. Паникует на неизвестной теме — -/// ключи в паке контролируются генератором, внешний вход сюда не попадает. +/// Условия просмотра для kebab-ключа темы — ЛОКАЛЬНЫЙ fixture-словарь пака +/// (C5.1: канонический словарь тем принадлежит клиентскому конфигу; ядро +/// встроенных имён не несёт). Ключи совпадают со словарём labui-паспорта. +/// Паникует на неизвестной теме — ключи в паке контролируются генератором, +/// внешний вход сюда не попадает. fn vc_for_theme(theme_key: &str) -> ViewingConditions { - Theme::parse(theme_key) - .expect("ключ темы в паке всегда канонический") - .viewing_conditions() + use labcolors_core::VcPreset; + let preset = match theme_key { + "light" => VcPreset::Srgb, + "dark" => VcPreset::Dim, + "light-ic" => VcPreset::SrgbIc, + "dark-ic" => VcPreset::DimIc, + other => panic!("ключ темы в паке всегда канонический, получено: {other}"), + }; + preset.viewing_conditions() } /// Все четыре канонические темы в стабильном порядке. diff --git a/crates/labcolors-core/src/cleanliness.rs b/crates/labcolors-core/src/cleanliness.rs index 83b7f931..efed5d4d 100644 --- a/crates/labcolors-core/src/cleanliness.rs +++ b/crates/labcolors-core/src/cleanliness.rs @@ -421,177 +421,6 @@ pub fn drab(c: f64) -> f64 { // Ноль новых параметров: все VC-параметры — таблица CIECAM16 (Li et al. 2017). // ───────────────────────────────────────────────────────────────────────────── -/// Compatibility theme input used to select the frozen CAM16 viewing conditions. -/// -/// Соответствие CIECAM16 (Li et al. 2017, Table 1): -/// - `Light` → average surround (F=1.0, c=0.69, Nc=1.0) -/// - `Dark` → dim surround (F=0.9, c=0.59, Nc=0.9) -/// - `LightIc` / `DarkIc` — то же, но с флагом повышенного контраста -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Theme { - /// Light compatibility context: average surround (CIECAM16 Table 1). - Light, - /// Dark compatibility context: dim surround (CIECAM16 Table 1). - Dark, - /// Light compatibility context with the increased-contrast flag (IC). - LightIc, - /// Dark compatibility context with the increased-contrast flag (IC). - DarkIc, -} - -impl Theme { - /// Разобрать стабильный kebab-контракт границы (`"light"` / `"dark"` / - /// `"light-ic"` / `"dark-ic"`). Неизвестная строка — ошибка вызывающего, - /// возвращается как есть (граница оборачивает в свой тип ошибки), никогда - /// не коэрсится в тему по умолчанию. - /// - /// # Errors - /// - /// `Err` с непринятой строкой. - pub fn parse(raw: &str) -> Result { - match raw { - "light" => Ok(Theme::Light), - "dark" => Ok(Theme::Dark), - "light-ic" => Ok(Theme::LightIc), - "dark-ic" => Ok(Theme::DarkIc), - other => Err(other.to_string()), - } - } - - /// Стабильный kebab-ключ темы — обратная к [`parse`](Self::parse). - pub fn key(self) -> &'static str { - match self { - Theme::Light => "light", - Theme::Dark => "dark", - Theme::LightIc => "light-ic", - Theme::DarkIc => "dark-ic", - } - } - - /// Условия просмотра, под которыми ядро резолвит эту тему: та же карта - /// surround-ов, что у `vc_for_context` (Light → average, Dark → dim, - /// IC-темы → high-contrast двойники), но с дефолтным Yb — вход границы, - /// где фон ещё неизвестен. - pub fn viewing_conditions(self) -> crate::spaces::vc::ViewingConditions { - self.vc_by( - crate::spaces::vc::ViewingConditions::srgb, - crate::spaces::vc::ViewingConditions::dim_surround, - ) - } - - /// ЕДИНАЯ карта тема → surround: light-темы берут average-конструктор, - /// dark-темы — dim, IC-темы поднимают флаг повышенного контраста. Обе - /// точки входа (дефолтный Yb на границе, Yb-от-фона в контексте дефектов) - /// обязаны выбирать surround здесь — вторая копия карты в цветовом коде - /// расходилась бы тихо при добавлении темы. Эта карта не утверждает полноту - /// perceptual context; она только сохраняет legacy boundary contract. - fn vc_by( - self, - srgb: impl FnOnce() -> crate::spaces::vc::ViewingConditions, - dim: impl FnOnce() -> crate::spaces::vc::ViewingConditions, - ) -> crate::spaces::vc::ViewingConditions { - let (mut vc, ic) = match self { - Theme::Light => (srgb(), false), - Theme::Dark => (dim(), false), - Theme::LightIc => (srgb(), true), - Theme::DarkIc => (dim(), true), - }; - vc.high_contrast = vc.high_contrast || ic; - vc - } -} - -/// Partial context input for the legacy experimental compatibility proxy. -/// -/// Сочетает один фон (hex-строка, задаёт mean Yb) и тему (выбирает CAM16 surround). -/// Это не полный appearance context: variance, geometry, adaptation history and -/// observer state отсутствуют. Не использовать как human cleanliness verdict. -/// Передаётся в `muddiness_in_context` / `drab_in_context`. -#[derive(Debug, Clone, Copy)] -pub struct DefectContext<'a> { - /// Фоновый цвет в hex (`#RRGGBB`). Задаёт яркость фона Yb для CIECAM16. - pub bg_hex: &'a str, - /// Тема просмотра. - pub theme: Theme, -} - -/// Y-компонент (относительная яркость) hex-цвета в % от D65-белого. -/// -/// Формула: Y = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin (IEC 61966-2-1 D65), -/// затем умножаем на 100 для CIECAM16 (где Yb задаётся в %). -/// -/// Диапазон результата: [0.0, 100.0]. -fn y_pct_from_hex(hex: &str) -> Result { - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - // Строка srgb_from_hex возвращает ЛИНЕЙНЫЙ sRGB (без гаммы). - // Y (IEC 61966-2-1, D65): Y = 0.2126 R + 0.7152 G + 0.0722 B - let y = 0.212_639_005_871_510_27 * rgb[0] - + 0.715_168_678_767_756 * rgb[1] - + 0.072_192_315_360_733_71 * rgb[2]; - Ok(y * 100.0) -} - -/// Viewing conditions для заданной темы и яркости фона Yb (в %). -/// -/// Параметры surround — CIECAM16 Table 1 (Li et al. 2017). Ноль новых констант. -fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingConditions { - theme.vc_by( - || crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct), - || crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct), - ) -} - -/// Compute the legacy experimental compatibility proxy with local Yb+theme inputs. -/// -/// This is context-parameterized, not fully surround-aware: it does not model -/// spatial distribution, surround variance, geometry, adaptation history, or observers. -/// -/// # Алгоритм -/// -/// 1. Из `hex` получаем Oklab `(L, C, h)` для геометрии (C, h не зависят от surround). -/// 2. Из `ctx.bg_hex` получаем Yb — яркость фона в % от D65-белого. -/// 3. Строим `ViewingConditions` для темы `ctx.theme` с данным Yb (CIECAM16 Table 1). -/// 4. Из `hex` → XYZ → CAM16 `J` (apparent lightness под фоном+temой). -/// 5. `l_app = J / 100` — нормированный apparent lightness ∈ [0, 1]. -/// 6. legacy proxy = `raw_chromatic(l_app, C_oklab, h_oklab)` — формула без изменений, -/// но `l_app` учитывает surround и фон вместо Oklab L. -/// -/// # Провенанс -/// -/// VC-параметры: CIECAM16, Li et al. 2017, DOI 10.1002/col.22131, Table 1. -/// Legacy formula is unchanged; provenance statuses are recorded in the empirical inventory. -pub fn muddiness_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { - // Oklab-координаты: C и h не зависят от viewing conditions - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - let h_oklab = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); - - // Apparent lightness J через CAM16 под фоном+surround - let y_b_pct = y_pct_from_hex(ctx.bg_hex)?; - let vc = vc_for_context(ctx.theme, y_b_pct); - let xyz = crate::spaces::srgb::srgb_to_xyz(rgb); - let (j, _m, _h_cam) = crate::spaces::cam16::forward(xyz, &vc); - let l_app = (j / 100.0).clamp(0.0, 1.0); - - Ok(raw_chromatic(l_app, c_oklab, h_oklab).clamp(0.0, 1.0)) -} - -/// Compute the historical `drab` compatibility coordinate while accepting the same context shape. -/// -/// `drab(C) = 1 - N_pure(C)` depends only on Oklab chroma C. It is not an -/// observer-validated dullness estimate. Context is accepted only for API symmetry. -/// -/// Returns the same value as `drab(C_oklab)`; local context inputs are ignored explicitly. -pub fn drab_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { - // Historical formula depends only on C_oklab; context is not part of this coordinate. - let _ = ctx; // принимается для симметрии API - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - Ok(drab(c_oklab)) -} - #[cfg(test)] mod tests { use super::*; @@ -925,207 +754,4 @@ mod tests { ); } } - - // ─── Zone G: local-context sensitivity tests (Fowler class A) ───────────── - // - // Characterization contract: changing the supplied Yb+theme inputs changes - // CAM16 J and therefore the frozen proxy in the recorded directions. - // This proves wiring and regression sensitivity, not human cleanliness truth - // or completeness of the visual context model. - // - // Почему тест кусается (mutation-bite): - // Если заменить l_app = J/100 на l_app = Oklab-L (убрать local-context path), - // оба теста провалятся: без учёта Yb фона CAM16 J не меняется при смене bg_hex, - // поэтому muddiness_in_context вернёт одинаковое значение для обоих фонов. - // - // TDD RED-first: до добавления `muddiness_in_context` в файл эти тесты не - // компилировались (функция не существовала) — RED доказан структурно. - // - // CAM16 viewing-condition parameters: Li et al. 2017 Table 1. Legacy proxy - // parameter statuses are separate and recorded in the empirical inventory. - - use super::{DefectContext, Theme, drab_in_context, muddiness_in_context}; - - /// The frozen proxy is higher for #808080 with the supplied pastel background. - /// - /// In this partial model, #FFE4E1 supplies a higher Yb than #808080, changing - /// CAM16 J and the geometric depth input. No observer judgement is asserted. - /// - /// Направление дельты: mud_on_pastel > mud_on_neutral — строго. - #[test] - fn proxy_for_grey_is_higher_with_pastel_yb_than_neutral_yb() { - let grey = "#808080"; - let pastel_bg = "#FFE4E1"; // розово-белёсый, Yb≈82% - let neutral_bg = "#808080"; // нейтральный серый, Yb≈22% - - let mud_on_pastel = muddiness_in_context( - grey, - DefectContext { - bg_hex: pastel_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - let mud_on_neutral = muddiness_in_context( - grey, - DefectContext { - bg_hex: neutral_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - assert!( - mud_on_pastel > mud_on_neutral, - "local-context proxy ordering changed: pastel={mud_on_pastel:.6} \ - must be > neutral={mud_on_neutral:.6}; equality means Yb no longer reaches CAM16 J" - ); - } - - /// The frozen proxy is lower for #C2185B with black than with white inputs. - /// - /// In this partial model the two Yb+theme inputs produce different CAM16 J - /// and geometric depth values. No human clean/dirty direction is asserted. - /// - /// Направление дельты: mud_on_black < mud_on_white — строго. - #[test] - fn proxy_for_dark_pink_is_lower_with_black_input_than_white_input() { - let dark_pink = "#C2185B"; // тёмно-розовый (Material Design Pink 800) - let black_bg = "#000000"; - let white_bg = "#FFFFFF"; - - let mud_on_black = muddiness_in_context( - dark_pink, - DefectContext { - bg_hex: black_bg, - theme: Theme::Dark, - }, - ) - .unwrap(); - - let mud_on_white = muddiness_in_context( - dark_pink, - DefectContext { - bg_hex: white_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - assert!( - mud_on_black < mud_on_white, - "local-context proxy ordering changed: black={mud_on_black:.6} \ - must be < white={mud_on_white:.6}; equality means context no longer reaches the formula" - ); - } - - /// Mutation-bite: replacing l_app with a fixed value removes local-context sensitivity - /// должна РОНЯТЬ первый кейс-тест. Проверяем здесь что тест различает два значения. - /// - /// Реализация: вычисляем mud дважды — с пастельным и нейтральным фоном. - /// Разница строго ненулевая → epsilon-тест подтверждает укус. - #[test] - fn compatibility_proxy_differs_across_local_background_inputs() { - let grey = "#808080"; - let pastel_bg = "#FFE4E1"; - let neutral_bg = "#808080"; - - let mud_pastel = muddiness_in_context( - grey, - DefectContext { - bg_hex: pastel_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - let mud_neutral = muddiness_in_context( - grey, - DefectContext { - bg_hex: neutral_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - // Разница должна быть не менее 1e-4 — иначе тест не кусается - assert!( - (mud_pastel - mud_neutral).abs() > 1e-4, - "local-context proxy delta < 1e-4: pastel={mud_pastel:.8} neutral={mud_neutral:.8} \ - delta={:.2e} — тест не кусается (mutation-bite провален).", - (mud_pastel - mud_neutral).abs() - ); - } - - /// API-test: `drab_in_context` preserves the context-independent legacy coordinate. - /// - /// The boundary accepts context for compatibility, ignores it explicitly, and must not - /// fabricate zero instead of returning the historical arithmetic result. - #[test] - fn drab_in_context_matches_bare_drab() { - use super::n_pure; - let hex = "#937C00"; // frozen conformance fixture - - let ctx_light = DefectContext { - bg_hex: "#FFFFFF", - theme: Theme::Light, - }; - let ctx_dark = DefectContext { - bg_hex: "#000000", - theme: Theme::Dark, - }; - - let d_light = drab_in_context(hex, ctx_light).unwrap(); - let d_dark = drab_in_context(hex, ctx_dark).unwrap(); - - // drab зависит только от C_oklab — должно быть идентично для обоих контекстов - assert_eq!( - d_light, d_dark, - "drab_in_context должен возвращать одинаковый результат \ - независимо от контекста (drab зависит только от C_oklab): \ - light={d_light:.8} dark={d_dark:.8}" - ); - - // Проверяем, что D + N = 1 выполняется и через context-путь - let rgb = crate::spaces::srgb::srgb_from_hex(hex).unwrap(); - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - assert_eq!( - d_light + n_pure(c_oklab), - 1.0, - "drab_in_context({hex}) + n_pure должно быть ровно 1.0 (D+N=1 инвариант)" - ); - // For this fixture C >> C0, so the frozen arithmetic complement is < 0.1. - assert!( - d_light < 0.1, - "drab_in_context({hex}) = {d_light:.6} ожидается < 0.1 \ - (fixture C >> C0, so the compatibility coordinate approaches 0)" - ); - } - - /// IC-тема компилируется и возвращает корректный результат. - #[test] - fn ic_themes_compile_and_return_finite_value() { - let hex = "#6B6B2E"; // frozen conformance fixture - let ctx_lic = DefectContext { - bg_hex: "#FFFFFF", - theme: Theme::LightIc, - }; - let ctx_dic = DefectContext { - bg_hex: "#000000", - theme: Theme::DarkIc, - }; - - let m_lic = muddiness_in_context(hex, ctx_lic).unwrap(); - let m_dic = muddiness_in_context(hex, ctx_dic).unwrap(); - - assert!( - m_lic.is_finite() && (0.0..=1.0).contains(&m_lic), - "muddiness_in_context(LightIc) = {m_lic} вне [0,1]" - ); - assert!( - m_dic.is_finite() && (0.0..=1.0).contains(&m_dic), - "muddiness_in_context(DarkIc) = {m_dic} вне [0,1]" - ); - } } diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index d632f394..ca8733b5 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -235,6 +235,11 @@ pub enum ConfigError { /// ЗАГРУЗКЕ (`#[serde(default)]` на `roles` на границе WASM разрешает ОПУСТИТЬ /// словарь синтаксически, но не остаться совсем без контракта). EmptyContract, + /// Словарь тем пуст. Симметрия с [`EmptyContract`](Self::EmptyContract): + /// без единой темы `resolve`/`recheck` тотально неработоспособны (любой + /// ключ — unknown), и дефект уехал бы на использование неотличимым от + /// опечатки. Отказ на загрузке. + EmptyThemes, /// `material`-рецепту передан `floor: zero` — у материала нет цели для вывода /// альфы без пола читаемости. Отказ на загрузке (а не молчаливая невидимая /// роль): материал обязан нести `aa-text` или `aa-ui`. @@ -303,6 +308,7 @@ impl std::fmt::Display for ConfigError { reason, } => write!(f, "сентимент `{sentiment}` (роль `{role}`): {reason}"), ConfigError::EmptyContract => write!(f, "контракт пуст: передайте roles"), + ConfigError::EmptyThemes => write!(f, "словарь тем пуст: передайте themes"), ConfigError::MaterialFloorRequired { role } => write!( f, "material-роль `{role}` требует пол читаемости (aa-text/aa-ui), получен zero-floor" @@ -1261,8 +1267,9 @@ impl ThemeConfig { /// /// # Errors /// - /// [`ConfigError`] структурной/деривационной фазы либо - /// [`ConfigError::EmptyContract`] на голом контракте (без ролей и алиасов). + /// [`ConfigError`] структурной/деривационной фазы, + /// [`ConfigError::EmptyContract`] на голом контракте (без ролей и алиасов) + /// либо [`ConfigError::EmptyThemes`] на пустом словаре тем. pub fn compile_named_role_table(&self) -> Result { self.validate_syntactic()?; @@ -1273,6 +1280,12 @@ impl ThemeConfig { if self.roles.is_empty() && self.aliases.is_empty() { return Err(ConfigError::EmptyContract); } + // Пустой словарь тем — тот же класс дефекта, что и пустой контракт + // ролей: без темы resolve/recheck невозможны, отказ обязан быть на + // загрузке, а не поздним unknown_theme на использовании. + if self.themes.entries.is_empty() { + return Err(ConfigError::EmptyThemes); + } let mut entries: Vec<(String, RoleSpec)> = Vec::with_capacity(self.roles.len()); for (name, recipe) in &self.roles { diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 6888239e..b2732c18 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1613,6 +1613,18 @@ fn validator_rejects_duplicate_dictionary_keys() { }) )); + // C5.1: имя темы — ключ клиентского словаря; дубликат делал бы lookup + // неоднозначным (first-wins тихо хоронит вторую декларацию). + let mut c = labui_reference(); + c.themes.entries.push(c.themes.entries[0].clone()); + assert!(matches!( + c.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "themes", + .. + }) + )); + let mut c = labui_reference(); c.palette.push(c.palette[0].clone()); assert!(matches!( diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 2ec54771..92962a5b 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -95,8 +95,7 @@ pub use accent_surface::{ }; pub use alpha::composite_over_encoded; pub use cleanliness::{ - DefectContext, Theme, drab, drab_in_context, muddiness_from_hex, muddiness_from_linear_srgb, - muddiness_in_context, muddiness_oklch, n_pure, + drab, muddiness_from_hex, muddiness_from_linear_srgb, muddiness_oklch, n_pure, }; pub use config::{ Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index de4808d7..7f92de82 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -32,11 +32,11 @@ //! как characterization реального поведения (не одобрение — фиксация факта для владельца). use labcolors_core::{ - BgInput, Brand, DefectContext, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, - NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure, - RoleRecipe, SentimentCategory, SentimentsConfig, Theme, ThemeAnchors, ThemeConfig, - ThemesConfig, VcPreset, ViewingConditions, muddiness_in_context, muddiness_oklch, - oklch_from_hex, p3_from_hex, resolve_named_set, srgb_encoded_from_hex, + BgInput, Brand, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, NeutralAnchors, + NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure, RoleRecipe, + SentimentCategory, SentimentsConfig, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, + ViewingConditions, muddiness_oklch, oklch_from_hex, p3_from_hex, resolve_named_set, + srgb_encoded_from_hex, }; use proptest::prelude::*; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; @@ -200,28 +200,6 @@ fn muddiness_strictly_increases_in_chroma_in_the_steep_warm_band() { ); } -/// Закрытие ДЫРЫ, найденной mutation-прогоном (survivor `cleanliness.rs:541:35 -/// replace + with * in muddiness_in_context`): local-context path must calculate -/// Euclidean Oklab chroma `sqrt(a²+b²)`. The `a²*b²` mutant collapses these -/// fixtures below C0 and moves the frozen proxy from roughly 0.5-0.7 to ~0.03. -/// The `> 0.3` boundary is mutation characterization, not a human verdict. -/// Заземлено замером: #6B6B2E=0.651, #8A7A2E=0.622, #7A5A20=0.666, #B8860B=0.506. -#[test] -fn muddiness_in_context_uses_euclidean_oklab_chroma() { - let ctx = DefectContext { - bg_hex: "#FFFFFF", - theme: Theme::Light, - }; - for hex in ["#6B6B2E", "#8A7A2E", "#7A5A20", "#B8860B"] { - let mud = muddiness_in_context(hex, ctx).unwrap(); - assert!( - mud > 0.3, - "fixture {hex} proxy must remain > 0.3 with Euclidean sqrt(a²+b²); \ - collapsed chroma would approach zero. Got {mud}" - ); - } -} - /// Characterization: for a cool hue (h=270°, sin=-1), the compatibility /// coordinate is not monotone in C: it first rises, then falls. This records /// current mathematics without assigning perceptual meaning. diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index 6809d4b2..184fd887 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -51,8 +51,7 @@ use labcolors_core::{ GlowDecisionProfileV1, GlowDiagnosticProfileV1, GlowTargetStatus as CoreGlowTargetStatus, Hue, LadderPosition, LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1, NumericalDecisionEvidenceV1, NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1, - Theme as CoreTheme, ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj, - srgb_encoded_from_hex, + ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj, srgb_encoded_from_hex, }; // Регистрирует UniFFI-scaffolding под namespace = имя крейта (`labcolors`). @@ -78,17 +77,20 @@ pub enum Theme { } impl Theme { - fn to_core(self) -> CoreTheme { + /// ЛОКАЛЬНЫЙ adapter-словарь FFI → физический VC-пресет ядра (C5.1: + /// канонический словарь тем клиентский; ядро встроенных имён не несёт). + fn preset(self) -> labcolors_core::VcPreset { + use labcolors_core::VcPreset; match self { - Theme::Light => CoreTheme::Light, - Theme::Dark => CoreTheme::Dark, - Theme::LightIc => CoreTheme::LightIc, - Theme::DarkIc => CoreTheme::DarkIc, + Theme::Light => VcPreset::Srgb, + Theme::Dark => VcPreset::Dim, + Theme::LightIc => VcPreset::SrgbIc, + Theme::DarkIc => VcPreset::DimIc, } } fn vc(self) -> ViewingConditions { - self.to_core().viewing_conditions() + self.preset().viewing_conditions() } } diff --git a/crates/labcolors-wasm/src/cache.rs b/crates/labcolors-wasm/src/cache.rs index 8b8006e5..f1b66345 100644 --- a/crates/labcolors-wasm/src/cache.rs +++ b/crates/labcolors-wasm/src/cache.rs @@ -1,5 +1,5 @@ -//! A contract cache for resolved theme sets, keyed by `(bgHex, theme, table -//! fingerprint)`. +//! A contract cache for resolved theme sets, keyed by `(bgHex, theme-binding +//! slot, table fingerprint)`. //! //! Re-solving the same background under the same theme is the common case while //! a tool tweaks a colour, and a resolve sweep is real work. The cache returns @@ -20,8 +20,6 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; -use crate::theme::Theme; - /// A stable, arbitrary fingerprint used by the cache's own unit tests as a /// single key namespace. Production keys always carry a real config fingerprint /// (an FNV-1a over the canonical DTO, computed in the engine); this constant @@ -30,21 +28,27 @@ use crate::theme::Theme; pub(crate) const DEFAULT_TABLE_FINGERPRINT: u64 = 0; /// The full key of a cached resolve: every input that can change the output. +/// +/// Тема входит НОМЕРОМ слота в словаре тем загруженного конфига (порядок +/// объявления), не строкой: ключ не аллоцирует и не зависит от длины +/// клиентского имени. Отпечаток конфига разводит пространства словарей, так +/// что слот всегда читается в правильном словаре. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct CacheKey { bg_hex: String, - theme: &'static str, + theme_slot: u32, table_fingerprint: u64, } impl CacheKey { - /// Build a key from a normalised background hex, a theme, and a table - /// fingerprint. The hex is normalised by the caller (uppercased, `#`-led) - /// so `#fff` and `#FFFFFF` never split the cache once expanded upstream. - pub fn new(bg_hex: String, theme: Theme, table_fingerprint: u64) -> Self { + /// Build a key from a normalised background hex, a theme-binding slot, and + /// a table fingerprint. The hex is normalised by the caller (uppercased, + /// `#`-led) so `#fff` and `#FFFFFF` never split the cache once expanded + /// upstream. + pub fn new(bg_hex: String, theme_slot: u32, table_fingerprint: u64) -> Self { Self { bg_hex, - theme: theme.key(), + theme_slot, table_fingerprint, } } @@ -152,7 +156,7 @@ mod tests { fn failed_build_is_not_cached_and_a_later_success_is_shared() { let cache: ContractCache> = ContractCache::new(8); let calls = Cell::new(0); - let key = || CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT); + let key = || CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT); let failed: Result, &'static str> = cache.get_or_try_insert_with(key(), || { calls.set(calls.get() + 1); @@ -179,8 +183,8 @@ mod tests { #[test] fn failed_miss_at_capacity_preserves_every_successful_entry() { let cache: ContractCache> = ContractCache::new(2); - let first_key = CacheKey::new("#000000".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT); - let second_key = CacheKey::new("#111111".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT); + let first_key = CacheKey::new("#000000".into(), 0, DEFAULT_TABLE_FINGERPRINT); + let second_key = CacheKey::new("#111111".into(), 0, DEFAULT_TABLE_FINGERPRINT); let first = cache .get_or_try_insert_with(first_key.clone(), || Ok::<_, &'static str>(Rc::new(1))) .unwrap(); @@ -190,7 +194,7 @@ mod tests { assert_eq!(cache.len(), 2); let failed: Result, &'static str> = cache.get_or_try_insert_with( - CacheKey::new("#222222".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT), + CacheKey::new("#222222".into(), 0, DEFAULT_TABLE_FINGERPRINT), || Err("injected failure"), ); assert_eq!(failed, Err("injected failure")); @@ -208,7 +212,7 @@ mod tests { fn builds_once_then_serves_from_cache() { let cache: ContractCache = ContractCache::new(8); let calls = Cell::new(0); - let key = || CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT); + let key = || CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT); let first = cache .get_or_try_insert_with(key(), || { @@ -233,13 +237,13 @@ mod tests { let cache: ContractCache<&str> = ContractCache::new(8); let light = cache .get_or_try_insert_with( - CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT), + CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT), || Ok::<_, ()>("light"), ) .unwrap(); let dark = cache .get_or_try_insert_with( - CacheKey::new("#FFFFFF".into(), Theme::Dark, DEFAULT_TABLE_FINGERPRINT), + CacheKey::new("#FFFFFF".into(), 1, DEFAULT_TABLE_FINGERPRINT), || Ok::<_, ()>("dark"), ) .unwrap(); @@ -254,11 +258,7 @@ mod tests { for i in 0..2 { cache .get_or_try_insert_with( - CacheKey::new( - format!("#00000{i}"), - Theme::Light, - DEFAULT_TABLE_FINGERPRINT, - ), + CacheKey::new(format!("#00000{i}"), 0, DEFAULT_TABLE_FINGERPRINT), || Ok::<_, ()>(i), ) .unwrap(); @@ -267,7 +267,7 @@ mod tests { // The third distinct key trips the cap → wholesale clear, then insert. cache .get_or_try_insert_with( - CacheKey::new("#0000FF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT), + CacheKey::new("#0000FF".into(), 0, DEFAULT_TABLE_FINGERPRINT), || Ok::<_, ()>(3), ) .unwrap(); @@ -289,11 +289,7 @@ mod reentrancy_tests { #[should_panic(expected = "реентерабельный build")] fn same_key_reentrant_build_panics_deterministically() { let cache: ContractCache = ContractCache::new(4); - let key = CacheKey::new( - "#FFFFFF".to_string(), - Theme::Light, - DEFAULT_TABLE_FINGERPRINT, - ); + let key = CacheKey::new("#FFFFFF".to_string(), 0, DEFAULT_TABLE_FINGERPRINT); let key_inner = key.clone(); let _ = cache.get_or_try_insert_with::<()>(key, || { // Тот же ключ изнутри build — обязан паниковать, не рекурсировать. @@ -308,16 +304,8 @@ mod reentrancy_tests { #[test] fn different_key_nested_build_is_safe_and_guard_lifts_on_error() { let cache: ContractCache = ContractCache::new(4); - let a = CacheKey::new( - "#FFFFFF".to_string(), - Theme::Light, - DEFAULT_TABLE_FINGERPRINT, - ); - let b = CacheKey::new( - "#000000".to_string(), - Theme::Light, - DEFAULT_TABLE_FINGERPRINT, - ); + let a = CacheKey::new("#FFFFFF".to_string(), 0, DEFAULT_TABLE_FINGERPRINT); + let b = CacheKey::new("#000000".to_string(), 0, DEFAULT_TABLE_FINGERPRINT); let b_inner = b.clone(); let nested = cache .get_or_try_insert_with::<()>(a.clone(), || { @@ -328,11 +316,7 @@ mod reentrancy_tests { .unwrap(); assert_eq!(nested, 8); - let c = CacheKey::new( - "#123456".to_string(), - Theme::Dark, - DEFAULT_TABLE_FINGERPRINT, - ); + let c = CacheKey::new("#123456".to_string(), 1, DEFAULT_TABLE_FINGERPRINT); assert!( cache .get_or_try_insert_with(c.clone(), || Err::("boom")) diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index 399f3579..6f1fe4e3 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -11,8 +11,9 @@ /// после того, как ВЕСЬ именованный набор атомарно прошёл допуск. #[derive(Debug, Clone, PartialEq)] pub struct ResolvedTheme { - /// The theme key this was resolved under (`"light"`, `"dark"`, …). - pub theme: &'static str, + /// ИСХОДНЫЙ клиентский ключ темы из словаря конфига, под которым решён + /// набор (результат сохраняет имя клиента, не физический пресет). + pub theme: String, /// The normalised background hex the set was resolved against. pub background: String, /// One entry per role the core returned, in the core's deterministic order. diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 877dae89..a163fdfa 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -14,7 +14,7 @@ use std::borrow::Cow; use std::collections::HashMap; use std::rc::Rc; -use labcolors_core::config::ThemeConfig; +use labcolors_core::config::{ThemeConfig, VcPreset}; use labcolors_core::semantic::NamedRoleTable; use labcolors_core::{BgInput, ResolveSetError, Resolved, Solved}; @@ -22,7 +22,6 @@ use crate::cache::{CacheKey, ContractCache}; use crate::config_dto::{ConfigDto, fingerprint}; use crate::dto::{ResolvedTheme, RgbaColor, RoleEntry, RoleOutcome, SolvedColor}; use crate::error::BindingError; -use crate::theme::Theme; /// How many distinct `(bg, theme, table)` resolves the cache holds before a /// целиком. Фиксированная граница исключает неограниченный рост при @@ -50,6 +49,21 @@ struct NamedState { table: NamedRoleTable, fingerprint: u64, floors: HashMap>, + /// Иммутабельный словарь тем конфига: (клиентский ключ, VC-пресет) в + /// порядке объявления. Позиция пары — слот ключа кэша. + themes: Vec<(String, VcPreset)>, +} + +impl NamedState { + /// Найти клиентский ключ темы в словаре: `(слот, VC-пресет)`. + /// Неизвестный ключ (включая любой ключ при пустом словаре) — `None`; + /// типизацию ошибки выбирает вызывающий. + fn theme_binding(&self, key: &str) -> Option<(u32, VcPreset)> { + self.themes + .iter() + .position(|(name, _)| name == key) + .map(|slot| (slot as u32, self.themes[slot].1)) + } } impl Default for Engine { @@ -86,6 +100,7 @@ impl Engine { let fp = fingerprint(&dto); let cfg = ThemeConfig::try_from(dto).map_err(|reason| BindingError::InvalidConfig { reason })?; + let themes = cfg.themes.entries.clone(); let table = cfg .compile_named_role_table() .map_err(|e| BindingError::InvalidConfig { @@ -109,6 +124,7 @@ impl Engine { table, fingerprint: fp, floors, + themes, }); Ok(fp) } @@ -123,9 +139,8 @@ impl Engine { pub fn resolve_theme( &self, bg_hex: &str, - theme: Theme, + theme_key: &str, ) -> Result, BindingError> { - let vc = theme.viewing_conditions(); // Validate and normalise the background once, before the cache lookup, // so an invalid hex fails fast and the cache key is canonical. let normalised = normalise_hex(bg_hex)?; @@ -134,9 +149,18 @@ impl Engine { })?; // Конфиг загружен → эмитится ЕГО контракт (string-keyed) той же - // физикой; отпечаток в ключе разводит кэш-пространства конфигов. + // физикой; тема — КЛИЕНТСКИЙ ключ словаря конфига (канонический путь + // client key → binding → VcPreset → ViewingConditions), отпечаток в + // ключе разводит кэш-пространства конфигов, слот — темы внутри одного. if let Some(named) = &self.named { - let key = CacheKey::new(normalised.clone(), theme, named.fingerprint); + let (slot, preset) = + named + .theme_binding(theme_key) + .ok_or_else(|| BindingError::UnknownTheme { + requested: theme_key.to_string(), + })?; + let vc = preset.viewing_conditions(); + let key = CacheKey::new(normalised.clone(), slot, named.fingerprint); let result = self.cache.get_or_try_insert_with(key, || { let set = labcolors_core::resolve_named_set(&bg, &named.table, &vc) .map_err(resolve_set_error_to_binding)?; @@ -163,7 +187,8 @@ impl Engine { } } Ok(Rc::new(ResolvedTheme { - theme: theme.key(), + // Результат несёт ИСХОДНЫЙ клиентский ключ, не пресет. + theme: theme_key.to_string(), background: normalised.clone(), roles, })) @@ -190,9 +215,9 @@ impl Engine { &self, bg_hex: &str, fg_hexes: &[String], - theme: Theme, + theme_key: &str, ) -> Result, BindingError> { - let vc = theme.viewing_conditions(); + 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 @@ -226,9 +251,9 @@ impl Engine { &self, bg_hexes: &[String], fg_hexes: &[String], - theme: Theme, + theme_key: &str, ) -> Result, BindingError> { - let vc = theme.viewing_conditions(); + let vc = self.recheck_vc(theme_key)?; let bg_cows: Vec> = bg_hexes .iter() .map(|h| hex_for_recheck(h)) @@ -242,6 +267,23 @@ impl Engine { labcolors_core::recheck_against_multi(&bg_refs, &fg_refs, &vc) .map_err(|reason| BindingError::InvalidBackground { reason }) } + + /// Условия просмотра для recheck-пути: ТОТ ЖЕ канонический словарь, что у + /// [`resolve_theme`](Self::resolve_theme) — recheck без загруженного + /// конфига невозможен (нет словаря ключей), неизвестный ключ типизирован. + fn recheck_vc( + &self, + theme_key: &str, + ) -> 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(), + })?; + Ok(preset.viewing_conditions()) + } } /// Map one core [`Resolved`] into the boundary [`RoleOutcome`]. `legal_floor` is @@ -444,7 +486,7 @@ mod tests { // silent default system. let engine = Engine::new(); assert!(matches!( - engine.resolve_theme("#FFFFFF", Theme::Light), + engine.resolve_theme("#FFFFFF", "light"), Err(BindingError::ConfigRequired) )); } @@ -471,7 +513,7 @@ mod tests { #[test] fn resolves_white_light_to_keyed_entries() { let engine = engine_with_labui(); - let result = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let result = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert_eq!(result.theme, "light"); assert_eq!(result.background, "#FFFFFF"); // Generic over the role set: the config's own role names key each entry. @@ -491,9 +533,9 @@ mod tests { // same thing as the original solve. let engine = engine_with_labui(); for (bg, theme) in [ - ("#FFFFFF", Theme::Light), - ("#3478F6", Theme::Light), - ("#1C1C1E", Theme::Dark), + ("#FFFFFF", "light"), + ("#3478F6", "light"), + ("#1C1C1E", "dark"), ] { let result = engine.resolve_theme(bg, theme).unwrap(); let mut fgs = Vec::new(); @@ -514,12 +556,14 @@ mod tests { ); } } - // Invalid foreground hex surfaces a structured error, not a panic. - assert!( - Engine::new() - .recheck("#FFFFFF", &["nothex".to_string()], Theme::Light) - .is_err() - ); + // Invalid foreground hex surfaces a structured error, not a panic — + // проверяется С ЗАГРУЖЕННЫМ конфигом, иначе первым сработал бы + // ConfigRequired и hex-путь остался бы вакуумным (C5.1: recheck + // требует словарь тем). + assert!(matches!( + engine_with_labui().recheck("#FFFFFF", &["nothex".to_string()], "light"), + Err(BindingError::InvalidBackground { .. }) + )); } #[test] @@ -529,13 +573,15 @@ mod tests { // 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. - let engine = Engine::new(); + // C5.1: recheck требует конфиг (словарь тем клиентский) — путь тот же, + // что у resolve. + let engine = engine_with_labui(); let canonical = engine - .recheck("#FFFFFF", &["#112233".to_string()], Theme::Light) + .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()], Theme::Light).unwrap(); + 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"); } @@ -577,7 +623,7 @@ mod tests { #[test] fn none_role_resolves_to_none_outcome() { let engine = engine_with_labui(); - let result = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let result = engine.resolve_theme("#FFFFFF", "light").unwrap(); let none_entry = result.roles.iter().find(|r| r.role_key == "none").unwrap(); assert_eq!(none_entry.outcome, RoleOutcome::None); } @@ -585,7 +631,7 @@ mod tests { #[test] fn label_primary_on_white_is_a_dark_colour() { let engine = engine_with_labui(); - let result = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let result = engine.resolve_theme("#FFFFFF", "light").unwrap(); let tp = result .roles .iter() @@ -606,7 +652,7 @@ mod tests { // the floor while easing. Anchored roles report their conformance ratio; // decorative / zero roles report None. let engine = engine_with_labui(); - let result = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let result = engine.resolve_theme("#FFFFFF", "light").unwrap(); let floor_of = |key: &str| { result .roles @@ -634,8 +680,8 @@ mod tests { #[test] fn cache_returns_identical_shared_result() { let engine = engine_with_labui(); - let first = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); - let second = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let first = engine.resolve_theme("#FFFFFF", "light").unwrap(); + let second = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert!( Rc::ptr_eq(&first, &second), "second call must be a cache hit" @@ -645,8 +691,8 @@ mod tests { #[test] fn cache_key_is_hex_normalised() { let engine = engine_with_labui(); - let canonical = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); - let shorthand = engine.resolve_theme("#fff", Theme::Light).unwrap(); + let canonical = engine.resolve_theme("#FFFFFF", "light").unwrap(); + let shorthand = engine.resolve_theme("#fff", "light").unwrap(); assert!( Rc::ptr_eq(&canonical, &shorthand), "equivalent hex spellings must share a cache entry" @@ -656,7 +702,7 @@ mod tests { #[test] fn ic_theme_resolves_without_error() { let engine = engine_with_labui(); - assert!(engine.resolve_theme("#FFFFFF", Theme::LightIc).is_ok()); + assert!(engine.resolve_theme("#FFFFFF", "light-ic").is_ok()); } #[test] @@ -669,17 +715,17 @@ mod tests { // is unique, and every key is constructible into a CSS var name. let engine = engine_with_labui(); let reps = [ - ("#FFFFFF", Theme::Light), - ("#000000", Theme::Dark), - ("#808080", Theme::Light), + ("#FFFFFF", "light"), + ("#000000", "dark"), + ("#808080", "light"), // Increased-contrast variants: the same contract must hold. - ("#FFFFFF", Theme::LightIc), - ("#000000", Theme::DarkIc), + ("#FFFFFF", "light-ic"), + ("#000000", "dark-ic"), ]; // The role count is a property of the loaded contract, not a magic number: // pin it to the first sweep and assert every background emits the same set. let expected_len = engine - .resolve_theme("#FFFFFF", Theme::Light) + .resolve_theme("#FFFFFF", "light") .unwrap() .roles .len(); @@ -790,6 +836,129 @@ mod tests { cfg.compile_named_role_table().expect("labui компилируется") } + /// Конфиг с ПРОИЗВОЛЬНЫМИ клиентскими именами тем: словарь принадлежит + /// клиенту (C5.1), встроенных имён у движка нет. + fn custom_theme_names_json() -> String { + let mut v: serde_json::Value = serde_json::from_str(&labui_json()).unwrap(); + v["themes"] = serde_json::json!([ + {"name": "paper", "preset": "srgb"}, + {"name": "oled", "preset": "dim"}, + {"name": "paper-contrast", "preset": "srgb-ic"} + ]); + v.to_string() + } + + /// C5.1, канонический путь: клиентский ключ словаря резолвится, а прежние + /// «встроенные» имена БЕЗ объявления в словаре — типизированный отказ. + #[test] + fn client_theme_keys_resolve_and_builtin_names_are_gone() { + let mut engine = Engine::new(); + engine + .load_config(&custom_theme_names_json()) + .expect("конфиг с клиентскими именами валиден"); + + let resolved = engine.resolve_theme("#FFFFFF", "paper").unwrap(); + assert_eq!( + resolved.theme, "paper", + "результат несёт исходный клиентский ключ" + ); + + // «light» больше не встроен: его нет в словаре ЭТОГО конфига. + match engine.resolve_theme("#FFFFFF", "light") { + Err(BindingError::UnknownTheme { requested }) => assert_eq!(requested, "light"), + other => panic!("ожидался UnknownTheme для необъявленного ключа, got {other:?}"), + } + } + + /// Два клиентских ключа одного VcPreset: одинаковая физика (байт-в-байт + /// те же роли), но результат сохраняет РАЗНЫЕ имена; перестановка + /// объявлений не меняет физику по имени. + #[test] + fn two_keys_of_one_preset_share_physics_but_keep_names() { + let mut v: serde_json::Value = serde_json::from_str(&labui_json()).unwrap(); + v["themes"] = serde_json::json!([ + {"name": "day", "preset": "srgb"}, + {"name": "paper", "preset": "srgb"} + ]); + let mut engine = Engine::new(); + engine.load_config(&v.to_string()).unwrap(); + + let day = engine.resolve_theme("#FFFFFF", "day").unwrap(); + let paper = engine.resolve_theme("#FFFFFF", "paper").unwrap(); + assert_eq!(day.theme, "day"); + assert_eq!(paper.theme, "paper"); + assert_eq!( + day.roles, paper.roles, + "один пресет ⇒ идентичная физика ролей" + ); + assert_eq!(day.background, paper.background); + + // Перестановка словаря: физика по имени не меняется. + v["themes"] = serde_json::json!([ + {"name": "paper", "preset": "srgb"}, + {"name": "day", "preset": "srgb"} + ]); + let mut engine2 = Engine::new(); + engine2.load_config(&v.to_string()).unwrap(); + let day2 = engine2.resolve_theme("#FFFFFF", "day").unwrap(); + assert_eq!(day.roles, day2.roles, "слот в ключе кэша — не физика"); + } + + /// Пустой словарь тем — отказ НА ЗАГРУЗКЕ (симметрия с EmptyContract у + /// ролей): без единой темы resolve/recheck тотально неработоспособны, и + /// поздний unknown_theme был бы неотличим от опечатки. Прежнее состояние + /// движка не тронуто (атомарность). + #[test] + fn empty_theme_dictionary_is_rejected_at_load() { + let mut v: serde_json::Value = serde_json::from_str(&labui_json()).unwrap(); + v["themes"] = serde_json::json!([]); + let mut engine = engine_with_labui(); + match engine.load_config(&v.to_string()) { + Err(BindingError::InvalidConfig { reason }) => { + assert!( + reason.contains("словарь тем пуст"), + "причина обязана называть пустой словарь тем, got: {reason}" + ); + } + other => panic!("пустой словарь тем обязан отклоняться на загрузке, got {other:?}"), + } + // Прежний конфиг жив: resolve по его словарю работает. + assert!(engine.resolve_theme("#FFFFFF", "light").is_ok()); + } + + /// C5.1: recheck-путь требует загруженный конфиг НАРАВНЕ с resolve — + /// без словаря нет ни одного валидного ключа темы. + #[test] + fn recheck_without_config_is_config_required() { + let engine = Engine::new(); + assert!(matches!( + engine.recheck("#FFFFFF", &["#112233".to_string()], "light"), + Err(BindingError::ConfigRequired) + )); + assert!(matches!( + engine.recheck_multi(&["#FFFFFF".to_string()], &["#112233".to_string()], "light"), + Err(BindingError::ConfigRequired) + )); + } + + /// Неудачный reload сохраняет прежние state и cache: движок продолжает + /// отвечать прежним контрактом (атомарность загрузки). + #[test] + fn failed_reload_preserves_state_and_cache() { + let mut engine = engine_with_labui(); + let before = engine.resolve_theme("#FFFFFF", "light").unwrap(); + + assert!(engine.load_config("{не json").is_err()); + // И валидный JSON с невалидным конфигом: + assert!(engine.load_config("{}").is_err()); + + let after = engine.resolve_theme("#FFFFFF", "light").unwrap(); + assert!( + Rc::ptr_eq(&before, &after), + "после неудачного reload прежний cache-hit жив (state не тронут)" + ); + } + /// Минимальный конфиг второго клиента: другой бренд, своё пространство имён. fn acme_json() -> String { r##"{ @@ -819,14 +988,14 @@ mod tests { // resolve обязан честно отказать, а не отдать встроенный дефолт. assert!( matches!( - engine.resolve_theme("#FFFFFF", Theme::Light), + engine.resolve_theme("#FFFFFF", "light"), Err(BindingError::ConfigRequired) ), "до load_config resolve_theme = ConfigRequired" ); let fp_labui = engine.load_config(&labui_json()).expect("labui валиден"); - let labui_set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let labui_set = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert!( labui_set .roles @@ -845,7 +1014,7 @@ mod tests { ); // Тот же (bg, тема) СРАЗУ после смены конфига: попадание в чужую запись // было бы кэш-коллизией — пространство ключей обязано быть acme. - let acme_set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let acme_set = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert!(acme_set.roles.iter().any(|r| r.role_key == "accent-fill")); assert!( acme_set @@ -902,13 +1071,16 @@ mod tests { fn loaded_config_matches_direct_named_resolve() { let mut engine = Engine::new(); engine.load_config(&labui_json()).unwrap(); - let via_engine = engine.resolve_theme("#101012", Theme::Dark).unwrap(); + let via_engine = engine.resolve_theme("#101012", "dark").unwrap(); let table = labui_table(); let bg = labcolors_core::BgInput::solid("#101012").unwrap(); - let direct = - labcolors_core::resolve_named_set(&bg, &table, &Theme::Dark.viewing_conditions()) - .expect("valid loaded table resolves atomically"); + let direct = labcolors_core::resolve_named_set( + &bg, + &table, + &labcolors_core::VcPreset::Dim.viewing_conditions(), + ) + .expect("valid loaded table resolves atomically"); assert_eq!( via_engine.roles.len(), @@ -1116,7 +1288,7 @@ mod tests { } // Состояние прежнее: контракт acme жив. - let set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let set = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert!(set.roles.iter().any(|r| r.role_key == "accent-fill")); } @@ -1128,7 +1300,7 @@ mod tests { .load_config(&stable_json) .expect("stable profile валиден"); let resolved = engine - .resolve_theme("#101012", Theme::Dark) + .resolve_theme("#101012", "dark") .expect("resolve возвращает per-role terminal outcomes"); let role = resolved .roles @@ -1165,7 +1337,7 @@ mod tests { .load_config(&stable_json) .expect("stable profile валиден"); let resolved = engine - .resolve_theme("#FFFFFF", Theme::Light) + .resolve_theme("#FFFFFF", "light") .expect("white screen point is an exact no-op"); let role = resolved .roles @@ -1218,7 +1390,7 @@ mod tests { let mut engine = Engine::new(); engine.load_config(&labui_json()).expect("labui валиден"); let resolved = engine - .resolve_theme("#101012", Theme::Dark) + .resolve_theme("#101012", "dark") .expect("валидный контракт резолвится"); let projected: serde_json::Value = serde_json::from_str(&crate::projection::resolved_json(&resolved).unwrap()).unwrap(); @@ -1276,7 +1448,7 @@ mod tests { .load_config(&valid.to_string()) .expect("контрольный многоключевой рецепт валиден"); let resolved = engine - .resolve_theme("#101012", Theme::Dark) + .resolve_theme("#101012", "dark") .expect("контрольный рецепт резолвится"); let projected: serde_json::Value = serde_json::from_str(&crate::projection::resolved_json(&resolved).unwrap()) diff --git a/crates/labcolors-wasm/src/error.rs b/crates/labcolors-wasm/src/error.rs index d5b6c86c..9e934773 100644 --- a/crates/labcolors-wasm/src/error.rs +++ b/crates/labcolors-wasm/src/error.rs @@ -47,7 +47,7 @@ pub enum BindingError { /// agnostic (ADR-0001 PR-c): it carries no built-in design system, so a /// resolve has nothing to emit until `load_config` supplies one. Honest, /// matchable failure — never a panic and never a silent built-in default. - #[error("no config loaded: call load_config before resolve_theme")] + #[error("no config loaded: call load_config before resolve_theme or recheck")] ConfigRequired, /// A core-generated value violated an internal postcondition or the adapter @@ -61,10 +61,12 @@ pub enum BindingError { reason: String, }, - /// The theme string is not one of the public spellings. - #[error("unknown theme: '{requested}' (expected light | dark | light-ic | dark-ic)")] + /// The theme key is absent from the loaded config's `themes` dictionary + /// (в частности, ЛЮБОЙ ключ при пустом словаре). Словарь тем принадлежит + /// клиенту; встроенных имён у движка нет. + #[error("unknown theme: '{requested}' (not declared in the loaded config's themes dictionary)")] UnknownTheme { - /// The unrecognised theme string the caller passed. + /// The unrecognised theme key the caller passed. requested: String, }, diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 02eee905..c63ae315 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -22,7 +22,6 @@ mod dto; mod engine; mod error; mod projection; -mod theme; use std::cell::RefCell; use std::rc::Rc; @@ -40,8 +39,8 @@ use crate::error::BindingError; const TS_RESULT_TYPES: &'static str = r##" import type { Wcag22CriterionV1 } from "../wcag22.js"; -/** The stable theme contract. `-ic` variants apply increased contrast; all four spellings are fully supported. */ -export type ThemeName = "light" | "dark" | "light-ic" | "dark-ic"; +/** Ключ темы из словаря `themes` загруженного конфига (клиентское имя). */ +export type ThemeName = string; /** A solved colour and the contrasts it actually achieves. */ export interface SolvedColor { @@ -623,8 +622,9 @@ impl LabColors { } } - /// Resolve every role for `bgHex` under `theme` (`"light" | "dark" | - /// "light-ic" | "dark-ic"`). + /// Resolve every role for `bgHex` under `theme` — КЛИЕНТСКОГО ключа из + /// словаря `themes` загруженного конфига (канонический путь: ключ → + /// `VcPreset` → viewing conditions). Ключ вне словаря — `unknown_theme`. /// /// Возвращает полный `ResolvedTheme`. Локальный `unreachable`/`unresolved` /// остаётся типизированными данными роли. Rejected/unsupported/internal @@ -633,7 +633,6 @@ impl LabColors { /// в JavaScript не разматывается. #[wasm_bindgen(js_name = resolveTheme)] pub fn resolve_theme(&self, bg_hex: &str, theme: &str) -> Result { - let theme = crate::theme::parse_theme(theme).map_err(to_js_error)?; let resolved = self .inner .resolve_theme(bg_hex, theme) @@ -677,6 +676,10 @@ impl LabColors { #[wasm_bindgen(js_name = loadConfig)] pub fn load_config(&mut self, json: &str) -> Result { let fp = self.inner.load_config(json).map_err(to_js_error)?; + // Успешный atomic reload чистит и projection-memo: следующая проекция + // пересобирается для нового контракта. Неудачный reload возвращается + // выше ДО этой строки — прежние state/cache/memo не тронуты. + *self.proj_memo.borrow_mut() = None; Ok(format!("{fp:016x}")) } @@ -698,7 +701,6 @@ impl LabColors { fg_hexes: Vec, theme: &str, ) -> Result, JsError> { - let theme = crate::theme::parse_theme(theme).map_err(to_js_error)?; self.inner .recheck(bg_hex, &fg_hexes, theme) .map_err(to_js_error) @@ -752,7 +754,6 @@ impl LabColors { fg_hexes: Vec, theme: &str, ) -> Result, JsError> { - let theme = crate::theme::parse_theme(theme).map_err(to_js_error)?; self.inner .recheck_multi(&bg_hexes, &fg_hexes, theme) .map_err(to_js_error) diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index ff81337f..93e85d70 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -326,7 +326,7 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { vars.len() + roles.len() + resolved.background.len() + resolved.theme.len() + 64, ); out.push_str("{\"theme\":"); - push_str_lit(&mut out, resolved.theme); + push_str_lit(&mut out, &resolved.theme); out.push_str(",\"background\":"); push_str_lit(&mut out, &resolved.background); out.push_str(",\"vars\":{"); @@ -864,7 +864,7 @@ mod tests { fn fixture() -> ResolvedTheme { ResolvedTheme { - theme: "dark", + theme: "dark".to_string(), background: "#3A3A3C".to_string(), roles: vec![ color_entry("label-primary"), @@ -1251,7 +1251,7 @@ mod tests { #[test] fn material_projects_two_layer_css_vars() { let theme = ResolvedTheme { - theme: "light", + theme: "light".to_string(), background: "#FFFFFF".to_string(), roles: vec![RoleEntry { role_key: "bg-material-base".to_string(), @@ -1351,7 +1351,7 @@ mod tests { }) }; let theme = ResolvedTheme { - theme: "light", + theme: "light".to_string(), background: "#FFFFFF".to_string(), roles: vec![ RoleEntry { @@ -1415,7 +1415,7 @@ mod tests { )); let indeterminate_theme = ResolvedTheme { - theme: "light", + theme: "light".to_string(), background: "#FFFFFF".to_string(), roles: vec![RoleEntry { role_key: "indeterminate".to_string(), @@ -1439,7 +1439,7 @@ mod tests { let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; let material_theme = ResolvedTheme { - theme: "light", + theme: "light".to_string(), background: "#FFFFFF".to_string(), roles: vec![RoleEntry { role_key: "material".to_string(), @@ -1505,7 +1505,7 @@ mod tests { #[test] fn hostile_role_keys_escape_reversibly() { let theme = ResolvedTheme { - theme: "light", + theme: "light".to_string(), background: "#FFFFFF".to_string(), roles: vec![RoleEntry { role_key: "we\"ird\\key\n\t\u{0001}".to_string(), diff --git a/crates/labcolors-wasm/src/theme.rs b/crates/labcolors-wasm/src/theme.rs deleted file mode 100644 index 8811552a..00000000 --- a/crates/labcolors-wasm/src/theme.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Тематический словарь границы — реэкспорт КАНОНИЧЕСКОГО [`Theme`] ядра. -//! -//! Раньше здесь жила вторая копия enum-а (31 ссылка) — два словаря одного -//! понятия расходились бы молча. Канон один, в ядре -//! (`labcolors_core::Theme`): kebab-контракт (`"light"` / `"dark"` / -//! `"light-ic"` / `"dark-ic"`), ключи и карта условий просмотра живут на нём. -//! Граница добавляет ТОЛЬКО свой тип ошибки: неизвестная тема — ошибка -//! вызывающего, оборачивается в [`BindingError::UnknownTheme`], никогда не -//! коэрсится в тему по умолчанию. -//! -//! «dim surround» — внутренний термин CIECAM16 для тёмной темы и наружу не -//! утекает: граница говорит темами, ядро — [`ViewingConditions`] -//! (labcolors_core::ViewingConditions). - -pub use labcolors_core::Theme; - -use crate::error::BindingError; - -/// Разобрать kebab-строку границы в тему, с границевой ошибкой. -pub fn parse_theme(raw: &str) -> Result { - Theme::parse(raw).map_err(|requested| BindingError::UnknownTheme { requested }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parses_every_public_spelling() { - assert_eq!(parse_theme("light").unwrap(), Theme::Light); - assert_eq!(parse_theme("dark").unwrap(), Theme::Dark); - assert_eq!(parse_theme("light-ic").unwrap(), Theme::LightIc); - assert_eq!(parse_theme("dark-ic").unwrap(), Theme::DarkIc); - } - - #[test] - fn rejects_unknown_theme_with_reason() { - match parse_theme("solarized") { - Err(BindingError::UnknownTheme { requested }) => assert_eq!(requested, "solarized"), - other => panic!("expected UnknownTheme, got {other:?}"), - } - } - - #[test] - fn key_round_trips_through_parse() { - for theme in [Theme::Light, Theme::Dark, Theme::LightIc, Theme::DarkIc] { - assert_eq!(parse_theme(theme.key()).unwrap(), theme); - } - } - - #[test] - fn light_and_dark_map_to_distinct_viewing_conditions() { - let light = Theme::Light.viewing_conditions(); - let dark = Theme::Dark.viewing_conditions(); - assert!( - dark.aw < light.aw, - "dim surround lowers the achromatic response" - ); - } - - #[test] - fn increased_contrast_themes_are_fully_calibrated() { - for theme in [Theme::LightIc, Theme::DarkIc] { - assert!(theme.viewing_conditions().high_contrast); - } - } -} diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index c70261be..03750795 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -279,21 +279,22 @@ fn public_muddiness_binding_matches_committed_conformance_vectors() { /// Shared parity assertion: for a passport, the binding's `resolveTheme` /// must reproduce the core `resolve_named_set`, role for role. Expectations /// come straight from the core inside the same wasm runtime — never hand-typed. -/// The core side derives its ViewingConditions from the SAME enum the -/// boundary resolves through (`Theme::viewing_conditions()`): a hardcoded -/// `srgb()` here silently diverges on any non-srgb theme (dark = dim surround) -/// — exactly the miss that kept the old light-only test blind to dim parity. -/// (String→Theme mapping is the boundary parser's contract, covered by its own -/// unit tests; the literals here mirror it 1:1.) +/// The core side derives its ViewingConditions from the SAME physical presets +/// the engine's theme dictionary binds (C5.1: client key → `VcPreset` → +/// `viewing_conditions()`); a hardcoded `srgb()` here silently diverges on any +/// non-srgb theme (dark = dim surround). The literals mirror the labui +/// passport's `themes` dictionary 1:1 — the fixture's local dictionary, not a +/// built-in engine vocabulary (the engine no longer has one). fn theme_vc(theme: &str) -> ViewingConditions { - let t = match theme { - "light" => labcolors_core::Theme::Light, - "dark" => labcolors_core::Theme::Dark, - "light-ic" => labcolors_core::Theme::LightIc, - "dark-ic" => labcolors_core::Theme::DarkIc, + use labcolors_core::VcPreset; + let preset = match theme { + "light" => VcPreset::Srgb, + "dark" => VcPreset::Dim, + "light-ic" => VcPreset::SrgbIc, + "dark-ic" => VcPreset::DimIc, other => panic!("test scaffolding: unmapped theme literal {other}"), }; - t.viewing_conditions() + preset.viewing_conditions() } fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { @@ -553,8 +554,9 @@ 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`). recheck - // is stateless, so no config is needed for this half. + // 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"); @@ -690,18 +692,32 @@ fn resolve_without_config_rejects_config_required() { ); } -/// An unknown theme name rejects with a structured error — not a panic. Theme -/// parsing happens before the config check, so this holds with no config loaded. +/// C5.1: словарь тем принадлежит загруженному конфигу. Без конфига любой +/// resolve — `config_required`; с конфигом ключ вне словаря — `unknown_theme`. +/// Оба — структурные ошибки, не паника. #[wasm_bindgen_test] fn unknown_theme_rejects_without_panic() { - let engine = LabColors::new(); - // `JsResolvedTheme` is not `Debug`, so map the Ok arm away before unwrapping - // the error — we only care that the call rejected and why. + // Без конфига словаря нет — честный config_required даже для «знакомого» имени. + let bare = LabColors::new(); + let err = bare + .resolve_theme("#FFFFFF", "light") + .map(|_| ()) + .expect_err("resolve до load_config обязан отказать"); + let message = error_message(err); + assert!( + message.contains("config_required"), + "error must carry the stable code, got: {message}" + ); + + // С конфигом: ключ вне клиентского словаря — unknown_theme. + let mut engine = LabColors::new(); + engine + .load_config(include_str!("data/labui.config.json")) + .expect("labui passport loads"); let err = engine .resolve_theme("#FFFFFF", "__not_a_theme__") .map(|_| ()) - .expect_err("unrecognised theme must reject"); - // The error message carries the stable code. + .expect_err("ключ вне словаря обязан отказать"); let message = error_message(err); assert!( message.contains("unknown_theme"), diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index 0c693e5c..a4df70ac 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -153,8 +153,9 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch резолвится без правки кода — демо входит в exit-criteria поезда. - Ломающие изменения ядра отложены за зелёный поезд (шаг 4) — ни одного дня без работающей пары. -- Двойной enum тем унифицирован: канон один в ядре (`labcolors_core::Theme`), - граница WASM его реэкспортирует (`wasm/theme.rs`). +- Двойной enum тем унифицирован, затем (C5.1) fixed-enum вырезан целиком: + канонический словарь тем принадлежит клиентскому конфигу (`themes` — имя → + `VcPreset`), встроенных имён у движка нет. - Нейминг теней канонизирует конфиг labui (`fx-shadow-*`). Тени уже мигрированы на полупрозрачные ladder-позиции (`Shadow*` с пер-темными α, тёмный якорь нейтрали `#101012` в обеих темах); открыта лишь научная подзадача diff --git a/packages/colors/README.md b/packages/colors/README.md index 445e13c4..808625ac 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -147,12 +147,22 @@ await initRuntime({ module_or_path: runtimeWasm }); ## Темы -| Имя темы | Назначение | -|----------|------------| -| `"light"` | Светлая тема | -| `"dark"` | Тёмная тема | -| `"light-ic"` | Светлая с повышенным контрастом | -| `"dark-ic"` | Тёмная с повышенным контрастом | +Словарь тем принадлежит конфигу: `themes` объявляет пары «клиентское имя → +физический VC-пресет», и `resolveTheme`/`recheckContrast` принимают ИМЕННО эти +имена (ключ вне словаря — ошибка `unknown_theme`; встроенных имён у движка +нет). Физических пресетов четыре: + +| VC-пресет | Условия просмотра | +|-----------|-------------------| +| `"srgb"` | светлое окружение (average surround) | +| `"dim"` | тёмное окружение (dim surround) | +| `"srgb-ic"` | светлое + повышенный контраст | +| `"dim-ic"` | тёмное + повышенный контраст | + +Словарь labui-паспорта: `light → srgb`, `dark → dim`, `light-ic → srgb-ic`, +`dark-ic → dim-ic` — поэтому примеры ниже используют `"light"`/`"dark"`. +Несколько имён могут разделять один пресет: физика одинакова, имя в +результате сохраняется клиентское. --- @@ -167,7 +177,7 @@ await initRuntime({ module_or_path: runtimeWasm }); ### `engine.resolveTheme(bgHex, theme): ResolvedTheme` - `bgHex` — фон в формате `#RGB` или `#RRGGBB`. -- `theme` — `"light" | "dark" | "light-ic" | "dark-ic"`. +- `theme` — клиентский ключ из словаря `themes` загруженного конфига. Возвращает объект `ResolvedTheme`: @@ -322,7 +332,7 @@ diagnostic-компонент текущего LPC и legacy `wcagRatio` не м ### `engine.recheckContrast(bgHex, fgHexes, theme): Float64Array` -Дешёвая покадровая проверка: какие контрасты дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковый `Lc` цвета `i`, `2·i+1` — его WCAG-отношение. Это примитив, которым `adaptTheme` решает, пора ли пересчитывать. +Дешёвая покадровая проверка: какие контрасты дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Требует загруженный конфиг — `theme` ищется в его словаре (`config_required` без конфига, `unknown_theme` для необъявленного ключа), как и `resolveTheme`. Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковый `Lc` цвета `i`, `2·i+1` — его WCAG-отношение. Это примитив, которым `adaptTheme` решает, пора ли пересчитывать. --- diff --git a/packages/colors/bench/wasm-size-budget-v12.json b/packages/colors/bench/wasm-size-budget-v12.json new file mode 100644 index 00000000..1e7be2e7 --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v12.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 8, + "budgetId": "labcolors-wasm-runtime-c5-theme-keys-v12", + "predecessor": { + "path": "packages/colors/bench/wasm-size-budget-v11.json", + "fileSha256": "fa11531ee390dd6dfdfadfadab99bbe8277f2b152b567951b17ef6093d42b1e4" + }, + "toolchainSource": { + "path": "packages/colors/bench/wasm-size-budget-v1.json", + "fileSha256": "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1" + }, + "buildRecipes": { + "runtime": { + "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", + "recipeSha256": "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4" + } + }, + "roles": { + "runtime": { + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "measurement": { + "source": "github-actions-run-29609974767", + "measurementPlatform": "linux-x64", + "rawBytes": 459765 + }, + "policy": { + "maxRawBytes": 459765, + "basis": "accepted-c5-theme-dictionary-snapshot", + "gzip": "diagnostic-only" + } + } + } +} diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index 8d0adc41..45dc9b61 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -1145,7 +1145,7 @@ test("release evidence carries no trace of the excised offline line", () => { test("WASM role size budgets are exact, append-only, and acyclic", async () => { const bench = join(root, "packages", "colors", "bench"); const paths = Object.fromEntries( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11].map((version) => [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((version) => [ `v${version}`, join(bench, `wasm-size-budget-v${version}.json`), ]), @@ -1165,6 +1165,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { v9: "e00fa0549d67ab027f589c053aeb4374f6437704a6277cc9784dcaa1d8015ad4", v10: "6f3318c29c633860a146be5dcd29e4ce85a3a52296b9719b506aba16951a58e6", v11: "fa11531ee390dd6dfdfadfadab99bbe8277f2b152b567951b17ef6093d42b1e4", + v12: "925452113b18b63137b9dae4786e3a8f7ba098eb47a2631a97107fbd52aa9a95", }; const documents = {}; for (const version of Object.keys(paths)) { @@ -1175,7 +1176,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { if (version !== "v1") assert.equal(bytes.toString("utf8"), canonicalJson(value)); } - const { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11 } = documents; + const { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12 } = documents; assert.equal(v1.budgetId, "labcolors-wasm-raw-issue-284-v1"); assert.equal(v2.budgetId, "labcolors-wasm-raw-issue-295-v2"); assert.equal(v3.budgetId, "labcolors-wasm-raw-issue-296-v3"); @@ -1387,11 +1388,38 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { assert.deepEqual(v11.buildRecipes.runtime, v10.buildRecipes.runtime); assert.deepEqual(v11.roles.runtime, v10.roles.runtime); + // V12 (C5.1): словарь клиентских theme-ключей вместо fixed enum + отказ + // EmptyThemes на загрузке — принятый рост runtime +524B, зафиксирован новым + // точным снапшотом (run 29609974767). + assert.equal(v12.schemaVersion, 8); + assert.equal(v12.budgetId, "labcolors-wasm-runtime-c5-theme-keys-v12"); + assert.deepEqual(v12.predecessor, { + path: "packages/colors/bench/wasm-size-budget-v11.json", + fileSha256: expectedHashes.v11, + }); + assert.deepEqual(v12.toolchainSource, v11.toolchainSource); + assert.deepEqual(v12.buildRecipes, v11.buildRecipes); + assert.deepEqual(v12.roles.runtime.measurement, { + source: "github-actions-run-29609974767", + measurementPlatform: "linux-x64", + rawBytes: 459765, + }); + assert.deepEqual(v12.roles.runtime.policy, { + maxRawBytes: 459765, + basis: "accepted-c5-theme-dictionary-snapshot", + gzip: "diagnostic-only", + }); + assert.equal( + v12.roles.runtime.policy.maxRawBytes - v11.roles.runtime.policy.maxRawBytes, + 524, + "C5.1 growth is the exact accepted dictionary-lookup delta", + ); + const checker = await import( new URL("../../../scripts/check-wasm-size-budget.mjs", import.meta.url) ); - assert.equal(checker.DEFAULT_BUDGET, paths.v11); - for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) { + assert.equal(checker.DEFAULT_BUDGET, paths.v12); + for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) { assert.equal(checker[`V${version}_FILE_SHA256`], expectedHashes[`v${version}`]); } assert.equal(checker.V1_RECIPE_SHA256, v5.buildRecipes.runtime.recipeSha256); @@ -1417,7 +1445,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { return `"--remap-path-prefix=\$${mapping.slice(0, separator)}=${mapping.slice(separator + 1)}"`; }) .join("$'\\x1f'")}`; - const runtimeCommand = v11.buildRecipes.runtime.command; + const runtimeCommand = v12.buildRecipes.runtime.command; assert.ok(runtimeCommand.startsWith(recipePrefix)); const expectedBuild = runtimeCommand.slice(recipePrefix.length); const expectedDiffBlock = [ @@ -1500,7 +1528,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { const fixtureBudgetPath = join(temporary, "budget.json"); const runtimeBytes = Buffer.alloc(16); runtimeBytes.set([0x00, 0x61, 0x73, 0x6d]); - const fixture = structuredClone(v11); + const fixture = structuredClone(v12); fixture.roles.runtime.measurement.rawBytes = runtimeBytes.length; fixture.roles.runtime.policy.maxRawBytes = runtimeBytes.length; writeFileSync(runtimePath, runtimeBytes); @@ -1572,7 +1600,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { // acceptedCeiling-закона. Чекер обязан отклонить его всё равно: // рост сверх принятого снапшота требует НОВОЙ версии бюджета, // а не правки текущей. - const ceiling = v11.roles.runtime.policy.maxRawBytes; + const ceiling = v12.roles.runtime.policy.maxRawBytes; value.roles.runtime.measurement.rawBytes = ceiling + 1; value.roles.runtime.policy.maxRawBytes = ceiling + 1; }], @@ -1646,8 +1674,8 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { coordinatedMutation.roles.runtime.measurement.rawBytes -= 1; coordinatedMutation.roles.runtime.policy.maxRawBytes -= 1; assert.throws( - () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v11), - /current v11 file SHA-256 mismatch/u, + () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v12), + /current v12 file SHA-256 mismatch/u, "coordinated artifact and document drift must still fail the default identity", ); } finally { diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index e85b9820..cb327754 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -18,10 +18,11 @@ const V7_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v7.js const V8_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v8.json"); const V9_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v9.json"); const V10_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v10.json"); +const V11_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v11.json"); export const DEFAULT_BUDGET = resolve( REPO_ROOT, - "packages/colors/bench/wasm-size-budget-v11.json", + "packages/colors/bench/wasm-size-budget-v12.json", ); export const V1_FILE_SHA256 = "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1"; @@ -47,9 +48,11 @@ export const V10_FILE_SHA256 = "6f3318c29c633860a146be5dcd29e4ce85a3a52296b9719b506aba16951a58e6"; export const V11_FILE_SHA256 = "fa11531ee390dd6dfdfadfadab99bbe8277f2b152b567951b17ef6093d42b1e4"; +export const V12_FILE_SHA256 = + "925452113b18b63137b9dae4786e3a8f7ba098eb47a2631a97107fbd52aa9a95"; const V1_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v1.json"; -const V10_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v10.json"; +const V11_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v11.json"; const V5_BUDGET_ID = "labcolors-wasm-roles-issue-296-c1-v5"; const V6_BUDGET_ID = "labcolors-wasm-roles-issue-296-c3-v6"; const V7_BUDGET_ID = "labcolors-wasm-roles-issue-307-c7a-v7"; @@ -57,6 +60,7 @@ const V8_BUDGET_ID = "labcolors-wasm-roles-pr-338-v8"; const V9_BUDGET_ID = "labcolors-wasm-roles-c4a-v9"; const V10_BUDGET_ID = "labcolors-wasm-roles-failure-admissibility-v10"; const V11_BUDGET_ID = "labcolors-wasm-runtime-c4cd-v11"; +const V12_BUDGET_ID = "labcolors-wasm-runtime-c5-theme-keys-v12"; const ROLE_ORDER = ["runtime"]; const ROLE_SPECS = { runtime: { @@ -64,13 +68,13 @@ const ROLE_SPECS = { command: "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", recipeSha256: V1_RECIPE_SHA256, - basis: "accepted-failure-admissibility-runtime-snapshot", - measurementSource: "github-actions-run-29578036842", - // Pinned Linux run 29578036842 измерил failure-admissibility head точно - // (+2545B над PR-338 снапшотом: wire-строки ролевых отказов + жёсткий - // страж реентерабельности кэша). Любой дальнейший рост требует НОВОЙ - // версии снапшота, не headroom здесь. - acceptedCeiling: 459241, + // Pinned Linux run 29609974767 измерил C5.1 head точно (+524B над v11: + // словарь клиентских ключей тем вместо fixed enum + отказ EmptyThemes на + // загрузке). Любой дальнейший рост требует НОВОЙ версии снапшота, + // не headroom здесь. + basis: "accepted-c5-theme-dictionary-snapshot", + measurementSource: "github-actions-run-29609974767", + acceptedCeiling: 459765, }, }; @@ -191,7 +195,11 @@ function verifyImmutableHistory() { if (v10?.schemaVersion !== 7 || v10?.budgetId !== V10_BUDGET_ID) { fail("immutable v10 budget identity drifted"); } - return { v1, v4, v5, v6, v7, v8, v9, v10 }; + const v11 = readImmutableJson(V11_PATH, V11_FILE_SHA256, "v11"); + if (v11?.schemaVersion !== 8 || v11?.budgetId !== V11_BUDGET_ID) { + fail("immutable v11 budget identity drifted"); + } + return { v1, v4, v5, v6, v7, v8, v9, v10, v11 }; } function validateBudgetValue(budget) { @@ -208,14 +216,14 @@ function validateBudgetValue(budget) { "budget", ); if (budget.schemaVersion !== 8) fail("supported schemaVersion is exactly 8"); - if (budget.budgetId !== V11_BUDGET_ID) fail(`budgetId must be ${V11_BUDGET_ID}`); + if (budget.budgetId !== V12_BUDGET_ID) fail(`budgetId must be ${V12_BUDGET_ID}`); exactKeys(budget.predecessor, ["path", "fileSha256"], "predecessor"); if ( - budget.predecessor.path !== V10_REPOSITORY_PATH || - budget.predecessor.fileSha256 !== V10_FILE_SHA256 + budget.predecessor.path !== V11_REPOSITORY_PATH || + budget.predecessor.fileSha256 !== V11_FILE_SHA256 ) { - fail("predecessor must bind the immutable v10 document"); + fail("predecessor must bind the immutable v11 document"); } exactKeys(budget.toolchainSource, ["path", "fileSha256"], "toolchainSource"); @@ -228,7 +236,7 @@ function validateBudgetValue(budget) { exactKeys(budget.buildRecipes, ROLE_ORDER, "buildRecipes"); exactKeys(budget.roles, ROLE_ORDER, "roles"); - const { v1, v10 } = verifyImmutableHistory(); + const { v1 } = verifyImmutableHistory(); for (const role of ROLE_ORDER) { const spec = ROLE_SPECS[role]; @@ -281,9 +289,6 @@ function validateBudgetValue(budget) { } } - if (budget.roles.runtime.policy.maxRawBytes > v10.roles.runtime.policy.maxRawBytes) { - fail("untouched runtime role must not exceed the immutable predecessor ceiling"); - } } export function parseBudgetDocument(bytes, budgetPath) { @@ -301,10 +306,10 @@ export function parseBudgetDocument(bytes, budgetPath) { validateBudgetValue(budget); if (resolve(budgetPath) === DEFAULT_BUDGET) { const actualFileSha256 = sha256(document); - if (actualFileSha256 !== V11_FILE_SHA256) { + if (actualFileSha256 !== V12_FILE_SHA256) { fail( - `current v11 file SHA-256 mismatch: ` + - `expected=${V11_FILE_SHA256} actual=${actualFileSha256}`, + `current v12 file SHA-256 mismatch: ` + + `expected=${V12_FILE_SHA256} actual=${actualFileSha256}`, ); } }