diff --git a/crates/labcolors-core/src/cleanliness.rs b/crates/labcolors-core/src/cleanliness.rs index 6394a117..ae8605df 100644 --- a/crates/labcolors-core/src/cleanliness.rs +++ b/crates/labcolors-core/src/cleanliness.rs @@ -416,6 +416,67 @@ pub enum Theme { 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 здесь — вторая копия карты в цветовом коде + /// расходилась бы тихо при добавлении темы. + 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 + } +} + /// Контекст просмотра для surround-aware оценки дефектов. /// /// Сочетает фон (hex-строка, задаёт яркость Yb) и тему (определяет surround). @@ -448,20 +509,10 @@ fn y_pct_from_hex(hex: &str) -> Result { /// /// Параметры surround — CIECAM16 Table 1 (Li et al. 2017). Ноль новых констант. fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingConditions { - match theme { - Theme::Light => crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct), - Theme::Dark => crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct), - Theme::LightIc => { - let mut vc = crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct); - vc.high_contrast = true; - vc - } - Theme::DarkIc => { - let mut vc = crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct); - vc.high_contrast = true; - vc - } - } + theme.vc_by( + || crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct), + || crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct), + ) } /// Surround-aware оценка грязи цвета в заданном контексте просмотра. diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 271dab30..75e4d0dd 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -993,10 +993,7 @@ impl RoleTable { /// against the live background. The value is a property of the contract, /// not of any one solve, so it is exposed alongside each resolved role. pub fn legal_floor(&self, role: Role) -> Option { - match self.spec(role) { - RoleSpec::Anchor(anchor) => anchor.conformance().min_ratio(), - _ => None, - } + self.spec(role).legal_floor() } /// Return a copy with `role`'s recipe replaced — every other role keeps its @@ -1739,6 +1736,20 @@ pub struct NamedRoleTable { chroma: RoleChroma, } +impl RoleSpec { + /// WCAG-пол этой спеки — свойство контракта, не резолва: текст/UI-якорь + /// несёт пол своего [`TextAnchor`] (AaText → 4.5, AaUi → 3.0), все + /// остальные формы (декоративные, dJ', лестница, альфа-аналог, zero) — + /// без легального пола. Одна семантика для обеих таблиц + /// ([`RoleTable::legal_floor`] и string-keyed границы). + pub fn legal_floor(&self) -> Option { + match self { + RoleSpec::Anchor(anchor) => anchor.conformance().min_ratio(), + _ => None, + } + } +} + impl NamedRoleTable { /// Build a named table from its `(name, recipe)` entries and an undertone /// policy. Names are the CSS contract downstream (`--lab-{name}`); this diff --git a/crates/labcolors-wasm/Cargo.toml b/crates/labcolors-wasm/Cargo.toml index b7c21418..21fa0904 100644 --- a/crates/labcolors-wasm/Cargo.toml +++ b/crates/labcolors-wasm/Cargo.toml @@ -21,6 +21,11 @@ js-sys = "0.3" # Derive-only: thiserror is a proc-macro with no runtime code, so it adds # nothing to the WASM bundle while giving matchable, well-described errors. thiserror = "2" +# Граница конфига: JSON живёт ТОЛЬКО в этом крейте (ядро — ноль +# runtime-зависимостей). Размер бандла отслеживается CI-шагом report bundle +# size (информационный до perf-bench). +serde = { version = "1", features = ["derive"] } +serde_json = "1" [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/crates/labcolors-wasm/src/cache.rs b/crates/labcolors-wasm/src/cache.rs index ae8362ba..358f8b05 100644 --- a/crates/labcolors-wasm/src/cache.rs +++ b/crates/labcolors-wasm/src/cache.rs @@ -100,6 +100,14 @@ impl ContractCache { value } + /// Очистить кэш целиком. Смена таблицы (загрузка конфига) обязана снести + /// прошлое пространство записей: одновременно в кэше живёт ровно ОДНО + /// пространство ключей, и корректность не опирается на вероятностную + /// уникальность отпечатка. + pub fn clear(&self) { + self.entries.borrow_mut().clear(); + } + /// Number of live entries — for tests and introspection. #[cfg(test)] pub fn len(&self) -> usize { diff --git a/crates/labcolors-wasm/src/config_dto.rs b/crates/labcolors-wasm/src/config_dto.rs new file mode 100644 index 00000000..281b54b2 --- /dev/null +++ b/crates/labcolors-wasm/src/config_dto.rs @@ -0,0 +1,567 @@ +//! Сериализуемое зеркало [`ThemeConfig`] — JSON-контракт границы WASM. +//! +//! Ядро намеренно не знает сериализации (ноль runtime-зависимостей); JSON живёт +//! только здесь, на границе. DTO повторяет структуру ядра 1:1 (snake_case поля, +//! enum-ы — tagged `{"kind": …}` и kebab-строки), конверсия — в обе стороны: +//! `TryFrom for ThemeConfig` (вход `load_config`) и +//! `TryFrom<&ThemeConfig> for ConfigDto` (сериализация эталонов в тестах и +//! пакете). Обе стороны честно падают на неизвестном варианте — ядро несёт +//! `#[non_exhaustive]`-меню, и молчаливый пропуск варианта был бы тихой потерей +//! роли. +//! +//! Отпечаток конфига ([`fingerprint`]) — FNV-1a 64 над канонической +//! JSON-сериализацией DTO: порядок полей структур фиксирован serde, поэтому +//! один и тот же конфиг даёт один и тот же отпечаток независимо от порядка +//! ключей и пробелов входного JSON. Отпечаток — компонент ключа контракт-кэша: +//! два разных конфига обязаны давать разные ключи (кэш-коллизия = чужие цвета). + +use labcolors_core::config::{ + Brand, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, + RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, VcPreset, +}; +use labcolors_core::solve::Floor; +use labcolors_core::{LadderPosition, ThemeAnchors}; +use serde::{Deserialize, Serialize}; + +/// Пер-темная четвёрка якорных hex. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnchorsDto { + pub light: String, + pub dark: String, + pub light_ic: String, + pub dark_ic: String, +} + +/// Тройка якорей нейтральной шкалы. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NeutralAnchorsDto { + pub light: String, + pub mid: String, + pub dark: String, +} + +/// Ручки нейтрального подтона. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NeutralTintDto { + pub ratio: f64, + pub target_mp: f64, + pub hue_stiffness: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hue_override_deg: Option, +} + +/// Нейтраль: якоря + подтон + опциональные пер-темные края. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NeutralDto { + pub anchors: NeutralAnchorsDto, + pub tint: NeutralTintDto, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub edge: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inverted: Option, +} + +/// Именованное семейство палитры. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FamilyDto { + pub key: String, + pub anchors: AnchorsDto, +} + +/// Одна сентимент-категория. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SentimentCategoryDto { + pub name: String, + pub family: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hue_floor_deg: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preferred_side: Option, +} + +/// Сентимент-политика. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SentimentsDto { + pub categories: Vec, + pub hardness: f64, + pub chroma_fraction: f64, +} + +/// VC-пресет закрытого меню. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum VcPresetDto { + Srgb, + Dim, + SrgbIc, + DimIc, +} + +/// Запись словаря тем. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ThemeEntryDto { + pub name: String, + pub preset: VcPresetDto, +} + +/// Источник тинта лестницы/альфа-аналога. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum LadderSourceDto { + Brand, + Family { key: String }, + Sentiment { name: String }, + Neutral { pick: NeutralPickDto }, +} + +/// Выбор нейтрального якоря. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum NeutralPickDto { + Mid, + Edge, + Inverted, + Light, + Dark, +} + +/// WCAG-пол текстового якоря. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum FloorDto { + AaText, + AaUi, + None, +} + +/// Рецепт роли (физическое меню ядра). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum RoleRecipeDto { + TextAnchor { + fraction: f64, + floor: FloorDto, + }, + DjAnchor { + light: f64, + dark: f64, + }, + DecorativeLc { + magnitude: f64, + }, + Ladder { + source: LadderSourceDto, + position: String, + }, + AlphaAnalog { + of: LadderSourceDto, + alpha: f64, + }, + Zero, +} + +/// Роль: имя + рецепт. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleDto { + pub name: String, + pub recipe: RoleRecipeDto, +} + +/// Компонентный алиас. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasDto { + pub alias: String, + pub target: String, +} + +/// Полный конфиг темы потребителя — JSON-форма [`ThemeConfig`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigDto { + pub brand: AnchorsDto, + pub neutral: NeutralDto, + pub palette: Vec, + pub sentiments: SentimentsDto, + pub themes: Vec, + pub roles: Vec, + #[serde(default)] + pub aliases: Vec, +} + +/// FNV-1a 64 над канонической JSON-сериализацией DTO — отпечаток конфига. +/// +/// Не криптографический: различение конфигов ВЕРОЯТНОСТНОЕ, поэтому оно не +/// несущая гарантия — корректность кэша держит очистка при загрузке (в кэше +/// одномоментно одно пространство ключей); отпечаток — идентичность конфига +/// наружу и belt-and-suspenders в ключе. Детерминизм даёт serde: порядок +/// полей структур фиксирован, вход нормализуется парсингом. +pub fn fingerprint(dto: &ConfigDto) -> u64 { + let bytes = serde_json::to_vec(dto).expect("DTO без не-сериализуемых типов"); + const OFFSET: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = OFFSET; + for b in bytes { + hash ^= u64::from(b); + hash = hash.wrapping_mul(PRIME); + } + hash +} + +// ───────────────────────────────────────────────────────────────────────────── +// DTO → ядро (вход load_config). +// ───────────────────────────────────────────────────────────────────────────── + +impl From for ThemeAnchors { + fn from(a: AnchorsDto) -> Self { + ThemeAnchors { + light: a.light, + dark: a.dark, + light_ic: a.light_ic, + dark_ic: a.dark_ic, + } + } +} + +impl From for VcPreset { + fn from(p: VcPresetDto) -> Self { + match p { + VcPresetDto::Srgb => VcPreset::Srgb, + VcPresetDto::Dim => VcPreset::Dim, + VcPresetDto::SrgbIc => VcPreset::SrgbIc, + VcPresetDto::DimIc => VcPreset::DimIc, + } + } +} + +impl From for NeutralPick { + fn from(p: NeutralPickDto) -> Self { + match p { + NeutralPickDto::Mid => NeutralPick::Mid, + NeutralPickDto::Edge => NeutralPick::Edge, + NeutralPickDto::Inverted => NeutralPick::Inverted, + NeutralPickDto::Light => NeutralPick::Light, + NeutralPickDto::Dark => NeutralPick::Dark, + } + } +} + +impl From for Floor { + fn from(f: FloorDto) -> Self { + match f { + FloorDto::AaText => Floor::AaText, + FloorDto::AaUi => Floor::AaUi, + FloorDto::None => Floor::None, + } + } +} + +impl From for LadderSource { + fn from(s: LadderSourceDto) -> Self { + match s { + LadderSourceDto::Brand => LadderSource::Brand, + LadderSourceDto::Family { key } => LadderSource::Family(key), + LadderSourceDto::Sentiment { name } => LadderSource::Sentiment(name), + LadderSourceDto::Neutral { pick } => LadderSource::Neutral(pick.into()), + } + } +} + +/// Позиция лестницы из стабильного kebab-ключа ([`LadderPosition::key`]). +fn position_from_key(key: &str) -> Result { + LadderPosition::ALL + .into_iter() + .find(|p| p.key() == key) + .ok_or_else(|| { + format!( + "неизвестная позиция лестницы `{key}` (меню: {})", + LadderPosition::ALL.map(|p| p.key()).join(", ") + ) + }) +} + +impl TryFrom for RoleRecipe { + type Error = String; + + fn try_from(r: RoleRecipeDto) -> Result { + Ok(match r { + RoleRecipeDto::TextAnchor { fraction, floor } => RoleRecipe::TextAnchor { + fraction, + floor: floor.into(), + }, + RoleRecipeDto::DjAnchor { light, dark } => RoleRecipe::DjAnchor { light, dark }, + RoleRecipeDto::DecorativeLc { magnitude } => RoleRecipe::DecorativeLc { magnitude }, + RoleRecipeDto::Ladder { source, position } => RoleRecipe::Ladder { + source: source.into(), + position: position_from_key(&position)?, + }, + RoleRecipeDto::AlphaAnalog { of, alpha } => RoleRecipe::AlphaAnalog { + of: of.into(), + alpha, + }, + RoleRecipeDto::Zero => RoleRecipe::Zero, + }) + } +} + +impl TryFrom for ThemeConfig { + type Error = String; + + fn try_from(dto: ConfigDto) -> Result { + let mut roles = Vec::with_capacity(dto.roles.len()); + for role in dto.roles { + roles.push((role.name, RoleRecipe::try_from(role.recipe)?)); + } + Ok(ThemeConfig { + brand: Brand { + anchors: dto.brand.into(), + }, + neutral: NeutralConfig { + anchors: NeutralAnchors { + light: dto.neutral.anchors.light, + mid: dto.neutral.anchors.mid, + dark: dto.neutral.anchors.dark, + }, + tint: NeutralTint { + ratio: dto.neutral.tint.ratio, + target_mp: dto.neutral.tint.target_mp, + hue_stiffness: dto.neutral.tint.hue_stiffness, + hue_override_deg: dto.neutral.tint.hue_override_deg, + }, + edge: dto.neutral.edge.map(Into::into), + inverted: dto.neutral.inverted.map(Into::into), + }, + palette: dto + .palette + .into_iter() + .map(|f| PaletteFamily { + key: f.key, + anchors: f.anchors.into(), + }) + .collect(), + sentiments: SentimentsConfig { + categories: dto + .sentiments + .categories + .into_iter() + .map(|c| SentimentCategory { + name: c.name, + family: c.family, + hue_floor_deg: c.hue_floor_deg, + preferred_side: c.preferred_side, + }) + .collect(), + hardness: dto.sentiments.hardness, + chroma_fraction: dto.sentiments.chroma_fraction, + }, + themes: ThemesConfig { + entries: dto + .themes + .into_iter() + .map(|t| (t.name, t.preset.into())) + .collect(), + }, + roles, + aliases: dto + .aliases + .into_iter() + .map(|a| (a.alias, a.target)) + .collect(), + }) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Ядро → DTO (сериализация эталонов; честный Err на неизвестном варианте). +// ───────────────────────────────────────────────────────────────────────────── + +impl From<&ThemeAnchors> for AnchorsDto { + fn from(a: &ThemeAnchors) -> Self { + AnchorsDto { + light: a.light.clone(), + dark: a.dark.clone(), + light_ic: a.light_ic.clone(), + dark_ic: a.dark_ic.clone(), + } + } +} + +impl TryFrom<&LadderSource> for LadderSourceDto { + type Error = String; + + fn try_from(s: &LadderSource) -> Result { + Ok(match s { + LadderSource::Brand => LadderSourceDto::Brand, + LadderSource::Family(key) => LadderSourceDto::Family { key: key.clone() }, + LadderSource::Sentiment(name) => LadderSourceDto::Sentiment { name: name.clone() }, + LadderSource::Neutral(pick) => LadderSourceDto::Neutral { + pick: match pick { + NeutralPick::Mid => NeutralPickDto::Mid, + NeutralPick::Edge => NeutralPickDto::Edge, + NeutralPick::Inverted => NeutralPickDto::Inverted, + NeutralPick::Light => NeutralPickDto::Light, + NeutralPick::Dark => NeutralPickDto::Dark, + other => return Err(format!("несериализуемый NeutralPick: {other:?}")), + }, + }, + other => return Err(format!("несериализуемый LadderSource: {other:?}")), + }) + } +} + +impl TryFrom<&RoleRecipe> for RoleRecipeDto { + type Error = String; + + fn try_from(r: &RoleRecipe) -> Result { + Ok(match r { + RoleRecipe::TextAnchor { fraction, floor } => RoleRecipeDto::TextAnchor { + fraction: *fraction, + floor: match floor { + Floor::AaText => FloorDto::AaText, + Floor::AaUi => FloorDto::AaUi, + Floor::None => FloorDto::None, + other => return Err(format!("несериализуемый Floor: {other:?}")), + }, + }, + RoleRecipe::DjAnchor { light, dark } => RoleRecipeDto::DjAnchor { + light: *light, + dark: *dark, + }, + RoleRecipe::DecorativeLc { magnitude } => RoleRecipeDto::DecorativeLc { + magnitude: *magnitude, + }, + RoleRecipe::Ladder { source, position } => RoleRecipeDto::Ladder { + source: source.try_into()?, + position: position.key().to_string(), + }, + RoleRecipe::AlphaAnalog { of, alpha } => RoleRecipeDto::AlphaAnalog { + of: of.try_into()?, + alpha: *alpha, + }, + RoleRecipe::Zero => RoleRecipeDto::Zero, + other => return Err(format!("несериализуемый RoleRecipe: {other:?}")), + }) + } +} + +impl TryFrom<&ThemeConfig> for ConfigDto { + type Error = String; + + fn try_from(cfg: &ThemeConfig) -> Result { + let mut roles = Vec::with_capacity(cfg.roles.len()); + for (name, recipe) in &cfg.roles { + roles.push(RoleDto { + name: name.clone(), + recipe: recipe.try_into()?, + }); + } + Ok(ConfigDto { + brand: (&cfg.brand.anchors).into(), + neutral: NeutralDto { + anchors: NeutralAnchorsDto { + light: cfg.neutral.anchors.light.clone(), + mid: cfg.neutral.anchors.mid.clone(), + dark: cfg.neutral.anchors.dark.clone(), + }, + tint: NeutralTintDto { + ratio: cfg.neutral.tint.ratio, + target_mp: cfg.neutral.tint.target_mp, + hue_stiffness: cfg.neutral.tint.hue_stiffness, + hue_override_deg: cfg.neutral.tint.hue_override_deg, + }, + edge: cfg.neutral.edge.as_ref().map(Into::into), + inverted: cfg.neutral.inverted.as_ref().map(Into::into), + }, + palette: cfg + .palette + .iter() + .map(|f| FamilyDto { + key: f.key.clone(), + anchors: (&f.anchors).into(), + }) + .collect(), + sentiments: SentimentsDto { + categories: cfg + .sentiments + .categories + .iter() + .map(|c| SentimentCategoryDto { + name: c.name.clone(), + family: c.family.clone(), + hue_floor_deg: c.hue_floor_deg, + preferred_side: c.preferred_side, + }) + .collect(), + hardness: cfg.sentiments.hardness, + chroma_fraction: cfg.sentiments.chroma_fraction, + }, + themes: cfg + .themes + .entries + .iter() + .map(|(name, preset)| ThemeEntryDto { + name: name.clone(), + preset: match preset { + VcPreset::Srgb => VcPresetDto::Srgb, + VcPreset::Dim => VcPresetDto::Dim, + VcPreset::SrgbIc => VcPresetDto::SrgbIc, + VcPreset::DimIc => VcPresetDto::DimIc, + }, + }) + .collect(), + roles, + aliases: cfg + .aliases + .iter() + .map(|(alias, target)| AliasDto { + alias: alias.clone(), + target: target.clone(), + }) + .collect(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use labcolors_core::config::labui_reference; + + /// Канонический конфиг гоняется через JSON туда-обратно без потерь: + /// ядро → DTO → JSON → DTO → ядро даёт РАВНЫЙ конфиг (PartialEq ядра). + #[test] + fn labui_reference_round_trips_through_json() { + let cfg = labui_reference(); + let dto = ConfigDto::try_from(&cfg).expect("эталон сериализуем"); + let json = serde_json::to_string(&dto).expect("JSON"); + let back: ConfigDto = serde_json::from_str(&json).expect("парсится"); + let restored = ThemeConfig::try_from(back).expect("конвертируется"); + assert_eq!(cfg, restored, "JSON-путь без потерь"); + restored + .compile_named_role_table() + .expect("восстановленный конфиг компилируется"); + } + + /// Отпечаток: детерминирован для одного конфига (включая нормализацию + /// пробелов/порядка через парсинг) и различает разные конфиги. + #[test] + fn fingerprint_is_deterministic_and_discriminating() { + let cfg = labui_reference(); + let dto = ConfigDto::try_from(&cfg).unwrap(); + let fp1 = fingerprint(&dto); + // Реконструкция из JSON — тот же отпечаток. + let json = serde_json::to_string_pretty(&dto).unwrap(); + let re: ConfigDto = serde_json::from_str(&json).unwrap(); + assert_eq!(fp1, fingerprint(&re), "детерминизм через JSON-нормализацию"); + + // Минимальная мутация (один якорь бренда) — другой отпечаток. + let mut other = ConfigDto::try_from(&cfg).unwrap(); + other.brand.light = "#007AFE".to_string(); + assert_ne!(fp1, fingerprint(&other), "разные конфиги различимы"); + } + + /// Неизвестная позиция лестницы — честная ошибка с перечнем меню. + #[test] + fn unknown_ladder_position_is_rejected_with_menu() { + let err = position_from_key("label-quinary").unwrap_err(); + assert!(err.contains("label-quinary") && err.contains("label-primary")); + } +} diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index 6508edac..e05f1043 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -26,8 +26,10 @@ pub struct ResolvedTheme { /// One role's outcome, keyed by its stable role key. #[derive(Debug, Clone, PartialEq)] pub struct RoleEntry { - /// The stable role key from `Role::key()` — the CSS-variable stem. - pub role_key: &'static str, + /// The stable role key — the CSS-variable stem. Built-in roles use + /// `Role::key()`; config-defined roles carry the config's own name + /// (the string-keyed contract), so the key is owned. + pub role_key: String, /// What the role resolved to. pub outcome: RoleOutcome, } @@ -40,6 +42,10 @@ pub enum RoleOutcome { Color(SolvedColor), /// The explicit zero token (`Role::None`): no colour here, by design. None, + /// A semi-transparent ladder / alpha-analog role: the emission is + /// `rgba(tint, alpha)` and the browser composites it; the measured + /// contrasts are those of the composite on the resolve background. + Rgba(RgbaColor), /// No colour can satisfy this role on this background, with the reason. Unreachable { /// A stable machine code for the unreachability reason. @@ -49,6 +55,21 @@ pub enum RoleOutcome { }, } +/// A semi-transparent emission and the contrasts its composite achieves. +#[derive(Debug, Clone, PartialEq)] +pub struct RgbaColor { + /// The tint as `#RRGGBB` — the colour the CSS `rgba()` carries. + pub tint_hex: String, + /// The alpha the emission carries, `(0, 1]`. + pub alpha: f64, + /// The solid the tint composites to on the resolve background. + pub composite_hex: String, + /// The signed perceptual contrast `Lc` of the composite. + pub composite_lc: f64, + /// The WCAG 2.1 ratio of the composite. + pub composite_wcag: f64, +} + /// A resolved colour and the contrasts it actually achieves. #[derive(Debug, Clone, PartialEq)] pub struct SolvedColor { diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index eab856f8..df0d30b9 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -2,17 +2,24 @@ //! generically over whatever role set the core provides. //! //! This layer knows the core and the DTOs; it does NOT know wasm-bindgen. It -//! holds the role table and the contract cache, runs `resolve_set`, and maps -//! the core's `Vec<(Role, Resolved)>` into [`ResolvedTheme`]. The mapping never -//! enumerates roles — it walks the vector the core returns and keys each entry -//! by `Role::key()` — so issue #59's role growth flows through on a rebuild. +//! holds the role table (built-in, or the compiled config table after +//! `load_config`) and the contract cache, runs the core resolve, and maps the +//! resolved vector into [`ResolvedTheme`]. The mapping never enumerates roles — +//! it walks whatever the core returns and keys each entry by its stable key +//! (`Role::key()` built-in, the config's own names after load) — so role +//! growth flows through on a rebuild. use std::rc::Rc; +use std::collections::HashMap; + +use labcolors_core::config::ThemeConfig; +use labcolors_core::semantic::NamedRoleTable; use labcolors_core::{BgInput, Resolved, RoleTable, Solved, Unreachable}; use crate::cache::{CacheKey, ContractCache, DEFAULT_TABLE_FINGERPRINT}; -use crate::dto::{ResolvedTheme, RoleEntry, RoleOutcome, SolvedColor}; +use crate::config_dto::{ConfigDto, fingerprint}; +use crate::dto::{ResolvedTheme, RgbaColor, RoleEntry, RoleOutcome, SolvedColor}; use crate::error::BindingError; use crate::theme::Theme; @@ -30,9 +37,20 @@ const CACHE_CAPACITY: usize = 4096; pub struct Engine { table: RoleTable, table_fingerprint: u64, + named: Option, cache: ContractCache>, } +/// Загруженный конфиг потребителя: скомпилированная таблица + её отпечаток +/// (компонент ключа кэша — два конфига не делят записи) + полы ролей, +/// предвычисленные на загрузке (свойство контракта, не резолва; алиас несёт +/// пол своей цели). +struct NamedState { + table: NamedRoleTable, + fingerprint: u64, + floors: HashMap>, +} + impl Default for Engine { fn default() -> Self { Self::new() @@ -45,10 +63,55 @@ impl Engine { Self { table: RoleTable::default(), table_fingerprint: DEFAULT_TABLE_FINGERPRINT, + named: None, cache: ContractCache::new(CACHE_CAPACITY), } } + /// Загрузить конфиг потребителя из JSON: полный preflight ядра + /// (validate = компиляция) + вычисленный отпечаток. После успешной + /// загрузки [`resolve_theme`](Self::resolve_theme) эмитит РОЛИ КОНФИГА + /// (string-keyed контракт) той же физикой; сигнатура resolve_theme + /// неизменна. Возвращает отпечаток — компонент ключа кэша: другой конфиг + /// даёт другой отпечаток, записи не делятся (нет кэш-коллизии). + /// + /// Ошибочный конфиг НЕ трогает текущее состояние: движок остаётся на + /// прежней таблице (загрузка атомарна). + pub fn load_config(&mut self, json: &str) -> Result { + let dto: ConfigDto = + serde_json::from_str(json).map_err(|e| BindingError::InvalidConfig { + reason: e.to_string(), + })?; + let fp = fingerprint(&dto); + let cfg = + ThemeConfig::try_from(dto).map_err(|reason| BindingError::InvalidConfig { reason })?; + let table = cfg + .compile_named_role_table() + .map_err(|e| BindingError::InvalidConfig { + reason: e.to_string(), + })?; + let mut floors: HashMap> = table + .entries() + .iter() + .map(|(name, spec)| (name.clone(), spec.legal_floor())) + .collect(); + for (alias, target) in table.aliases() { + let floor = floors.get(target).copied().flatten(); + floors.insert(alias.clone(), floor); + } + // Прошлое пространство записей сносится целиком: гарантия «чужой + // конфиг не отдаст свои цвета» — очистка, а не вероятностная + // уникальность 64-битного отпечатка (отпечаток в ключе остаётся + // belt-and-suspenders и идентичностью конфига наружу). + self.cache.clear(); + self.named = Some(NamedState { + table, + fingerprint: fp, + floors, + }); + Ok(fp) + } + /// Resolve every role for `bg_hex` under `theme`, returning the shared /// result. Repeated identical calls hit the contract cache. /// @@ -59,7 +122,7 @@ impl Engine { bg_hex: &str, theme: Theme, ) -> Result, BindingError> { - let vc = theme.viewing_conditions()?; + 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)?; @@ -67,13 +130,50 @@ impl Engine { reason: u.to_string(), })?; + // Конфиг загружен → эмитится ЕГО контракт (string-keyed) той же + // физикой; отпечаток в ключе разводит кэш-пространства конфигов. + if let Some(named) = &self.named { + let key = CacheKey::new(normalised.clone(), theme, named.fingerprint); + let result = self.cache.get_or_insert_with(key, || { + let set = labcolors_core::resolve_named_set(&bg, &named.table, &vc); + let mut roles: Vec = set + .into_iter() + .map(|(name, resolved)| { + let floor = named.floors.get(&name).copied().flatten(); + RoleEntry { + role_key: name, + outcome: map_resolved(resolved, floor), + } + }) + .collect(); + // Алиасы — часть эмитируемого контракта (--lab-{alias} обязан + // существовать у потребителя): ядро их не резолвит (алиас — не + // рецепт), граница эмитит исход ЦЕЛИ под именем алиаса. + for (alias, target) in named.table.aliases() { + if let Some(entry) = roles.iter().find(|e| &e.role_key == target) { + let outcome = entry.outcome.clone(); + roles.push(RoleEntry { + role_key: alias.clone(), + outcome, + }); + } + } + Rc::new(ResolvedTheme { + theme: theme.key(), + background: normalised.clone(), + roles, + }) + }); + return Ok(result); + } + let key = CacheKey::new(normalised.clone(), theme, self.table_fingerprint); let result = self.cache.get_or_insert_with(key, || { let set = labcolors_core::resolve_set(&bg, &self.table, &vc); let roles = set .into_iter() .map(|(role, resolved)| RoleEntry { - role_key: role.key(), + role_key: role.key().to_string(), outcome: map_resolved(resolved, self.table.legal_floor(role)), }) .collect(); @@ -102,7 +202,7 @@ impl Engine { fg_hexes: &[String], theme: Theme, ) -> Result, BindingError> { - let vc = theme.viewing_conditions()?; + let vc = theme.viewing_conditions(); let bg = normalise_hex(bg_hex)?; // Normalise foregrounds through the same parser as the background and // `resolveTheme`, so the three entry points agree on what a valid hex is @@ -136,18 +236,16 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { code: unreachable_code(&reason), message: reason.to_string(), }, - // Полупрозрачные роли лестницы/альфа-аналога появляются только на - // конфиг-пути (`resolve_named_set`), который ЭТА поверхность ещё не - // экспортирует: `resolve_theme` идёт по встроенной `RoleTable`, где - // Ladder/AlphaAnalog-рецептов нет, поэтому вариант здесь недостижим. - // rgba-форма границы WASM ещё не экспортирована; до неё маппим в стабильный код, - // а не молчаливо роняем неверный цвет (`Resolved` теперь non_exhaustive). - Resolved::Rgba(_) => RoleOutcome::Unreachable { - code: "rgba_boundary_not_yet_exported", - message: "semi-transparent ladder/alpha-analog role is not exported by resolve_theme \ - (solid-only surface)" - .to_string(), - }, + // Полупрозрачная эмиссия лестницы/альфа-аналога (конфиг-путь): + // наружу уходит rgba(tint, α), браузер композитит; контраст — свойство + // композита на фоне резолва (закон лестницы ядра). + Resolved::Rgba(rgba) => RoleOutcome::Rgba(RgbaColor { + tint_hex: rgba.tint_hex().to_string(), + alpha: rgba.alpha(), + composite_hex: rgba.composite_hex().to_string(), + composite_lc: rgba.composite_lc(), + composite_wcag: rgba.composite_wcag(), + }), // ОСОЗНАННЫЙ ДОЛГ: `Resolved` — `#[non_exhaustive]`, поэтому catch-all // обязателен для будущих вариантов ядра. Пока маппит в стабильный код, // а не молча роняет неверный цвет; при экспорте rgba-границы каждый @@ -252,7 +350,7 @@ mod tests { // Generic over the role set: at least the v1 roles are present, each // keyed by Role::key(). We assert the keys exist, not their count, so // issue #59's growth does not break this test. - let keys: Vec<_> = result.roles.iter().map(|r| r.role_key).collect(); + let keys: Vec<_> = result.roles.iter().map(|r| r.role_key.as_str()).collect(); assert!(keys.contains(&"label-primary")); assert!(keys.contains(&"none")); } @@ -435,11 +533,7 @@ mod tests { #[test] fn ic_theme_resolves_without_error() { let engine = Engine::new(); - assert!( - engine - .resolve_theme("#FFFFFF", Theme::LightIncreasedContrast) - .is_ok() - ); + assert!(engine.resolve_theme("#FFFFFF", Theme::LightIc).is_ok()); } #[test] @@ -456,8 +550,8 @@ mod tests { ("#000000", Theme::Dark), ("#808080", Theme::Light), // Increased-contrast variants: same 20-role contract must hold. - ("#FFFFFF", Theme::LightIncreasedContrast), - ("#000000", Theme::DarkIncreasedContrast), + ("#FFFFFF", Theme::LightIc), + ("#000000", Theme::DarkIc), ]; for (bg, theme) in reps { let result = engine.resolve_theme(bg, theme).unwrap(); @@ -469,7 +563,7 @@ mod tests { let mut seen = std::collections::HashSet::new(); for entry in &result.roles { assert!( - seen.insert(entry.role_key), + seen.insert(entry.role_key.as_str()), "{bg}: duplicate role_key {}", entry.role_key ); @@ -480,6 +574,18 @@ mod tests { entry.role_key ); match &entry.outcome { + RoleOutcome::Rgba(r) => { + assert!( + r.tint_hex.starts_with('#') && r.composite_hex.starts_with('#'), + "{bg} {}: rgba-эмиссия несёт hex-тинт и hex-композит", + entry.role_key + ); + assert!( + r.alpha > 0.0 && r.alpha <= 1.0, + "{bg} {}: α в (0,1]", + entry.role_key + ); + } RoleOutcome::Color(c) => { assert!( c.hex.starts_with('#'), @@ -501,4 +607,215 @@ mod tests { assert_eq!(seen.len(), 20, "{bg}: all 20 role keys must be unique"); } } + + /// JSON канонического labui-конфига — через сериализуемое зеркало границы. + fn labui_json() -> String { + let dto = + crate::config_dto::ConfigDto::try_from(&labcolors_core::config::labui_reference()) + .expect("эталон сериализуем"); + serde_json::to_string(&dto).expect("JSON") + } + + /// Минимальный конфиг второго клиента: другой бренд, своё пространство имён. + fn acme_json() -> String { + r##"{ + "brand": {"light": "#7C3AED", "dark": "#8B5CF6", "light_ic": "#5B21B6", "dark_ic": "#A78BFA"}, + "neutral": { + "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"}, + "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} + }, + "palette": [], + "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "themes": [{"name": "light", "preset": "srgb"}, {"name": "dark", "preset": "dim"}], + "roles": [ + {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, + {"name": "body-text", "recipe": {"kind": "text-anchor", "fraction": 0.62, "floor": "aa-text"}} + ], + "aliases": [{"alias": "btn-label", "target": "body-text"}] + }"## + .to_string() + } + + /// Загрузка конфига переключает контракт на string-keyed, отпечатки разных + /// конфигов различны, и кэш не отдаёт чужие записи на одинаковом (bg, тема). + #[test] + fn load_config_switches_contract_and_separates_cache_spaces() { + let mut engine = Engine::new(); + let before = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + assert!( + before + .roles + .iter() + .all(|r| r.role_key != "fill-brand-primary"), + "встроенный контракт не несёт ролей конфига" + ); + + let fp_labui = engine.load_config(&labui_json()).expect("labui валиден"); + let labui_set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + assert!( + labui_set + .roles + .iter() + .any(|r| r.role_key == "fill-brand-primary" + && matches!(r.outcome, RoleOutcome::Rgba(_))), + "конфиг-контракт несёт rgba-роль лестницы" + ); + + let fp_acme = engine.load_config(&acme_json()).expect("acme валиден"); + assert_ne!(fp_labui, fp_acme, "разные конфиги → разные отпечатки"); + assert_eq!( + engine.cache.len(), + 0, + "загрузка конфига сносит прошлое пространство записей целиком — корректность кэша не опирается на вероятностную уникальность отпечатка" + ); + // Тот же (bg, тема) СРАЗУ после смены конфига: попадание в чужую запись + // было бы кэш-коллизией — пространство ключей обязано быть acme. + let acme_set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + assert!(acme_set.roles.iter().any(|r| r.role_key == "accent-fill")); + assert!( + acme_set + .roles + .iter() + .all(|r| r.role_key != "fill-brand-primary"), + "кэш-коллизия: под ключом acme отдан labui-контракт" + ); + // Алиас наследует пол цели и через named-путь (btn-label → body-text, + // aa-text → 4.5) — вторая половина класса «потерянный legal_floor». + let alias_entry = acme_set + .roles + .iter() + .find(|r| r.role_key == "btn-label") + .expect("алиас в контракте acme"); + match &alias_entry.outcome { + RoleOutcome::Color(c) => assert_eq!( + c.legal_floor, + Some(4.5), + "алиас несёт AA-пол своей цели через named-путь" + ), + other => panic!("btn-label ожидался цветом, получено {other:?}"), + } + } + + /// Паритет: загруженный конфиг эмитит байт-в-байт то же, что прямой + /// resolve_named_set той же таблицы (граница ничего не подменяет). + #[test] + 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 table = labcolors_core::config::labui_reference() + .compile_named_role_table() + .unwrap(); + let bg = labcolors_core::BgInput::solid("#101012").unwrap(); + let direct = + labcolors_core::resolve_named_set(&bg, &table, &Theme::Dark.viewing_conditions()); + + assert_eq!( + via_engine.roles.len(), + direct.len() + table.aliases().len(), + "полный контракт: роли ядра + алиасы границы" + ); + // Оракул пола: та же семантика, что у загрузки — спека роли, алиас + // наследует пол цели. Мутация, теряющая пол на named-пути, обязана + // падать ЗДЕСЬ (выживший мутант map_resolved(_, None) — дыра ЗАКРЫТА). + let mut expected_floor: std::collections::HashMap<&str, Option> = table + .entries() + .iter() + .map(|(n, spec)| (n.as_str(), spec.legal_floor())) + .collect(); + for (alias, target) in table.aliases() { + let floor = expected_floor.get(target.as_str()).copied().flatten(); + expected_floor.insert(alias.as_str(), floor); + } + let mut anchored_seen = 0usize; + for ((name, resolved), entry) in direct.iter().zip(via_engine.roles.iter()) { + assert_eq!(name, &entry.role_key, "порядок и имена совпадают"); + if let RoleOutcome::Color(c) = &entry.outcome { + let want = expected_floor.get(name.as_str()).copied().flatten(); + assert_eq!(c.legal_floor, want, "{name}: legal_floor конфиг-роли"); + if want.is_some() { + anchored_seen += 1; + } + } + match (resolved, &entry.outcome) { + (Resolved::Color { solved, compressed }, RoleOutcome::Color(c)) => { + assert_eq!(solved.hex(), c.hex, "{name}: hex"); + assert_eq!(solved.lc(), c.lc, "{name}: lc"); + assert_eq!(solved.wcag_ratio(), c.wcag_ratio, "{name}: wcag"); + assert_eq!(*compressed, c.compressed, "{name}: compressed"); + assert_eq!( + solved.floor_override(), + c.floor_override, + "{name}: floor_override" + ); + } + (Resolved::Rgba(r), RoleOutcome::Rgba(o)) => { + assert_eq!(r.tint_hex(), o.tint_hex, "{name}: tint"); + assert_eq!(r.alpha(), o.alpha, "{name}: alpha"); + assert_eq!(r.composite_hex(), o.composite_hex, "{name}: composite"); + assert_eq!(r.composite_lc(), o.composite_lc, "{name}: composite_lc"); + assert_eq!( + r.composite_wcag(), + o.composite_wcag, + "{name}: composite_wcag" + ); + } + (Resolved::None, RoleOutcome::None) => {} + (a, b) => panic!("расхождение форм {name}: ядро {a:?} vs граница {b:?}"), + } + } + assert!( + anchored_seen > 0, + "оракул пола вакуумный: ни одной конфиг-роли с ненулевым полом" + ); + let label = via_engine + .roles + .iter() + .find(|r| r.role_key == "label-primary") + .expect("label-primary в контракте"); + match &label.outcome { + RoleOutcome::Color(c) => assert_eq!( + c.legal_floor, + Some(4.5), + "AA-пол текстового якоря доходит до границы через named-путь" + ), + other => panic!("label-primary ожидался цветом, получено {other:?}"), + } + } + + /// Невалидный конфиг отклоняется и НЕ меняет состояние (атомарность). + #[test] + fn invalid_config_is_rejected_atomically() { + let mut engine = Engine::new(); + engine.load_config(&acme_json()).unwrap(); + + assert!(matches!( + engine.load_config("{ не json"), + Err(BindingError::InvalidConfig { .. }) + )); + let bad_position = acme_json().replace("fill-primary", "fill-quinary"); + match engine.load_config(&bad_position) { + Err(BindingError::InvalidConfig { reason }) => { + assert!(reason.contains("fill-quinary"), "ошибка называет позицию"); + } + other => panic!("ждали InvalidConfig, получено {other:?}"), + } + // Недоменная α альфа-аналога режется полным preflight-ом ядра + // (validate = компиляция), а не отдельной проверкой границы. + let bad_alpha = acme_json().replace( + r#"{"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}"#, + r#"{"kind": "alpha-analog", "of": {"kind": "brand"}, "alpha": 1.5}"#, + ); + match engine.load_config(&bad_alpha) { + Err(BindingError::InvalidConfig { reason }) => { + assert!(reason.contains("alpha"), "ошибка называет ручку: {reason}"); + } + other => panic!("α=1.5 обязана быть отвергнута, получено {other:?}"), + } + + // Состояние прежнее: контракт acme жив. + let set = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + assert!(set.roles.iter().any(|r| r.role_key == "accent-fill")); + } } diff --git a/crates/labcolors-wasm/src/error.rs b/crates/labcolors-wasm/src/error.rs index b43f26e0..c2ea7047 100644 --- a/crates/labcolors-wasm/src/error.rs +++ b/crates/labcolors-wasm/src/error.rs @@ -25,6 +25,14 @@ pub enum BindingError { reason: String, }, + /// The config JSON was rejected: parse error, unknown menu item, or a + /// core validation/compile error (the full preflight message is carried). + #[error("invalid config: {reason}")] + InvalidConfig { + /// The parse/validation reason, verbatim. + reason: String, + }, + /// The theme string is not one of the public spellings. #[error("unknown theme: '{requested}' (expected light | dark | light-ic | dark-ic)")] UnknownTheme { @@ -39,6 +47,7 @@ impl BindingError { pub fn code(&self) -> &'static str { match self { BindingError::InvalidBackground { .. } => "invalid_background", + BindingError::InvalidConfig { .. } => "invalid_config", BindingError::UnknownTheme { .. } => "unknown_theme", } } @@ -52,12 +61,16 @@ mod tests { fn codes_are_stable_and_distinct() { let errors = [ BindingError::InvalidBackground { reason: "x".into() }, + BindingError::InvalidConfig { reason: "x".into() }, BindingError::UnknownTheme { requested: "x".into(), }, ]; let codes: Vec<_> = errors.iter().map(BindingError::code).collect(); - assert_eq!(codes, ["invalid_background", "unknown_theme"]); + assert_eq!( + codes, + ["invalid_background", "invalid_config", "unknown_theme"] + ); // Distinctness, asserted directly so the test earns its name: a future // variant must not reuse an existing code. let unique: std::collections::HashSet<_> = codes.iter().collect(); diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 4178d28c..26e34c60 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -15,6 +15,7 @@ //! a vanilla helper for that lives in the npm package, not in the WASM core. mod cache; +mod config_dto; mod dto; mod engine; mod error; @@ -25,7 +26,6 @@ use wasm_bindgen::prelude::*; use crate::dto::{ResolvedTheme, RoleOutcome}; use crate::engine::Engine; use crate::error::BindingError; -use crate::theme::Theme; /// TypeScript shapes for the values `resolveTheme` returns. wasm-bindgen emits /// `LabColors.resolveTheme(...): ResolvedTheme` against these, so consumers get @@ -75,13 +75,89 @@ export interface UnreachableRole { readonly message: string; } -export type RoleResult = SolvedColor | NoneRole | UnreachableRole; +/** A semi-transparent ladder / alpha-analog emission: the CSS carries rgba(), the browser composites it. */ +export interface RgbaRole { + readonly kind: "rgba"; + readonly cssVar: string; + /** The tint as #RRGGBB — the colour the rgba() carries. */ + readonly tintHex: string; + /** The alpha of the emission, (0, 1]. */ + readonly alpha: number; + /** The solid the tint composites to on the resolve background. */ + readonly compositeHex: string; + /** Signed perceptual contrast (Lc) of the composite. */ + readonly compositeLc: number; + /** WCAG 2.1 ratio of the composite. */ + readonly compositeWcag: number; + /** Ready-to-serve CSS value: "rgb(R G B / A)". `vars` carries the same string. */ + readonly css: string; +} + +export type RoleResult = SolvedColor | RgbaRole | NoneRole | UnreachableRole; + +/** Пер-темная четвёрка якорных hex (light / dark / light-ic / dark-ic). */ +export interface ThemeAnchors { + readonly light: string; + readonly dark: string; + readonly light_ic: string; + readonly dark_ic: string; +} + +/** Источник тинта лестницы/альфа-аналога. */ +export type LadderSource = + | { kind: "brand" } + | { kind: "family"; key: string } + | { kind: "sentiment"; name: string } + | { kind: "neutral"; pick: "mid" | "edge" | "inverted" | "light" | "dark" }; + +/** Рецепт роли из физического меню движка. */ +export type RoleRecipe = + | { kind: "text-anchor"; fraction: number; floor: "aa-text" | "aa-ui" | "none" } + | { kind: "dj-anchor"; light: number; dark: number } + | { kind: "decorative-lc"; magnitude: number } + | { kind: "ladder"; source: LadderSource; position: string } + | { kind: "alpha-analog"; of: LadderSource; alpha: number } + | { kind: "zero" }; + +/** Полный конфиг дизайн-системы клиента — вход loadConfig (JSON.stringify(config)). */ +export interface ThemeConfig { + readonly brand: ThemeAnchors; + readonly neutral: { + readonly anchors: { light: string; mid: string; dark: string }; + readonly tint: { + ratio: number; + target_mp: number; + hue_stiffness: number; + hue_override_deg?: number; + }; + readonly edge?: ThemeAnchors; + readonly inverted?: ThemeAnchors; + }; + readonly palette: ReadonlyArray<{ key: string; anchors: ThemeAnchors }>; + readonly sentiments: { + readonly categories: ReadonlyArray<{ + name: string; + family: string; + hue_floor_deg?: number; + preferred_side?: -1 | 1; + }>; + readonly hardness: number; + readonly chroma_fraction: number; + }; + readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>; + readonly roles: ReadonlyArray<{ name: string; recipe: RoleRecipe }>; + readonly aliases?: ReadonlyArray<{ alias: string; target: string }>; +} /** The full result of resolving one background under one theme. */ export interface ResolvedTheme { readonly theme: ThemeName; readonly background: string; - /** Reachable roles only: { "--lab-label-primary": "#1a1a1a", ... }. */ + /** + * Reachable roles only. Values are ready-to-serve CSS: "#RRGGBB" for solid + * roles, "rgb(R G B / A)" for semi-transparent ladder/alpha-analog roles — + * do not validate them as hex. + */ readonly vars: Record; /** Every role, keyed by its stable role key (without the --lab- prefix). */ readonly roles: Record; @@ -126,7 +202,7 @@ impl LabColors { /// structured `{ code, message }` error, never an unwound panic. #[wasm_bindgen(js_name = resolveTheme)] pub fn resolve_theme(&self, bg_hex: &str, theme: &str) -> Result { - let theme = Theme::parse(theme).map_err(to_js_error)?; + let theme = crate::theme::parse_theme(theme).map_err(to_js_error)?; let resolved = self .inner .resolve_theme(bg_hex, theme) @@ -134,6 +210,19 @@ impl LabColors { Ok(project_resolved(&resolved).unchecked_into()) } + /// Загрузить конфиг дизайн-системы (JSON по типу `ThemeConfig` из `.d.ts`). + /// + /// Полный preflight движка: невалидный конфиг отклоняется структурной + /// ошибкой `invalid_config: …` и НЕ меняет состояние. После успешной + /// загрузки `resolveTheme` эмитит роли конфига (включая полупрозрачные + /// `rgba`-роли лестницы). Возвращает отпечаток конфига — 16 hex-символов; + /// разные конфиги дают разные отпечатки (и разные кэш-пространства). + #[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)?; + Ok(format!("{fp:016x}")) + } + /// Recheck the contrasts `fgHexes` achieve against `bgHex` under `theme` — /// the cheap per-frame primitive a reactive runtime uses to decide whether /// already-resolved colours still pass against a changed background (re-solve @@ -151,7 +240,7 @@ impl LabColors { fg_hexes: Vec, theme: &str, ) -> Result, JsError> { - let theme = Theme::parse(theme).map_err(to_js_error)?; + 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) @@ -216,6 +305,26 @@ fn project_resolved(resolved: &ResolvedTheme) -> JsValue { ); set(&vars, &css_var, &JsValue::from_str(&c.hex)); } + RoleOutcome::Rgba(r) => { + set(&role_obj, "kind", &JsValue::from_str("rgba")); + set(&role_obj, "tintHex", &JsValue::from_str(&r.tint_hex)); + set(&role_obj, "alpha", &JsValue::from_f64(r.alpha)); + set( + &role_obj, + "compositeHex", + &JsValue::from_str(&r.composite_hex), + ); + set(&role_obj, "compositeLc", &JsValue::from_f64(r.composite_lc)); + set( + &role_obj, + "compositeWcag", + &JsValue::from_f64(r.composite_wcag), + ); + // Эмиссия — rgba(): переменная несёт то, что скомпозитит браузер. + let css = rgba_css(&r.tint_hex, r.alpha); + set(&role_obj, "css", &JsValue::from_str(&css)); + set(&vars, &css_var, &JsValue::from_str(&css)); + } RoleOutcome::None => { set(&role_obj, "kind", &JsValue::from_str("none")); } @@ -225,13 +334,21 @@ fn project_resolved(resolved: &ResolvedTheme) -> JsValue { set(&role_obj, "message", &JsValue::from_str(message)); } } - set(&roles, entry.role_key, &role_obj); + set(&roles, &entry.role_key, &role_obj); } set(&out, "vars", &vars); set(&out, "roles", &roles); out.into() } +/// CSS-эмиссия полупрозрачной роли: современный синтаксис `rgb(R G B / A)` — +/// тот же формат, что стаб labui; браузер композитит на живой подложке. +fn rgba_css(tint_hex: &str, alpha: f64) -> String { + let hex = tint_hex.trim_start_matches('#'); + let ch = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap_or(0); + format!("rgb({} {} {} / {})", ch(0), ch(2), ch(4), alpha) +} + /// Set a property on a JS object. `Reflect::set` on a freshly created `Object` /// cannot fail (the target is always a real object and the key a string), so /// the result is intentionally ignored — there is no recoverable error here and diff --git a/crates/labcolors-wasm/src/theme.rs b/crates/labcolors-wasm/src/theme.rs index 4b83e8a5..8811552a 100644 --- a/crates/labcolors-wasm/src/theme.rs +++ b/crates/labcolors-wasm/src/theme.rs @@ -1,79 +1,24 @@ -//! The public theme vocabulary and its mapping to core viewing conditions. +//! Тематический словарь границы — реэкспорт КАНОНИЧЕСКОГО [`Theme`] ядра. //! -//! The owner's HIG naming (2026-06-12) is the contract the web sees: -//! `Light` / `Dark` / `Light-IC` / `Dark-IC`. "dim surround" is the *internal* -//! CIECAM16 term for the dark theme's viewing conditions and never leaks out — -//! the boundary speaks themes, the core speaks [`ViewingConditions`]. +//! Раньше здесь жила вторая копия enum-а (31 ссылка) — два словаря одного +//! понятия расходились бы молча. Канон один, в ядре +//! (`labcolors_core::Theme`): kebab-контракт (`"light"` / `"dark"` / +//! `"light-ic"` / `"dark-ic"`), ключи и карта условий просмотра живут на нём. +//! Граница добавляет ТОЛЬКО свой тип ошибки: неизвестная тема — ошибка +//! вызывающего, оборачивается в [`BindingError::UnknownTheme`], никогда не +//! коэрсится в тему по умолчанию. //! -//! The `-IC` ("increased contrast") themes are calibrated and resolve to their -//! respective high-contrast viewing conditions: `LightIncreasedContrast` → -//! `srgb_high_contrast()`, `DarkIncreasedContrast` → `dim_surround_high_contrast()`. -//! All four public spellings are fully supported; there is no reserved or -//! not-yet-calibrated theme in the current contract. +//! «dim surround» — внутренний термин CIECAM16 для тёмной темы и наружу не +//! утекает: граница говорит темами, ядро — [`ViewingConditions`] +//! (labcolors_core::ViewingConditions). -use labcolors_core::ViewingConditions; +pub use labcolors_core::Theme; use crate::error::BindingError; -/// Тема, в которой движок вычисляет контраст. -/// -/// Парсится из стабильного kebab-строкового контракта на границе -/// (`"light"`, `"dark"`, `"light-ic"`, `"dark-ic"`). Все четыре варианта -/// полностью поддержаны: `-ic`-темы разрешаются в `srgb_high_contrast()` / -/// `dim_surround_high_contrast()` — режимы повышенного контраста. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Theme { - /// Light theme — sRGB average-surround viewing conditions. - Light, - /// Dark theme — dim-surround viewing conditions internally. - Dark, - /// Increased-contrast light theme — `srgb_high_contrast()` viewing conditions. - LightIncreasedContrast, - /// Increased-contrast dark theme — `dim_surround_high_contrast()` viewing conditions. - DarkIncreasedContrast, -} - -impl Theme { - /// Parse the stable string contract into a theme. - /// - /// The accepted spellings are the public contract; an unknown string is a - /// caller error, surfaced — never coerced to a default theme. - pub fn parse(raw: &str) -> Result { - match raw { - "light" => Ok(Theme::Light), - "dark" => Ok(Theme::Dark), - "light-ic" => Ok(Theme::LightIncreasedContrast), - "dark-ic" => Ok(Theme::DarkIncreasedContrast), - other => Err(BindingError::UnknownTheme { - requested: other.to_owned(), - }), - } - } - - /// The stable string key for this theme — the inverse of [`parse`](Self::parse). - pub fn key(self) -> &'static str { - match self { - Theme::Light => "light", - Theme::Dark => "dark", - Theme::LightIncreasedContrast => "light-ic", - Theme::DarkIncreasedContrast => "dark-ic", - } - } - - /// The viewing conditions the core resolves under for this theme. - /// - /// - `Light` → `srgb()` (average surround) - /// - `Dark` → `dim_surround()` (the internal CIECAM16 term) - /// - `LightIncreasedContrast` → `srgb_high_contrast()` - /// - `DarkIncreasedContrast` → `dim_surround_high_contrast()` - pub fn viewing_conditions(self) -> Result { - match self { - Theme::Light => Ok(ViewingConditions::srgb()), - Theme::Dark => Ok(ViewingConditions::dim_surround()), - Theme::LightIncreasedContrast => Ok(ViewingConditions::srgb_high_contrast()), - Theme::DarkIncreasedContrast => Ok(ViewingConditions::dim_surround_high_contrast()), - } - } +/// Разобрать kebab-строку границы в тему, с границевой ошибкой. +pub fn parse_theme(raw: &str) -> Result { + Theme::parse(raw).map_err(|requested| BindingError::UnknownTheme { requested }) } #[cfg(test)] @@ -82,21 +27,15 @@ mod tests { #[test] fn parses_every_public_spelling() { - assert_eq!(Theme::parse("light").unwrap(), Theme::Light); - assert_eq!(Theme::parse("dark").unwrap(), Theme::Dark); - assert_eq!( - Theme::parse("light-ic").unwrap(), - Theme::LightIncreasedContrast - ); - assert_eq!( - Theme::parse("dark-ic").unwrap(), - Theme::DarkIncreasedContrast - ); + 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 Theme::parse("solarized") { + match parse_theme("solarized") { Err(BindingError::UnknownTheme { requested }) => assert_eq!(requested, "solarized"), other => panic!("expected UnknownTheme, got {other:?}"), } @@ -104,20 +43,15 @@ mod tests { #[test] fn key_round_trips_through_parse() { - for theme in [ - Theme::Light, - Theme::Dark, - Theme::LightIncreasedContrast, - Theme::DarkIncreasedContrast, - ] { - assert_eq!(Theme::parse(theme.key()).unwrap(), theme); + 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().unwrap(); - let dark = Theme::Dark.viewing_conditions().unwrap(); + let light = Theme::Light.viewing_conditions(); + let dark = Theme::Dark.viewing_conditions(); assert!( dark.aw < light.aw, "dim surround lowers the achromatic response" @@ -126,9 +60,8 @@ mod tests { #[test] fn increased_contrast_themes_are_fully_calibrated() { - for theme in [Theme::LightIncreasedContrast, Theme::DarkIncreasedContrast] { - let vc = theme.viewing_conditions().unwrap(); - assert!(vc.high_contrast); + 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 9fcac2fe..da962e14 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -211,3 +211,63 @@ fn invalid_background_rejects() { "error must carry the stable code, got: {message}" ); } + +/// Смоук границы конфига в живом wasm-рантайме: два РАЗНЫХ конфига дают разные +/// отпечатки, разные пространства ключей и разные эмиссии; rgba-роль лестницы +/// доходит до JS-объекта с готовой css-строкой. +#[wasm_bindgen_test] +fn config_boundary_two_configs_diverge() { + let acme = r##"{ + "brand": {"light": "#7C3AED", "dark": "#8B5CF6", "light_ic": "#5B21B6", "dark_ic": "#A78BFA"}, + "neutral": { + "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"}, + "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} + }, + "palette": [], + "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "themes": [{"name": "light", "preset": "srgb"}], + "roles": [ + {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, + {"name": "body-text", "recipe": {"kind": "text-anchor", "fraction": 0.62, "floor": "aa-text"}} + ] + }"##; + // Второй клиент: тот же контракт имён, другой бренд → другая эмиссия. + let other = acme.replace("#7C3AED", "#0E7490"); + + let mut colors = LabColors::new(); + let fp_a = colors.load_config(acme).expect("acme валиден"); + let set_a = colors.resolve_theme("#FFFFFF", "light").expect("резолв"); + let fp_b = colors.load_config(&other).expect("вариант валиден"); + let set_b = colors.resolve_theme("#FFFFFF", "light").expect("резолв"); + + assert_ne!(fp_a, fp_b, "разные конфиги → разные отпечатки"); + + let roles_a = get_obj(set_a.as_ref(), "roles"); + let accent_a = get_obj(&roles_a, "accent-fill"); + assert_eq!(get_str(&accent_a, "kind").as_deref(), Some("rgba")); + let css_a = get_str(&accent_a, "css").expect("rgba несёт css"); + assert!(css_a.starts_with("rgb("), "css-эмиссия rgba: {css_a}"); + + let roles_b = get_obj(set_b.as_ref(), "roles"); + let accent_b = get_obj(&roles_b, "accent-fill"); + let css_b = get_str(&accent_b, "css").expect("rgba несёт css"); + assert_ne!(css_a, css_b, "другой бренд → другая эмиссия той же роли"); + + // Пространство ключей — конфига, не встроенной таблицы. + let keys = js_sys::Object::keys(&roles_b.clone().into()); + let mut keys: Vec = keys.iter().filter_map(|k| k.as_string()).collect(); + keys.sort(); + assert_eq!( + keys, + ["accent-fill", "body-text"], + "после загрузки конфига пространство ключей — РОВНО его контракт, без примеси встроенной таблицы" + ); + + // Невалидный конфиг — структурная ошибка invalid_config. + let err = colors.load_config("{").expect_err("битый JSON отклонён"); + let msg = js_sys::Reflect::get(&err.into(), &JsValue::from_str("message")) + .ok() + .and_then(|v| v.as_string()) + .unwrap_or_default(); + assert!(msg.contains("invalid_config"), "код в сообщении: {msg}"); +}