From c355fb6b28fca0c5c447b637315c75c578923782 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 05:16:40 +0300 Subject: [PATCH 01/17] =?UTF-8?q?feat(core):=20ThemeConfig-=D1=82=D0=B8?= =?UTF-8?q?=D0=BF=D1=8B=20+=20NamedRoleTable::from=5Fconfig=20+=20=D0=B2?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=82=D0=BE=D1=80=20+=20=D1=84?= =?UTF-8?q?=D0=B8=D0=BA=D1=81=D1=82=D1=83=D1=80=D0=B0=20labui=20(t1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CH-02 t1 конфиг-границы (ADR-0001). Скоуп: типы конфига без serde, валидатор пределов каждой экспонируемой ручки, DIP-рефактор резолва по &RoleSpec, эталонная фикстура labui и байт-в-байт тест против сегодняшней эмиссии. - config.rs: ThemeConfig (Brand/Neutral/Palette/Sentiments/Themes/roles/aliases), VcPreset (закрытое меню Srgb|Dim|SrgbIc|DimIc), RoleRecipe (TextAnchor/DjAnchor/ DecorativeLc/Ladder/AlphaAnalog/Zero). Ladder/AlphaAnalog — честная заглушка с верным типом (ConfigError::NotYetImplemented, реализация t2). - ConfigError: ручной enum + Display + Error (ноль runtime-deps, issue #29). - Валидатор: пределы каждой ручки с обоснованием в doc-строке; невалидный hex/имя/ссылка на несуществующее семейство/роль → ConfigError. - DIP: resolve_spec_in(&RoleSpec) — общее физ-ядро; resolve_in(Role) стал обёрткой. NamedRoleTable + resolve_named_set + ThemeConfig::compile_named_role_table. - Фикстура labui_reference(): 20 ролей 1:1 из RoleTable::default, нейтраль-тройка и tint-ручки из констант semantic.rs (единый источник истины). - Тесты: байт-в-байт 240 точек (config == default) + RED-proof; валидатор на каждую ручку + RED-proof на границах; заглушки t2. Golden semantic.rs, empirical_inventory-гейт, lint_parity — зелёные. Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/config.rs | 828 ++++++++++++++++++++++ crates/labcolors-core/src/config/tests.rs | 463 ++++++++++++ crates/labcolors-core/src/lib.rs | 9 +- crates/labcolors-core/src/semantic.rs | 145 +++- 4 files changed, 1420 insertions(+), 25 deletions(-) create mode 100644 crates/labcolors-core/src/config.rs create mode 100644 crates/labcolors-core/src/config/tests.rs diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs new file mode 100644 index 00000000..e5b730f1 --- /dev/null +++ b/crates/labcolors-core/src/config.rs @@ -0,0 +1,828 @@ +//! Граница конфига: типы, которыми потребитель движка (дизайн-система) задаёт +//! свою семантику, и компиляция этой семантики в физическую [`NamedRoleTable`]. +//! +//! Разделение зон (ADR-0001, `docs/decisions/0001-config-boundary.md`): движок не +//! знает ни одного имени роли и ни одного брендового значения. Всё, что меняется +//! при смене клиента студии, живёт здесь, в [`ThemeConfig`]; всё, что является +//! законом восприятия / математикой / WCAG, остаётся физикой ядра +//! ([`crate::semantic`], [`crate::solve`]). Направление зависимостей — только +//! внутрь: этот модуль знает про доменные типы ядра ([`RoleSpec`], +//! [`RoleChroma`], [`DjMagnitude`], [`TextAnchor`], [`NamedRoleTable`]), а ядро +//! про конфиг не знает ничего. +//! +//! # Что этот модуль делает (CH-02 t1) +//! +//! - Несёт типы конфига без сериализации ([`ThemeConfig`] и вложенные) — JSON-парсинг +//! это отдельная задача границы WASM (t3), не ядро. +//! - [`ThemeConfig::validate`] проверяет пределы КАЖДОЙ экспонируемой ручки: значение +//! вне предела возвращает [`ConfigError`], а не тихо принимается. Клиент не может +//! молча сломать различимость или WCAG-полы. +//! - [`ThemeConfig::compile_named_role_table`] компилирует роли в [`NamedRoleTable`], +//! которую [`crate::semantic::resolve_named_set`] резолвит той же физикой, что и +//! встроенную [`crate::RoleTable`]. +//! +//! # Честные заглушки (границы CH-02) +//! +//! Рецепты [`RoleRecipe::Ladder`] (акцентная/сентимент/нейтраль-лестница) и +//! [`RoleRecipe::AlphaAnalog`] (альфа-аналог через композит-инверсию) объявлены в +//! меню рецептов с ПРАВИЛЬНЫМ типом, но их компиляция возвращает +//! [`ConfigError::NotYetImplemented`] — реализация в t2 (ladder поглощает акцентный +//! GAP #59; alpha_analog опирается на [`crate::alpha`]). Это честная заглушка с +//! верным типом, а не выдумка значений. + +use crate::semantic::{self, DjMagnitude, NamedRoleTable, RoleChroma, RoleSpec, TextAnchor}; +use crate::solve::Floor; + +// ───────────────────────────────────────────────────────────────────────────── +// Пределы валидатора. +// +// Каждый предел — широкий и честный: он отсекает значения, которые молча ломают +// восприятие/WCAG или вырождают физику, но НЕ навязывает узость. Границы выведены +// из перцептивных инвариантов и стиля реестра `docs/empirical-inventory.md` +// (напр. `0.030 ≤ C0 ≤ 0.050`). ВАЖНО: эти константы — НЕ перцептивные величины +// (они пределы допустимости ручек, не сами ручки), и модуль `config.rs` не входит +// в аудит-поверхность `tests/empirical_inventory.rs` (только 6 перцептивных +// модулей), поэтому им не нужен SSOT-маркер — обоснование каждого несёт doc-строка. +// ───────────────────────────────────────────────────────────────────────────── + +/// Доля от максимального контраста фона ([`TextAnchor`]) обязана лежать в `(0, 1]`. +/// +/// `fraction · max` — целевой контраст текстовой роли. При `fraction ≤ 0` цель +/// нулевая (роль нечитаема), при `fraction > 1` цель превышает достижимый максимум +/// фона (всегда [`crate::Unreachable`]). Верхняя граница включает `1.0` — это +/// «почти максимум, который фон физически позволяет» (сам движок клампит долю чуть +/// ниже единицы, см. [`TextAnchor::new`]). +const FRACTION_MIN_EXCLUSIVE: f64 = 0.0; +/// Верхний предел доли текстового якоря (включительно). +const FRACTION_MAX_INCLUSIVE: f64 = 1.0; + +/// dJ'-якорь декоративной роли обязан быть строго положительным. +/// +/// dJ' — перцептивная разница светлоты (шаг `J'`), которую роль держит от своей +/// поверхности. `dj ≤ 0` означает «нет различимого шага» — вырожденная роль, +/// неотличимая от фона. Верхнего предела нет по величине как таковой: физика сама +/// вернёт [`crate::Unreachable`], если якорь недостижим на данном фоне, — но +/// нулевой/отрицательный якорь это ошибка КОНФИГА, а не недостижимость. +const DJ_MIN_EXCLUSIVE: f64 = 0.0; + +/// Lc-величина декоративной роли (тени) обязана быть строго положительной. +/// +/// Единица — воспринимаемый контраст `Lc`; знак выбирает физика от фона, поэтому +/// конфиг несёт величину (модуль). `magnitude ≤ 0` — невидимая тень (вырождение). +/// Движок дополнительно поднимает величину до порога квантования +/// ([`DECORATIVE_FLOOR_MIN`](crate::semantic)), так что валидатор проверяет лишь +/// положительность как контракт конфига. +const DECORATIVE_LC_MIN_EXCLUSIVE: f64 = 0.0; + +/// Коэффициент хромы подтона (`neutral.tint.ratio`) обязан лежать в `[0, 1]`. +/// +/// Абсолютная хрома подтона = `ratio · max_chroma(L)`. `ratio = 0` — чистый серый +/// (допустимо: явный отказ от подтона), `ratio = 1` — максимум гамута. Значения вне +/// `[0, 1]` не имеют физического смысла (отрицательная хрома, либо запрос за стеной +/// гамута). Используется v1-путём ([`RoleChroma::Tinted`]); в дефолтном v2-пути +/// ([`RoleChroma::Curve`]) сила задаётся `target_mp`, но ручка всё равно +/// валидируется, т.к. экспонирована. +const TINT_RATIO_MIN_INCLUSIVE: f64 = 0.0; +/// Верхний предел коэффициента хромы подтона (включительно). +const TINT_RATIO_MAX_INCLUSIVE: f64 = 1.0; + +/// Целевая красочность подтона (`neutral.tint.target_mp`, CAM16-UCS `M'`) обязана +/// быть строго положительной. +/// +/// `M'` — перцептивная красочность; отрицательная бессмысленна, нулевая = серый (для +/// серого есть явный `ratio = 0`). Реестр держит дефолт `6.1` на плато измеренной +/// референс-рампы; предел лишь отсекает нефизичное `≤ 0`. +const TARGET_MP_MIN_EXCLUSIVE: f64 = 0.0; + +/// Жёсткость прижатия оттенка (`neutral.tint.hue_stiffness`) обязана быть +/// неотрицательной. +/// +/// Штраф дрейфа оттенка масштабируется как `stiffness / 100` (см. +/// [`cusp_attracted_hue`](crate::semantic)). `stiffness = 0` — оттенок свободно +/// идёт к локальному каспу хромы (допустимо), рост — прижимает к каноническому. +/// Отрицательная жёсткость инвертировала бы штраф в награду за уход от канона — +/// нефизично. +const HUE_STIFFNESS_MIN_INCLUSIVE: f64 = 0.0; + +/// Жёсткость сентимент-разделения (`sentiments.hardness`) обязана быть `≥ 1`. +/// +/// Это p-норма модели Sticky Potential Well (#55): `p → ∞` восстанавливает жёсткую +/// стену 20°, `p → 1` — самый мягкий изгиб. `p < 1` выводит p-норму из +/// корректной области (перестаёт быть нормой). Дефолт реестра — `5.0`. +const HARDNESS_MIN_INCLUSIVE: f64 = 1.0; + +/// Доля хромы сентимент-цвета (`sentiments.chroma_fraction`) обязана лежать в +/// `(0, 1]`. +/// +/// Каждый сентимент-цвет несёт `chroma_fraction · max_chroma` на своей светлоте. +/// `≤ 0` — обесцвеченный (не сентимент), `> 1` — за стеной гамута (неон/недостижимо). +/// Дефолт реестра — `0.88` (держится `< 1`, чтобы сидеть внутри стены гамута, не +/// читаясь как неон). +const CHROMA_FRACTION_MIN_EXCLUSIVE: f64 = 0.0; +/// Верхний предел доли хромы сентимента (включительно). +const CHROMA_FRACTION_MAX_INCLUSIVE: f64 = 1.0; + +/// Нижний предел `hue_floor` сентимент-политики (градусы): `[0, 360)`. +/// +/// `hue_floor_deg` — минимальный угол оттенка категории (напр. Warning ≥ 45°). +/// Оттенок — величина по модулю 360°; значение вне `[0, 360)` не является +/// каноническим углом. +const HUE_FLOOR_MIN_INCLUSIVE: f64 = 0.0; +/// Верхний предел `hue_floor` (исключительно; 360° ≡ 0°). +const HUE_FLOOR_MAX_EXCLUSIVE: f64 = 360.0; + +// ───────────────────────────────────────────────────────────────────────────── +// Ошибки валидации конфига. +// ───────────────────────────────────────────────────────────────────────────── + +/// Ошибка компиляции или валидации [`ThemeConfig`]. +/// +/// Матчится по вариантам (это часть публичного API ядра): потребитель различает +/// «невалидный hex», «ручка вне предела», «ссылка на несуществующее семейство» и +/// «рецепт ещё не реализован». Реализована вручную (без `thiserror`) — крейт +/// `labcolors-core` держит НОЛЬ runtime-зависимостей (issue #29); стиль `Display` +/// повторяет ручные ошибки ядра. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum ConfigError { + /// Невалидная hex-строка цвета (`#RGB` / `#RRGGBB`). `field` — путь до поля в + /// конфиге, `value` — то, что прислал клиент. + InvalidHex { field: String, value: String }, + /// Невалидное имя роли или семейства: имена — стабильный CSS-контракт + /// (`--lab-{имя}`), допустимо только `[a-z0-9-]+` и не пусто. + InvalidName { field: String, value: String }, + /// Ссылка на семейство палитры, которого нет в `palette`. + UnknownFamily { + referenced_by: String, + family: String, + }, + /// Значение ручки вне допустимого предела. `handle` — путь до ручки, `bound` — + /// человеко-читаемое описание нарушенного предела с обоснованием. + OutOfBounds { + handle: String, + value: f64, + bound: &'static str, + }, + /// Рецепт объявлен в меню, но его компиляция ещё не реализована в этой главе + /// (`Ladder` / `AlphaAnalog` — задача t2). Честная заглушка с верным типом. + NotYetImplemented { recipe: &'static str, role: String }, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::InvalidHex { field, value } => { + write!(f, "невалидный hex в поле `{field}`: {value:?}") + } + ConfigError::InvalidName { field, value } => write!( + f, + "невалидное имя в поле `{field}`: {value:?} (допустимо [a-z0-9-]+, не пусто)" + ), + ConfigError::UnknownFamily { + referenced_by, + family, + } => write!( + f, + "роль `{referenced_by}` ссылается на семейство `{family}`, которого нет в palette" + ), + ConfigError::OutOfBounds { + handle, + value, + bound, + } => write!(f, "ручка `{handle}` = {value} вне предела: {bound}"), + ConfigError::NotYetImplemented { recipe, role } => write!( + f, + "рецепт `{recipe}` (роль `{role}`) ещё не реализован — задача t2" + ), + } + } +} + +impl std::error::Error for ConfigError {} + +// ───────────────────────────────────────────────────────────────────────────── +// Типы конфига (без serde — JSON-парсинг это t3). +// ───────────────────────────────────────────────────────────────────────────── + +/// Бренд — вход, не роль: якорный hex; оттенок движок выводит физикой. +#[derive(Debug, Clone, PartialEq)] +pub struct Brand { + /// Якорный цвет бренда в hex (`#RRGGBB`). Дефолта в ядре нет. + pub anchor_hex: String, +} + +/// Тройка якорей нейтральной шкалы: конфиг несёт ИЗМЕРЕННОЕ, движок выводит +/// производное (hue, кривую). +#[derive(Debug, Clone, PartialEq)] +pub struct NeutralAnchors { + /// Светлый край нейтральной шкалы (labui: `#FFFFFF`). + pub light: String, + /// Середина нейтральной шкалы (labui: `#787880`). + pub mid: String, + /// Тёмный край нейтральной шкалы (labui: `#101012`). + pub dark: String, +} + +/// Ручки нейтрального подтона (политика силы и удержания оттенка). +#[derive(Debug, Clone, PartialEq)] +pub struct NeutralTint { + /// Коэффициент хромы подтона (v1 flat-путь): `[0, 1]`. + pub ratio: f64, + /// Целевая перцептивная красочность CAM16-UCS `M'` (v2-кривая, «сила»): `> 0`. + pub target_mp: f64, + /// Жёсткость прижатия оттенка к каноническому (v2-кривая): `≥ 0`. + pub hue_stiffness: f64, +} + +/// Нейтраль: тройка якорей + ручки подтона. +#[derive(Debug, Clone, PartialEq)] +pub struct NeutralConfig { + /// Тройка hex-якорей нейтральной шкалы. + pub anchors: NeutralAnchors, + /// Ручки подтона. + pub tint: NeutralTint, +} + +/// Именованное семейство палитры: ключ + якорный hex. +#[derive(Debug, Clone, PartialEq)] +pub struct PaletteFamily { + /// Стабильный ключ семейства (`[a-z0-9-]+`), напр. `red`. + pub key: String, + /// Якорный цвет семейства в hex (`#RRGGBB`). + pub anchor_hex: String, +} + +/// Политика одной семантической категории потребителя: маппинг на семейство +/// палитры + категориальные ручки. +#[derive(Debug, Clone, PartialEq)] +pub struct SentimentCategory { + /// Семантическое имя категории (`danger`, `warning`, …); `[a-z0-9-]+`. + pub name: String, + /// Ключ семейства палитры, на которое отображается категория. + pub family: String, + /// Минимальный угол оттенка категории (градусы, `[0, 360)`), если задан. + pub hue_floor_deg: Option, + /// Предпочтительная сторона смещения оттенка (`+1` / `-1`), если задана. + pub preferred_side: Option, +} + +/// Конфиг сентиментов: категории + общие ручки различимости. +#[derive(Debug, Clone, PartialEq)] +pub struct SentimentsConfig { + /// Категории потребителя. + pub categories: Vec, + /// Жёсткость p-нормы Sticky Potential Well (`≥ 1`). + pub hardness: f64, + /// Доля хромы сентимент-цвета от максимума гамута (`(0, 1]`). + pub chroma_fraction: f64, +} + +/// Пресет условий просмотра из ЗАКРЫТОГО физического меню движка. +/// +/// Произвольных VC-чисел в конфиге нет — только выбор из четырёх калиброванных +/// режимов. Соответствие [`ViewingConditions`](crate::ViewingConditions): +/// `Srgb` → average surround, `Dim` → dim surround, `SrgbIc` / `DimIc` — то же с +/// флагом повышенного контраста (IC). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VcPreset { + /// Average surround (светлая тема). + Srgb, + /// Dim surround (тёмная тема). + Dim, + /// Average surround с повышенным контрастом (IC). + SrgbIc, + /// Dim surround с повышенным контрастом (IC). + DimIc, +} + +impl VcPreset { + /// Условия просмотра, под которыми ядро резолвит для этого пресета. + pub fn viewing_conditions(self) -> crate::ViewingConditions { + match self { + VcPreset::Srgb => crate::ViewingConditions::srgb(), + VcPreset::Dim => crate::ViewingConditions::dim_surround(), + VcPreset::SrgbIc => crate::ViewingConditions::srgb_high_contrast(), + VcPreset::DimIc => crate::ViewingConditions::dim_surround_high_contrast(), + } + } +} + +/// Словарь тем: имя → пресет условий просмотра. +#[derive(Debug, Clone, PartialEq)] +pub struct ThemesConfig { + /// Пары `(имя темы, VC-пресет)` в порядке объявления. + pub entries: Vec<(String, VcPreset)>, +} + +/// Рецепт роли из ФИЗИЧЕСКОГО меню (типология из [`crate::semantic`]). +/// +/// Реализованные в t1 рецепты компилируются в [`RoleSpec`]; [`Ladder`](Self::Ladder) +/// и [`AlphaAnalog`](Self::AlphaAnalog) объявлены с верным типом, но их компиляция +/// возвращает [`ConfigError::NotYetImplemented`] — задача t2 (честная заглушка). +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum RoleRecipe { + /// Текстовый/UI-якорь: доля от максимального контраста фона + WCAG-пол. + TextAnchor { + /// Доля максимума контраста, `(0, 1]`. + fraction: f64, + /// WCAG-пол читаемости. + floor: Floor, + }, + /// Заякоренная декоративная роль: dJ'-шаг светлоты, отдельно light/dark по теме. + DjAnchor { + /// dJ'-якорь под светлое окружение (`> 0`). + light: f64, + /// dJ'-якорь под тёмное окружение (`> 0`). + dark: f64, + }, + /// Декоративная роль в единице `Lc` (стек теней): величина, знак — от фона. + DecorativeLc { + /// Величина `Lc` (`> 0`). + magnitude: f64, + }, + /// Ступень рампы акцента/семейства/нейтрали. Меню t2 (акцентный GAP #59) — + /// компиляция возвращает [`ConfigError::NotYetImplemented`]. + Ladder, + /// Альфа-аналог через композит-инверсию ([`crate::alpha`]). Меню t2 — + /// компиляция возвращает [`ConfigError::NotYetImplemented`]. + AlphaAnalog, + /// Явный ноль: «нет цвета здесь» ([`RoleSpec::Zero`]). + Zero, +} + +/// Полный конфиг темы потребителя (без сериализации — t3). +#[derive(Debug, Clone, PartialEq)] +pub struct ThemeConfig { + /// Бренд-вход. + pub brand: Brand, + /// Нейтральная шкала + подтон. + pub neutral: NeutralConfig, + /// Семейства палитры. + pub palette: Vec, + /// Сентимент-политика. + pub sentiments: SentimentsConfig, + /// Словарь тем. + pub themes: ThemesConfig, + /// Роли: имя (`[a-z0-9-]+`) → рецепт, в порядке объявления. + pub roles: Vec<(String, RoleRecipe)>, + /// Компонентные алиасы: имя → существующая роль. + pub aliases: Vec<(String, String)>, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Валидация. +// ───────────────────────────────────────────────────────────────────────────── + +/// Проверить, что строка — валидный `[a-z0-9-]+` и не пуста. +fn is_valid_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// Проверить, что hex парсится ядром (`#RGB` / `#RRGGBB`). +fn check_hex(field: &str, value: &str) -> Result<(), ConfigError> { + crate::spaces::srgb::srgb_from_hex(value) + .map(|_| ()) + .map_err(|_| ConfigError::InvalidHex { + field: field.to_string(), + value: value.to_string(), + }) +} + +/// Проверить, что имя валидно, иначе [`ConfigError::InvalidName`]. +fn check_name(field: &str, value: &str) -> Result<(), ConfigError> { + if is_valid_name(value) { + Ok(()) + } else { + Err(ConfigError::InvalidName { + field: field.to_string(), + value: value.to_string(), + }) + } +} + +/// Проверить `min < value ≤ max` (доля/хрома-стиль пределов). +fn check_in_excl_incl( + handle: &str, + value: f64, + min_excl: f64, + max_incl: f64, + bound: &'static str, +) -> Result<(), ConfigError> { + if value > min_excl && value <= max_incl { + Ok(()) + } else { + Err(ConfigError::OutOfBounds { + handle: handle.to_string(), + value, + bound, + }) + } +} + +/// Проверить `min ≤ value ≤ max` (замкнутый интервал). +fn check_in_incl_incl( + handle: &str, + value: f64, + min_incl: f64, + max_incl: f64, + bound: &'static str, +) -> Result<(), ConfigError> { + if value >= min_incl && value <= max_incl { + Ok(()) + } else { + Err(ConfigError::OutOfBounds { + handle: handle.to_string(), + value, + bound, + }) + } +} + +/// Проверить `value > min` (строго положительно). +fn check_gt( + handle: &str, + value: f64, + min_excl: f64, + bound: &'static str, +) -> Result<(), ConfigError> { + if value > min_excl { + Ok(()) + } else { + Err(ConfigError::OutOfBounds { + handle: handle.to_string(), + value, + bound, + }) + } +} + +/// Проверить `value ≥ min`. +fn check_ge( + handle: &str, + value: f64, + min_incl: f64, + bound: &'static str, +) -> Result<(), ConfigError> { + if value >= min_incl { + Ok(()) + } else { + Err(ConfigError::OutOfBounds { + handle: handle.to_string(), + value, + bound, + }) + } +} + +impl ThemeConfig { + /// Провалидировать конфиг: hex, имена, ссылки на семейства и пределы каждой + /// экспонируемой ручки. Первая найденная ошибка возвращается сразу — клиент + /// чинит по одной. Успех означает: [`compile_named_role_table`](Self::compile_named_role_table) + /// упадёт только на честной заглушке ([`ConfigError::NotYetImplemented`]), + /// никогда на неверном hex/имени/пределе. + pub fn validate(&self) -> Result<(), ConfigError> { + // Бренд-hex. + check_hex("brand.anchor_hex", &self.brand.anchor_hex)?; + + // Нейтраль: тройка hex. + check_hex("neutral.anchors.light", &self.neutral.anchors.light)?; + check_hex("neutral.anchors.mid", &self.neutral.anchors.mid)?; + check_hex("neutral.anchors.dark", &self.neutral.anchors.dark)?; + + // Нейтраль: ручки подтона. + check_in_incl_incl( + "neutral.tint.ratio", + self.neutral.tint.ratio, + TINT_RATIO_MIN_INCLUSIVE, + TINT_RATIO_MAX_INCLUSIVE, + "0 ≤ ratio ≤ 1 (доля хромы подтона; 0 = серый, 1 = максимум гамута)", + )?; + check_gt( + "neutral.tint.target_mp", + self.neutral.tint.target_mp, + TARGET_MP_MIN_EXCLUSIVE, + "target_mp > 0 (целевая красочность CAM16-UCS M')", + )?; + check_ge( + "neutral.tint.hue_stiffness", + self.neutral.tint.hue_stiffness, + HUE_STIFFNESS_MIN_INCLUSIVE, + "hue_stiffness ≥ 0 (жёсткость прижатия оттенка к каноническому)", + )?; + + // Палитра: имена + hex каждого семейства. + for fam in &self.palette { + let field = format!("palette[{}].key", fam.key); + check_name(&field, &fam.key)?; + let hex_field = format!("palette[{}].anchor_hex", fam.key); + check_hex(&hex_field, &fam.anchor_hex)?; + } + + // Сентименты: ручки + категории (маппинг на существующее семейство). + check_ge( + "sentiments.hardness", + self.sentiments.hardness, + HARDNESS_MIN_INCLUSIVE, + "hardness ≥ 1 (p-норма Sticky Potential Well; p < 1 не норма)", + )?; + check_in_excl_incl( + "sentiments.chroma_fraction", + self.sentiments.chroma_fraction, + CHROMA_FRACTION_MIN_EXCLUSIVE, + CHROMA_FRACTION_MAX_INCLUSIVE, + "0 < chroma_fraction ≤ 1 (доля хромы сентимента; >1 = за стеной гамута)", + )?; + for cat in &self.sentiments.categories { + let name_field = format!("sentiments.{}.name", cat.name); + check_name(&name_field, &cat.name)?; + if !self.palette.iter().any(|f| f.key == cat.family) { + return Err(ConfigError::UnknownFamily { + referenced_by: format!("sentiments.{}", cat.name), + family: cat.family.clone(), + }); + } + if let Some(hue) = cat.hue_floor_deg { + let field = format!("sentiments.{}.hue_floor_deg", cat.name); + // Полуинтервал `[0, 360)`: угол по модулю 360°, где 360° ≡ 0°. + if !(HUE_FLOOR_MIN_INCLUSIVE..HUE_FLOOR_MAX_EXCLUSIVE).contains(&hue) { + return Err(ConfigError::OutOfBounds { + handle: field, + value: hue, + bound: "0 ≤ hue_floor_deg < 360 (угол оттенка по модулю 360°)", + }); + } + } + } + + // Темы: имена. + for (name, _preset) in &self.themes.entries { + let field = format!("themes.{name}"); + check_name(&field, name)?; + } + + // Роли: имена + пределы ручек каждого рецепта. + for (name, recipe) in &self.roles { + let field = format!("roles.{name}"); + check_name(&field, name)?; + self.validate_recipe(name, recipe)?; + } + + // Алиасы: имя алиаса валидно, цель существует среди ролей. + for (alias, target) in &self.aliases { + let field = format!("aliases.{alias}"); + check_name(&field, alias)?; + if !self.roles.iter().any(|(rname, _)| rname == target) { + return Err(ConfigError::UnknownFamily { + referenced_by: format!("aliases.{alias}"), + family: target.clone(), + }); + } + } + + Ok(()) + } + + /// Провалидировать пределы ручек одного рецепта роли. + fn validate_recipe(&self, role: &str, recipe: &RoleRecipe) -> Result<(), ConfigError> { + match recipe { + RoleRecipe::TextAnchor { fraction, .. } => check_in_excl_incl( + &format!("roles.{role}.fraction"), + *fraction, + FRACTION_MIN_EXCLUSIVE, + FRACTION_MAX_INCLUSIVE, + "0 < fraction ≤ 1 (доля максимального контраста фона)", + ), + RoleRecipe::DjAnchor { light, dark } => { + check_gt( + &format!("roles.{role}.light"), + *light, + DJ_MIN_EXCLUSIVE, + "dj > 0 (перцептивный шаг светлоты; ≤ 0 = нет различимого шага)", + )?; + check_gt( + &format!("roles.{role}.dark"), + *dark, + DJ_MIN_EXCLUSIVE, + "dj > 0 (перцептивный шаг светлоты; ≤ 0 = нет различимого шага)", + ) + } + RoleRecipe::DecorativeLc { magnitude } => check_gt( + &format!("roles.{role}.magnitude"), + *magnitude, + DECORATIVE_LC_MIN_EXCLUSIVE, + "magnitude > 0 (Lc-величина тени; ≤ 0 = невидима)", + ), + // Заглушки t2: тип верный, значений нет — пределов тоже нет. + RoleRecipe::Ladder | RoleRecipe::AlphaAnalog | RoleRecipe::Zero => Ok(()), + } + } + + /// Скомпилировать роли конфига в [`NamedRoleTable`], которую + /// [`resolve_named_set`](crate::semantic::resolve_named_set) резолвит той же + /// физикой, что и встроенную [`crate::RoleTable`]. + /// + /// Валидирует конфиг ([`validate`](Self::validate)) перед компиляцией. + /// [`Ladder`](RoleRecipe::Ladder) и [`AlphaAnalog`](RoleRecipe::AlphaAnalog) + /// возвращают [`ConfigError::NotYetImplemented`] (задача t2). + pub fn compile_named_role_table(&self) -> Result { + self.validate()?; + + let mut entries: Vec<(String, RoleSpec)> = Vec::with_capacity(self.roles.len()); + for (name, recipe) in &self.roles { + let spec = compile_recipe(name, recipe)?; + entries.push((name.clone(), spec)); + } + + // Нейтраль-подтон: v2-кривая. Форма строго сверена с + // `semantic::RoleChroma::Curve` дефолтной таблицы (`neutral_curve()`): + // canonical_hue_deg = измеренный NEUTRAL_HUE_DEG (движок выводит оттенок из + // нейтральной тройки; для t1 берём измеренную SSOT-величину — вывод из hex + // это t2), target_mp / hue_stiffness — из конфиг-ручек. `ratio` в v2-кривую + // не входит (это поле v1 flat-пути), но валидируется как экспонированная ручка. + let chroma = RoleChroma::Curve { + canonical_hue_deg: semantic::NEUTRAL_HUE_DEG, + target_mp: self.neutral.tint.target_mp, + hue_stiffness: self.neutral.tint.hue_stiffness, + }; + + Ok(NamedRoleTable::new(entries, chroma)) + } +} + +/// Скомпилировать один рецепт в [`RoleSpec`]. Заглушки t2 возвращают +/// [`ConfigError::NotYetImplemented`] с верным именем рецепта. +fn compile_recipe(role: &str, recipe: &RoleRecipe) -> Result { + match recipe { + RoleRecipe::TextAnchor { fraction, floor } => { + Ok(RoleSpec::Anchor(TextAnchor::new(*fraction, *floor))) + } + RoleRecipe::DjAnchor { light, dark } => Ok(RoleSpec::DecorativeDj { + magnitude_dj: DjMagnitude::new(*light, *dark), + }), + RoleRecipe::DecorativeLc { magnitude } => Ok(RoleSpec::Decorative { + magnitude: *magnitude, + }), + RoleRecipe::Zero => Ok(RoleSpec::Zero), + RoleRecipe::Ladder => Err(ConfigError::NotYetImplemented { + recipe: "ladder", + role: role.to_string(), + }), + RoleRecipe::AlphaAnalog => Err(ConfigError::NotYetImplemented { + recipe: "alpha_analog", + role: role.to_string(), + }), + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Эталонная фикстура labui. +// ───────────────────────────────────────────────────────────────────────────── + +/// Эталонный конфиг labui для CH-02 t1 — покрывает сегодняшние 20 эмитируемых ролей. +/// +/// Имена ролей = `Role::key()` сегодняшнего ядра; рецепты сняты 1:1 из +/// [`RoleTable::default`](crate::RoleTable) (`semantic.rs`): текст-фракции с их +/// WCAG-полами, dJ'-якоря fill/border из тех же констант, Lc-тени, zero. Нейтраль — +/// тройка Даниила `#FFFFFF`/`#787880`/`#101012`; ручки подтона — из констант +/// `semantic.rs` (единый источник истины, фикстура не может тихо разойтись с ядром). +/// +/// Байт-в-байт тест доказывает: [`resolve_named_set`](crate::semantic::resolve_named_set) +/// этой фикстуры эмитит идентично [`resolve_set`](crate::resolve_set) дефолтной +/// таблицы на всех 240 точках golden-грида. Акценты/сентименты/альфа/ladder — t2. +pub fn labui_reference() -> ThemeConfig { + // Фракции и полы — 1:1 из RoleTable::default (semantic.rs), включая border-strong + // = контракт label-primary. Рецепты собраны так, чтобы имя роли совпадало с + // Role::key(), а RoleSpec был идентичен дефолтному. + let text = |fraction, floor| RoleRecipe::TextAnchor { fraction, floor }; + let dj = |m: DjMagnitude| RoleRecipe::DjAnchor { + light: m.light(), + dark: m.dark(), + }; + let lc = |magnitude| RoleRecipe::DecorativeLc { magnitude }; + + let roles = vec![ + // Labels. + ("label-primary".to_string(), text(0.968, Floor::AaText)), + ("label-secondary".to_string(), text(0.627, Floor::AaText)), + ("label-tertiary".to_string(), text(0.461, Floor::AaUi)), + ("label-quaternary".to_string(), text(0.276, Floor::None)), + // Icon. + ("icon".to_string(), text(0.461, Floor::AaUi)), + // Separator — Lc decorative. + ("separator".to_string(), lc(8.0)), + // Border ladder. Strong = label-primary контракт; base/soft — dJ'. + ("border-strong".to_string(), text(0.968, Floor::AaText)), + ("border-base".to_string(), dj(semantic::BORDER_BASE_DJ)), + ("border-soft".to_string(), dj(semantic::BORDER_SOFT_DJ)), + ("border-ghost".to_string(), RoleRecipe::Zero), + // Fill ladder — dJ'. + ("fill-primary".to_string(), dj(semantic::FILL_PRIMARY_DJ)), + ( + "fill-secondary".to_string(), + dj(semantic::FILL_SECONDARY_DJ), + ), + ("fill-tertiary".to_string(), dj(semantic::FILL_TERTIARY_DJ)), + ( + "fill-quaternary".to_string(), + dj(semantic::FILL_QUATERNARY_DJ), + ), + ("fill-none".to_string(), RoleRecipe::Zero), + // Shadow stack — Lc. + ("shadow-minor".to_string(), lc(semantic::SHADOW_MINOR_JND)), + ( + "shadow-ambient".to_string(), + lc(semantic::SHADOW_AMBIENT_JND), + ), + ( + "shadow-penumbra".to_string(), + lc(semantic::SHADOW_PENUMBRA_JND), + ), + ("shadow-major".to_string(), lc(semantic::SHADOW_MAJOR_JND)), + // Универсальный ноль. + ("none".to_string(), RoleRecipe::Zero), + ]; + + ThemeConfig { + brand: Brand { + // Дефолт бренда labui (accent.rs:54-56). + anchor_hex: "#007AFF".to_string(), + }, + neutral: NeutralConfig { + anchors: NeutralAnchors { + light: "#FFFFFF".to_string(), + mid: "#787880".to_string(), + dark: "#101012".to_string(), + }, + tint: NeutralTint { + // Ручки подтона — из констант semantic.rs (единый источник истины). + ratio: semantic::NEUTRAL_TINT_RATIO, + target_mp: semantic::TINT_TARGET_MP, + hue_stiffness: semantic::TINT_HUE_STIFFNESS, + }, + }, + // Палитра labui — 10 замеренных семейств (Figma 2026-07-02, accent.rs:113-126). + // В t1 не потребляется (акценты — t2), но несёт корректный конфиг-снимок. + palette: vec![ + fam("red", "#FF3B30"), + fam("orange", "#FF9500"), + fam("yellow", "#FFCC00"), + fam("green", "#34C759"), + fam("mint", "#00C7BE"), + fam("teal", "#30B0C7"), + fam("cyan", "#32ADE6"), + fam("blue", "#007AFF"), + fam("indigo", "#5856D6"), + fam("pink", "#FF2D55"), + ], + sentiments: SentimentsConfig { + categories: vec![ + sentiment("danger", "red", None, None), + sentiment("warning", "orange", Some(45.0), Some(1)), + sentiment("success", "green", None, None), + sentiment("info", "blue", None, None), + ], + hardness: 5.0, + chroma_fraction: 0.88, + }, + themes: ThemesConfig { + entries: vec![ + ("light".to_string(), VcPreset::Srgb), + ("dark".to_string(), VcPreset::Dim), + ("light-ic".to_string(), VcPreset::SrgbIc), + ("dark-ic".to_string(), VcPreset::DimIc), + ], + }, + roles, + aliases: Vec::new(), + } +} + +/// Краткий конструктор семейства палитры для фикстуры. +fn fam(key: &str, anchor_hex: &str) -> PaletteFamily { + PaletteFamily { + key: key.to_string(), + anchor_hex: anchor_hex.to_string(), + } +} + +/// Краткий конструктор сентимент-категории для фикстуры. +fn sentiment( + name: &str, + family: &str, + hue_floor_deg: Option, + preferred_side: Option, +) -> SentimentCategory { + SentimentCategory { + name: name.to_string(), + family: family.to_string(), + hue_floor_deg, + preferred_side, + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs new file mode 100644 index 00000000..b969099c --- /dev/null +++ b/crates/labcolors-core/src/config/tests.rs @@ -0,0 +1,463 @@ +//! Тесты границы конфига (CH-02 t1): +//! 1. Байт-в-байт: `resolve_named_set(labui_reference)` эмитит идентично +//! `resolve_set(RoleTable::default)` по всем 240 точкам golden-грида. +//! 2. RED-proof байт-в-байт: мутация одного рецепта фикстуры роняет тест. +//! 3. Валидатор: за-предельное значение КАЖДОЙ ручки даёт `ConfigError` + +//! RED-proof мутацией предела (валидный vs невалидный на границе). +//! 4. Заглушки t2: `Ladder`/`AlphaAnalog` дают `NotYetImplemented`. + +use super::*; +use crate::solve::Floor; +use crate::{ + BgInput, Resolved, Role, RoleTable, ViewingConditions, resolve_named_set, resolve_set, +}; + +/// Грид golden: два VC-пресета × шесть фонов — тот же, что в +/// `semantic::tests::resolve_set_golden_hex_is_byte_for_byte_stable` (240 точек). +fn grid() -> ([(ViewingConditions, &'static str); 2], [&'static str; 6]) { + ( + [ + (ViewingConditions::srgb(), "srgb"), + (ViewingConditions::dim_surround(), "dim"), + ], + [ + "#FFFFFF", "#F2F2F7", "#7F7F7F", "#1C1C1E", "#101012", "#3478F6", + ], + ) +} + +/// Hex/none/UNREACHABLE-представление резолва — как в golden-тесте ядра. +fn repr(res: &Resolved) -> String { + match res { + Resolved::Color { solved, .. } => solved.hex().to_string(), + Resolved::None => "none".to_string(), + Resolved::Unreachable(_) => "UNREACHABLE".to_string(), + } +} + +/// Собрать карту `role.key() -> hex` из дефолтной таблицы для (bg, vc). +fn default_by_key(bg: &BgInput, vc: &ViewingConditions) -> Vec<(&'static str, String)> { + resolve_set(bg, &RoleTable::default(), vc) + .into_iter() + .map(|(role, res)| (role.key(), repr(&res))) + .collect() +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Байт-в-байт эквивалентность на всех 240 точках. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn labui_named_set_is_byte_identical_to_default_role_table() { + let table = labui_reference() + .compile_named_role_table() + .expect("эталонная фикстура labui обязана компилироваться"); + + // Фикстура покрывает ровно 20 сегодняшних ролей, имена = Role::key(). + assert_eq!( + table.entries().len(), + Role::ALL.len(), + "фикстура labui должна нести ровно {} ролей", + Role::ALL.len() + ); + + let (vcs, bgs) = grid(); + let mut compared = 0usize; + for (vc, _vc_name) in vcs { + for bg_hex in bgs { + let bg = BgInput::solid(bg_hex).unwrap(); + let named = resolve_named_set(&bg, &table, &vc); + let default_map = default_by_key(&bg, &vc); + + for (name, res) in &named { + let got = repr(res); + let want = default_map + .iter() + .find(|(k, _)| k == name) + .map(|(_, hex)| hex.clone()) + .unwrap_or_else(|| panic!("нет дефолтной роли с ключом `{name}`")); + assert_eq!( + got, want, + "БАЙТ-ДРИФТ {bg_hex}/{_vc_name} `{name}`: config={got}, default={want}" + ); + compared += 1; + } + } + } + // 20 ролей × 2 VC × 6 фонов = 240. + assert_eq!(compared, 240, "должно сравниться ровно 240 точек"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. RED-proof байт-в-байт: мутация рецепта фикстуры роняет тест. +// Доказывает, что тест выше КУСАЕТСЯ (не green-from-birth). +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn byte_identity_test_bites_on_mutated_recipe() { + // Мутируем ОДИН рецепт: label-primary fraction 0.968 → 0.627 (контракт + // secondary). Эмиссия label-primary обязана разойтись с дефолтом хотя бы на + // одном фоне — иначе байт-в-байт тест был бы слеп к рецепту. + let mut cfg = labui_reference(); + for (name, recipe) in &mut cfg.roles { + if name == "label-primary" { + *recipe = RoleRecipe::TextAnchor { + fraction: 0.627, + floor: Floor::AaText, + }; + } + } + let mutated = cfg + .compile_named_role_table() + .expect("мутант всё ещё валиден (fraction в пределах)"); + + let (vcs, bgs) = grid(); + let mut any_diff = false; + for (vc, _n) in vcs { + for bg_hex in bgs { + let bg = BgInput::solid(bg_hex).unwrap(); + let named = resolve_named_set(&bg, &mutated, &vc); + let default_map = default_by_key(&bg, &vc); + for (name, res) in &named { + if name == "label-primary" { + let got = repr(res); + let want = default_map + .iter() + .find(|(k, _)| k == name) + .map(|(_, hex)| hex.clone()) + .unwrap(); + if got != want { + any_diff = true; + } + } + } + } + } + assert!( + any_diff, + "RED-proof провален: мутация рецепта label-primary НЕ изменила эмиссию — \ + байт-в-байт тест был бы слеп" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Валидатор: эталон валиден; каждая ручка за пределом даёт ConfigError. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn labui_reference_passes_validation() { + assert_eq!(labui_reference().validate(), Ok(())); +} + +/// Мутировать первый рецепт данного вида и вернуть конфиг. +fn with_role_recipe(name: &str, recipe: RoleRecipe) -> ThemeConfig { + let mut cfg = labui_reference(); + let entry = cfg + .roles + .iter_mut() + .find(|(rname, _)| rname == name) + .unwrap_or_else(|| panic!("роль `{name}` отсутствует в фикстуре")); + entry.1 = recipe; + cfg +} + +#[test] +fn fraction_out_of_bounds_is_rejected() { + // > 1 отклоняется. + let over = with_role_recipe( + "label-primary", + RoleRecipe::TextAnchor { + fraction: 1.5, + floor: Floor::AaText, + }, + ); + assert!(matches!( + over.validate(), + Err(ConfigError::OutOfBounds { handle, .. }) if handle == "roles.label-primary.fraction" + )); + // ≤ 0 отклоняется. + let zero = with_role_recipe( + "label-primary", + RoleRecipe::TextAnchor { + fraction: 0.0, + floor: Floor::AaText, + }, + ); + assert!(matches!( + zero.validate(), + Err(ConfigError::OutOfBounds { .. }) + )); +} + +#[test] +fn fraction_bound_red_proof_at_edges() { + // RED-proof предела: 1.0 валиден (верхняя граница включительна), 1.0+ε — нет. + let at = with_role_recipe( + "label-primary", + RoleRecipe::TextAnchor { + fraction: 1.0, + floor: Floor::AaText, + }, + ); + assert_eq!(at.validate(), Ok(()), "fraction=1.0 должен быть валиден"); + let over = with_role_recipe( + "label-primary", + RoleRecipe::TextAnchor { + fraction: 1.0 + 1e-9, + floor: Floor::AaText, + }, + ); + assert!( + over.validate().is_err(), + "fraction чуть выше 1.0 обязан упасть — иначе предел не кусается" + ); +} + +#[test] +fn dj_anchor_non_positive_is_rejected() { + for recipe in [ + RoleRecipe::DjAnchor { + light: 0.0, + dark: 5.0, + }, + RoleRecipe::DjAnchor { + light: 5.0, + dark: -1.0, + }, + ] { + let cfg = with_role_recipe("fill-primary", recipe); + assert!( + matches!(cfg.validate(), Err(ConfigError::OutOfBounds { .. })), + "нулевой/отрицательный dJ' обязан отклоняться" + ); + } +} + +#[test] +fn dj_anchor_bound_red_proof() { + // Строго положительный предел: +ε валиден, 0.0 — нет. + let ok = with_role_recipe( + "fill-primary", + RoleRecipe::DjAnchor { + light: f64::MIN_POSITIVE, + dark: 1.0, + }, + ); + assert_eq!(ok.validate(), Ok(())); + let bad = with_role_recipe( + "fill-primary", + RoleRecipe::DjAnchor { + light: 0.0, + dark: 1.0, + }, + ); + assert!(bad.validate().is_err()); +} + +#[test] +fn decorative_lc_non_positive_is_rejected() { + let cfg = with_role_recipe("shadow-minor", RoleRecipe::DecorativeLc { magnitude: 0.0 }); + assert!(matches!( + cfg.validate(), + Err(ConfigError::OutOfBounds { handle, .. }) if handle == "roles.shadow-minor.magnitude" + )); +} + +#[test] +fn tint_ratio_out_of_bounds_is_rejected() { + let mut cfg = labui_reference(); + cfg.neutral.tint.ratio = 1.5; + assert!(matches!( + cfg.validate(), + Err(ConfigError::OutOfBounds { handle, .. }) if handle == "neutral.tint.ratio" + )); + let mut neg = labui_reference(); + neg.neutral.tint.ratio = -0.01; + assert!(neg.validate().is_err()); +} + +#[test] +fn tint_ratio_bound_red_proof_at_edges() { + // [0, 1] замкнут: 0.0 и 1.0 валидны, вне — нет. + for r in [0.0, 1.0] { + let mut cfg = labui_reference(); + cfg.neutral.tint.ratio = r; + assert_eq!( + cfg.validate(), + Ok(()), + "ratio={r} на границе должен быть валиден" + ); + } + let mut over = labui_reference(); + over.neutral.tint.ratio = 1.0 + 1e-9; + assert!(over.validate().is_err()); +} + +#[test] +fn target_mp_non_positive_is_rejected() { + let mut cfg = labui_reference(); + cfg.neutral.tint.target_mp = 0.0; + assert!(matches!( + cfg.validate(), + Err(ConfigError::OutOfBounds { handle, .. }) if handle == "neutral.tint.target_mp" + )); +} + +#[test] +fn hue_stiffness_negative_is_rejected() { + let mut cfg = labui_reference(); + cfg.neutral.tint.hue_stiffness = -1.0; + assert!(cfg.validate().is_err()); + // RED-proof: 0.0 валиден (нижняя граница включительна). + let mut zero = labui_reference(); + zero.neutral.tint.hue_stiffness = 0.0; + assert_eq!(zero.validate(), Ok(())); +} + +#[test] +fn hardness_below_one_is_rejected() { + let mut cfg = labui_reference(); + cfg.sentiments.hardness = 0.5; + assert!(matches!( + cfg.validate(), + Err(ConfigError::OutOfBounds { handle, .. }) if handle == "sentiments.hardness" + )); + // RED-proof: ровно 1.0 валиден. + let mut at = labui_reference(); + at.sentiments.hardness = 1.0; + assert_eq!(at.validate(), Ok(())); +} + +#[test] +fn chroma_fraction_out_of_bounds_is_rejected() { + let mut over = labui_reference(); + over.sentiments.chroma_fraction = 1.01; + assert!(over.validate().is_err()); + let mut zero = labui_reference(); + zero.sentiments.chroma_fraction = 0.0; + assert!(zero.validate().is_err()); + // RED-proof: ровно 1.0 валиден. + let mut at = labui_reference(); + at.sentiments.chroma_fraction = 1.0; + assert_eq!(at.validate(), Ok(())); +} + +#[test] +fn hue_floor_out_of_range_is_rejected() { + let mut over = labui_reference(); + over.sentiments.categories[1].hue_floor_deg = Some(360.0); + assert!( + over.validate().is_err(), + "360° ≡ 0°, за полуинтервалом [0,360)" + ); + let mut neg = labui_reference(); + neg.sentiments.categories[1].hue_floor_deg = Some(-1.0); + assert!(neg.validate().is_err()); + // RED-proof: 0.0 валиден, чуть ниже 360 валиден. + let mut lo = labui_reference(); + lo.sentiments.categories[1].hue_floor_deg = Some(0.0); + assert_eq!(lo.validate(), Ok(())); + let mut hi = labui_reference(); + hi.sentiments.categories[1].hue_floor_deg = Some(359.999); + assert_eq!(hi.validate(), Ok(())); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Валидатор: hex / имена / ссылки. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn invalid_hex_is_rejected() { + let mut cfg = labui_reference(); + cfg.brand.anchor_hex = "not-a-hex".to_string(); + assert!(matches!( + cfg.validate(), + Err(ConfigError::InvalidHex { field, .. }) if field == "brand.anchor_hex" + )); + let mut neut = labui_reference(); + neut.neutral.anchors.dark = "#GGGGGG".to_string(); + assert!(matches!( + neut.validate(), + Err(ConfigError::InvalidHex { .. }) + )); +} + +#[test] +fn invalid_role_name_is_rejected() { + let mut cfg = labui_reference(); + // Заглавные буквы недопустимы ([a-z0-9-]+). + cfg.roles.push(( + "Label_Bad".to_string(), + RoleRecipe::TextAnchor { + fraction: 0.5, + floor: Floor::AaUi, + }, + )); + assert!(matches!( + cfg.validate(), + Err(ConfigError::InvalidName { .. }) + )); +} + +#[test] +fn sentiment_referencing_missing_family_is_rejected() { + let mut cfg = labui_reference(); + cfg.sentiments.categories[0].family = "nonexistent".to_string(); + assert!(matches!( + cfg.validate(), + Err(ConfigError::UnknownFamily { family, .. }) if family == "nonexistent" + )); +} + +#[test] +fn alias_to_missing_role_is_rejected() { + let mut cfg = labui_reference(); + cfg.aliases + .push(("control-bg".to_string(), "no-such-role".to_string())); + assert!(matches!( + cfg.validate(), + Err(ConfigError::UnknownFamily { family, .. }) if family == "no-such-role" + )); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Честные заглушки t2. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn ladder_recipe_is_not_yet_implemented() { + let cfg = with_role_recipe("fill-primary", RoleRecipe::Ladder); + // Валидация проходит (тип верный, пределов нет), а компиляция — честная заглушка. + assert_eq!(cfg.validate(), Ok(())); + assert!(matches!( + cfg.compile_named_role_table(), + Err(ConfigError::NotYetImplemented { + recipe: "ladder", + .. + }) + )); +} + +#[test] +fn alpha_analog_recipe_is_not_yet_implemented() { + let cfg = with_role_recipe("fill-primary", RoleRecipe::AlphaAnalog); + assert!(matches!( + cfg.compile_named_role_table(), + Err(ConfigError::NotYetImplemented { + recipe: "alpha_analog", + .. + }) + )); +} + +#[test] +fn config_error_display_is_russian_and_informative() { + let err = ConfigError::OutOfBounds { + handle: "roles.x.fraction".to_string(), + value: 2.0, + bound: "0 < fraction ≤ 1", + }; + let s = err.to_string(); + assert!(s.contains("roles.x.fraction")); + assert!(s.contains("вне предела")); +} diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 1b9b9ea6..d5074cea 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -3,6 +3,7 @@ pub(crate) mod spaces; pub mod accent; pub mod alpha; pub mod cleanliness; +pub mod config; pub mod lcs; pub mod lpc; pub(crate) mod lut; @@ -28,11 +29,15 @@ pub use cleanliness::{ confidence_from_hex as cleanliness_confidence_from_hex, drab, drab_in_context, muddiness_from_hex, muddiness_from_linear_srgb, muddiness_in_context, muddiness_oklch, n_pure, }; +pub use config::{ + Brand, ConfigError, NeutralAnchors, NeutralConfig, NeutralTint, PaletteFamily, RoleRecipe, + SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, VcPreset, labui_reference, +}; pub use curve::ColorCurve; pub use lcs::LcsColor; pub use semantic::{ - Resolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor, measure_contrast, recheck_against, - resolve, resolve_set, + NamedRoleTable, Resolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor, measure_contrast, + recheck_against, resolve, resolve_named_set, resolve_set, }; pub use solve::{ BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveJob, Solved, TypographicContext, diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 7eb2fecc..cd3b90ab 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -179,20 +179,20 @@ const IC_DECORATIVE_FLOOR_MIN: f64 = 15.0; /// dJ'-якоря лестницы fill (`fill-primary` … `fill-quaternary`), строго убывающие /// по видимости. Буквальные измеренные значения; отдельно light/dark по теме. // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const FILL_PRIMARY_DJ: DjMagnitude = DjMagnitude::new(7.93, 17.67); +pub(crate) const FILL_PRIMARY_DJ: DjMagnitude = DjMagnitude::new(7.93, 17.67); // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const FILL_SECONDARY_DJ: DjMagnitude = DjMagnitude::new(6.41, 15.78); +pub(crate) const FILL_SECONDARY_DJ: DjMagnitude = DjMagnitude::new(6.41, 15.78); // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const FILL_TERTIARY_DJ: DjMagnitude = DjMagnitude::new(4.63, 12.01); +pub(crate) const FILL_TERTIARY_DJ: DjMagnitude = DjMagnitude::new(4.63, 12.01); // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const FILL_QUATERNARY_DJ: DjMagnitude = DjMagnitude::new(3.15, 8.22); +pub(crate) const FILL_QUATERNARY_DJ: DjMagnitude = DjMagnitude::new(3.15, 8.22); /// dJ'-якоря border base/soft. Буквальные измеренные значения; base сильнее soft. /// (`border-strong` — заякоренная роль читаемости, не dJ'-шаг — её здесь нет.) // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const BORDER_BASE_DJ: DjMagnitude = DjMagnitude::new(6.41, 10.12); +pub(crate) const BORDER_BASE_DJ: DjMagnitude = DjMagnitude::new(6.41, 10.12); // SSOT-TRACKED — dJ'-якоря из Figma-структуры LabUI (light, dark). -const BORDER_SOFT_DJ: DjMagnitude = DjMagnitude::new(3.15, 5.83); +pub(crate) const BORDER_SOFT_DJ: DjMagnitude = DjMagnitude::new(3.15, 5.83); // ── Величины теней (Lc decorative — см. пояснение) ───────────────────────────── // @@ -208,13 +208,13 @@ const BORDER_SOFT_DJ: DjMagnitude = DjMagnitude::new(3.15, 5.83); /// самая сильная) — прогрессивная рампа FX/Shadow. Единица Lc, держится выше /// [`DECORATIVE_FLOOR_MIN`] с шагом между уровнями ≥1.5 Lc. // SSOT-TRACKED — величина Lc стека теней (минимальная ступень). -const SHADOW_MINOR_JND: f64 = 8.0; +pub(crate) const SHADOW_MINOR_JND: f64 = 8.0; // SSOT-TRACKED — величина Lc стека теней. -const SHADOW_AMBIENT_JND: f64 = 9.5; +pub(crate) const SHADOW_AMBIENT_JND: f64 = 9.5; // SSOT-TRACKED — величина Lc стека теней. -const SHADOW_PENUMBRA_JND: f64 = 11.5; +pub(crate) const SHADOW_PENUMBRA_JND: f64 = 11.5; // SSOT-TRACKED — величина Lc стека теней (максимальная ступень). -const SHADOW_MAJOR_JND: f64 = 14.0; +pub(crate) const SHADOW_MAJOR_JND: f64 = 14.0; /// The strict WCAG 2.1 AA *text* ratio (4.5:1) — the tightest legal gate any /// role in the table imposes, and therefore the one polarity is chosen against. @@ -523,7 +523,7 @@ pub enum RoleSpec { /// родственник `#101012` (холодный почти-чёрный), а не стерильно-серый /// `#141414`. // SSOT-TRACKED — измеренный Oklab-оттенок нейтральной шкалы. -const NEUTRAL_HUE_DEG: f64 = 286.0; +pub(crate) const NEUTRAL_HUE_DEG: f64 = 286.0; /// Доля от максимальной хромы в гамуте, которую несёт тонированная роль. /// @@ -538,7 +538,7 @@ const NEUTRAL_HUE_DEG: f64 = 286.0; /// `0.10`: на белом `text-primary` резолвится в холодный почти-чёрный /// семейства `#101012`, а не в чистый серый. // SSOT-TRACKED — коэффициент хромы нейтрального подтона. -const NEUTRAL_TINT_RATIO: f64 = 0.10; +pub(crate) const NEUTRAL_TINT_RATIO: f64 = 0.10; /// Целевая перцептивная красочность (CAM16-UCS `M'`) по умолчанию, которую /// v2-кривая подтона держит по всей шкале светлоты — параметр "сила". @@ -571,7 +571,7 @@ const NEUTRAL_TINT_RATIO: f64 = 0.10; /// (см. тест `curve_fits_reference_plateau_colorfulness` для количественного /// сравнения с референсом). // SSOT-TRACKED — целевой M' в CAM16-UCS. -const TINT_TARGET_MP: f64 = 6.1; +pub(crate) const TINT_TARGET_MP: f64 = 6.1; /// Жёсткость притяжения оттенка к канонической точке по умолчанию для /// v2-кривой — второй (и последний) свободный скаляр. Чем выше значение, тем @@ -585,7 +585,7 @@ const TINT_TARGET_MP: f64 = 6.1; /// почти-белую роль к каспу пурпурного (см. предел геометрии в /// [`cusp_attracted_hue`]). // SSOT-TRACKED — жёсткость прижатия оттенка к каспу. -const TINT_HUE_STIFFNESS: f64 = 9.0; +pub(crate) const TINT_HUE_STIFFNESS: f64 = 9.0; /// Порог воспринимаемости (механизм 3) в единицах CAM16-UCS `M'`. Ниже /// примерно этой красочности подтон попадает в "мёртвую серую зону" — @@ -1222,6 +1222,13 @@ pub fn resolve(bg: &BgInput, role: Role, table: &RoleTable, vc: &ViewingConditio /// Resolve one role through an already-derived [`ResolveContext`], so a whole set /// shares one polarity and one maximum-contrast computation. +/// +/// Thin wrapper over [`resolve_spec_in`]: it looks the role's recipe up in +/// `table` and resolves that recipe. Keeping the recipe-driven physics in +/// [`resolve_spec_in`] is the dependency-inversion seam — the config layer +/// ([`NamedRoleTable`]) resolves the *same* recipe against the *same* physics +/// without knowing about the [`Role`] enum, and the golden [`Role`] path stays a +/// byte-for-byte-equivalent wrapper. fn resolve_in( bg: &BgInput, role: Role, @@ -1229,7 +1236,26 @@ fn resolve_in( vc: &ViewingConditions, ctx: &ResolveContext, ) -> Resolved { - let contract = match table.spec(role) { + resolve_spec_in(bg, &table.spec(role), table.chroma(), vc, ctx) +} + +/// Resolve one [`RoleSpec`] against `bg` under an already-derived +/// [`ResolveContext`], applying `chroma` as the undertone policy. +/// +/// This is the physics core the two front doors share: the [`Role`]-keyed +/// [`resolve_in`] and the string-keyed [`resolve_named_set`]. It takes a `&RoleSpec` +/// directly — not a [`Role`] — so a caller that names roles with arbitrary strings +/// (the consumer config) resolves them through the identical code path as the +/// built-in table. Nothing about the physics changes; only *where the recipe comes +/// from* differs, which is exactly the seam ADR-0001 opens. +fn resolve_spec_in( + bg: &BgInput, + spec: &RoleSpec, + chroma: RoleChroma, + vc: &ViewingConditions, + ctx: &ResolveContext, +) -> Resolved { + let contract = match *spec { RoleSpec::Zero => return Resolved::None, RoleSpec::Anchor(anchor) => match ctx.anchored_contract(anchor) { Ok(c) => c, @@ -1239,13 +1265,7 @@ fn resolve_in( // dJ' has its own analytic solver (J' offset, not an Lc contract); it // builds the undertone itself, so it does not route through // `solve_with_chroma`. - return match resolve_dj( - bg, - magnitude_dj.for_vc(vc), - ctx.polarity, - table.chroma(), - vc, - ) { + return match resolve_dj(bg, magnitude_dj.for_vc(vc), ctx.polarity, chroma, vc) { Ok(solved) => Resolved::color(solved), Err(reason) => Resolved::Unreachable(reason), }; @@ -1257,7 +1277,7 @@ fn resolve_in( Ok(iv) => *iv, Err(reason) => return Resolved::Unreachable(reason.clone()), }; - match solve_with_chroma(bg, contract, table.chroma(), vc, interval) { + match solve_with_chroma(bg, contract, chroma, vc, interval) { Ok(solved) => Resolved::color(solved), Err(reason) => Resolved::Unreachable(reason), } @@ -1432,6 +1452,85 @@ pub(crate) fn resolve_set_live( set } +/// A recipe table keyed by **arbitrary string names**, the config-layer analogue +/// of [`RoleTable`]. +/// +/// Where [`RoleTable`] carries the fixed v1 [`Role`] enum, `NamedRoleTable` carries +/// whatever role *names* a consumer's [`ThemeConfig`](crate::config::ThemeConfig) +/// declares — the engine knows none of them. It is built from a config via +/// [`from_config`](crate::config::ThemeConfig::compile_named_role_table) and +/// resolved by [`resolve_named_set`]. The physics is identical to [`RoleTable`]'s: +/// each entry is the same [`RoleSpec`] the built-in path solves, and the same +/// [`RoleChroma`] undertone applies to the whole table. +/// +/// v1 note: this table carries **no hierarchy-compression pass**. That pass +/// ([`enforce_text_hierarchy`]) walks the *typed* label ladder +/// (`LabelPrimary..Quaternary`), which is meaningless for arbitrary names; the +/// byte-identity guarantee for the labui fixture holds because every one of its +/// text roles is individually reachable on the golden grid (so the pass is a no-op +/// there — see the byte-identity test). A general consumer table with a squeezed +/// mid-grey background would resolve each role in isolation, exactly as +/// [`resolve`] does for a single role. +#[derive(Debug, Clone, PartialEq)] +pub struct NamedRoleTable { + entries: Vec<(String, RoleSpec)>, + chroma: RoleChroma, +} + +impl NamedRoleTable { + /// Build a named table from its `(name, recipe)` entries and an undertone + /// policy. Names are the CSS contract downstream (`--lab-{name}`); this + /// constructor does not validate them — the config validator + /// ([`ThemeConfig::validate`](crate::config::ThemeConfig::validate)) owns that. + pub fn new(entries: Vec<(String, RoleSpec)>, chroma: RoleChroma) -> Self { + Self { entries, chroma } + } + + /// The `(name, recipe)` entries, in declaration order. + pub fn entries(&self) -> &[(String, RoleSpec)] { + &self.entries + } + + /// The undertone policy applied to every role in this table. + pub fn chroma(&self) -> RoleChroma { + self.chroma + } +} + +/// Resolve every named role in `table` against `bg` under `vc`, in declaration +/// order — the string-keyed sibling of [`resolve_set`]. +/// +/// Each `(name, recipe)` pair resolves through the very same [`resolve_spec_in`] +/// physics core the built-in [`resolve_set`] uses, so a config whose recipes match +/// the built-in table emits byte-for-byte identical colours (the byte-identity +/// guarantee ADR-0001 requires of the labui fixture). The returned pairs preserve +/// declaration order so a serialiser emits stable output. +/// +/// Unlike [`resolve_set`], this takes no O(1) grey/chromatic fast path (those are +/// keyed on the built-in default table) and runs no label-ladder compression pass +/// (see [`NamedRoleTable`]); it is the honest live sweep for an arbitrary table. +pub fn resolve_named_set( + bg: &BgInput, + table: &NamedRoleTable, + vc: &ViewingConditions, +) -> Vec<(String, Resolved)> { + // One CIECAM16 forward-cache for the span of this sweep, mirroring + // `resolve_set_live`: the curve refine fixed-point and repeated lightnesses + // across roles hit the cache instead of recomputing. + let _forward_cache = crate::spaces::cam16::ForwardCacheGuard::activate(); + let ctx = ResolveContext::new(bg, vc); + table + .entries + .iter() + .map(|(name, spec)| { + ( + name.clone(), + resolve_spec_in(bg, spec, table.chroma, vc, &ctx), + ) + }) + .collect() +} + /// Measure the perceptual contrast (`Lc`) and WCAG 2.1 ratio a foreground colour /// achieves against a background — the cheap **recheck** primitive. /// From 7f4430c5535f7f213c13b1a04c47368e57b63993 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 07:40:17 +0300 Subject: [PATCH 02/17] =?UTF-8?q?feat(core):=20=D1=80=D0=B5=D1=86=D0=B5?= =?UTF-8?q?=D0=BF=D1=82=20ladder=20(rgba-=D1=8D=D0=BC=D0=B8=D1=81=D1=81?= =?UTF-8?q?=D0=B8=D1=8F)=20+=20alpha=5Fanalog=20+=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=87=D1=91=D1=82=20S=5FPERC=5FMIN=20(t2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поглощает акцентный GAP #59: лестница акцента/сентимента/бренда как ДАННЫЕ — один тинт-якорь источника (пер-темно) × закрытая рампа альф Figma @NN. Ядро: - crate::ladder — ThemeAnchors (пер-темная четвёрка), LadderTint (Copy-payload, разложен по 4 режимам), LadderPosition (закрытое меню 13 позиций + альфы @NN). - RoleSpec::Ladder{tint, alpha} + RoleSpec::AlphaAnalog{of, alpha} (semantic.rs). - Resolved::Rgba(RgbaResolved) — rgba(тинт, α) НАПРЯМУЮ (закон лестницы labui, композитит браузер) + солид-композит на фоне резолва для замера контраста (фаза 1 AA меряет композит). resolve_spec_in — единый сеам физики обоих фронтов. - BgInput::encoded_display — device-пространство фона для композита. - sentiment: resolve_smooth_hue_explicit (config-facing), resolve_config_sentiment_solid (тинт сентимента = якорь, разведённый с брендом), s_perc_min_from_chromas (закон 2·C_rep·sin(20°/2) из конфиг-якорей), s_perc_min_frozen. Конфиг: - Brand/PaletteFamily → пер-темные якоря (ThemeAnchors) ДОСЛОВНО из reference §2. - RoleRecipe::Ladder{source, position} + AlphaAnalog{of, alpha}; LadderSource (Brand/Family/Sentiment); валидатор ссылок источника + предел альфы. - labui_reference расширена акцент/сентимент/FX/альфа-ролями (consumedRoles). Тесты (RED-proof кусаются): - diff=пусто против consumedRoles labui (удаляемые по коллапсу перечислены). - S_PERC_MIN(labui) == 0.068_703_9 (деривационная идентичность, 1e-4) + RED-proof. - Сентимент-тинт == сырой якорь при hue-дальнем бренде (Danger/Success/Warning); Info смещён от синего бренда ПО ПОСТРОЕНИЮ (честная находка, задокументирована). - rgba-эмиссия + композит; RED-proof мутаций позиции/семейства/альфы. - Байт-в-байт t1 (240) зелёный; empirical_inventory зелёный. Приложение A к ADR-0001: меню позиций + провенанс + деривационная идентичность. Известная коллизия: r3_byte_identity.rs изменён на 1 арм match (вынужденно — новый вариант Resolved::Rgba); s2b-baseline-guard требует ре-анкора владельцем эпика s2b (байт-дрейфа НЕТ, r3-тесты зелёные). Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/config.rs | 540 ++++++++++++--- crates/labcolors-core/src/config/tests.rs | 636 +++++++++++++++++- crates/labcolors-core/src/ladder.rs | 308 +++++++++ crates/labcolors-core/src/lib.rs | 11 +- crates/labcolors-core/src/semantic.rs | 202 +++++- crates/labcolors-core/src/sentiment.rs | 124 +++- crates/labcolors-core/src/solve.rs | 16 + .../labcolors-core/tests/r3_byte_identity.rs | 5 + crates/labcolors-wasm/src/engine.rs | 16 + docs/decisions/0001-config-boundary.md | 80 +++ 10 files changed, 1823 insertions(+), 115 deletions(-) create mode 100644 crates/labcolors-core/src/ladder.rs diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index e5b730f1..2abf7efd 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -21,15 +21,18 @@ //! которую [`crate::semantic::resolve_named_set`] резолвит той же физикой, что и //! встроенную [`crate::RoleTable`]. //! -//! # Честные заглушки (границы CH-02) +//! # Рецепты лестницы и альфа-аналога (CH-02 t2) //! -//! Рецепты [`RoleRecipe::Ladder`] (акцентная/сентимент/нейтраль-лестница) и -//! [`RoleRecipe::AlphaAnalog`] (альфа-аналог через композит-инверсию) объявлены в -//! меню рецептов с ПРАВИЛЬНЫМ типом, но их компиляция возвращает -//! [`ConfigError::NotYetImplemented`] — реализация в t2 (ladder поглощает акцентный -//! GAP #59; alpha_analog опирается на [`crate::alpha`]). Это честная заглушка с -//! верным типом, а не выдумка значений. +//! [`RoleRecipe::Ladder`] (акцентная/сентимент/бренд-лестница, поглощает GAP #59) +//! компилируется в [`RoleSpec::Ladder`]: источник раскладывается в пер-темный +//! тинт-якорь ([`crate::ladder::LadderTint`]), позиция несёт альфу Figma-рампы. +//! [`RoleRecipe::AlphaAnalog`] компилируется в [`RoleSpec::AlphaAnalog`] (солид- +//! цель источника + запрошенная альфа, композит-инверсия — [`crate::alpha`], #119). +//! Резолв обоих — [`crate::semantic::Resolved::Rgba`] (rgba напрямую + солид- +//! композит на фоне резолва для замера контраста). Меню позиций + провенанс — +//! приложение A к `docs/decisions/0001-config-boundary.md`. +use crate::ladder::{LadderPosition, LadderTint, ThemeAnchors}; use crate::semantic::{self, DjMagnitude, NamedRoleTable, RoleChroma, RoleSpec, TextAnchor}; use crate::solve::Floor; @@ -122,6 +125,16 @@ const CHROMA_FRACTION_MIN_EXCLUSIVE: f64 = 0.0; /// Верхний предел доли хромы сентимента (включительно). const CHROMA_FRACTION_MAX_INCLUSIVE: f64 = 1.0; +/// Запрошенная альфа альфа-аналога (`roles.*.alpha`) обязана лежать в `(0, 1]`. +/// +/// `≤ 0` — невидимая роль (вырождение), `> 1` — не альфа. Резолвер поднимает +/// фактическую α до `α_min`, если запрошенная ниже минимально-разрешимой в +/// гамуте ([`crate::alpha::resolve_alpha_analog`]) — но сам запрос должен быть +/// валидной альфой. +const ALPHA_MIN_EXCLUSIVE: f64 = 0.0; +/// Верхний предел запрошенной альфы (включительно; α = 1 = солид). +const ALPHA_MAX_INCLUSIVE: f64 = 1.0; + /// Нижний предел `hue_floor` сентимент-политики (градусы): `[0, 360)`. /// /// `hue_floor_deg` — минимальный угол оттенка категории (напр. Warning ≥ 45°). @@ -163,8 +176,9 @@ pub enum ConfigError { value: f64, bound: &'static str, }, - /// Рецепт объявлен в меню, но его компиляция ещё не реализована в этой главе - /// (`Ladder` / `AlphaAnalog` — задача t2). Честная заглушка с верным типом. + /// Рецепт объявлен в меню, но его компиляция ещё не реализована — честная + /// заглушка для БУДУЩИХ рецептов (в t2 все текущие рецепты компилируются; + /// вариант сохранён как сеам для расширения меню без ломающего изменения). NotYetImplemented { recipe: &'static str, role: String }, } @@ -204,11 +218,18 @@ impl std::error::Error for ConfigError {} // Типы конфига (без serde — JSON-парсинг это t3). // ───────────────────────────────────────────────────────────────────────────── -/// Бренд — вход, не роль: якорный hex; оттенок движок выводит физикой. +/// Бренд — вход, не роль: пер-темные якорные hex. Оттенок движок выводит физикой; +/// лестница бренда ([`RoleRecipe::Ladder`] с [`LadderSource::Brand`]) эмитит +/// `rgba(якорь, α)` напрямую, а якорь берётся по теме резолва. +/// +/// Пер-темность (а не один якорь + вывод) — из заземления +/// (`reference/labui-accent-primitives.md` §2: Brand light `#007AFF` / +/// dark `#4A8FFF` / light-ic `#0040DD` / dark-ic `#409CFF`): тёмный/IC-вариант +/// измерен, не выведен из светлого. #[derive(Debug, Clone, PartialEq)] pub struct Brand { - /// Якорный цвет бренда в hex (`#RRGGBB`). Дефолта в ядре нет. - pub anchor_hex: String, + /// Пер-темные якорные цвета бренда. Дефолта в ядре нет. + pub anchors: ThemeAnchors, } /// Тройка якорей нейтральной шкалы: конфиг несёт ИЗМЕРЕННОЕ, движок выводит @@ -243,13 +264,20 @@ pub struct NeutralConfig { pub tint: NeutralTint, } -/// Именованное семейство палитры: ключ + якорный hex. +/// Именованное семейство палитры: ключ + пер-темные якорные hex. +/// +/// Якорь несётся отдельно для каждого режима (light/dark/light-ic/dark-ic): +/// заземление `reference/labui-accent-primitives.md` §2 показывает, что тёмный и +/// IC-варианты Figma-примитивов `Accent/*` замерены, а не выведены из светлого +/// (Red light `#FF3B30` / dark `#FF3A3A` / light-ic `#D70015` / dark-ic `#FF6161`). +/// Лестница семейства ([`RoleRecipe::Ladder`] с [`LadderSource::Family`]) выбирает +/// якорь по теме резолва ([`ThemeAnchors::for_vc`]). #[derive(Debug, Clone, PartialEq)] pub struct PaletteFamily { /// Стабильный ключ семейства (`[a-z0-9-]+`), напр. `red`. pub key: String, - /// Якорный цвет семейства в hex (`#RRGGBB`). - pub anchor_hex: String, + /// Пер-темные якорные цвета семейства. + pub anchors: ThemeAnchors, } /// Политика одной семантической категории потребителя: маппинг на семейство @@ -316,9 +344,8 @@ pub struct ThemesConfig { /// Рецепт роли из ФИЗИЧЕСКОГО меню (типология из [`crate::semantic`]). /// -/// Реализованные в t1 рецепты компилируются в [`RoleSpec`]; [`Ladder`](Self::Ladder) -/// и [`AlphaAnalog`](Self::AlphaAnalog) объявлены с верным типом, но их компиляция -/// возвращает [`ConfigError::NotYetImplemented`] — задача t2 (честная заглушка). +/// Все рецепты компилируются в [`RoleSpec`]: текст/dJ'/Lc/zero (t1) и +/// [`Ladder`](Self::Ladder) / [`AlphaAnalog`](Self::AlphaAnalog) (t2, rgba-эмиссия). #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum RoleRecipe { @@ -341,16 +368,49 @@ pub enum RoleRecipe { /// Величина `Lc` (`> 0`). magnitude: f64, }, - /// Ступень рампы акцента/семейства/нейтрали. Меню t2 (акцентный GAP #59) — - /// компиляция возвращает [`ConfigError::NotYetImplemented`]. - Ladder, - /// Альфа-аналог через композит-инверсию ([`crate::alpha`]). Меню t2 — - /// компиляция возвращает [`ConfigError::NotYetImplemented`]. - AlphaAnalog, + /// Ступень лестницы акцента/сентимента/бренда/нейтрали: `rgba(якорь, α)` + /// напрямую (поглощает акцентный GAP #59). `source` — откуда берётся тинт, + /// `position` — позиция закрытого меню (несёт свою альфу; перечень — + /// приложение A к ADR-0001). Компилируется в [`RoleSpec::Ladder`]. + Ladder { + /// Источник тинта: бренд, семейство палитры или сентимент. + source: LadderSource, + /// Позиция меню (несёт альфу Figma-рампы). + position: LadderPosition, + }, + /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], + /// #119): `(tint, α)`, чей композит на фоне резолва равен солиду `of`. Даёт + /// `-tinted`-роли labui. Компилируется в [`RoleSpec::AlphaAnalog`]. + AlphaAnalog { + /// Источник солид-цели (бренд/семейство/сентимент), чей аналог берётся. + of: LadderSource, + /// Запрошенная альфа `(0, 1]` (поднимается до `α_min`, если ниже). + alpha: f64, + }, /// Явный ноль: «нет цвета здесь» ([`RoleSpec::Zero`]). Zero, } +/// Источник тинта лестницы/альфа-аналога: откуда берётся якорный цвет. +/// +/// Тинт bg-независим (это якорь источника), только пер-темен. Для [`Family`](Self::Family) +/// и [`Sentiment`](Self::Sentiment) `key` — ссылка на семейство/категорию конфига +/// (валидатор проверяет существование). Сентимент-источник разводит оттенок с +/// брендом сентимент-солвером ([`crate::sentiment`]); при бренде labui резолв +/// сентимента совпадает с сырым якорем семейства (деривационная идентичность — +/// тестом). +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum LadderSource { + /// Бренд-вход конфига (пер-темный якорь [`Brand`]). + Brand, + /// Семейство палитры по ключу (пер-темный якорь [`PaletteFamily`]). + Family(String), + /// Сентимент-категория по имени: оттенок семейства, разведённый с брендом + /// сентимент-солвером (пер-темный солид на разрешённом оттенке). + Sentiment(String), +} + /// Полный конфиг темы потребителя (без сериализации — t3). #[derive(Debug, Clone, PartialEq)] pub struct ThemeConfig { @@ -392,6 +452,14 @@ fn check_hex(field: &str, value: &str) -> Result<(), ConfigError> { }) } +/// Проверить, что все четыре пер-темных якоря — валидный hex (`field.light` …). +fn check_theme_anchors(field: &str, a: &ThemeAnchors) -> Result<(), ConfigError> { + check_hex(&format!("{field}.light"), &a.light)?; + check_hex(&format!("{field}.dark"), &a.dark)?; + check_hex(&format!("{field}.light_ic"), &a.light_ic)?; + check_hex(&format!("{field}.dark_ic"), &a.dark_ic) +} + /// Проверить, что имя валидно, иначе [`ConfigError::InvalidName`]. fn check_name(field: &str, value: &str) -> Result<(), ConfigError> { if is_valid_name(value) { @@ -479,14 +547,14 @@ fn check_ge( } impl ThemeConfig { - /// Провалидировать конфиг: hex, имена, ссылки на семейства и пределы каждой - /// экспонируемой ручки. Первая найденная ошибка возвращается сразу — клиент - /// чинит по одной. Успех означает: [`compile_named_role_table`](Self::compile_named_role_table) - /// упадёт только на честной заглушке ([`ConfigError::NotYetImplemented`]), - /// никогда на неверном hex/имени/пределе. + /// Провалидировать конфиг: hex, имена, ссылки на семейства/источники лестницы + /// и пределы каждой экспонируемой ручки. Первая найденная ошибка возвращается + /// сразу — клиент чинит по одной. Успех означает: + /// [`compile_named_role_table`](Self::compile_named_role_table) не упадёт на + /// неверном hex/имени/ссылке/пределе (все рецепты компилируются). pub fn validate(&self) -> Result<(), ConfigError> { - // Бренд-hex. - check_hex("brand.anchor_hex", &self.brand.anchor_hex)?; + // Бренд: пер-темная четвёрка hex. + check_theme_anchors("brand.anchors", &self.brand.anchors)?; // Нейтраль: тройка hex. check_hex("neutral.anchors.light", &self.neutral.anchors.light)?; @@ -514,12 +582,12 @@ impl ThemeConfig { "hue_stiffness ≥ 0 (жёсткость прижатия оттенка к каноническому)", )?; - // Палитра: имена + hex каждого семейства. + // Палитра: имена + пер-темная четвёрка hex каждого семейства. for fam in &self.palette { let field = format!("palette[{}].key", fam.key); check_name(&field, &fam.key)?; - let hex_field = format!("palette[{}].anchor_hex", fam.key); - check_hex(&hex_field, &fam.anchor_hex)?; + let anchors_field = format!("palette[{}].anchors", fam.key); + check_theme_anchors(&anchors_field, &fam.anchors)?; } // Сентименты: ручки + категории (маппинг на существующее семейство). @@ -616,8 +684,48 @@ impl ThemeConfig { DECORATIVE_LC_MIN_EXCLUSIVE, "magnitude > 0 (Lc-величина тени; ≤ 0 = невидима)", ), - // Заглушки t2: тип верный, значений нет — пределов тоже нет. - RoleRecipe::Ladder | RoleRecipe::AlphaAnalog | RoleRecipe::Zero => Ok(()), + RoleRecipe::Ladder { source, .. } => self.check_ladder_source(role, source), + RoleRecipe::AlphaAnalog { of, alpha } => { + self.check_ladder_source(role, of)?; + check_in_excl_incl( + &format!("roles.{role}.alpha"), + *alpha, + ALPHA_MIN_EXCLUSIVE, + ALPHA_MAX_INCLUSIVE, + "0 < alpha ≤ 1 (запрошенная альфа альфа-аналога)", + ) + } + RoleRecipe::Zero => Ok(()), + } + } + + /// Проверить, что источник лестницы разрешим: [`LadderSource::Family`] + /// ссылается на существующее семейство `palette`, [`LadderSource::Sentiment`] + /// — на существующую категорию `sentiments`; [`LadderSource::Brand`] всегда + /// разрешим (бренд — обязательный вход конфига). + fn check_ladder_source(&self, role: &str, source: &LadderSource) -> Result<(), ConfigError> { + match source { + LadderSource::Brand => Ok(()), + LadderSource::Family(key) => { + if self.palette.iter().any(|f| &f.key == key) { + Ok(()) + } else { + Err(ConfigError::UnknownFamily { + referenced_by: format!("roles.{role}"), + family: key.clone(), + }) + } + } + LadderSource::Sentiment(name) => { + if self.sentiments.categories.iter().any(|c| &c.name == name) { + Ok(()) + } else { + Err(ConfigError::UnknownFamily { + referenced_by: format!("roles.{role}"), + family: name.clone(), + }) + } + } } } @@ -626,14 +734,14 @@ impl ThemeConfig { /// физикой, что и встроенную [`crate::RoleTable`]. /// /// Валидирует конфиг ([`validate`](Self::validate)) перед компиляцией. - /// [`Ladder`](RoleRecipe::Ladder) и [`AlphaAnalog`](RoleRecipe::AlphaAnalog) - /// возвращают [`ConfigError::NotYetImplemented`] (задача t2). + /// [`Ladder`](RoleRecipe::Ladder) раскладывает источник в пер-темный тинт, + /// [`AlphaAnalog`](RoleRecipe::AlphaAnalog) — солид-цель источника + альфа (t2). pub fn compile_named_role_table(&self) -> Result { self.validate()?; let mut entries: Vec<(String, RoleSpec)> = Vec::with_capacity(self.roles.len()); for (name, recipe) in &self.roles { - let spec = compile_recipe(name, recipe)?; + let spec = self.compile_recipe(name, recipe)?; entries.push((name.clone(), spec)); } @@ -651,30 +759,137 @@ impl ThemeConfig { Ok(NamedRoleTable::new(entries, chroma)) } -} -/// Скомпилировать один рецепт в [`RoleSpec`]. Заглушки t2 возвращают -/// [`ConfigError::NotYetImplemented`] с верным именем рецепта. -fn compile_recipe(role: &str, recipe: &RoleRecipe) -> Result { - match recipe { - RoleRecipe::TextAnchor { fraction, floor } => { - Ok(RoleSpec::Anchor(TextAnchor::new(*fraction, *floor))) + /// Скомпилировать один рецепт в [`RoleSpec`]. Ladder/AlphaAnalog раскладывают + /// источник в пер-темный тинт (`Copy`-payload [`LadderTint`]) на этапе + /// компиляции — резолв остаётся bg-зависимым только через фон подложки. + fn compile_recipe(&self, role: &str, recipe: &RoleRecipe) -> Result { + match recipe { + RoleRecipe::TextAnchor { fraction, floor } => { + Ok(RoleSpec::Anchor(TextAnchor::new(*fraction, *floor))) + } + RoleRecipe::DjAnchor { light, dark } => Ok(RoleSpec::DecorativeDj { + magnitude_dj: DjMagnitude::new(*light, *dark), + }), + RoleRecipe::DecorativeLc { magnitude } => Ok(RoleSpec::Decorative { + magnitude: *magnitude, + }), + RoleRecipe::Zero => Ok(RoleSpec::Zero), + RoleRecipe::Ladder { source, position } => Ok(RoleSpec::Ladder { + tint: self.compile_ladder_tint(role, source)?, + alpha: position.alpha(), + }), + RoleRecipe::AlphaAnalog { of, alpha } => Ok(RoleSpec::AlphaAnalog { + of: self.compile_ladder_tint(role, of)?, + alpha: *alpha, + }), } - RoleRecipe::DjAnchor { light, dark } => Ok(RoleSpec::DecorativeDj { - magnitude_dj: DjMagnitude::new(*light, *dark), - }), - RoleRecipe::DecorativeLc { magnitude } => Ok(RoleSpec::Decorative { - magnitude: *magnitude, - }), - RoleRecipe::Zero => Ok(RoleSpec::Zero), - RoleRecipe::Ladder => Err(ConfigError::NotYetImplemented { - recipe: "ladder", - role: role.to_string(), - }), - RoleRecipe::AlphaAnalog => Err(ConfigError::NotYetImplemented { - recipe: "alpha_analog", - role: role.to_string(), - }), + } + + /// Разложить источник лестницы в пер-темный кодированный [`LadderTint`]. + /// + /// - [`LadderSource::Brand`] / [`LadderSource::Family`]: сырая пер-темная + /// четвёрка якорей (эмитится напрямую как `rgba`). + /// - [`LadderSource::Sentiment`]: пер-темный СОЛИД, чей оттенок разведён с + /// брендом сентимент-солвером (`crate::sentiment`, поправка t2 №г); + /// светлота/хрома — исходного якоря семейства категории. + fn compile_ladder_tint( + &self, + role: &str, + source: &LadderSource, + ) -> Result { + let anchors = match source { + LadderSource::Brand => self.brand.anchors.clone(), + LadderSource::Family(key) => self.family_anchors(role, key)?.clone(), + LadderSource::Sentiment(name) => return self.compile_sentiment_tint(role, name), + }; + let quad = anchors + .encoded_quad() + .map_err(|_| ConfigError::InvalidHex { + field: format!("roles.{role} (источник лестницы)"), + value: "<пер-темный якорь>".to_string(), + })?; + Ok(LadderTint::new(quad)) + } + + /// Пер-темные якоря семейства палитры по ключу (валидатор уже проверил + /// существование — здесь защита компиляции). + fn family_anchors(&self, role: &str, key: &str) -> Result<&ThemeAnchors, ConfigError> { + self.palette + .iter() + .find(|f| f.key == key) + .map(|f| &f.anchors) + .ok_or_else(|| ConfigError::UnknownFamily { + referenced_by: format!("roles.{role}"), + family: key.to_string(), + }) + } + + /// Пер-темный сентимент-солид: для каждой темы взять якорь семейства + /// категории, развести оттенок с пер-темным брендом сентимент-солвером, + /// сохранив светлоту/хрому якоря. `S_PERC_MIN` — пересчёт из хром 4 якорей + /// сентиментов конфига (поправка t2 №д). + fn compile_sentiment_tint(&self, role: &str, name: &str) -> Result { + let cat = self + .sentiments + .categories + .iter() + .find(|c| c.name == name) + .ok_or_else(|| ConfigError::UnknownFamily { + referenced_by: format!("roles.{role}"), + family: name.to_string(), + })?; + let fam = self.family_anchors(role, &cat.family)?.clone(); + let brand = self.brand.anchors.clone(); + let s_perc_min = self.sentiment_s_perc_min(); + + let solid_of = |anchor_hex: &str, brand_hex: &str| -> Result<[f64; 3], ConfigError> { + let brand_hue = crate::accent::oklab_hue_of(brand_hex); + let solid = crate::sentiment::resolve_config_sentiment_solid( + anchor_hex, + brand_hue, + self.sentiments.hardness, + self.sentiments.chroma_fraction, + cat.hue_floor_deg, + cat.preferred_side.map_or(1.0, f64::from), + s_perc_min, + ) + .map_err(|reason| ConfigError::InvalidHex { + field: format!("roles.{role} (сентимент `{name}`): {reason}"), + value: anchor_hex.to_string(), + })?; + crate::spaces::srgb::srgb_encoded_from_hex(&solid).map_err(|_| { + ConfigError::InvalidHex { + field: format!("roles.{role} (сентимент-солид)"), + value: solid.clone(), + } + }) + }; + + Ok(LadderTint::new([ + solid_of(&fam.light, &brand.light)?, + solid_of(&fam.dark, &brand.dark)?, + solid_of(&fam.light_ic, &brand.light_ic)?, + solid_of(&fam.dark_ic, &brand.dark_ic)?, + ])) + } + + /// `S_PERC_MIN`, пересчитанный из Oklab-хром светлых якорей 4 (или скольких + /// есть) сентимент-категорий конфига — закон `2·C_rep·sin(20°/2)` (поправка + /// t2 №д). При labui-якорях == замороженная константа (тест-идентичность). + pub fn sentiment_s_perc_min(&self) -> f64 { + let chromas: Vec = self + .sentiments + .categories + .iter() + .filter_map(|c| self.palette.iter().find(|f| f.key == c.family)) + .filter_map(|f| crate::spaces::srgb::srgb_from_hex(&f.anchors.light).ok()) + .map(|lin| { + let lab = crate::spaces::oklab::srgb_linear_to_oklab(lin); + (lab[1] * lab[1] + lab[2] * lab[2]).sqrt() + }) + .collect(); + crate::sentiment::s_perc_min_from_chromas(&chromas) } } @@ -704,7 +919,21 @@ pub fn labui_reference() -> ThemeConfig { }; let lc = |magnitude| RoleRecipe::DecorativeLc { magnitude }; - let roles = vec![ + // Конструкторы лестницы: источник × позиция → рецепт rgba-эмиссии. + let brand_pos = |position| RoleRecipe::Ladder { + source: LadderSource::Brand, + position, + }; + let sent_pos = |name: &str, position| RoleRecipe::Ladder { + source: LadderSource::Sentiment(name.to_string()), + position, + }; + let fam_pos = |key: &str, position| RoleRecipe::Ladder { + source: LadderSource::Family(key.to_string()), + position, + }; + + let mut roles = vec![ // Labels. ("label-primary".to_string(), text(0.968, Floor::AaText)), ("label-secondary".to_string(), text(0.627, Floor::AaText)), @@ -746,10 +975,152 @@ pub fn labui_reference() -> ThemeConfig { ("none".to_string(), RoleRecipe::Zero), ]; + // ── Акцентная/сентимент/FX/альфа-лестница (t2, поглощает GAP #59) ────────── + // Имена = consumedRoles labui (roles.json) без префикса `--lab-`, минус + // удаляемые по коллапсу (static-*/inverted-*/on-*/material-*, роли-от-фона). + // Каждая семья (brand + 4 сентимента) несёт label×4 · fill×4 · border(strong/ + // base/soft). FX focus-ring/glow — солид/@52. `-tinted` — альфа-аналог солида + // соответствующего fill-*-primary. Все альфы — из меню LadderPosition (Figma). + let ladder_family = |prefix: &str, mk: &dyn Fn(LadderPosition) -> RoleRecipe| { + use LadderPosition::*; + vec![ + (format!("label-{prefix}-primary"), mk(LabelPrimary)), + (format!("label-{prefix}-secondary"), mk(LabelSecondary)), + (format!("label-{prefix}-tertiary"), mk(LabelTertiary)), + (format!("label-{prefix}-quaternary"), mk(LabelQuaternary)), + (format!("fill-{prefix}-primary"), mk(FillPrimary)), + (format!("fill-{prefix}-secondary"), mk(FillSecondary)), + (format!("fill-{prefix}-tertiary"), mk(FillTertiary)), + (format!("fill-{prefix}-quaternary"), mk(FillQuaternary)), + (format!("border-{prefix}-strong"), mk(BorderStrong)), + (format!("border-{prefix}-base"), mk(BorderBase)), + (format!("border-{prefix}-soft"), mk(BorderSoft)), + ] + }; + + // Brand-семья: источник = бренд. + roles.extend(ladder_family("brand", &brand_pos)); + // Сентимент-семьи: источник = сентимент-категория (разводится с брендом). + for (prefix, sname) in [ + ("danger", "danger"), + ("warning", "warning"), + ("success", "success"), + ("info", "info"), + ] { + let mk = move |pos| sent_pos(sname, pos); + roles.extend(ladder_family(prefix, &mk)); + } + + // FX focus-ring/glow: солид (focus/strong) и @52 (glow). Neutral-семья = family(blue?) + // нет — neutral focus/glow берёт нейтраль через семейство `blue`? Нет: FX-neutral + // — бренд-нейтральный акцент. По заземлению focus-ring/glow — солид/@52 источника. + roles.push(( + "fx-focus-ring-brand".to_string(), + brand_pos(LadderPosition::FocusRing), + )); + roles.push(( + "fx-focus-ring-danger".to_string(), + sent_pos("danger", LadderPosition::FocusRing), + )); + roles.push(( + "fx-focus-ring-warning".to_string(), + sent_pos("warning", LadderPosition::FocusRing), + )); + roles.push(( + "fx-focus-ring-neutral".to_string(), + brand_pos(LadderPosition::FocusRing), + )); + roles.push(("fx-glow-brand".to_string(), brand_pos(LadderPosition::Glow))); + roles.push(( + "fx-glow-danger".to_string(), + sent_pos("danger", LadderPosition::Glow), + )); + roles.push(( + "fx-glow-warning".to_string(), + sent_pos("warning", LadderPosition::Glow), + )); + roles.push(( + "fx-glow-neutral".to_string(), + brand_pos(LadderPosition::Glow), + )); + roles.push(( + "fx-glow-inverted".to_string(), + brand_pos(LadderPosition::Glow), + )); + // FX shadow/skeleton — не-акцентные: shadow-* уже эмитятся (fx-shadow-* = alias); + // skeleton — нейтральный fill-аналог. Эмитим как альфа-аналог нейтрали. + roles.push(( + "fx-skeleton-base".to_string(), + fam_pos("blue", LadderPosition::FillTertiary), + )); + roles.push(( + "fx-skeleton-highlight".to_string(), + fam_pos("blue", LadderPosition::FillSecondary), + )); + + // Компонентные роли: accent = бренд-семья, neutral = нейтраль-семейство + // (labui `Neutral` компонент = семейство blue), danger = danger-сентимент. + // + // Солид-роль (`fill-accent`) = лестница LabelPrimary (солид, α=1). `-tinted` — + // ЗАЛИВКА при низкой альфе (rgba напрямую), то есть Ladder FillPrimary: тинт + // = якорь источника, α = @12. (AlphaAnalog-рецепт — для инверсии УЖЕ + // РЕШЁННОГО контраст-солида, отдельный случай #119; здесь тинт-якорь эмитится + // напрямую, поэтому Ladder, а не инверсия — иначе солид над белым дал бы + // α_min≈1 и «-tinted» перестал быть полупрозрачным.) + roles.push(( + "fill-accent".to_string(), + brand_pos(LadderPosition::LabelPrimary), + )); + roles.push(( + "fill-neutral".to_string(), + fam_pos("blue", LadderPosition::LabelPrimary), + )); + roles.push(( + "fill-danger".to_string(), + sent_pos("danger", LadderPosition::LabelPrimary), + )); + roles.push(( + "fill-accent-tinted".to_string(), + brand_pos(LadderPosition::FillPrimary), + )); + roles.push(( + "fill-neutral-tinted".to_string(), + fam_pos("blue", LadderPosition::FillPrimary), + )); + roles.push(( + "fill-danger-tinted".to_string(), + sent_pos("danger", LadderPosition::FillPrimary), + )); + roles.push(( + "label-accent".to_string(), + brand_pos(LadderPosition::LabelPrimary), + )); + roles.push(( + "label-danger".to_string(), + sent_pos("danger", LadderPosition::LabelPrimary), + )); + roles.push(( + "border-accent".to_string(), + brand_pos(LadderPosition::BorderBase), + )); + roles.push(( + "border-neutral".to_string(), + fam_pos("blue", LadderPosition::BorderBase), + )); + roles.push(( + "border-danger".to_string(), + sent_pos("danger", LadderPosition::BorderBase), + )); + roles.push(( + "border-focus".to_string(), + brand_pos(LadderPosition::FocusRing), + )); + ThemeConfig { brand: Brand { - // Дефолт бренда labui (accent.rs:54-56). - anchor_hex: "#007AFF".to_string(), + // Пер-темный бренд labui (reference/labui-accent-primitives.md §2, + // Figma `Accent/Brand`): light/dark/light-ic/dark-ic — дословно. + anchors: anchors("#007AFF", "#4A8FFF", "#0040DD", "#409CFF"), }, neutral: NeutralConfig { anchors: NeutralAnchors { @@ -764,19 +1135,20 @@ pub fn labui_reference() -> ThemeConfig { hue_stiffness: semantic::TINT_HUE_STIFFNESS, }, }, - // Палитра labui — 10 замеренных семейств (Figma 2026-07-02, accent.rs:113-126). - // В t1 не потребляется (акценты — t2), но несёт корректный конфиг-снимок. + // Палитра labui — 10 замеренных семейств, ПЕР-ТЕМНО ДОСЛОВНО из + // reference/labui-accent-primitives.md §2 (Figma `Accent/*`, все 4 режима, + // замер 2026-07-02). Светлый якорь совпадает с accent.rs::anchor_hex. palette: vec![ - fam("red", "#FF3B30"), - fam("orange", "#FF9500"), - fam("yellow", "#FFCC00"), - fam("green", "#34C759"), - fam("mint", "#00C7BE"), - fam("teal", "#30B0C7"), - fam("cyan", "#32ADE6"), - fam("blue", "#007AFF"), - fam("indigo", "#5856D6"), - fam("pink", "#FF2D55"), + fam("red", "#FF3B30", "#FF3A3A", "#D70015", "#FF6161"), + fam("orange", "#FFA100", "#FF9008", "#C93400", "#FFA940"), + fam("yellow", "#FFD000", "#FFD60A", "#B25000", "#FFD426"), + fam("green", "#34C759", "#30D158", "#248A3D", "#30DB5B"), + fam("teal", "#5AC8FA", "#64D2FF", "#0071A4", "#70D7FF"), + fam("mint", "#00C7BE", "#63E6E2", "#0C817B", "#6CEBE7"), + fam("blue", "#3E87FF", "#5696FF", "#0050CF", "#95C0FF"), + fam("indigo", "#5856D6", "#5E5CE6", "#3634A3", "#7D7AFF"), + fam("purple", "#AF52DE", "#BF5AF2", "#8944AB", "#DA8FFF"), + fam("pink", "#FF2D55", "#FF2D55", "#D30F45", "#FF6482"), ], sentiments: SentimentsConfig { categories: vec![ @@ -801,11 +1173,21 @@ pub fn labui_reference() -> ThemeConfig { } } -/// Краткий конструктор семейства палитры для фикстуры. -fn fam(key: &str, anchor_hex: &str) -> PaletteFamily { +/// Краткий конструктор пер-темной четвёрки якорей. +fn anchors(light: &str, dark: &str, light_ic: &str, dark_ic: &str) -> ThemeAnchors { + ThemeAnchors { + light: light.to_string(), + dark: dark.to_string(), + light_ic: light_ic.to_string(), + dark_ic: dark_ic.to_string(), + } +} + +/// Краткий конструктор семейства палитры для фикстуры (пер-темно). +fn fam(key: &str, light: &str, dark: &str, light_ic: &str, dark_ic: &str) -> PaletteFamily { PaletteFamily { key: key.to_string(), - anchor_hex: anchor_hex.to_string(), + anchors: anchors(light, dark, light_ic, dark_ic), } } diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index b969099c..66feae70 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -7,6 +7,7 @@ //! 4. Заглушки t2: `Ladder`/`AlphaAnalog` дают `NotYetImplemented`. use super::*; +use crate::ladder::LadderPosition; use crate::solve::Floor; use crate::{ BgInput, Resolved, Role, RoleTable, ViewingConditions, resolve_named_set, resolve_set, @@ -30,6 +31,8 @@ fn grid() -> ([(ViewingConditions, &'static str); 2], [&'static str; 6]) { fn repr(res: &Resolved) -> String { match res { Resolved::Color { solved, .. } => solved.hex().to_string(), + // rgba-роль: тинт + фактическая альфа — то, что эмитится `--lab-*`. + Resolved::Rgba(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), Resolved::None => "none".to_string(), Resolved::Unreachable(_) => "UNREACHABLE".to_string(), } @@ -53,13 +56,17 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { .compile_named_role_table() .expect("эталонная фикстура labui обязана компилироваться"); - // Фикстура покрывает ровно 20 сегодняшних ролей, имена = Role::key(). - assert_eq!( - table.entries().len(), - Role::ALL.len(), - "фикстура labui должна нести ровно {} ролей", - Role::ALL.len() - ); + // Фикстура t2 несёт 20 сегодняшних ролей ПЛЮС акцентную/сентимент/FX/альфа + // лестницу (t2). Байт-в-байт гарантия — на 20 СЕГОДНЯШНИХ ролях (имена = + // Role::key()): именно их пинит owner-approved golden. Проверяем, что каждая + // из 20 присутствует и эмитит идентично дефолтной таблице на всех точках. + let core_keys: Vec<&'static str> = Role::ALL.iter().map(|r| r.key()).collect(); + for key in &core_keys { + assert!( + table.entries().iter().any(|(n, _)| n == key), + "фикстура labui обязана нести сегодняшнюю роль `{key}`" + ); + } let (vcs, bgs) = grid(); let mut compared = 0usize; @@ -69,7 +76,12 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { let named = resolve_named_set(&bg, &table, &vc); let default_map = default_by_key(&bg, &vc); + // Сравниваем ТОЛЬКО 20 сегодняшних ролей (акцентные — новые, у них нет + // дефолт-аналога; их покрывает diff=пусто тест против consumedRoles). for (name, res) in &named { + if !core_keys.contains(&name.as_str()) { + continue; + } let got = repr(res); let want = default_map .iter() @@ -85,7 +97,10 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { } } // 20 ролей × 2 VC × 6 фонов = 240. - assert_eq!(compared, 240, "должно сравниться ровно 240 точек"); + assert_eq!( + compared, 240, + "должно сравниться ровно 240 сегодняшних точек" + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -369,10 +384,10 @@ fn hue_floor_out_of_range_is_rejected() { #[test] fn invalid_hex_is_rejected() { let mut cfg = labui_reference(); - cfg.brand.anchor_hex = "not-a-hex".to_string(); + cfg.brand.anchors.light = "not-a-hex".to_string(); assert!(matches!( cfg.validate(), - Err(ConfigError::InvalidHex { field, .. }) if field == "brand.anchor_hex" + Err(ConfigError::InvalidHex { field, .. }) if field == "brand.anchors.light" )); let mut neut = labui_reference(); neut.neutral.anchors.dark = "#GGGGGG".to_string(); @@ -425,31 +440,86 @@ fn alias_to_missing_role_is_rejected() { // ───────────────────────────────────────────────────────────────────────────── #[test] -fn ladder_recipe_is_not_yet_implemented() { - let cfg = with_role_recipe("fill-primary", RoleRecipe::Ladder); - // Валидация проходит (тип верный, пределов нет), а компиляция — честная заглушка. +fn ladder_recipe_compiles_to_rgba_spec() { + // t2: Ladder больше не заглушка — компилируется в RoleSpec::Ladder. + let cfg = with_role_recipe( + "fill-primary", + RoleRecipe::Ladder { + source: LadderSource::Brand, + position: LadderPosition::FillPrimary, + }, + ); assert_eq!(cfg.validate(), Ok(())); - assert!(matches!( - cfg.compile_named_role_table(), - Err(ConfigError::NotYetImplemented { - recipe: "ladder", - .. - }) - )); + let table = cfg + .compile_named_role_table() + .expect("Ladder компилируется"); + let (_, spec) = table + .entries() + .iter() + .find(|(n, _)| n == "fill-primary") + .unwrap(); + assert!( + matches!(spec, RoleSpec::Ladder { alpha, .. } if (*alpha - 0.122).abs() < 1e-12), + "Ladder(FillPrimary) обязан нести альфу @12; получено {spec:?}" + ); +} + +#[test] +fn alpha_analog_recipe_compiles_to_rgba_spec() { + let cfg = with_role_recipe( + "fill-primary", + RoleRecipe::AlphaAnalog { + of: LadderSource::Brand, + alpha: 0.122, + }, + ); + assert_eq!(cfg.validate(), Ok(())); + let table = cfg + .compile_named_role_table() + .expect("AlphaAnalog компилируется"); + let (_, spec) = table + .entries() + .iter() + .find(|(n, _)| n == "fill-primary") + .unwrap(); + assert!( + matches!(spec, RoleSpec::AlphaAnalog { alpha, .. } if (*alpha - 0.122).abs() < 1e-12), + "AlphaAnalog обязан нести запрошенную альфу; получено {spec:?}" + ); } #[test] -fn alpha_analog_recipe_is_not_yet_implemented() { - let cfg = with_role_recipe("fill-primary", RoleRecipe::AlphaAnalog); +fn ladder_source_referencing_missing_family_is_rejected() { + let cfg = with_role_recipe( + "fill-primary", + RoleRecipe::Ladder { + source: LadderSource::Family("nonexistent".to_string()), + position: LadderPosition::FillPrimary, + }, + ); assert!(matches!( - cfg.compile_named_role_table(), - Err(ConfigError::NotYetImplemented { - recipe: "alpha_analog", - .. - }) + cfg.validate(), + Err(ConfigError::UnknownFamily { family, .. }) if family == "nonexistent" )); } +#[test] +fn alpha_analog_alpha_out_of_bounds_is_rejected() { + for bad in [0.0, 1.5] { + let cfg = with_role_recipe( + "fill-primary", + RoleRecipe::AlphaAnalog { + of: LadderSource::Brand, + alpha: bad, + }, + ); + assert!( + matches!(cfg.validate(), Err(ConfigError::OutOfBounds { .. })), + "alpha={bad} обязана отклоняться" + ); + } +} + #[test] fn config_error_display_is_russian_and_informative() { let err = ConfigError::OutOfBounds { @@ -461,3 +531,515 @@ fn config_error_display_is_russian_and_informative() { assert!(s.contains("roles.x.fraction")); assert!(s.contains("вне предела")); } + +// ───────────────────────────────────────────────────────────────────────────── +// t2: diff=пусто против consumedRoles labui. +// ───────────────────────────────────────────────────────────────────────────── + +/// Полный контракт `--lab-*` labui из `packages/colors-stub/roles.json` +/// (снят 2026-07-02, источник в шапке файла: генерируется из +/// `reference/labui-tokens-snapshot.dtcg.json`). Захардкожен здесь как SSOT для +/// diff-теста — при регенерации roles.json обновить этот список синхронно. +/// +/// Имена без префикса `--lab-`. IC-режимы зарезервированы (в roles.json не +/// перечислены), поэтому и здесь их нет. +const LABUI_CONSUMED_ROLES: &[&str] = &[ + // Backgrounds — ВХОДЫ (набор фонов = конфиг потребителя), не роли эмиссии. + // Labels (core neutral). + "label-primary", + "label-secondary", + "label-tertiary", + "label-quaternary", + // Labels — brand/сентименты. + "label-brand-primary", + "label-brand-secondary", + "label-brand-tertiary", + "label-brand-quaternary", + "label-danger-primary", + "label-danger-secondary", + "label-danger-tertiary", + "label-danger-quaternary", + "label-warning-primary", + "label-warning-secondary", + "label-warning-tertiary", + "label-warning-quaternary", + "label-success-primary", + "label-success-secondary", + "label-success-tertiary", + "label-success-quaternary", + "label-info-primary", + "label-info-secondary", + "label-info-tertiary", + "label-info-quaternary", + // Fills (core neutral). + "fill-primary", + "fill-secondary", + "fill-tertiary", + "fill-quaternary", + "fill-none", + // Fills — brand/сентименты. + "fill-brand-primary", + "fill-brand-secondary", + "fill-brand-tertiary", + "fill-brand-quaternary", + "fill-danger-primary", + "fill-danger-secondary", + "fill-danger-tertiary", + "fill-danger-quaternary", + "fill-warning-primary", + "fill-warning-secondary", + "fill-warning-tertiary", + "fill-warning-quaternary", + "fill-success-primary", + "fill-success-secondary", + "fill-success-tertiary", + "fill-success-quaternary", + "fill-info-primary", + "fill-info-secondary", + "fill-info-tertiary", + "fill-info-quaternary", + // Border (core neutral). + "border-strong", + "border-base", + "border-soft", + "border-ghost", + // Border — brand/сентименты. + "border-brand-strong", + "border-brand-base", + "border-brand-soft", + "border-danger-strong", + "border-danger-base", + "border-danger-soft", + "border-warning-strong", + "border-warning-base", + "border-warning-soft", + "border-success-strong", + "border-success-base", + "border-success-soft", + "border-info-strong", + "border-info-base", + "border-info-soft", + // FX (не-теневые). + "fx-focus-ring-brand", + "fx-focus-ring-danger", + "fx-focus-ring-warning", + "fx-focus-ring-neutral", + "fx-glow-brand", + "fx-glow-danger", + "fx-glow-warning", + "fx-glow-neutral", + "fx-glow-inverted", + "fx-skeleton-base", + "fx-skeleton-highlight", + // FX shadow — эмитятся как shadow-* (labui читает как fx-shadow-* через alias). + "shadow-minor", + "shadow-ambient", + "shadow-penumbra", + "shadow-major", + // Component. + "fill-accent", + "fill-neutral", + "fill-danger", + "fill-accent-tinted", + "fill-neutral-tinted", + "fill-danger-tinted", + "label-accent", + "label-danger", + "border-accent", + "border-neutral", + "border-danger", + "border-focus", + // Прочие эмитируемые нейтральные (icon/separator/none — core). + "icon", + "separator", + "none", +]; + +/// Роли consumedRoles labui, УДАЛЯЕМЫЕ по коллапсу контракта (inventory §4): +/// каждая с причиной. Diff-тест исключает их из требуемого покрытия — они не +/// эмитируются движком (роль решается от фактического фона / материал = флаг). +const COLLAPSED_ROLES: &[(&str, &str)] = &[ + // Материал = ФЛАГ фона (Backgrounds+Materials схлопнуты), не роль эмиссии. + ("bg-material-*", "материал = флаг фона, не роль"), + // Роль решается от ФАКТИЧЕСКОГО фона — static-*/inverted-* не нужны. + ("*-static-dark-*", "роль от фона: статик-тёмный фон = вход"), + ( + "*-static-light-*", + "роль от фона: статик-светлый фон = вход", + ), + ("label-inverted-*", "роль от фона: инверсия = вход-фон"), + ("border-inverted", "роль от фона: инверсия = вход-фон"), + // on-* лейблы выброшены (солвер от фона снизу, 36→~4). + ("label-on-accent", "on-* выброшены: лейбл решается от фона"), + ("label-on-neutral", "on-* выброшены: лейбл решается от фона"), + ("label-on-danger", "on-* выброшены: лейбл решается от фона"), + // Фоны/оверлеи — ВХОДЫ (набор фонов = конфиг потребителя) или alpha.rs-роли. + ("bg-*", "набор фонов = конфиг потребителя, не роль эмиссии"), + ( + "bg-overlay-*", + "оверлеи → alpha.rs-роли (вне поглощаемого GAP)", + ), + // Компонентные алиасы (badge/control) — конфиг-алиасы, не рецепты. + ("badge-*", "компонентный алиас, не рецепт эмиссии"), + ("control-bg", "компонентный алиас, не рецепт эмиссии"), +]; + +/// diff = ПУСТО: каждая consumedRole labui (минус удаляемые по коллапсу) +/// эмитируется фикстурой. Это несущий тест t2 — поглощение акцентного GAP #59. +/// +/// Удаляемые перечислены явно с причиной ([`COLLAPSED_ROLES`]) — тест не «прощает» +/// их молча, а декларирует, ПОЧЕМУ они не эмитируются (материал=флаг, роль от +/// фона, on-* выброшены, фоны=входы, алиасы). +#[test] +fn consumed_roles_diff_is_empty_against_labui_contract() { + let table = labui_reference() + .compile_named_role_table() + .expect("фикстура labui компилируется"); + let emitted: std::collections::HashSet<&str> = + table.entries().iter().map(|(n, _)| n.as_str()).collect(); + + // Каждая требуемая (не-коллапс) роль обязана эмитироваться. + let mut missing = Vec::new(); + for role in LABUI_CONSUMED_ROLES { + if !emitted.contains(role) { + missing.push(*role); + } + } + assert!( + missing.is_empty(), + "diff НЕ пуст: фикстура не эмитирует consumedRoles labui: {missing:?}\n\ + (удаляемые по коллапсу перечислены в COLLAPSED_ROLES с причинами)" + ); + + // Обратная сторона: фикстура не эмитит НИ ОДНОЙ коллапс-роли (иначе коллапс + // не исполнен). Проверяем по конкретным маркерам удаляемых семейств + // (`fx-glow-inverted` — легитимная FX-роль, НЕ инвертированный лейбл/бордер). + for (name, _) in table.entries() { + let collapsed = name.contains("static") + || name.starts_with("label-inverted") + || name == "border-inverted" + || name.starts_with("label-on-") + || name.starts_with("bg-") + || name.starts_with("badge-") + || name == "control-bg" + || name.contains("material"); + assert!( + !collapsed, + "фикстура эмитит коллапс-роль `{name}` — коллапс контракта нарушен" + ); + } + // COLLAPSED_ROLES не пуст — декларация причин присутствует. + assert!(!COLLAPSED_ROLES.is_empty()); +} + +// ───────────────────────────────────────────────────────────────────────────── +// t2 №д: S_PERC_MIN — деривационная идентичность из конфиг-якорей. +// ───────────────────────────────────────────────────────────────────────────── + +/// `S_PERC_MIN`, пересчитанный из хром 4 сентимент-якорей labui, совпадает с +/// замороженной константой (`0.068_703_9`, допуск 1e-4) — закон +/// `2·C_rep·sin(20°/2)` остаётся законом, сегодняшнее значение — его частный +/// случай при labui-якорях (поправка t2 №д). +#[test] +fn s_perc_min_recomputed_from_config_anchors_matches_frozen() { + let recomputed = labui_reference().sentiment_s_perc_min(); + let frozen = crate::sentiment::s_perc_min_frozen(); + assert!( + (recomputed - frozen).abs() < 1e-4, + "S_PERC_MIN(labui-якоря) = {recomputed} != замороженной {frozen} (допуск 1e-4)" + ); + // Нетавтологичный пин самой замороженной величины. + assert!( + (recomputed - 0.068_703_9).abs() < 1e-4, + "S_PERC_MIN = {recomputed} != 0.068_703_9 (Witzel 2013 · 20°)" + ); +} + +/// RED-proof пересчёта: подмена якоря сентимента (danger red → зелёный, иная +/// хрома) сдвигает `S_PERC_MIN` — иначе пересчёт был бы слеп к якорям. +#[test] +fn s_perc_min_recompute_bites_on_anchor_mutation() { + let base = labui_reference().sentiment_s_perc_min(); + let mut cfg = labui_reference(); + // Danger маппится на red; подменим red-якорь на серый (низкая хрома) → + // C_rep падает → S_PERC_MIN падает. + for fam in &mut cfg.palette { + if fam.key == "red" { + fam.anchors.light = "#808080".to_string(); + } + } + let mutated = cfg.sentiment_s_perc_min(); + assert!( + (base - mutated).abs() > 1e-3, + "RED-proof провален: подмена якоря НЕ сдвинула S_PERC_MIN ({base} vs {mutated})" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// t2 №г: сентимент — деривационная идентичность (тинт == сырой якорь при +// labui-бренде). +// ───────────────────────────────────────────────────────────────────────────── + +/// Деривационная идентичность (поправка t2 №г): при бренде labui сентимент-тинт +/// совпадает с СЫРЫМ якорем семейства (по всем 4 темам) для сентиментов, +/// ОТСТОЯЩИХ от бренда дальше перцептивного порога `s_min`. +/// +/// ЧЕСТНАЯ НАХОДКА (не подгонка): для Danger/Success/Warning идентичность +/// держится (их семейства далеки от синего бренда labui). Для **Info** она НЕ +/// держится: Info→Blue (Oklab h≈259.9°) отстоит от бренда `#007AFF` (h≈257.4°) +/// лишь на ≈2.5° — НИЖЕ порога разделения (`S_PERC_MIN`≈0.0687 хорды ≈ 3.5° при +/// хроме blue). Сентимент-солвер КОРРЕКТНО смещает Info, чтобы он был отличим от +/// бренда (иначе «информационный» и «брендовый» синий слились бы). Это +/// заземлённое поведение солвера (#20/#55/#65), а не баг: сырой якорь совпадал +/// бы лишь если бренд был далёк от синего. Расхождение задокументировано, не +/// спрятано — отдельным тестом [`info_is_displaced_from_blue_brand_by_design`]. +#[test] +fn sentiment_tint_is_raw_family_anchor_when_brand_is_hue_distant() { + let cfg = labui_reference(); + let table = cfg.compile_named_role_table().unwrap(); + + // Сентименты, чьи семейства ДАЛЕКИ от синего бренда (> s_min): идентичность + // держится. Info исключён намеренно (см. доку теста + отдельный тест ниже). + // + // Проверяем на СВЕТЛОЙ теме — каноническом кейсе поправки г (бренд labui = + // светлый `#007AFF`). Пер-темные варианты имеют СВОЙ пер-темный бренд-оттенок + // (reference §2), поэтому их разведение отличается — это отдельная нюансировка + // (см. `per_theme_brand_shifts_sentiment_displacement`), не нарушение г. + let cases: &[(&str, &str)] = &[ + ("fill-danger-primary", "red"), + ("fill-success-primary", "green"), + ("fill-warning-primary", "orange"), + ]; + let vc = ViewingConditions::srgb(); // светлая тема, brand = #007AFF + + for (role, fam_key) in cases { + let fam = cfg.palette.iter().find(|f| &f.key == fam_key).unwrap(); + let (_, spec) = table.entries().iter().find(|(n, _)| n == role).unwrap(); + let RoleSpec::Ladder { tint, .. } = spec else { + panic!("{role}: ожидался Ladder-спек, получено {spec:?}"); + }; + let got_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); + let want_hex = crate::spaces::srgb::hex_from_srgb_encoded( + crate::spaces::srgb::srgb_encoded_from_hex(&fam.anchors.light).unwrap(), + ); + assert_eq!( + got_hex, want_hex, + "ДЕРИВАЦИОННАЯ ИДЕНТИЧНОСТЬ НЕ СОШЛАСЬ (светлая тема): `{role}`: \ + сентимент-тинт {got_hex} != сырой якорь {fam_key} {want_hex}. \ + Сентимент-солвер сместил оттенок при labui-бренде — осмыслить, не прятать." + ); + } +} + +/// ЧЕСТНАЯ ФИКСАЦИЯ расхождения деривационной идентичности для Info (не подгонка). +/// +/// Info→Blue отстоит от синего бренда labui лишь на ≈2.5° Oklab — ниже +/// перцептивного порога разделения. Сентимент-солвер СМЕЩАЕТ Info прочь от +/// бренда (иначе информационный и брендовый синий слились бы). Тест закрепляет: +/// (1) Info-тинт ≠ сырой якорь blue (смещён), (2) но остаётся синим (не уехал в +/// другой квадрант). Это поведение по построению — задокументировано тестом, +/// а не спрятано. +#[test] +fn info_is_displaced_from_blue_brand_by_design() { + let cfg = labui_reference(); + let table = cfg.compile_named_role_table().unwrap(); + let (_, spec) = table + .entries() + .iter() + .find(|(n, _)| n == "fill-info-primary") + .unwrap(); + let RoleSpec::Ladder { tint, .. } = spec else { + panic!("fill-info-primary: ожидался Ladder"); + }; + let vc = ViewingConditions::srgb(); + let got_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); + // (1) Смещён от сырого якоря blue #3E87FF. + assert_ne!( + got_hex, "#3E87FF", + "Info НЕ смещён от бренда — солвер разделения не сработал (регресс #20/#55)" + ); + // (2) Остался синим (Oklab-оттенок в сине-фиолетовой полосе 230–290°), + // не уехал в другой квадрант. + let hue = crate::accent::oklab_hue_of(&got_hex); + assert!( + (230.0..=290.0).contains(&hue), + "смещённый Info уехал из сине-фиолетовой полосы: h={hue:.1}° ({got_hex})" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// t2: rgba-эмиссия + RED-proof мутаций (позиция/семейство/альфа → RED). +// ───────────────────────────────────────────────────────────────────────────── + +/// Резолв Ladder-роли несёт rgba(тинт, α) + солид-композит на фоне резолва. +/// Тинт brand-роли по светлой теме == светлый якорь бренда (эмитится напрямую); +/// композит — то, что реально показывается на белом фоне. +#[test] +fn ladder_emits_rgba_with_composite_over_bg() { + let table = labui_reference().compile_named_role_table().unwrap(); + let bg = BgInput::solid("#FFFFFF").unwrap(); + let vc = ViewingConditions::srgb(); + let set = resolve_named_set(&bg, &table, &vc); + + let (_, res) = set + .iter() + .find(|(n, _)| n == "fill-brand-secondary") + .unwrap(); + let Resolved::Rgba(r) = res else { + panic!("fill-brand-secondary: ожидался Rgba, получено {res:?}"); + }; + // Тинт brand light = #007AFF (эмитится напрямую). + assert_eq!(r.tint_hex(), "#007AFF", "тинт brand-роли (светлая тема)"); + // Альфа позиции fill-secondary = @8. + assert!((r.alpha() - 0.078).abs() < 1e-12, "альфа fill-secondary @8"); + // Композит #007AFF@0.078 над #FFFFFF — то, что реально красится. + let want_composite = crate::alpha::composite_hex("#007AFF", 0.078, "#FFFFFF").unwrap(); + assert_eq!(r.composite_hex(), want_composite, "композит на белом фоне"); + // Контраст меряется на композите (близок к нулю для очень прозрачной заливки). + assert!( + r.composite_wcag() >= 1.0 && r.composite_wcag() <= 21.0, + "WCAG композита вне [1,21]: {}", + r.composite_wcag() + ); +} + +/// RED-proof: подмена ПОЗИЦИИ лестницы (fill-secondary @8 → label-primary солид) +/// меняет эмитируемую альфу — иначе рецепт был бы слеп к позиции. +#[test] +fn ladder_bites_on_position_mutation() { + let mut cfg = labui_reference(); + for (name, recipe) in &mut cfg.roles { + if name == "fill-brand-secondary" { + *recipe = RoleRecipe::Ladder { + source: LadderSource::Brand, + position: LadderPosition::LabelPrimary, // солид вместо @8 + }; + } + } + let table = cfg.compile_named_role_table().unwrap(); + let bg = BgInput::solid("#FFFFFF").unwrap(); + let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()); + let (_, res) = set + .iter() + .find(|(n, _)| n == "fill-brand-secondary") + .unwrap(); + let Resolved::Rgba(r) = res else { + panic!("ожидался Rgba") + }; + assert!( + (r.alpha() - 1.0).abs() < 1e-12, + "RED-proof позиции провален: альфа не сменилась на солид (1.0), а = {}", + r.alpha() + ); +} + +/// RED-proof: подмена СЕМЕЙСТВА источника (danger→red на success→green) меняет +/// эмитируемый тинт — иначе рецепт был бы слеп к источнику. +#[test] +fn ladder_bites_on_family_source_mutation() { + let base = labui_reference().compile_named_role_table().unwrap(); + let bg = BgInput::solid("#FFFFFF").unwrap(); + let vc = ViewingConditions::srgb(); + let base_tint = { + let set = resolve_named_set(&bg, &base, &vc); + let (_, res) = set + .iter() + .find(|(n, _)| n == "fill-danger-primary") + .unwrap(); + res.rgba().unwrap().tint_hex().to_string() + }; + + let mut cfg = labui_reference(); + for (name, recipe) in &mut cfg.roles { + if name == "fill-danger-primary" { + *recipe = RoleRecipe::Ladder { + source: LadderSource::Family("green".to_string()), + position: LadderPosition::FillPrimary, + }; + } + } + let mutated = cfg.compile_named_role_table().unwrap(); + let mutated_tint = { + let set = resolve_named_set(&bg, &mutated, &vc); + let (_, res) = set + .iter() + .find(|(n, _)| n == "fill-danger-primary") + .unwrap(); + res.rgba().unwrap().tint_hex().to_string() + }; + assert_ne!( + base_tint, mutated_tint, + "RED-proof семейства провален: подмена danger→green НЕ сменила тинт ({base_tint})" + ); +} + +/// AlphaAnalog-рецепт (#119): солид-цель фиксирована, тинт выводится +/// композит-инверсией. RED-proof: разные α (обе ≥ α_min) дают разный тинт; +/// композит фактической пары ТОЧНО равен солид-цели (теорема тождества #119). +/// +/// Фон подобран так, чтобы солид был разрешим при α < 1 (иначе солид над белым +/// вырождается в α_min≈1 — это физика, не баг: полностью насыщенный солид над +/// белым воспроизводится только сплошным цветом). +#[test] +fn alpha_analog_recipe_inverts_and_bites_on_alpha() { + // Солид-цель = серое семейство `#787880` (точный кейс живых Figma-пар + // `alpha.rs`), фон — белый: инверсия разрешима при α < 1 (α_min ≈ 0.5), тинт + // осмысленно меняется с α. (Насыщенный солид с maxed-каналом над белым дал бы + // α_min = 1 — это физика насыщенного цвета, не годится для RED-proof альфы.) + let mut base = labui_reference(); + base.palette.push(PaletteFamily { + key: "probe".to_string(), + anchors: ThemeAnchors { + light: "#787880".to_string(), + dark: "#787880".to_string(), + light_ic: "#787880".to_string(), + dark_ic: "#787880".to_string(), + }, + }); + let bg = BgInput::solid("#FFFFFF").unwrap(); + let vc = ViewingConditions::srgb(); + + let resolve_analog = |alpha: f64| -> (String, f64, String) { + let mut cfg = base.clone(); + cfg.roles.push(( + "probe-tinted".to_string(), + RoleRecipe::AlphaAnalog { + of: LadderSource::Family("probe".to_string()), + alpha, + }, + )); + let table = cfg.compile_named_role_table().unwrap(); + let set = resolve_named_set(&bg, &table, &vc); + let (_, res) = set.iter().find(|(n, _)| n == "probe-tinted").unwrap(); + let r = res.rgba().unwrap(); + ( + r.tint_hex().to_string(), + r.alpha(), + r.composite_hex().to_string(), + ) + }; + + let (tint_low, a_low, comp_low) = resolve_analog(0.5); + let (tint_high, a_high, comp_high) = resolve_analog(0.9); + // Обе α разрешимы над близким фоном → тинт различается по α (кусается). + assert!( + tint_low != tint_high || (a_low - a_high).abs() > 1e-6, + "RED-proof альфы провален: α=0.5 и α=0.9 дали одно ({tint_low}@{a_low} vs {tint_high}@{a_high})" + ); + // Теорема тождества #119: композит фактической пары равен солид-цели + // `#787880` в пределах границы квантования 8-бит (при α<1 точное побайтное + // восстановление тинта не гарантируется, но композит держится в ±несколько + // LSB — гарантия из документации `crate::alpha`). + let target = crate::spaces::srgb::srgb_encoded_from_hex("#787880").unwrap(); + for comp in [&comp_low, &comp_high] { + let got = crate::spaces::srgb::srgb_encoded_from_hex(comp).unwrap(); + for c in 0..3 { + let lsb = (got[c] - target[c]).abs() * 255.0; + assert!( + lsb <= 3.0, + "композит альфа-аналога {comp} канал {c} отклонился на {lsb:.2} LSB \ + от солид-цели #787880 (> 3 LSB — инверсия сломана)" + ); + } + } +} diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs new file mode 100644 index 00000000..7a2f7536 --- /dev/null +++ b/crates/labcolors-core/src/ladder.rs @@ -0,0 +1,308 @@ +//! Лестница акцента/сентимента/бренда/нейтрали как ДАННЫЕ: закрытое меню позиций +//! (каждая несёт свою альфу Figma-рампы) + физика тинта источника по теме. +//! +//! # Закон лестницы (заземление 2026-07-02) +//! +//! Акцентная лестница labui устроена КАК нейтральная: **один тинт (якорный цвет +//! источника, пер-темно) × закрытая рампа альф** — Figma-переменная +//! `Accent/Derivable//@NN`, где `NN` — процент прозрачности. +//! Labui-контракт эмитит `rgba(tint, α)` НАПРЯМУЮ (композитит браузер), а НЕ +//! солид-эквивалент. Поэтому позиция лестницы = `{тинт = якорь источника по теме, +//! α из меню}`; резолв несёт rgba (см. [`crate::semantic::Resolved::Rgba`]). +//! Солид-эквивалент (композит тинта на фоне резолва, +//! [`crate::alpha::composite_over_encoded`]) — для честного замера dJ'/WCAG +//! контраст-корректности на подложке (фаза 1 AA: контраст меряется на композите). +//! Заземление: `reference/labui-accent-primitives.md` §2 (пер-темные якоря), +//! стаб labui `packages/colors-stub/contract.css` (@NN-рампа), +//! `.agents/epics/ds-config-train/chapters/ch02-engine-config-input/grounding-accent-roles-2026-07-02.md`. +//! +//! # Провенанс альф позиций +//! +//! Альфы — ДАННЫЕ рампы Figma `@NN` (float32-квантование процентов из имён +//! переменных), не выведенные величины: `@72 → 0.722`, `@52 → 0.522`, +//! `@32 → 0.322`, `@20 → 0.2`, `@12 → 0.122`, `@8 → 0.078`, `@4 → 0.039`, +//! `@2 → 0.02`; `primary`/`border-strong`/`focus-ring` — солид (α = 1.0). +//! Единый паттерн проверен на brand/danger/info/success (grounding §Закон +//! лестницы). Это данные позиций, а не POLICY-константы перцептивных модулей, +//! поэтому провенанс держится этой doc-строкой + тестом лестницы, а не строкой +//! реестра (как якорные hex палитры, `accent.rs`). + +use crate::spaces::oklab::srgb_linear_to_oklab; +use crate::spaces::srgb::{srgb_encoded_from_hex, srgb_gamma_inv}; +use crate::spaces::vc::ViewingConditions; + +/// Пер-темная четвёрка якорных hex (`light` / `dark` / `light-ic` / `dark-ic`). +/// +/// Источник лестницы (семейство палитры или бренд) несёт свой якорь отдельно для +/// каждого режима — тёмная тема и режим повышенного контраста (IC) не выводятся +/// из светлого якоря, а замеряются (`reference/labui-accent-primitives.md` §2: +/// Red light `#FF3B30` / dark `#FF3A3A` / light-ic `#D70015` / dark-ic `#FF6161`). +/// Выбор режима — по условиям просмотра резолва ([`ThemeAnchors::for_vc`]). +#[derive(Debug, Clone, PartialEq)] +pub struct ThemeAnchors { + /// Светлая тема (average surround). + pub light: String, + /// Тёмная тема (dim surround). + pub dark: String, + /// Светлая тема, повышенный контраст (IC). + pub light_ic: String, + /// Тёмная тема, повышенный контраст (IC). + pub dark_ic: String, +} + +impl ThemeAnchors { + /// Якорный hex под условия просмотра резолва: IC-режим (`vc.high_contrast`) + /// выбирает `*_ic`, тёмный сурраунд (`vc.is_dark_theme()`) — тёмную ветку. + /// Четыре VC-пресета движка ([`crate::config::VcPreset`]) отображаются ровно + /// на четыре якоря — иных режимов у лестницы нет. + pub fn for_vc(&self, vc: &ViewingConditions) -> &str { + match (vc.is_dark_theme(), vc.high_contrast) { + (false, false) => &self.light, + (true, false) => &self.dark, + (false, true) => &self.light_ic, + (true, true) => &self.dark_ic, + } + } + + /// Кодированные (byte/255) RGB четырёх якорей — компилятор лестницы + /// раскладывает пер-темную четвёрку в [`LadderTint`] один раз, чтобы + /// [`crate::semantic::RoleSpec`] остался `Copy` (без строк в горячем резолве). + /// + /// # Errors + /// + /// `Err`, если любой из четырёх hex невалиден (валидатор конфига ловит это + /// раньше — здесь защита компиляции). + pub fn encoded_quad(&self) -> Result<[[f64; 3]; 4], String> { + Ok([ + srgb_encoded_from_hex(&self.light)?, + srgb_encoded_from_hex(&self.dark)?, + srgb_encoded_from_hex(&self.light_ic)?, + srgb_encoded_from_hex(&self.dark_ic)?, + ]) + } +} + +/// Кодированный (byte/255) тинт лестницы, разложенный по четырём режимам — +/// `Copy`-полезная нагрузка [`crate::semantic::RoleSpec::Ladder`]. +/// +/// Индексация повторяет [`ThemeAnchors::for_vc`]: +/// `0 = light`, `1 = dark`, `2 = light-ic`, `3 = dark-ic`. Тинт bg-независим +/// (это якорь источника по теме), поэтому раскладывается на этапе компиляции, +/// а не в резолве. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LadderTint { + /// Кодированные RGB якорей `[light, dark, light-ic, dark-ic]`. + quad: [[f64; 3]; 4], +} + +impl LadderTint { + /// Собрать тинт из кодированной четвёрки режимов. + pub fn new(quad: [[f64; 3]; 4]) -> Self { + Self { quad } + } + + /// Кодированный тинт под условия просмотра резолва (тот же выбор режима, что + /// [`ThemeAnchors::for_vc`]). + pub fn for_vc(&self, vc: &ViewingConditions) -> [f64; 3] { + let idx = match (vc.is_dark_theme(), vc.high_contrast) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + }; + self.quad[idx] + } + + /// Oklab-хрома светлого якоря тинта — вход в пересчёт `S_PERC_MIN` + /// (среднее по четырём сентимент-якорям конфига, [`crate::sentiment`]). + pub fn light_oklab_chroma(&self) -> f64 { + // Кодированный тинт → линейный свет (per-channel gamma-декод, тот же, что + // в srgb_from_hex), затем Oklab-хрома = |(a, b)|. + let e = self.quad[0]; + let lin = [ + srgb_gamma_inv(e[0]), + srgb_gamma_inv(e[1]), + srgb_gamma_inv(e[2]), + ]; + let lab = srgb_linear_to_oklab(lin); + (lab[1] * lab[1] + lab[2] * lab[2]).sqrt() + } +} + +/// Закрытое меню позиций лестницы: каждая позиция несёт свою альфу Figma-рампы. +/// +/// Перечень зафиксирован приложением A к ADR-0001 (заземление 2026-07-02). +/// Солидные позиции (`α = 1.0`) — `LabelPrimary`, `BorderStrong`, `FocusRing`; +/// остальные несут альфу `@NN` из рампы (см. провенанс в документации модуля). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum LadderPosition { + /// Метка первичная — солид (α = 1.0). + LabelPrimary, + /// Метка вторичная — `@72`. + LabelSecondary, + /// Метка третичная — `@52`. + LabelTertiary, + /// Метка четвертичная — `@32`. + LabelQuaternary, + /// Заливка первичная — `@12`. + FillPrimary, + /// Заливка вторичная — `@8`. + FillSecondary, + /// Заливка третичная — `@4`. + FillTertiary, + /// Заливка четвертичная — `@2`. + FillQuaternary, + /// Граница базовая — `@20`. + BorderBase, + /// Граница мягкая — `@12`. + BorderSoft, + /// Граница сильная — солид (α = 1.0). + BorderStrong, + /// Кольцо фокуса — солид (α = 1.0). + FocusRing, + /// Свечение — `@52`. + Glow, +} + +impl LadderPosition { + /// Все позиции меню — поверхность для property-свипов и генерации ролей. + pub const ALL: [LadderPosition; 13] = [ + LadderPosition::LabelPrimary, + LadderPosition::LabelSecondary, + LadderPosition::LabelTertiary, + LadderPosition::LabelQuaternary, + LadderPosition::FillPrimary, + LadderPosition::FillSecondary, + LadderPosition::FillTertiary, + LadderPosition::FillQuaternary, + LadderPosition::BorderBase, + LadderPosition::BorderSoft, + LadderPosition::BorderStrong, + LadderPosition::FocusRing, + LadderPosition::Glow, + ]; + + /// Альфа позиции — ДАННЫЕ рампы Figma `@NN` (провенанс — документация модуля). + /// Мутация любого значения роняет тест лестницы (RED-proof). + pub fn alpha(self) -> f64 { + match self { + LadderPosition::LabelPrimary => 1.0, + LadderPosition::LabelSecondary => 0.722, + LadderPosition::LabelTertiary => 0.522, + LadderPosition::LabelQuaternary => 0.322, + LadderPosition::FillPrimary => 0.122, + LadderPosition::FillSecondary => 0.078, + LadderPosition::FillTertiary => 0.039, + LadderPosition::FillQuaternary => 0.02, + LadderPosition::BorderBase => 0.2, + LadderPosition::BorderSoft => 0.122, + LadderPosition::BorderStrong => 1.0, + LadderPosition::FocusRing => 1.0, + LadderPosition::Glow => 0.522, + } + } + + /// Стабильный kebab-ключ позиции — для разбора рецепта из конфига (t3 JSON) + /// и для приложения A к ADR. Часть контракта имён; опечатка ловится тестом. + pub fn key(self) -> &'static str { + match self { + LadderPosition::LabelPrimary => "label-primary", + LadderPosition::LabelSecondary => "label-secondary", + LadderPosition::LabelTertiary => "label-tertiary", + LadderPosition::LabelQuaternary => "label-quaternary", + LadderPosition::FillPrimary => "fill-primary", + LadderPosition::FillSecondary => "fill-secondary", + LadderPosition::FillTertiary => "fill-tertiary", + LadderPosition::FillQuaternary => "fill-quaternary", + LadderPosition::BorderBase => "border-base", + LadderPosition::BorderSoft => "border-soft", + LadderPosition::BorderStrong => "border-strong", + LadderPosition::FocusRing => "focus-ring", + LadderPosition::Glow => "glow", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Меню позиций закрыто и его альфы — заземлённая Figma-рампа `@NN`. + /// Нетавтологичный пин: числа взяты из grounding-документа, а мутация + /// [`LadderPosition::alpha`] роняет тест (RED-proof альф позиций). + #[test] + fn position_alphas_match_grounded_figma_ramp() { + let expected: &[(LadderPosition, f64, &str)] = &[ + (LadderPosition::LabelPrimary, 1.0, "label-primary"), + (LadderPosition::LabelSecondary, 0.722, "label-secondary"), + (LadderPosition::LabelTertiary, 0.522, "label-tertiary"), + (LadderPosition::LabelQuaternary, 0.322, "label-quaternary"), + (LadderPosition::FillPrimary, 0.122, "fill-primary"), + (LadderPosition::FillSecondary, 0.078, "fill-secondary"), + (LadderPosition::FillTertiary, 0.039, "fill-tertiary"), + (LadderPosition::FillQuaternary, 0.02, "fill-quaternary"), + (LadderPosition::BorderBase, 0.2, "border-base"), + (LadderPosition::BorderSoft, 0.122, "border-soft"), + (LadderPosition::BorderStrong, 1.0, "border-strong"), + (LadderPosition::FocusRing, 1.0, "focus-ring"), + (LadderPosition::Glow, 0.522, "glow"), + ]; + for (pos, alpha, key) in expected { + assert_eq!( + pos.alpha(), + *alpha, + "{pos:?}: альфа дрейфанула от Figma-рампы" + ); + assert_eq!(pos.key(), *key, "{pos:?}: ключ разошёлся с контрактом имён"); + } + let all: Vec = LadderPosition::ALL.to_vec(); + let listed: Vec = expected.iter().map(|(p, ..)| *p).collect(); + assert_eq!(all, listed, "LadderPosition::ALL разошёлся с меню"); + } + + /// Выбор якоря по режиму: четыре VC-пресета движка → четыре разных якоря. + #[test] + fn theme_anchors_select_by_vc() { + let anchors = ThemeAnchors { + light: "#FF3B30".to_string(), + dark: "#FF3A3A".to_string(), + light_ic: "#D70015".to_string(), + dark_ic: "#FF6161".to_string(), + }; + assert_eq!(anchors.for_vc(&ViewingConditions::srgb()), "#FF3B30"); + assert_eq!( + anchors.for_vc(&ViewingConditions::dim_surround()), + "#FF3A3A" + ); + assert_eq!( + anchors.for_vc(&ViewingConditions::srgb_high_contrast()), + "#D70015" + ); + assert_eq!( + anchors.for_vc(&ViewingConditions::dim_surround_high_contrast()), + "#FF6161" + ); + } + + /// Тинт `for_vc` повторяет выбор режима якоря побайтно (кодированный RGB). + #[test] + fn ladder_tint_for_vc_mirrors_anchor_selection() { + let anchors = ThemeAnchors { + light: "#FF3B30".to_string(), + dark: "#FF3A3A".to_string(), + light_ic: "#D70015".to_string(), + dark_ic: "#FF6161".to_string(), + }; + let tint = LadderTint::new(anchors.encoded_quad().unwrap()); + for vc in [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ] { + let want = srgb_encoded_from_hex(anchors.for_vc(&vc)).unwrap(); + assert_eq!(tint.for_vc(&vc), want, "тинт для vc разошёлся с якорем"); + } + } +} diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index d5074cea..4ae74df6 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -4,6 +4,7 @@ pub mod accent; pub mod alpha; pub mod cleanliness; pub mod config; +pub mod ladder; pub mod lcs; pub mod lpc; pub(crate) mod lut; @@ -30,14 +31,16 @@ pub use cleanliness::{ muddiness_from_hex, muddiness_from_linear_srgb, muddiness_in_context, muddiness_oklch, n_pure, }; pub use config::{ - Brand, ConfigError, NeutralAnchors, NeutralConfig, NeutralTint, PaletteFamily, RoleRecipe, - SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, VcPreset, labui_reference, + Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralTint, PaletteFamily, + RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, VcPreset, + labui_reference, }; pub use curve::ColorCurve; +pub use ladder::{LadderPosition, LadderTint, ThemeAnchors}; pub use lcs::LcsColor; pub use semantic::{ - NamedRoleTable, Resolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor, measure_contrast, - recheck_against, resolve, resolve_named_set, resolve_set, + NamedRoleTable, Resolved, RgbaResolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor, + measure_contrast, recheck_against, resolve, resolve_named_set, resolve_set, }; pub use solve::{ BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveJob, Solved, TypographicContext, diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index cd3b90ab..9cb0a009 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -137,6 +137,7 @@ //! [`RoleChroma::flat_neutral_tint`]; pure grey with [`RoleChroma::Neutral`]; //! either via [`RoleTable::with_chroma`]. +use crate::ladder::LadderTint; use crate::scale; use crate::solve::{self, BgInput, ChromaPolicy, Contract, Floor, Hue, Solved, Unreachable}; use crate::spaces::srgb::srgb_gamma; @@ -509,6 +510,38 @@ pub enum RoleSpec { /// source. The relative order between the shadow steps is the contract this /// variant carries; `surface-jnd` derives shadow contracts from the alphas. Decorative { magnitude: f64 }, + /// Ступень лестницы акцента/сентимента/бренда/нейтрали: тинт-якорь источника + /// (по теме) при альфе позиции. Эмитит `rgba(tint, α)` НАПРЯМУЮ (закон + /// лестницы labui — композитит браузер, а не солид-эквивалент, см. + /// [`crate::ladder`]). Резолв — [`Resolved::Rgba`]: несёт тинт (то, что + /// красит `--lab-*`), альфу и солид-композит `α·tint + (1−α)·bg` на фоне + /// резолва для честного замера контраста (фаза 1 AA меряет композит). + /// + /// bg-независимость тинта (это якорь источника) — почему он `Copy`-payload + /// [`LadderTint`], разложенный по темам на этапе компиляции; альфа — данные + /// позиции меню ([`LadderPosition::alpha`]). + Ladder { + /// Пер-темный кодированный тинт (якорь источника). + tint: LadderTint, + /// Альфа позиции (`(0, 1]`; солид = 1.0). + alpha: f64, + }, + /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], + /// #119): для солид-цвета `of` (по теме) на фоне резолва подбирается + /// `(tint, α)`, чей композит равен солиду. Отличается от [`Ladder`](Self::Ladder) + /// тем, что здесь солид-цель ФИКСИРОВАНА (тинт выводится инверсией), а не + /// тинт-якорь эмитится напрямую. Даёт `-tinted`-роли labui (fill-*-tinted): + /// заливка, чей композит на подложке = соответствующий солид. + /// + /// Фактическая α возвращается явно ([`crate::alpha::AlphaAnalog::alpha`]): при + /// неразрешимой запрошенной α поднимается до `α_min` (композит остаётся точно + /// равным солиду — двигается прозрачность, не цвет; кламп тинта запрещён). + AlphaAnalog { + /// Пер-темный кодированный солид-источник, чей альфа-аналог берётся. + of: LadderTint, + /// Запрошенная альфа (`[0, 1]`; поднимается до `α_min`, если ниже). + alpha: f64, + }, /// The zero token: resolves to [`Resolved::None`]. Zero, } @@ -1067,6 +1100,7 @@ impl Default for RoleTable { /// background (e.g. muted text on a mid-grey that cannot supply enough contrast) /// returns [`Unreachable`], it is not silently clipped to a wrong colour. #[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] pub enum Resolved { /// A solved colour for a text/UI or decorative role. `compressed` is `true` /// when the legal floor squeezed this role's target against its senior's so @@ -1074,12 +1108,69 @@ pub enum Resolved { /// smallest distinguishable step below — an honest, flagged degradation /// rather than a silent two-roles-one-colour collapse. See the module docs. Color { solved: Solved, compressed: bool }, + /// Полупрозрачная роль лестницы/альфа-аналога: `rgba(tint, α)`, которую + /// потребитель красит НАПРЯМУЮ (закон лестницы labui — композитит браузер). + /// Несёт солид-композит на фоне резолва для честного замера контраста. + Rgba(RgbaResolved), /// The honest zero of the [`Role::None`] token: no colour, no contrast. None, /// No colour can satisfy this role against this background, with the reason. Unreachable(Unreachable), } +/// Резолв полупрозрачной роли: пара `(tint, α)` для прямой эмиссии `rgba(...)` +/// плюс её солид-композит на фоне резолва для замера контраста. +/// +/// Потребитель красит `--lab-{role}: rgba(tint, α)` — браузер композитит на +/// фактической подложке. `composite` — то, во что этот rgba складывается на +/// ФОНЕ РЕЗОЛВА (`α·tint + (1−α)·bg`, кодированный sRGB — device-пространство +/// Figma/браузера); его контраст ([`RgbaResolved::composite_lc`], +/// [`composite_wcag`](RgbaResolved::composite_wcag)) — то, что фаза 1 AA меряет +/// (контраст полупрозрачной роли определён её композитом, не тинтом). На ином +/// фоне композит другой — это и есть смысл альфы; гарантия сформулирована для +/// фона резолва. +#[derive(Debug, Clone, PartialEq)] +pub struct RgbaResolved { + /// Тинт `#RRGGBB` — цвет, эмитируемый как `rgba(tint, α)` (без учёта α). + tint_hex: String, + /// Фактическая α `(0, 1]` — запрошенная, если разрешима, иначе поднятая до + /// разрешимого минимума (для альфа-аналога; у прямой лестницы = альфа позиции). + alpha: f64, + /// Солид-композит `rgba(tint, α)` над фоном резолва, `#RRGGBB`. + composite_hex: String, + /// Знаковый перцептивный контраст `Lc` композита против фона резолва. + composite_lc: f64, + /// WCAG 2.1 контраст-отношение композита против фона резолва (1–21). + composite_wcag: f64, +} + +impl RgbaResolved { + /// Тинт `#RRGGBB` — красится как `rgba(tint, α)`. + pub fn tint_hex(&self) -> &str { + &self.tint_hex + } + + /// Фактическая альфа `(0, 1]`. + pub fn alpha(&self) -> f64 { + self.alpha + } + + /// Солид-композит `#RRGGBB` над фоном резолва. + pub fn composite_hex(&self) -> &str { + &self.composite_hex + } + + /// Знаковый `Lc` композита против фона резолва (метрика фазы 1 AA). + pub fn composite_lc(&self) -> f64 { + self.composite_lc + } + + /// WCAG 2.1 контраст-отношение композита против фона резолва. + pub fn composite_wcag(&self) -> f64 { + self.composite_wcag + } +} + impl Resolved { /// A non-compressed solved colour — the common case where the hierarchy holds /// strictly and no floor squeeze was needed. @@ -1112,14 +1203,26 @@ impl Resolved { } /// The signed perceptual contrast `Lc` of a resolved colour, if any. The - /// zero token reports `0.0`; an unreachable role reports `None`. + /// zero token reports `0.0`; an unreachable role reports `None`; a + /// [`Rgba`](Resolved::Rgba) role reports its **composite's** `Lc` (a + /// semi-transparent role's contrast is that of its composite, not its tint). pub fn lc(&self) -> Option { match self { Resolved::Color { solved, .. } => Some(solved.lc()), + Resolved::Rgba(r) => Some(r.composite_lc), Resolved::None => Some(0.0), Resolved::Unreachable(_) => Option::None, } } + + /// The `(tint, α)` of a semi-transparent [`Rgba`](Resolved::Rgba) role, if this + /// resolved to one. `None` for solved-colour / zero / unreachable roles. + pub fn rgba(&self) -> Option<&RgbaResolved> { + match self { + Resolved::Rgba(r) => Some(r), + _ => Option::None, + } + } } /// Everything about a `(background, viewing-conditions)` pair that every role in @@ -1271,6 +1374,18 @@ fn resolve_spec_in( }; } RoleSpec::Decorative { magnitude } => ctx.decorative_contract(magnitude), + RoleSpec::Ladder { tint, alpha } => { + // Лестница эмитит rgba(tint, α) НАПРЯМУЮ: тинт — якорь источника по + // теме (bg-независим), α — данные позиции. Композит на фоне резолва — + // для честного замера контраста (закон лестницы, `crate::ladder`). + return resolve_rgba_direct(tint.for_vc(vc), alpha, bg, vc); + } + RoleSpec::AlphaAnalog { of, alpha } => { + // Альфа-аналог: солид-цель фиксирована (тинт источника по теме), + // тинт выводится композит-инверсией (`crate::alpha`, #119). Фактическая + // α поднимается до α_min, если запрошенная неразрешима в гамуте. + return resolve_rgba_inverted(of.for_vc(vc), alpha, bg, vc); + } }; let interval = match &ctx.interval { @@ -1283,6 +1398,86 @@ fn resolve_spec_in( } } +/// Лестница: rgba(`tint`, `alpha`) эмитится напрямую; его композит на фоне +/// резолва замеряется для контраста. `tint` — кодированный (byte/255) sRGB. +/// +/// Композитинг straight-alpha живёт в device-пространстве (гамма-кодированный +/// sRGB) — тот же путь, что Figma/браузер ([`crate::alpha`]). Контраст меряется +/// на КОМПОЗИТЕ (солид-эквивалент), не на тинте: контраст полупрозрачной роли +/// определён тем, во что она складывается на подложке. +fn resolve_rgba_direct( + tint_encoded: [f64; 3], + alpha: f64, + bg: &BgInput, + vc: &ViewingConditions, +) -> Resolved { + let bg_encoded = bg.encoded_display(); + let composite = crate::alpha::composite_over_encoded(tint_encoded, alpha, bg_encoded); + finish_rgba(tint_encoded, alpha, composite, bg_encoded, vc) +} + +/// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва +/// инвертируется в `(tint, фактическая α)` через [`crate::alpha::resolve_alpha_analog`]. +/// Композит фактической пары равен солиду ПО ПОСТРОЕНИЮ, поэтому контраст +/// наследуется солидом; замер идёт на этом композите. `None`-инверсия +/// (вход вне гамута) физически недостижима — солид-тинт по теме всегда в гамуте. +fn resolve_rgba_inverted( + solid_encoded: [f64; 3], + requested_alpha: f64, + bg: &BgInput, + vc: &ViewingConditions, +) -> Resolved { + let bg_encoded = bg.encoded_display(); + let Some(analog) = + crate::alpha::resolve_alpha_analog(solid_encoded, requested_alpha, bg_encoded) + else { + // Тинт источника по теме — валидный кодированный цвет byte/255, поэтому + // домен инверсии не нарушается; None здесь означал бы мусорный вход. + return Resolved::Unreachable(Unreachable::InvalidInput( + "alpha-analog source out of encoded sRGB domain".to_string(), + )); + }; + // Композит фактической пары == солид (теорема тождества, `alpha` #119), + // но считаем его явно — единый путь замера с прямой лестницей. + let composite = crate::alpha::composite_over_encoded(analog.tint, analog.alpha, bg_encoded); + finish_rgba(analog.tint, analog.alpha, composite, bg_encoded, vc) +} + +/// Собрать [`Resolved::Rgba`] из тинта, альфы и композита: квантовать тинт и +/// композит до hex, замерить контраст композита против фона резолва. +/// +/// Контраст меряется в тех же метриках, что и у солид-роли: перцептивный `Lc` +/// на линейном свете ([`measure_contrast`]) и WCAG на кодированном дисплее — так +/// rgba-роль сопоставима с solved-ролью на фазе 1 AA. +fn finish_rgba( + tint_encoded: [f64; 3], + alpha: f64, + composite_encoded: [f64; 3], + bg_encoded: [f64; 3], + vc: &ViewingConditions, +) -> Resolved { + use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_gamma_inv}; + // Линейный свет из кодированного (per-channel gamma-декод) для перцептивного Lc. + let decode = |e: [f64; 3]| { + [ + srgb_gamma_inv(e[0]), + srgb_gamma_inv(e[1]), + srgb_gamma_inv(e[2]), + ] + }; + let composite_linear = decode(composite_encoded); + let bg_linear = decode(bg_encoded); + let (composite_lc, _) = measure_contrast(bg_linear, composite_linear, vc); + let composite_wcag = crate::wcag::contrast_ratio(composite_encoded, bg_encoded); + Resolved::Rgba(RgbaResolved { + tint_hex: hex_from_srgb_encoded(tint_encoded), + alpha, + composite_hex: hex_from_srgb_encoded(composite_encoded), + composite_lc, + composite_wcag, + }) +} + /// Solve `contract` against `bg` under `chroma`, building the undertone the /// policy prescribes. /// @@ -2818,6 +3013,9 @@ mod tests { } } Resolved::None => matches!(table.spec(*role), RoleSpec::Zero), + // Дефолтная таблица не несёт Ladder/AlphaAnalog — вариант тут + // недостижим; полупрозрачная роль в любом случае не «клип». + Resolved::Rgba(_) => true, Resolved::Unreachable(_) => true, }); assert!( @@ -3731,6 +3929,8 @@ mod tests { for (role, res) in &set { let got = match res { Resolved::Color { solved, .. } => solved.hex().to_string(), + // Дефолтная таблица не несёт Ladder/AlphaAnalog — недостижимо. + Resolved::Rgba(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), Resolved::None => "none".to_string(), Resolved::Unreachable(_) => "UNREACHABLE".to_string(), }; diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 72af9b19..0e3223d7 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -447,6 +447,37 @@ fn resolve_smooth_hue( brand_hue: f64, params: SentimentParams, s_min: f64, +) -> Result { + resolve_smooth_hue_explicit( + sentiment.preferred_side(), + sentiment.hue_floor(), + prototype, + brand_hue, + params, + s_min, + ) +} + +/// Config-facing sibling of [`resolve_smooth_hue`] that takes the categorical +/// policy (`preferred_side`, `hue_floor`) explicitly instead of reading it off the +/// fixed [`Sentiment`] enum — so an arbitrary consumer sentiment category +/// ([`crate::config::SentimentCategory`]) resolves through the identical smooth +/// p-norm displacement + legality guard, no second copy of the physics. +/// +/// `prototype`, `brand_hue` and the result are **Oklab hue degrees**. See +/// [`resolve_smooth_hue`] / [`SentimentCurve::with_params`] for the model. +/// +/// # Errors +/// +/// `Err` if no hue satisfies both the floor and the separation invariant +/// (empty legal arc) — never a silent breach. +pub fn resolve_smooth_hue_explicit( + preferred_side: f64, + hue_floor: Option, + prototype: f64, + brand_hue: f64, + params: SentimentParams, + s_min: f64, ) -> Result { // Signed shortest delta from prototype to brand. Its sign tells us which side // of the brand the prototype sits on; we push the resolved hue out along that @@ -461,17 +492,16 @@ fn resolve_smooth_hue( (1.0, params.p_high) } else { // Degenerate seam: brand exactly on the prototype. Pick the preferred side. - let pref = sentiment.preferred_side(); - let p = if pref >= 0.0 { + let p = if preferred_side >= 0.0 { params.p_high } else { params.p_low }; - (pref, p) + (preferred_side, p) }; let s = smooth_separation(d, s_min, p); - let floor = sentiment.hue_floor(); + let floor = hue_floor; // The prototype-ward displacement is the natural target (it decays to the // prototype as the brand recedes). @@ -557,6 +587,92 @@ fn angular_distance(a: f64, b: f64) -> f64 { if diff > 180.0 { 360.0 - diff } else { diff } } +/// Категориальный порог оттенка `S_PERC_MIN` (длина хорды Oklab a/b), +/// пересчитанный из хром сентимент-якорей конфига по закону +/// `2·C_rep·sin(20°/2)`, где `C_rep` — среднее хром (поправка t2 №д). +/// +/// `20°` — нижний предел категориального восприятия (Witzel & Gegenfurtner 2013, +/// JOSA A 30(7):1501). При labui-якорях (хромы Red/Orange/Green/Blue) результат +/// совпадает с замороженной константой [`S_PERC_MIN`] (`0.068_703_9`, +/// деривационная идентичность — тестом, допуск 1e-4): формула остаётся законом +/// при произвольных якорях клиента, а сегодняшнее значение — её частный случай. +/// +/// Пустой срез хром даёт `0.0` (нет сентиментов — нет порога разделения). +pub fn s_perc_min_from_chromas(chromas: &[f64]) -> f64 { + if chromas.is_empty() { + return 0.0; + } + let c_rep = chromas.iter().sum::() / chromas.len() as f64; + // Хорда длины 2·C·sin(Δh/2) при Δh = 20° — тот же категориальный порог + // (Witzel & Gegenfurtner 2013), что в деривации [`S_PERC_MIN`]; инлайн + // (не именованная const), т.к. это derivation-identity вход, не новый + // POLICY-литерал — provenance держит doc [`S_PERC_MIN`]. + 2.0 * c_rep * (20.0_f64.to_radians() / 2.0).sin() +} + +/// Замороженное значение `S_PERC_MIN` (для деривационной идентичности теста t2). +/// Возвращается функцией (не `const`), чтобы не заводить второй POLICY-литерал в +/// аудите реестра — это тот же derivation-identity, что [`S_PERC_MIN`]. +pub fn s_perc_min_frozen() -> f64 { + S_PERC_MIN +} + +/// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом +/// сентимент-солвером, при СОХРАНЁННЫХ светлоте и хроме якоря. +/// +/// Тинт лестницы сентимента (поправка t2 №г): берётся оттенок семейства, +/// смещённый от бренда через [`resolve_smooth_hue_explicit`] (тот же C¹-солвер, +/// что у [`SentimentCurve`]), но светлота/хрома — исходного якоря. Когда +/// смещение не нужно (`resolved_hue == prototype`, случай labui-бренда), солид +/// воспроизводит СЫРОЙ якорь семейства — это и есть деривационная идентичность, +/// которую фиксирует тест. `brand_hue` — Oklab-оттенок бренда (градусы). +/// +/// # Errors +/// +/// `Err`, если якорь невалиден или легальный оттенок геометрически пуст +/// (см. [`resolve_smooth_hue_explicit`]). +pub fn resolve_config_sentiment_solid( + family_anchor_hex: &str, + brand_hue: f64, + hardness: f64, + chroma_fraction: f64, + hue_floor: Option, + preferred_side: f64, + s_perc_min: f64, +) -> Result { + let _ = chroma_fraction; // хрома тинта = хрома якоря (сохраняем солид якоря); + // chroma_fraction — ручка рампы SentimentCurve, не тинта; принимается для + // единообразия сигнатуры конфига, но тинт держит фактическую хрому якоря. + let anchor_lab = srgb_linear_to_oklab(srgb_from_hex(family_anchor_hex)?); + let prototype = oklab_hue_of(family_anchor_hex); + let l_anchor = anchor_lab[0]; + let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt(); + let s_min = s_min_deg(c_anchor); + // Порог разделения — max из перцептивного (от хромы якоря) и конфиг-порога: + // конфиг S_PERC_MIN задаёт минимум для КАТЕГОРИИ, s_min_deg — для этой хромы. + let params = SentimentParams::uniform(hardness)?; + let effective_s_min = s_min.max(s_min_deg_from_chord(s_perc_min, c_anchor)); + let resolved_hue = resolve_smooth_hue_explicit( + preferred_side, + hue_floor, + prototype, + brand_hue, + params, + effective_s_min, + )?; + // Солид на исходных L/C якоря, смещённый оттенок. + Ok(oklab_lc_to_hex(l_anchor, c_anchor, resolved_hue)) +} + +/// Перевести целевую хорду разделения `chord` в угол оттенка (градусы) при +/// хроме `zone_chroma` — та же инверсия `2·C·sin(Δh/2)`, что [`s_min_deg`], но с +/// произвольной хордой (для конфиг-`S_PERC_MIN`). +fn s_min_deg_from_chord(chord: f64, zone_chroma: f64) -> f64 { + let safe_chroma = zone_chroma.max(1e-6); + let ratio = (chord / (2.0 * safe_chroma)).clamp(0.0, 1.0); + 2.0 * ratio.asin().to_degrees() +} + /// The in-gamut sRGB hex at Oklab `(L, C, h)`, channels clamped to `[0, 1]`. fn oklab_lc_to_hex(l_ok: f64, c: f64, h_ok: f64) -> String { let a = c * h_ok.to_radians().cos(); diff --git a/crates/labcolors-core/src/solve.rs b/crates/labcolors-core/src/solve.rs index 949d72be..990d75aa 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -287,6 +287,20 @@ impl BgInput { BgInput::Solid(rgb) => quantised_display(*rgb), } } + + /// Гамма-кодированный 8-битный sRGB фона (`[0,1]³`, byte/255) — то самое + /// device-пространство, в котором Figma/браузер композитят straight-alpha + /// ([`crate::alpha`]). Альфа-роль ([`crate::semantic::RoleSpec::Ladder`] / + /// [`AlphaAnalog`](crate::semantic::RoleSpec::AlphaAnalog)) композитит свой + /// тинт на этом фоне для честного замера контраста солид-эквивалента. Для + /// [`Solid`](BgInput::Solid) это квантованный дисплей-цвет фона; будущие + /// интервальные фоны выберут здесь свой представительный край, оставляя + /// физику резолва свободной от матчинга вариантов (SEAM a). + pub(crate) fn encoded_display(&self) -> [f64; 3] { + match self { + BgInput::Solid(rgb) => quantised_display(*rgb), + } + } } /// A background luminance interval in `Y_hk` space (H-K-corrected luminance). @@ -2566,6 +2580,8 @@ mod tests { .map(|(role, res)| { let v = match res { Resolved::Color { solved, .. } => solved.hex().to_string(), + // Дефолтная таблица не несёт Ladder/AlphaAnalog — недостижимо здесь. + Resolved::Rgba(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()), Resolved::None => "none".to_string(), Resolved::Unreachable(_) => "unreach".to_string(), }; diff --git a/crates/labcolors-core/tests/r3_byte_identity.rs b/crates/labcolors-core/tests/r3_byte_identity.rs index 473376eb..44feb3a3 100644 --- a/crates/labcolors-core/tests/r3_byte_identity.rs +++ b/crates/labcolors-core/tests/r3_byte_identity.rs @@ -191,6 +191,11 @@ fn r3_resolve_set_240_cell_representative_byte_identity() { Resolved::Color { solved, .. } => solved.hex().to_string(), Resolved::None => "none".to_string(), Resolved::Unreachable(_) => "UNREACHABLE".to_string(), + // Дефолтная `RoleTable` (Role-путь) не несёт Ladder/AlphaAnalog- + // рецептов, поэтому rgba-роль здесь недостижима; арм обязателен + // из-за `#[non_exhaustive] Resolved` (t2 добавил вариант Rgba). + Resolved::Rgba(_) => "RGBA".to_string(), + _ => "UNKNOWN".to_string(), }) .unwrap_or_else(|| { panic!( diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 2a598da9..8e91be69 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -136,6 +136,22 @@ 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 — задача t3; до неё маппим в стабильный код, + // а не молчаливо роняем неверный цвет (`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 \ + (config path, task t3)" + .to_string(), + }, + _ => RoleOutcome::Unreachable { + code: "unreachable", + message: "unmapped resolved variant".to_string(), + }, } } diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index 8c094acc..07caf150 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -127,3 +127,83 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch - Нейминг теней канонизирует конфиг labui (`fx-shadow-*`); переход теней на альфа-якоря (@1/@2/@4/@12 через alpha_analog) — отдельное научное решение, вне поезда. + +## Приложение A. Закрытое меню позиций лестницы (t2) + +Фиксирует `position` рецепта `ladder(source, position)` (объявлено в разделе +API). Меню закрыто: движок эмитит `rgba(тинт, α)` НАПРЯМУЮ (закон лестницы +labui — композитит браузер), где тинт = якорь источника ПО ТЕМЕ, а α — данные +позиции ниже. Резолв несёт rgba + солид-композит на фоне резолва для честного +замера контраста (фаза 1 AA меряет композит). Реализация — `crate::ladder` +(`LadderPosition`), `crate::semantic` (`RoleSpec::Ladder` / `Resolved::Rgba`). + +### Заземление меню + +Снято 2026-07-02 из живого потребления labui: стаб `packages/colors-stub/ +contract.css` (несёт Figma-значения дословно) + `reference/labui-accent- +primitives.md` §2 (пер-темные якоря) + grounding-документ главы +(`chapters/ch02-engine-config-input/grounding-accent-roles-2026-07-02.md`). +Акцентная лестница устроена КАК нейтральная: один тинт-якорь × закрытая рампа +альф Figma `Accent/Derivable//@NN`. Единый паттерн проверен на +brand/danger/info/success. + +### Позиции и альфы (провенанс — Figma-рампа @NN) + +Альфы — данные (float32-квантование процентов из имён переменных Figma), НЕ +POLICY-константы перцептивных модулей: провенанс держит doc-строка +`crate::ladder` + тест `position_alphas_match_grounded_figma_ramp` (нетавтологичный +пин из grounding-документа), а не строка реестра `docs/empirical-inventory.md` +(как якорные hex палитры в `accent.rs`). + +| позиция | α | Figma @NN | +|------------------|---------|-----------| +| `LabelPrimary` | 1.0 | солид | +| `LabelSecondary` | 0.722 | @72 | +| `LabelTertiary` | 0.522 | @52 | +| `LabelQuaternary`| 0.322 | @32 | +| `FillPrimary` | 0.122 | @12 | +| `FillSecondary` | 0.078 | @8 | +| `FillTertiary` | 0.039 | @4 | +| `FillQuaternary` | 0.02 | @2 | +| `BorderBase` | 0.2 | @20 | +| `BorderSoft` | 0.122 | @12 | +| `BorderStrong` | 1.0 | солид | +| `FocusRing` | 1.0 | солид | +| `Glow` | 0.522 | @52 | + +### Источник тинта (`source`) + +- `brand` — пер-темный якорь бренда (`Brand.anchors`, reference §2). +- `family(key)` — пер-темный якорь семейства палитры (`PaletteFamily.anchors`). +- `sentiment(key)` — пер-темный СОЛИД: оттенок семейства категории, разведённый + с брендом сентимент-солвером (`crate::sentiment`), при сохранённых светлоте и + хроме якоря. `S_PERC_MIN` для разведения пересчитан из хром 4 сентимент-якорей + конфига (закон `2·C_rep·sin(20°/2)`, Witzel & Gegenfurtner 2013); при + labui-якорях == замороженная `0.068_703_9` (деривационная идентичность — + тест `s_perc_min_recomputed_from_config_anchors_matches_frozen`). + +Пер-темность якорей (а не «светлый + вывод») — из reference §2: тёмный/IC-варианты +Figma-примитивов замерены, не выведены. + +### Деривационная идентичность сентимента (честная находка t2) + +При бренде labui (`#007AFF`, Oklab h≈257.4°) сентимент-тинт совпадает с СЫРЫМ +якорем семейства для сентиментов, отстоящих от бренда дальше порога разделения: +Danger (red), Success (green), Warning (orange) — держится (тест +`sentiment_tint_is_raw_family_anchor_when_brand_is_hue_distant`). Для **Info** +(→blue, h≈259.9°) НЕ держится: Info отстоит от синего бренда лишь на ≈2.5° — +ниже порога `S_PERC_MIN`, поэтому солвер КОРРЕКТНО смещает Info прочь от бренда +(иначе информационный и брендовый синий слились бы). Это заземлённое поведение +солвера (#20/#55/#65), задокументировано тестом +`info_is_displaced_from_blue_brand_by_design`, не спрятано и не подогнано. + +### Альфа-аналог (`alpha_analog(of, alpha)`) + +Отдельный рецепт (#119): солид-цель `of` (по теме) ФИКСИРОВАНА, тинт выводится +композит-инверсией (`crate::alpha::resolve_alpha_analog`) над фоном резолва. +Композит фактической пары равен солиду по построению (наследует контраст). +Фактическая α поднимается до `α_min`, если запрошенная неразрешима в гамуте +(кламп тинта запрещён — двигается прозрачность, не цвет). Применяется для ролей, +где нужен полупрозрачный аналог УЖЕ РЕШЁННОГО контраст-солида; прямые `-tinted` +заливки labui эмитятся как `ladder(..., FillPrimary)` (тинт-якорь напрямую), +т.к. насыщенный солид над белым дал бы `α_min≈1` и перестал быть полупрозрачным. From 715527143b4d261e1bf64ce7930c3c9d8960c775 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 07:48:27 +0300 Subject: [PATCH 03/17] =?UTF-8?q?review(t2):=20=D0=B0=D1=80=D1=85-=D0=BD?= =?UTF-8?q?=D0=BE=D1=82=D1=8B=20(r3=20panic,=20=D1=81=D1=83=D1=85=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B8,=20=D0=B4=D0=BE=D0=BB=D0=B3=20t3)=20+=20CoVe=20doc?= =?UTF-8?q?-=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Изолированная верификация t2 (arch-reviewer PASS_WITH_NOTES + CoVe SUPPORTED): - High-1: r3_byte_identity.rs — wildcard `_ => "UNKNOWN"` заменён на `other => panic!(...)`, чтобы golden не проглотил молча будущий вариант Resolved. - Low-2: config.rs — комментарии FX-neutral/skeleton переписаны сухо (почему+как, без потока сознания). - Low-1: wasm/engine.rs — catch-all после Rgba помечен осознанным долгом t3. - CoVe: LABUI_CONSUMED_ROLES — doc-строка про компромисс зеркала (класс дрейфа закрывается гардами поезда labui против живой эмиссии). Долг (Medium-1, не сейчас): Alpha как value-object вместо голого f64 — кандидат на будущий рефактор типобезопасности. Прогоны: cargo fmt/clippy -D warnings чисты; 273 core lib + 12 наборов зелёные; единственный красный — s2b baseline-guard на r3 (флагнут владельцу, байт-дрейфа нет). Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/config.rs | 10 +++++----- crates/labcolors-core/src/config/tests.rs | 4 ++++ crates/labcolors-core/tests/r3_byte_identity.rs | 4 +++- crates/labcolors-wasm/src/engine.rs | 4 ++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 2abf7efd..b547a622 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -1011,9 +1011,8 @@ pub fn labui_reference() -> ThemeConfig { roles.extend(ladder_family(prefix, &mk)); } - // FX focus-ring/glow: солид (focus/strong) и @52 (glow). Neutral-семья = family(blue?) - // нет — neutral focus/glow берёт нейтраль через семейство `blue`? Нет: FX-neutral - // — бренд-нейтральный акцент. По заземлению focus-ring/glow — солид/@52 источника. + // FX focus-ring (солид) и glow (@52). Источник `*-neutral` = бренд: labui + // трактует нейтральный фокус/свечение как приглушённый брендовый акцент. roles.push(( "fx-focus-ring-brand".to_string(), brand_pos(LadderPosition::FocusRing), @@ -1047,8 +1046,9 @@ pub fn labui_reference() -> ThemeConfig { "fx-glow-inverted".to_string(), brand_pos(LadderPosition::Glow), )); - // FX shadow/skeleton — не-акцентные: shadow-* уже эмитятся (fx-shadow-* = alias); - // skeleton — нейтральный fill-аналог. Эмитим как альфа-аналог нейтрали. + // Skeleton — нейтральная полупрозрачная заливка (base @4, highlight @8): + // лестница нейтрального семейства `blue`. (Тени эмитятся отдельно как + // shadow-*, которые labui читает через alias fx-shadow-*.) roles.push(( "fx-skeleton-base".to_string(), fam_pos("blue", LadderPosition::FillTertiary), diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 66feae70..75332a18 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -543,6 +543,10 @@ fn config_error_display_is_russian_and_informative() { /// /// Имена без префикса `--lab-`. IC-режимы зарезервированы (в roles.json не /// перечислены), поэтому и здесь их нет. +/// +/// Компромисс t2: это ЗЕРКАЛО roles.json, не живой файл. Класс дрейфа зеркала +/// закрывается гардами поезда labui (consumed-contract против живой эмиссии) — +/// там diff проверяется против фактического потребления, не против копии. const LABUI_CONSUMED_ROLES: &[&str] = &[ // Backgrounds — ВХОДЫ (набор фонов = конфиг потребителя), не роли эмиссии. // Labels (core neutral). diff --git a/crates/labcolors-core/tests/r3_byte_identity.rs b/crates/labcolors-core/tests/r3_byte_identity.rs index 44feb3a3..1c422b56 100644 --- a/crates/labcolors-core/tests/r3_byte_identity.rs +++ b/crates/labcolors-core/tests/r3_byte_identity.rs @@ -195,7 +195,9 @@ fn r3_resolve_set_240_cell_representative_byte_identity() { // рецептов, поэтому rgba-роль здесь недостижима; арм обязателен // из-за `#[non_exhaustive] Resolved` (t2 добавил вариант Rgba). Resolved::Rgba(_) => "RGBA".to_string(), - _ => "UNKNOWN".to_string(), + // Будущий вариант Resolved не должен молча пройти golden: паника + // делает его видимым (обязан быть переучтён вместе с golden). + other => panic!("неучтённый Resolved-вариант в r3 golden: {other:?}"), }) .unwrap_or_else(|| { panic!( diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 8e91be69..3f732ddb 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -148,6 +148,10 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { (config path, task t3)" .to_string(), }, + // ОСОЗНАННЫЙ ДОЛГ t3: `Resolved` — `#[non_exhaustive]`, поэтому catch-all + // обязателен для будущих вариантов ядра. Пока маппит в стабильный код, + // а не молча роняет неверный цвет; при экспорте rgba-границы (t3) каждый + // новый вариант должен получить явный арм выше, а не оседать сюда. _ => RoleOutcome::Unreachable { code: "unreachable", message: "unmapped resolved variant".to_string(), From 5878014f28ab9c94ac1df55b5a478393e0387d70 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 08:04:45 +0300 Subject: [PATCH 04/17] =?UTF-8?q?fix(t2):=20=D0=B7=D0=B0=D0=B7=D0=B5=D0=BC?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B5=D0=B9=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D1=80=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20+=20=D0=BF=D0=B5=D1=80-=D1=82=D0=B5=D0=BC=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B0=D0=BB=D1=8C=D1=84=D1=8B=20+=20=D0=B7=D0=BD?= =?UTF-8?q?=D0=B0=D1=87=D0=B5=D0=BD=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приёмка нашла дефект заземления (посылка «labui Neutral = семейство blue» — ложна). Сверено построчно со стабом labui packages/colors-stub/contract.css: (1) НЕЙТРАЛЬНЫЙ источник ≠ blue. Добавлен LadderSource::Neutral(NeutralPick) из neutral.anchors (Mid #787880 / Light #FFFFFF / Dark #101012). Переведены на него: fx-skeleton-* (тинт #787880, стаб rgb(120 120 128 / …)), fx-glow-neutral (белый @52), fx-glow-inverted, fx-focus-ring-neutral, fill-neutral. fill-neutral-tinted/border-neutral — алиасы на core fill-primary/border-base (стаб var()). (2) ПЕР-ТЕМНЫЕ альфы: LadderPosition::alpha() → alpha_pair()(light,dark) + alpha_for_vc(); RoleSpec::Ladder несёт alpha_light/alpha_dark, резолв выбирает по теме. SkeletonBase пер-темна (light @8 / dark @12 по стабу) — исправлены перепутанные base/highlight альфы. Добавлены SkeletonBase/ SkeletonHighlight позиции. Акцентные пары равны (у Figma нет dark-рампов альф — стаб держит равными, помечено). (3) КЛАСС «имена без значений»: значенческий тест representative_roles_match_stub_values_light_and_dark — эмиссия rgba(тинт,α) представителя каждой группы == строка стаба ПОБАЙТНО в light И dark. Исключены намеренно расходящиеся (info — смещение солвером; focus-ring-neutral dark / glow-inverted / fill-neutral — задокументированные gap-и). RED-proof value_test_bites_on_alpha_mutation (мутация одной альфы → RED, проверено). Прогоны: fmt/clippy -D warnings чисты; 275 core lib + 20 наборов зелёные; s2b baseline-guard зелёный (r3/empirical_inventory не тронуты). Приложение A к ADR-0001 обновлено (пер-темная таблица альф + neutral-источник + значенческая сверка). Co-Authored-By: Claude Fable 5 --- crates/labcolors-core/src/config.rs | 133 ++++++++++++++----- crates/labcolors-core/src/config/tests.rs | 147 ++++++++++++++++++++- crates/labcolors-core/src/ladder.rs | 148 ++++++++++++++++------ crates/labcolors-core/src/lib.rs | 6 +- crates/labcolors-core/src/semantic.rs | 29 ++++- docs/decisions/0001-config-boundary.md | 69 ++++++---- 6 files changed, 421 insertions(+), 111 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index b547a622..68d158c8 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -373,9 +373,9 @@ pub enum RoleRecipe { /// `position` — позиция закрытого меню (несёт свою альфу; перечень — /// приложение A к ADR-0001). Компилируется в [`RoleSpec::Ladder`]. Ladder { - /// Источник тинта: бренд, семейство палитры или сентимент. + /// Источник тинта: бренд, семейство палитры, сентимент или нейтраль. source: LadderSource, - /// Позиция меню (несёт альфу Figma-рампы). + /// Позиция меню (несёт пер-темную пару альф из стаба labui). position: LadderPosition, }, /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], @@ -409,6 +409,30 @@ pub enum LadderSource { /// Сентимент-категория по имени: оттенок семейства, разведённый с брендом /// сентимент-солвером (пер-темный солид на разрешённом оттенке). Sentiment(String), + /// Нейтральный тинт из [`NeutralConfig::anchors`] — семейство `Neutral/Derivable` + /// стаба labui (`rgb(120 120 128 / …)` = `neutral.anchors.mid`). Скелетон и + /// нейтральные fill/border/glow/focus-роли берут ЭТОТ источник, НЕ семейство + /// палитры. Какой из трёх нейтральных якорей — задаёт [`NeutralPick`]. + Neutral(NeutralPick), +} + +/// Какой якорь нейтральной шкалы берёт [`LadderSource::Neutral`] как тинт. +/// +/// Нейтральная лестница labui тинтуется РАЗНЫМИ якорями по роли (заземление — +/// стаб `contract.css`): скелетон/тинты — средним (`neutral.anchors.mid`, +/// `#787880`, стаб `rgb(120 120 128 / …)`); нейтральное свечение — светлым краем +/// (`#FFFFFF`, стаб `rgb(255 255 255 / 0.522)`); нейтральный фокус — тёмным краем +/// (`#101012`, стаб `rgb(16 16 18)` на светлой теме). Выбор здесь держит тинт +/// пер-темными данными, а не веткой физики. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum NeutralPick { + /// Средний якорь `neutral.anchors.mid` (`#787880`) — скелетон, нейтральные тинты. + Mid, + /// Светлый край `neutral.anchors.light` (`#FFFFFF`) — нейтральное свечение. + Light, + /// Тёмный край `neutral.anchors.dark` (`#101012`) — нейтральный фокус. + Dark, } /// Полный конфиг темы потребителя (без сериализации — t3). @@ -726,6 +750,9 @@ impl ThemeConfig { }) } } + // Нейтральный источник всегда разрешим (neutral.anchors — обязательный + // вход конфига, провалидирован check_theme_anchors как тройка hex). + LadderSource::Neutral(_) => Ok(()), } } @@ -775,10 +802,14 @@ impl ThemeConfig { magnitude: *magnitude, }), RoleRecipe::Zero => Ok(RoleSpec::Zero), - RoleRecipe::Ladder { source, position } => Ok(RoleSpec::Ladder { - tint: self.compile_ladder_tint(role, source)?, - alpha: position.alpha(), - }), + RoleRecipe::Ladder { source, position } => { + let (alpha_light, alpha_dark) = position.alpha_pair(); + Ok(RoleSpec::Ladder { + tint: self.compile_ladder_tint(role, source)?, + alpha_light, + alpha_dark, + }) + } RoleRecipe::AlphaAnalog { of, alpha } => Ok(RoleSpec::AlphaAnalog { of: self.compile_ladder_tint(role, of)?, alpha: *alpha, @@ -802,6 +833,7 @@ impl ThemeConfig { LadderSource::Brand => self.brand.anchors.clone(), LadderSource::Family(key) => self.family_anchors(role, key)?.clone(), LadderSource::Sentiment(name) => return self.compile_sentiment_tint(role, name), + LadderSource::Neutral(pick) => self.neutral_anchors(*pick), }; let quad = anchors .encoded_quad() @@ -812,6 +844,24 @@ impl ThemeConfig { Ok(LadderTint::new(quad)) } + /// Нейтральный якорь из [`NeutralConfig::anchors`] по [`NeutralPick`], + /// продублированный на четыре режима (нейтральная шкала конфига несёт один + /// hex на край, без пер-темных IC-вариантов). Заземление — стаб labui: + /// `Neutral/Derivable` тинтуется этими краями (`#787880`/`#FFFFFF`/`#101012`). + fn neutral_anchors(&self, pick: NeutralPick) -> ThemeAnchors { + let hex = match pick { + NeutralPick::Mid => &self.neutral.anchors.mid, + NeutralPick::Light => &self.neutral.anchors.light, + NeutralPick::Dark => &self.neutral.anchors.dark, + }; + ThemeAnchors { + light: hex.clone(), + dark: hex.clone(), + light_ic: hex.clone(), + dark_ic: hex.clone(), + } + } + /// Пер-темные якоря семейства палитры по ключу (валидатор уже проверил /// существование — здесь защита компиляции). fn family_anchors(&self, role: &str, key: &str) -> Result<&ThemeAnchors, ConfigError> { @@ -928,10 +978,6 @@ pub fn labui_reference() -> ThemeConfig { source: LadderSource::Sentiment(name.to_string()), position, }; - let fam_pos = |key: &str, position| RoleRecipe::Ladder { - source: LadderSource::Family(key.to_string()), - position, - }; let mut roles = vec![ // Labels. @@ -1011,8 +1057,16 @@ pub fn labui_reference() -> ThemeConfig { roles.extend(ladder_family(prefix, &mk)); } - // FX focus-ring (солид) и glow (@52). Источник `*-neutral` = бренд: labui - // трактует нейтральный фокус/свечение как приглушённый брендовый акцент. + // Конструктор нейтрального источника (стаб: `Neutral/Derivable` тинтуется + // краями нейтральной шкалы, НЕ семейством палитры). + let neutral_pos = |pick, position| RoleRecipe::Ladder { + source: LadderSource::Neutral(pick), + position, + }; + + // FX focus-ring (солид) и glow (@52). Сентимент/бренд-источники — акцентные; + // `*-neutral`/`inverted` — НЕЙТРАЛЬНЫЕ (стаб: rgb(255 255 255 / .522) и т.п., + // НЕ бренд). roles.push(( "fx-focus-ring-brand".to_string(), brand_pos(LadderPosition::FocusRing), @@ -1025,9 +1079,13 @@ pub fn labui_reference() -> ThemeConfig { "fx-focus-ring-warning".to_string(), sent_pos("warning", LadderPosition::FocusRing), )); + // Нейтральный фокус: тёмный край нейтрали, солид (стаб light rgb(16 16 18) = + // #101012). Пер-темный флип к near-white на тёмной теме стаб несёт литералом + // (#F6F8FA) — движок из тройки anchors его не выводит: исключён из точного + // value-теста, помечен как gap пер-темного нейтрального края. roles.push(( "fx-focus-ring-neutral".to_string(), - brand_pos(LadderPosition::FocusRing), + neutral_pos(NeutralPick::Dark, LadderPosition::FocusRing), )); roles.push(("fx-glow-brand".to_string(), brand_pos(LadderPosition::Glow))); roles.push(( @@ -1038,28 +1096,33 @@ pub fn labui_reference() -> ThemeConfig { "fx-glow-warning".to_string(), sent_pos("warning", LadderPosition::Glow), )); + // Нейтральное свечение: светлый край нейтрали @52 (стаб rgb(255 255 255 / .522)). roles.push(( "fx-glow-neutral".to_string(), - brand_pos(LadderPosition::Glow), + neutral_pos(NeutralPick::Light, LadderPosition::Glow), )); + // Инвертированное свечение: нейтральный mid-тинт (стаб light #B0B0B9 / + // dark #3C3C43 — конкретные нейтральные литералы, не выводимые из тройки + // anchors). Приближено Neutral(Mid)@Glow; исключено из точного value-теста + // как известный gap (нужны отдельные inverted-якоря конфига). roles.push(( "fx-glow-inverted".to_string(), - brand_pos(LadderPosition::Glow), + neutral_pos(NeutralPick::Mid, LadderPosition::Glow), )); - // Skeleton — нейтральная полупрозрачная заливка (base @4, highlight @8): - // лестница нейтрального семейства `blue`. (Тени эмитятся отдельно как - // shadow-*, которые labui читает через alias fx-shadow-*.) + // Skeleton — нейтральный тинт #787880 (стаб rgb(120 120 128 / …)), ПЕР-ТЕМНАЯ + // альфа: base light @8 / dark @12, highlight @4. Источник = Neutral(Mid). roles.push(( "fx-skeleton-base".to_string(), - fam_pos("blue", LadderPosition::FillTertiary), + neutral_pos(NeutralPick::Mid, LadderPosition::SkeletonBase), )); roles.push(( "fx-skeleton-highlight".to_string(), - fam_pos("blue", LadderPosition::FillSecondary), + neutral_pos(NeutralPick::Mid, LadderPosition::SkeletonHighlight), )); - // Компонентные роли: accent = бренд-семья, neutral = нейтраль-семейство - // (labui `Neutral` компонент = семейство blue), danger = danger-сентимент. + // Компонентные роли. accent = бренд, danger = danger-сентимент, neutral — + // НЕЙТРАЛЬНЫЙ (стаб: fill-neutral солид-литерал; fill-neutral-tinted и + // border-neutral алиасят нейтральные core-роли fill-primary/border-base). // // Солид-роль (`fill-accent`) = лестница LabelPrimary (солид, α=1). `-tinted` — // ЗАЛИВКА при низкой альфе (rgba напрямую), то есть Ladder FillPrimary: тинт @@ -1071,9 +1134,11 @@ pub fn labui_reference() -> ThemeConfig { "fill-accent".to_string(), brand_pos(LadderPosition::LabelPrimary), )); + // fill-neutral — солид-литерал PROVISIONAL стаба (нет engine-деривации); + // приближено Neutral(Mid) солид, исключено из точного value-теста (owner-провизион). roles.push(( "fill-neutral".to_string(), - fam_pos("blue", LadderPosition::LabelPrimary), + neutral_pos(NeutralPick::Mid, LadderPosition::LabelPrimary), )); roles.push(( "fill-danger".to_string(), @@ -1083,10 +1148,8 @@ pub fn labui_reference() -> ThemeConfig { "fill-accent-tinted".to_string(), brand_pos(LadderPosition::FillPrimary), )); - roles.push(( - "fill-neutral-tinted".to_string(), - fam_pos("blue", LadderPosition::FillPrimary), - )); + // fill-neutral-tinted = var(fill-primary) → алиас на нейтральную core-заливку. + // border-neutral = var(border-base) → алиас (см. aliases ниже). roles.push(( "fill-danger-tinted".to_string(), sent_pos("danger", LadderPosition::FillPrimary), @@ -1103,10 +1166,7 @@ pub fn labui_reference() -> ThemeConfig { "border-accent".to_string(), brand_pos(LadderPosition::BorderBase), )); - roles.push(( - "border-neutral".to_string(), - fam_pos("blue", LadderPosition::BorderBase), - )); + // border-neutral = var(border-base): алиас на нейтральную dJ' границу core. roles.push(( "border-danger".to_string(), sent_pos("danger", LadderPosition::BorderBase), @@ -1169,7 +1229,16 @@ pub fn labui_reference() -> ThemeConfig { ], }, roles, - aliases: Vec::new(), + // Компонентные нейтральные роли, которые стаб алиасит через var() на + // нейтральные core-роли (одна истина, ноль дублирования значений): + // fill-neutral-tinted = var(--lab-fill-primary); border-neutral = var(--lab-border-base). + aliases: vec![ + ( + "fill-neutral-tinted".to_string(), + "fill-primary".to_string(), + ), + ("border-neutral".to_string(), "border-base".to_string()), + ], } } diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 75332a18..c4b08684 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -4,7 +4,9 @@ //! 2. RED-proof байт-в-байт: мутация одного рецепта фикстуры роняет тест. //! 3. Валидатор: за-предельное значение КАЖДОЙ ручки даёт `ConfigError` + //! RED-proof мутацией предела (валидный vs невалидный на границе). -//! 4. Заглушки t2: `Ladder`/`AlphaAnalog` дают `NotYetImplemented`. +//! 4. t2: Ladder/AlphaAnalog компилируются в rgba-специи; diff=пусто против +//! consumedRoles; S_PERC_MIN-идентичность; значенческая сверка со стабом +//! labui (light+dark) + RED-proof мутаций. use super::*; use crate::ladder::LadderPosition; @@ -459,8 +461,9 @@ fn ladder_recipe_compiles_to_rgba_spec() { .find(|(n, _)| n == "fill-primary") .unwrap(); assert!( - matches!(spec, RoleSpec::Ladder { alpha, .. } if (*alpha - 0.122).abs() < 1e-12), - "Ladder(FillPrimary) обязан нести альфу @12; получено {spec:?}" + matches!(spec, RoleSpec::Ladder { alpha_light, alpha_dark, .. } + if (*alpha_light - 0.122).abs() < 1e-12 && (*alpha_dark - 0.122).abs() < 1e-12), + "Ladder(FillPrimary) обязан нести альфу @12 (обе темы); получено {spec:?}" ); } @@ -696,16 +699,22 @@ const COLLAPSED_ROLES: &[(&str, &str)] = &[ /// фона, on-* выброшены, фоны=входы, алиасы). #[test] fn consumed_roles_diff_is_empty_against_labui_contract() { - let table = labui_reference() + let cfg = labui_reference(); + let table = cfg .compile_named_role_table() .expect("фикстура labui компилируется"); - let emitted: std::collections::HashSet<&str> = + // Покрытие = эмитируемые роли ∪ компонентные алиасы (стаб алиасит нейтральные + // компонентные роли через var() на core-роли — они покрыты алиасом, не рецептом). + let mut covered: std::collections::HashSet<&str> = table.entries().iter().map(|(n, _)| n.as_str()).collect(); + for (alias, _) in &cfg.aliases { + covered.insert(alias.as_str()); + } - // Каждая требуемая (не-коллапс) роль обязана эмитироваться. + // Каждая требуемая (не-коллапс) роль обязана быть покрыта (рецептом или алиасом). let mut missing = Vec::new(); for role in LABUI_CONSUMED_ROLES { - if !emitted.contains(role) { + if !covered.contains(role) { missing.push(*role); } } @@ -1047,3 +1056,127 @@ fn alpha_analog_recipe_inverts_and_bites_on_alpha() { } } } + +// ───────────────────────────────────────────────────────────────────────────── +// t2 (класс «имена без значений»): значенческий тест фикстуры против стаба. +// +// Класс дефекта: роль присутствует в diff-тесте по ИМЕНИ, но эмитит НЕ ТО +// значение (напр. нейтральный skeleton, ошибочно взятый из семейства blue). +// Здесь эмиссия rgba(тинт, α) представителя каждой группы сверяется со строкой +// стаба contract.css ПОБАЙТНО (нормализованный формат), в light И dark. +// ───────────────────────────────────────────────────────────────────────────── + +/// Нормализовать [`Resolved::Rgba`] в канонический `rgb(R G B / A)` (формат стаба +/// labui): тинт-hex → десятичные каналы, альфа как есть. Солид (α=1) → `rgb(R G B)`. +fn rgba_to_stub_string(res: &Resolved) -> String { + let r = res + .rgba() + .unwrap_or_else(|| panic!("ожидался Resolved::Rgba, получено {res:?}")); + let rgb = crate::spaces::srgb::srgb_encoded_from_hex(r.tint_hex()).unwrap(); + let ch = |v: f64| (v * 255.0).round() as u8; + let (rr, gg, bb) = (ch(rgb[0]), ch(rgb[1]), ch(rgb[2])); + if (r.alpha() - 1.0).abs() < 1e-9 { + format!("rgb({rr} {gg} {bb})") + } else { + // Стаб печатает альфу без ведущего нуля целой части и без хвостовых нулей + // (0.722, 0.2, 0.078…); {} по f64 это воспроизводит для наших величин. + format!("rgb({rr} {gg} {bb} / {})", r.alpha()) + } +} + +/// Значенческая сверка представителей групп против стаба labui в light И dark. +/// Закрывает класс «имя есть, значение врёт»: skeleton = нейтраль #787880 с +/// пер-темной альфой, glow-neutral = белый @52, акценты = пер-темный якорь. +/// +/// Исключены НАМЕРЕННО расходящиеся роли (с комментарием-ссылкой): +/// - `border-info-*`/`label-info-*`/`fill-info-*` — оттенок смещён сентимент- +/// солвером относительно бренда (тест `info_is_displaced_from_blue_brand_by_design`); +/// - `fx-focus-ring-neutral` (dark), `fx-glow-inverted`, `fill-neutral` — +/// задокументированные gap-и (пер-темный нейтральный край / inverted-якоря / +/// PROVISIONAL-литерал не выводятся из тройки neutral.anchors). +#[test] +fn representative_roles_match_stub_values_light_and_dark() { + let table = labui_reference().compile_named_role_table().unwrap(); + let bg_light = BgInput::solid("#FFFFFF").unwrap(); + let bg_dark = BgInput::solid("#101012").unwrap(); + + // (роль, стаб-light, стаб-dark). Значения — из contract.css (2026-07-02). + let cases: &[(&str, &str, &str)] = &[ + // Акцент/сентимент: пер-темный тинт, альфа @72/@12/@52. + ( + "label-danger-secondary", + "rgb(255 59 48 / 0.722)", + "rgb(255 58 58 / 0.722)", + ), + ( + "fill-brand-primary", + "rgb(0 122 255 / 0.122)", + "rgb(74 143 255 / 0.122)", + ), + ( + "border-success-base", + "rgb(52 199 89 / 0.2)", + "rgb(48 209 88 / 0.2)", + ), + ( + "fx-glow-brand", + "rgb(0 122 255 / 0.522)", + "rgb(74 143 255 / 0.522)", + ), + // Нейтральные: skeleton #787880 с ПЕР-ТЕМНОЙ альфой (base @8/@12), glow-neutral белый @52. + ( + "fx-skeleton-base", + "rgb(120 120 128 / 0.078)", + "rgb(120 120 128 / 0.122)", + ), + ( + "fx-skeleton-highlight", + "rgb(120 120 128 / 0.039)", + "rgb(120 120 128 / 0.039)", + ), + ( + "fx-glow-neutral", + "rgb(255 255 255 / 0.522)", + "rgb(255 255 255 / 0.522)", + ), + ]; + + for (role, want_light, want_dark) in cases { + let set_l = resolve_named_set(&bg_light, &table, &ViewingConditions::srgb()); + let set_d = resolve_named_set(&bg_dark, &table, &ViewingConditions::dim_surround()); + let got_l = rgba_to_stub_string(&set_l.iter().find(|(n, _)| n == role).unwrap().1); + let got_d = rgba_to_stub_string(&set_d.iter().find(|(n, _)| n == role).unwrap().1); + assert_eq!( + &got_l, want_light, + "ЗНАЧЕНИЕ РАЗОШЛОСЬ (light) `{role}`: эмиссия {got_l} != стаб {want_light}" + ); + assert_eq!( + &got_d, want_dark, + "ЗНАЧЕНИЕ РАЗОШЛОСЬ (dark) `{role}`: эмиссия {got_d} != стаб {want_dark}" + ); + } +} + +/// RED-proof значенческого теста: мутация ОДНОЙ альфы (skeleton-base dark @12→@2) +/// роняет сверку — тест кусается, не green-from-birth. +#[test] +fn value_test_bites_on_alpha_mutation() { + let mut cfg = labui_reference(); + for (name, recipe) in &mut cfg.roles { + if name == "fx-skeleton-base" { + // Подменяем позицию на FillQuaternary (@2) — dark-альфа уедет с @12 на @2. + *recipe = RoleRecipe::Ladder { + source: LadderSource::Neutral(crate::config::NeutralPick::Mid), + position: LadderPosition::FillQuaternary, + }; + } + } + let table = cfg.compile_named_role_table().unwrap(); + let bg_dark = BgInput::solid("#101012").unwrap(); + let set = resolve_named_set(&bg_dark, &table, &ViewingConditions::dim_surround()); + let got = rgba_to_stub_string(&set.iter().find(|(n, _)| n == "fx-skeleton-base").unwrap().1); + assert_ne!( + got, "rgb(120 120 128 / 0.122)", + "RED-proof значенческого теста провален: мутация альфы НЕ сдвинула эмиссию" + ); +} diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index 7a2f7536..d2c60d95 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -163,11 +163,15 @@ pub enum LadderPosition { FocusRing, /// Свечение — `@52`. Glow, + /// Скелетон-база — ПЕР-ТЕМНАЯ альфа (light `@8`, dark `@12` по стабу labui). + SkeletonBase, + /// Скелетон-хайлайт — `@4` (обе темы). + SkeletonHighlight, } impl LadderPosition { /// Все позиции меню — поверхность для property-свипов и генерации ролей. - pub const ALL: [LadderPosition; 13] = [ + pub const ALL: [LadderPosition; 15] = [ LadderPosition::LabelPrimary, LadderPosition::LabelSecondary, LadderPosition::LabelTertiary, @@ -181,28 +185,53 @@ impl LadderPosition { LadderPosition::BorderStrong, LadderPosition::FocusRing, LadderPosition::Glow, + LadderPosition::SkeletonBase, + LadderPosition::SkeletonHighlight, ]; - /// Альфа позиции — ДАННЫЕ рампы Figma `@NN` (провенанс — документация модуля). - /// Мутация любого значения роняет тест лестницы (RED-proof). - pub fn alpha(self) -> f64 { + /// Пер-темная пара альф `(light, dark)` позиции — ДАННЫЕ, снятые построчно из + /// стаба labui `packages/colors-stub/contract.css` (light-scope `[data-theme= + /// "light"]` и dark-scope `[data-theme="dark"]`, снято 2026-07-02). + /// + /// Для акцентных позиций пара РАВНА (свет и тьма несут одну альфу @NN, меняется + /// только тинт по теме — стаб dark = копия light-альфы). Скелетон-база — + /// ЕДИНСТВЕННАЯ пер-темная альфа: light `@8` (0.078) / dark `@12` (0.122). + /// IC-темы: стаб не несёт отдельных ic-скоупов, поэтому light-ic берёт + /// light-альфу, dark-ic — dark-альфу ([`alpha_for_vc`](Self::alpha_for_vc)). + /// + /// Замечание: у Figma нет отдельных dark-рампов альф акцентов — стаб держит + /// пары равными; когда рампы появятся, правится ЭТА таблица (данные), не код. + pub fn alpha_pair(self) -> (f64, f64) { match self { - LadderPosition::LabelPrimary => 1.0, - LadderPosition::LabelSecondary => 0.722, - LadderPosition::LabelTertiary => 0.522, - LadderPosition::LabelQuaternary => 0.322, - LadderPosition::FillPrimary => 0.122, - LadderPosition::FillSecondary => 0.078, - LadderPosition::FillTertiary => 0.039, - LadderPosition::FillQuaternary => 0.02, - LadderPosition::BorderBase => 0.2, - LadderPosition::BorderSoft => 0.122, - LadderPosition::BorderStrong => 1.0, - LadderPosition::FocusRing => 1.0, - LadderPosition::Glow => 0.522, + LadderPosition::LabelPrimary => (1.0, 1.0), + LadderPosition::LabelSecondary => (0.722, 0.722), + LadderPosition::LabelTertiary => (0.522, 0.522), + LadderPosition::LabelQuaternary => (0.322, 0.322), + LadderPosition::FillPrimary => (0.122, 0.122), + LadderPosition::FillSecondary => (0.078, 0.078), + LadderPosition::FillTertiary => (0.039, 0.039), + LadderPosition::FillQuaternary => (0.02, 0.02), + LadderPosition::BorderBase => (0.2, 0.2), + LadderPosition::BorderSoft => (0.122, 0.122), + LadderPosition::BorderStrong => (1.0, 1.0), + LadderPosition::FocusRing => (1.0, 1.0), + LadderPosition::Glow => (0.522, 0.522), + // Скелетон-база пер-темна: стаб light @8, dark @12. + LadderPosition::SkeletonBase => (0.078, 0.122), + LadderPosition::SkeletonHighlight => (0.039, 0.039), } } + /// Альфа позиции под условия просмотра резолва: тёмный сурраунд + /// (`vc.is_dark_theme()`) берёт `dark`-элемент пары, иначе `light`. IC-режим + /// наследует альфу базовой темы (стаб не несёт отдельных ic-альф — см. + /// [`alpha_pair`](Self::alpha_pair)); IC отличается только тинтом (пер-темный + /// якорь [`LadderTint::for_vc`]). + pub fn alpha_for_vc(self, vc: &ViewingConditions) -> f64 { + let (light, dark) = self.alpha_pair(); + if vc.is_dark_theme() { dark } else { light } + } + /// Стабильный kebab-ключ позиции — для разбора рецепта из конфига (t3 JSON) /// и для приложения A к ADR. Часть контракта имён; опечатка ловится тестом. pub fn key(self) -> &'static str { @@ -220,6 +249,8 @@ impl LadderPosition { LadderPosition::BorderStrong => "border-strong", LadderPosition::FocusRing => "focus-ring", LadderPosition::Glow => "glow", + LadderPosition::SkeletonBase => "skeleton-base", + LadderPosition::SkeletonHighlight => "skeleton-highlight", } } } @@ -228,33 +259,72 @@ impl LadderPosition { mod tests { use super::*; - /// Меню позиций закрыто и его альфы — заземлённая Figma-рампа `@NN`. - /// Нетавтологичный пин: числа взяты из grounding-документа, а мутация - /// [`LadderPosition::alpha`] роняет тест (RED-proof альф позиций). + /// Меню позиций закрыто; пер-темные пары альф `(light, dark)` — заземлены + /// построчно из стаба labui (contract.css light/dark-scope). Нетавтологичный + /// пин: числа из стаба, мутация [`LadderPosition::alpha_pair`] роняет тест. #[test] - fn position_alphas_match_grounded_figma_ramp() { - let expected: &[(LadderPosition, f64, &str)] = &[ - (LadderPosition::LabelPrimary, 1.0, "label-primary"), - (LadderPosition::LabelSecondary, 0.722, "label-secondary"), - (LadderPosition::LabelTertiary, 0.522, "label-tertiary"), - (LadderPosition::LabelQuaternary, 0.322, "label-quaternary"), - (LadderPosition::FillPrimary, 0.122, "fill-primary"), - (LadderPosition::FillSecondary, 0.078, "fill-secondary"), - (LadderPosition::FillTertiary, 0.039, "fill-tertiary"), - (LadderPosition::FillQuaternary, 0.02, "fill-quaternary"), - (LadderPosition::BorderBase, 0.2, "border-base"), - (LadderPosition::BorderSoft, 0.122, "border-soft"), - (LadderPosition::BorderStrong, 1.0, "border-strong"), - (LadderPosition::FocusRing, 1.0, "focus-ring"), - (LadderPosition::Glow, 0.522, "glow"), + fn position_alpha_pairs_match_grounded_stub() { + // (позиция, (light, dark), ключ) — дословно из contract.css. + let expected: &[(LadderPosition, (f64, f64), &str)] = &[ + (LadderPosition::LabelPrimary, (1.0, 1.0), "label-primary"), + ( + LadderPosition::LabelSecondary, + (0.722, 0.722), + "label-secondary", + ), + ( + LadderPosition::LabelTertiary, + (0.522, 0.522), + "label-tertiary", + ), + ( + LadderPosition::LabelQuaternary, + (0.322, 0.322), + "label-quaternary", + ), + (LadderPosition::FillPrimary, (0.122, 0.122), "fill-primary"), + ( + LadderPosition::FillSecondary, + (0.078, 0.078), + "fill-secondary", + ), + ( + LadderPosition::FillTertiary, + (0.039, 0.039), + "fill-tertiary", + ), + ( + LadderPosition::FillQuaternary, + (0.02, 0.02), + "fill-quaternary", + ), + (LadderPosition::BorderBase, (0.2, 0.2), "border-base"), + (LadderPosition::BorderSoft, (0.122, 0.122), "border-soft"), + (LadderPosition::BorderStrong, (1.0, 1.0), "border-strong"), + (LadderPosition::FocusRing, (1.0, 1.0), "focus-ring"), + (LadderPosition::Glow, (0.522, 0.522), "glow"), + // Скелетон-база пер-темна: light @8, dark @12 (единственная). + ( + LadderPosition::SkeletonBase, + (0.078, 0.122), + "skeleton-base", + ), + ( + LadderPosition::SkeletonHighlight, + (0.039, 0.039), + "skeleton-highlight", + ), ]; - for (pos, alpha, key) in expected { + for (pos, pair, key) in expected { assert_eq!( - pos.alpha(), - *alpha, - "{pos:?}: альфа дрейфанула от Figma-рампы" + pos.alpha_pair(), + *pair, + "{pos:?}: пара альф дрейфанула от стаба" ); assert_eq!(pos.key(), *key, "{pos:?}: ключ разошёлся с контрактом имён"); + // alpha_for_vc выбирает верный элемент пары по теме. + assert_eq!(pos.alpha_for_vc(&ViewingConditions::srgb()), pair.0); + assert_eq!(pos.alpha_for_vc(&ViewingConditions::dim_surround()), pair.1); } let all: Vec = LadderPosition::ALL.to_vec(); let listed: Vec = expected.iter().map(|(p, ..)| *p).collect(); diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 4ae74df6..baedcffd 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -31,9 +31,9 @@ pub use cleanliness::{ muddiness_from_hex, muddiness_from_linear_srgb, muddiness_in_context, muddiness_oklch, n_pure, }; pub use config::{ - Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralTint, PaletteFamily, - RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, VcPreset, - labui_reference, + Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, + PaletteFamily, RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, + VcPreset, labui_reference, }; pub use curve::ColorCurve; pub use ladder::{LadderPosition, LadderTint, ThemeAnchors}; diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 9cb0a009..30925568 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -518,13 +518,18 @@ pub enum RoleSpec { /// резолва для честного замера контраста (фаза 1 AA меряет композит). /// /// bg-независимость тинта (это якорь источника) — почему он `Copy`-payload - /// [`LadderTint`], разложенный по темам на этапе компиляции; альфа — данные - /// позиции меню ([`LadderPosition::alpha`]). + /// [`LadderTint`], разложенный по темам на этапе компиляции. Альфа — ПЕР-ТЕМНАЯ + /// пара `(light, dark)` данных позиции меню + /// ([`LadderPosition::alpha_pair`](crate::ladder::LadderPosition::alpha_pair)): + /// у акцентов пара равна, но скелетон-база пер-темна (стаб light @8 / dark @12), + /// поэтому альфа выбирается по теме резолва, как и тинт. Ladder { /// Пер-темный кодированный тинт (якорь источника). tint: LadderTint, - /// Альфа позиции (`(0, 1]`; солид = 1.0). - alpha: f64, + /// Альфа для светлой темы (`(0, 1]`; солид = 1.0). + alpha_light: f64, + /// Альфа для тёмной темы (`(0, 1]`; у акцентов = `alpha_light`). + alpha_dark: f64, }, /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], /// #119): для солид-цвета `of` (по теме) на фоне резолва подбирается @@ -1374,10 +1379,20 @@ fn resolve_spec_in( }; } RoleSpec::Decorative { magnitude } => ctx.decorative_contract(magnitude), - RoleSpec::Ladder { tint, alpha } => { + RoleSpec::Ladder { + tint, + alpha_light, + alpha_dark, + } => { // Лестница эмитит rgba(tint, α) НАПРЯМУЮ: тинт — якорь источника по - // теме (bg-независим), α — данные позиции. Композит на фоне резолва — - // для честного замера контраста (закон лестницы, `crate::ladder`). + // теме (bg-независим), α — пер-темные данные позиции (light/dark). + // Композит на фоне резолва — для честного замера контраста + // (закон лестницы, `crate::ladder`). + let alpha = if vc.is_dark_theme() { + alpha_dark + } else { + alpha_light + }; return resolve_rgba_direct(tint.for_vc(vc), alpha, bg, vc); } RoleSpec::AlphaAnalog { of, alpha } => { diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index 07caf150..c78487f3 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -147,29 +147,37 @@ primitives.md` §2 (пер-темные якоря) + grounding-документ альф Figma `Accent/Derivable//@NN`. Единый паттерн проверен на brand/danger/info/success. -### Позиции и альфы (провенанс — Figma-рампа @NN) - -Альфы — данные (float32-квантование процентов из имён переменных Figma), НЕ -POLICY-константы перцептивных модулей: провенанс держит doc-строка -`crate::ladder` + тест `position_alphas_match_grounded_figma_ramp` (нетавтологичный -пин из grounding-документа), а не строка реестра `docs/empirical-inventory.md` -(как якорные hex палитры в `accent.rs`). - -| позиция | α | Figma @NN | -|------------------|---------|-----------| -| `LabelPrimary` | 1.0 | солид | -| `LabelSecondary` | 0.722 | @72 | -| `LabelTertiary` | 0.522 | @52 | -| `LabelQuaternary`| 0.322 | @32 | -| `FillPrimary` | 0.122 | @12 | -| `FillSecondary` | 0.078 | @8 | -| `FillTertiary` | 0.039 | @4 | -| `FillQuaternary` | 0.02 | @2 | -| `BorderBase` | 0.2 | @20 | -| `BorderSoft` | 0.122 | @12 | -| `BorderStrong` | 1.0 | солид | -| `FocusRing` | 1.0 | солид | -| `Glow` | 0.522 | @52 | +### Позиции и ПЕР-ТЕМНЫЕ альфы (провенанс — стаб labui `contract.css`) + +Альфы — данные, снятые ПОСТРОЧНО из стаба labui `packages/colors-stub/contract.css` +(light-scope `[data-theme="light"]` и dark-scope `[data-theme="dark"]`, 2026-07-02), +НЕ POLICY-константы: провенанс держит doc-строка `crate::ladder` + тест +`position_alpha_pairs_match_grounded_stub` (нетавтологичный пин из стаба). + +Альфа — ПЕР-ТЕМНАЯ пара `(light, dark)`. У акцентов пара равна (меняется только +тинт по теме — у Figma ещё нет отдельных dark-рампов альф акцентов, стаб держит +пары равными). Скелетон-база — ЕДИНСТВЕННАЯ пер-темная альфа. IC-темы наследуют +альфу базовой темы (стаб не несёт ic-скоупов; IC отличается только тинтом). + +| позиция | α (light) | α (dark) | стаб @NN | +|---------------------|-----------|----------|--------------| +| `LabelPrimary` | 1.0 | 1.0 | солид | +| `LabelSecondary` | 0.722 | 0.722 | @72 | +| `LabelTertiary` | 0.522 | 0.522 | @52 | +| `LabelQuaternary` | 0.322 | 0.322 | @32 | +| `FillPrimary` | 0.122 | 0.122 | @12 | +| `FillSecondary` | 0.078 | 0.078 | @8 | +| `FillTertiary` | 0.039 | 0.039 | @4 | +| `FillQuaternary` | 0.02 | 0.02 | @2 | +| `BorderBase` | 0.2 | 0.2 | @20 | +| `BorderSoft` | 0.122 | 0.122 | @12 | +| `BorderStrong` | 1.0 | 1.0 | солид | +| `FocusRing` | 1.0 | 1.0 | солид | +| `Glow` | 0.522 | 0.522 | @52 | +| `SkeletonBase` | 0.078 | 0.122 | @8 / @12 ⚠ | +| `SkeletonHighlight` | 0.039 | 0.039 | @4 | + +⚠ `SkeletonBase` — единственная пер-темная альфа (стаб: light @8, dark @12). ### Источник тинта (`source`) @@ -181,10 +189,25 @@ POLICY-константы перцептивных модулей: провен конфига (закон `2·C_rep·sin(20°/2)`, Witzel & Gegenfurtner 2013); при labui-якорях == замороженная `0.068_703_9` (деривационная идентичность — тест `s_perc_min_recomputed_from_config_anchors_matches_frozen`). +- `neutral(pick)` — НЕЙТРАЛЬНЫЙ тинт из `neutral.anchors` (НЕ семейство палитры). + Заземление — стаб: `Neutral/Derivable` тинтуется краями нейтральной шкалы. + `pick`: `Mid` (`#787880`, стаб `rgb(120 120 128 / …)`) — скелетон/нейтральные + тинты; `Light` (`#FFFFFF`) — нейтральное свечение (стаб `rgb(255 255 255 / 0.522)`); + `Dark` (`#101012`) — нейтральный фокус (стаб `rgb(16 16 18)` на светлой теме). Пер-темность якорей (а не «светлый + вывод») — из reference §2: тёмный/IC-варианты Figma-примитивов замерены, не выведены. +### Значенческая сверка (класс «имя без значения») + +`representative_roles_match_stub_values_light_and_dark` сверяет эмиссию +`rgba(тинт, α)` представителя КАЖДОЙ группы со строкой стаба ПОБАЙТНО в light И +dark (нормализованный формат). Закрывает класс дефекта «роль в diff по имени, но +эмитит не то значение». Исключены намеренно расходящиеся роли (с ссылкой): +`*-info-*` (смещение солвером); `fx-focus-ring-neutral` (dark), `fx-glow-inverted`, +`fill-neutral` — задокументированные gap-и (пер-темный нейтральный край / +inverted-якоря / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`). + ### Деривационная идентичность сентимента (честная находка t2) При бренде labui (`#007AFF`, Oklab h≈257.4°) сентимент-тинт совпадает с СЫРЫМ From 8cd7e9bd60b4982c5ae3c04351d50a942dccd986 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 08:24:12 +0300 Subject: [PATCH 05/17] =?UTF-8?q?fix(config):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=B3=D0=B5=D0=B9=D1=82=D0=B0=20PR-a=20?= =?UTF-8?q?=E2=80=94=20wasm-=D0=B0=D1=80=D0=BC,=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D0=B9=20=D0=B2=D0=B0=D0=BB=D0=B8=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D0=BE=D1=80,=20=D1=80=D0=B0=D0=B7=D0=BB=D0=B8=D1=87=D0=B8?= =?UTF-8?q?=D0=BC=D1=8B=D0=B5=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: wasm_parity.rs не знал Resolved::Rgba (тест компилится только под wasm32 — локальный прогон слеп) — явный panic-арм на будущие варианты, не маска. CodeRabbit (4 major + trivial): (1) дубликаты ключей всех словарей конфига отвергаются (DuplicateKey; включая алиас, затеняющий роль) — повтор имени делал lookup неоднозначным; (2) ошибки ссылок различимы: UnknownSentiment/ UnknownRole вместо перегруженного UnknownFamily; (3) неконечные значения (±∞/NaN) отвергаются и open-сверху пределами (is_finite в check_gt/check_ge); (4) preferred_side — закрытое меню {-1,+1} (0/2 отвергаются); (5) единый vc_slot() — обе раскладки четвёрки режимов выбирают слот одним отображением. Тесты на каждый пункт (4 новых + обновлён alias-тест под различимую ошибку). --- crates/labcolors-core/src/config.rs | 99 ++++++++++++++++++-- crates/labcolors-core/src/config/tests.rs | 101 ++++++++++++++++++++- crates/labcolors-core/src/ladder.rs | 30 +++--- crates/labcolors-wasm/tests/wasm_parity.rs | 6 ++ 4 files changed, 212 insertions(+), 24 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 68d158c8..78927f26 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -169,6 +169,19 @@ pub enum ConfigError { referenced_by: String, family: String, }, + /// Ссылка на категорию сентиментов, которой нет в `sentiments.categories`. + UnknownSentiment { + referenced_by: String, + sentiment: String, + }, + /// Ссылка (алиас/alpha_analog) на роль, которой нет в `roles`. + UnknownRole { referenced_by: String, role: String }, + /// Дубликат ключа в словаре конфига: повтор имени сделал бы lookup и + /// эмиссию неоднозначными (какая запись выиграла — вопрос порядка, тихо). + DuplicateKey { + dictionary: &'static str, + key: String, + }, /// Значение ручки вне допустимого предела. `handle` — путь до ручки, `bound` — /// человеко-читаемое описание нарушенного предела с обоснованием. OutOfBounds { @@ -192,6 +205,24 @@ impl std::fmt::Display for ConfigError { f, "невалидное имя в поле `{field}`: {value:?} (допустимо [a-z0-9-]+, не пусто)" ), + ConfigError::UnknownSentiment { + referenced_by, + sentiment, + } => write!( + f, + "`{referenced_by}` ссылается на категорию сентиментов `{sentiment}`, которой нет в sentiments" + ), + ConfigError::UnknownRole { + referenced_by, + role, + } => write!( + f, + "`{referenced_by}` ссылается на роль `{role}`, которой нет в roles" + ), + ConfigError::DuplicateKey { dictionary, key } => write!( + f, + "дубликат ключа `{key}` в словаре `{dictionary}` — lookup был бы неоднозначным" + ), ConfigError::UnknownFamily { referenced_by, family, @@ -534,14 +565,15 @@ fn check_in_incl_incl( } } -/// Проверить `value > min` (строго положительно). +/// Проверить `value > min` (строго положительно). Неконечные значения (±∞, +/// NaN) отвергаются всегда: открытый сверху предел — не лазейка для мусора. fn check_gt( handle: &str, value: f64, min_excl: f64, bound: &'static str, ) -> Result<(), ConfigError> { - if value > min_excl { + if value.is_finite() && value > min_excl { Ok(()) } else { Err(ConfigError::OutOfBounds { @@ -552,14 +584,14 @@ fn check_gt( } } -/// Проверить `value ≥ min`. +/// Проверить `value ≥ min`. Неконечные значения отвергаются всегда. fn check_ge( handle: &str, value: f64, min_incl: f64, bound: &'static str, ) -> Result<(), ConfigError> { - if value >= min_incl { + if value.is_finite() && value >= min_incl { Ok(()) } else { Err(ConfigError::OutOfBounds { @@ -637,6 +669,16 @@ impl ThemeConfig { family: cat.family.clone(), }); } + if let Some(side) = cat.preferred_side + && side != 1 + && side != -1 + { + return Err(ConfigError::OutOfBounds { + handle: format!("sentiments.{}.preferred_side", cat.name), + value: f64::from(side), + bound: "preferred_side ∈ {-1, +1} (закрытое меню сторон смещения)", + }); + } if let Some(hue) = cat.hue_floor_deg { let field = format!("sentiments.{}.hue_floor_deg", cat.name); // Полуинтервал `[0, 360)`: угол по модулю 360°, где 360° ≡ 0°. @@ -650,6 +692,43 @@ impl ThemeConfig { } } + // Дубликаты ключей всех словарей: повтор имени = неоднозначный lookup. + fn check_unique<'a, I: Iterator>( + dictionary: &'static str, + keys: I, + ) -> Result<(), ConfigError> { + let mut seen = std::collections::BTreeSet::new(); + for k in keys { + if !seen.insert(k) { + return Err(ConfigError::DuplicateKey { + dictionary, + key: k.to_string(), + }); + } + } + Ok(()) + } + check_unique("palette", self.palette.iter().map(|f| f.key.as_str()))?; + check_unique( + "sentiments.categories", + self.sentiments.categories.iter().map(|c| c.name.as_str()), + )?; + check_unique( + "themes", + self.themes.entries.iter().map(|(n, _)| n.as_str()), + )?; + check_unique("roles", self.roles.iter().map(|(n, _)| n.as_str()))?; + check_unique("aliases", self.aliases.iter().map(|(n, _)| n.as_str()))?; + // Алиас не может затенять роль: одно имя — одна сущность эмиссии. + for (alias, _) in &self.aliases { + if self.roles.iter().any(|(rname, _)| rname == alias) { + return Err(ConfigError::DuplicateKey { + dictionary: "roles∪aliases", + key: alias.clone(), + }); + } + } + // Темы: имена. for (name, _preset) in &self.themes.entries { let field = format!("themes.{name}"); @@ -668,9 +747,9 @@ impl ThemeConfig { let field = format!("aliases.{alias}"); check_name(&field, alias)?; if !self.roles.iter().any(|(rname, _)| rname == target) { - return Err(ConfigError::UnknownFamily { + return Err(ConfigError::UnknownRole { referenced_by: format!("aliases.{alias}"), - family: target.clone(), + role: target.clone(), }); } } @@ -744,9 +823,9 @@ impl ThemeConfig { if self.sentiments.categories.iter().any(|c| &c.name == name) { Ok(()) } else { - Err(ConfigError::UnknownFamily { + Err(ConfigError::UnknownSentiment { referenced_by: format!("roles.{role}"), - family: name.clone(), + sentiment: name.clone(), }) } } @@ -885,9 +964,9 @@ impl ThemeConfig { .categories .iter() .find(|c| c.name == name) - .ok_or_else(|| ConfigError::UnknownFamily { + .ok_or_else(|| ConfigError::UnknownSentiment { referenced_by: format!("roles.{role}"), - family: name.to_string(), + sentiment: name.to_string(), })?; let fam = self.family_anchors(role, &cat.family)?.clone(); let brand = self.brand.anchors.clone(); diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index c4b08684..5297ce39 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -429,11 +429,13 @@ fn sentiment_referencing_missing_family_is_rejected() { #[test] fn alias_to_missing_role_is_rejected() { let mut cfg = labui_reference(); + // Уникальное имя алиаса: дубликат существующего поймался бы раньше как + // DuplicateKey — здесь проверяется именно различимая ошибка ссылки. cfg.aliases - .push(("control-bg".to_string(), "no-such-role".to_string())); + .push(("probe-unique-alias".to_string(), "no-such-role".to_string())); assert!(matches!( cfg.validate(), - Err(ConfigError::UnknownFamily { family, .. }) if family == "no-such-role" + Err(ConfigError::UnknownRole { role, .. }) if role == "no-such-role" )); } @@ -1180,3 +1182,98 @@ fn value_test_bites_on_alpha_mutation() { "RED-proof значенческого теста провален: мутация альфы НЕ сдвинула эмиссию" ); } + +/// Валидатор CodeRabbit-раунда: дубликаты ключей всех словарей отвергаются +/// (повтор имени = неоднозначный lookup), включая алиас, затеняющий роль. +#[test] +fn validator_rejects_duplicate_dictionary_keys() { + let mut c = labui_reference(); + c.roles.push(c.roles[0].clone()); + assert!(matches!( + c.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "roles", + .. + }) + )); + + let mut c = labui_reference(); + c.palette.push(c.palette[0].clone()); + assert!(matches!( + c.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "palette", + .. + }) + )); + + let mut c = labui_reference(); + let role_name = c.roles[0].0.clone(); + c.aliases.push((role_name, c.roles[1].0.clone())); + assert!(matches!( + c.validate(), + Err(ConfigError::DuplicateKey { + dictionary: "roles∪aliases", + .. + }) + )); +} + +/// preferred_side — закрытое меню {-1, +1}: 0 и 2 отвергаются. +#[test] +fn validator_rejects_preferred_side_outside_closed_menu() { + for bad in [0i8, 2, -3] { + let mut c = labui_reference(); + c.sentiments.categories[0].preferred_side = Some(bad); + assert!( + matches!(c.validate(), Err(ConfigError::OutOfBounds { .. })), + "preferred_side={bad} обязан быть отвергнут" + ); + } + let mut c = labui_reference(); + c.sentiments.categories[0].preferred_side = Some(-1); + assert!(c.validate().is_ok(), "-1 легален"); +} + +/// Неконечные значения ручек (∞/NaN) отвергаются и open-сверху пределами. +#[test] +fn validator_rejects_non_finite_handles() { + for bad in [f64::INFINITY, f64::NAN] { + let mut c = labui_reference(); + if let Some((_, RoleRecipe::DjAnchor { light, .. })) = c + .roles + .iter_mut() + .find(|(_, r)| matches!(r, RoleRecipe::DjAnchor { .. })) + { + *light = bad; + } else { + panic!("в фикстуре обязан быть dj_anchor"); + } + assert!( + matches!(c.validate(), Err(ConfigError::OutOfBounds { .. })), + "dj={bad} обязан быть отвергнут" + ); + } +} + +/// Ошибки ссылок различимы по виду: сентимент/роль/семейство — разные варианты. +#[test] +fn validator_reference_errors_are_distinguishable() { + let mut c = labui_reference(); + c.roles.push(( + "probe-bad-sentiment".to_string(), + RoleRecipe::Ladder { + source: LadderSource::Sentiment("nonexistent".to_string()), + position: LadderPosition::LabelPrimary, + }, + )); + assert!(matches!( + c.validate(), + Err(ConfigError::UnknownSentiment { .. }) + )); + + let mut c = labui_reference(); + c.aliases + .push(("probe-alias".to_string(), "nonexistent-role".to_string())); + assert!(matches!(c.validate(), Err(ConfigError::UnknownRole { .. }))); +} diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index d2c60d95..0a04652a 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -33,6 +33,18 @@ use crate::spaces::vc::ViewingConditions; /// Пер-темная четвёрка якорных hex (`light` / `dark` / `light-ic` / `dark-ic`). /// +/// ЕДИНСТВЕННОЕ отображение условий просмотра → слот четвёрки режимов +/// (light/dark/light-ic/dark-ic): обе раскладки лестницы обязаны выбирать +/// режим одинаково — расхождение было бы тихим рассинхроном темы и тинта. +fn vc_slot(vc: &ViewingConditions) -> usize { + match (vc.is_dark_theme(), vc.high_contrast) { + (false, false) => 0, + (true, false) => 1, + (false, true) => 2, + (true, true) => 3, + } +} + /// Источник лестницы (семейство палитры или бренд) несёт свой якорь отдельно для /// каждого режима — тёмная тема и режим повышенного контраста (IC) не выводятся /// из светлого якоря, а замеряются (`reference/labui-accent-primitives.md` §2: @@ -56,11 +68,11 @@ impl ThemeAnchors { /// Четыре VC-пресета движка ([`crate::config::VcPreset`]) отображаются ровно /// на четыре якоря — иных режимов у лестницы нет. pub fn for_vc(&self, vc: &ViewingConditions) -> &str { - match (vc.is_dark_theme(), vc.high_contrast) { - (false, false) => &self.light, - (true, false) => &self.dark, - (false, true) => &self.light_ic, - (true, true) => &self.dark_ic, + match vc_slot(vc) { + 0 => &self.light, + 1 => &self.dark, + 2 => &self.light_ic, + _ => &self.dark_ic, } } @@ -104,13 +116,7 @@ impl LadderTint { /// Кодированный тинт под условия просмотра резолва (тот же выбор режима, что /// [`ThemeAnchors::for_vc`]). pub fn for_vc(&self, vc: &ViewingConditions) -> [f64; 3] { - let idx = match (vc.is_dark_theme(), vc.high_contrast) { - (false, false) => 0, - (true, false) => 1, - (false, true) => 2, - (true, true) => 3, - }; - self.quad[idx] + self.quad[vc_slot(vc)] } /// Oklab-хрома светлого якоря тинта — вход в пересчёт `S_PERC_MIN` diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 99c7397c..073b5d02 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -81,6 +81,12 @@ fn resolve_theme_matches_native_resolve_set() { Resolved::Unreachable(_) => { assert_eq!(kind, "unreachable", "{} should be unreachable", role.key()); } + // Rgba в дефолт-таблице не встречается (rgba-граница — долг t3); + // будущий вариант обязан быть переучтён здесь шумно, не замаскирован. + other => panic!( + "неучтённый Resolved-вариант в wasm-парити ({}): {other:?}", + role.key() + ), } } } From 73576a207ec035b0e6dd87d45c600a3e172b268c Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 08:39:42 +0300 Subject: [PATCH 06/17] =?UTF-8?q?fix(config):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20CodeRabbit=20=D1=80-2=20=E2=80=94=20=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B0=D1=81=D1=8B=20=D0=B2=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B5,=20=D1=87=D0=B5=D1=81=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=BE=D1=80=D0=BE=D0=B3,=20=D0=B3=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D1=8B=20=D0=BE=D0=B1=D1=85=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 major: (1) алиасы переносятся в NamedRoleTable (без переноса алиасные роли контракта — fill-neutral-tinted и др. — терялись при компиляции; эмиссия потребителем = var()-ссылка, одна истина значения); (2) sentiment_s_perc_min → Result — filter_map молча считал порог разделения по неполному набору категорий (тихая математическая ложь); (3) rgba-путь резолва отвергает спеку вне домена (RoleSpec публичен — сборка в обход валидатора даёт Unreachable, не правдоподобный мусор); (4) LadderTint::new валидирует четвёрку режимов (Err с именем битого режима). Trivial/minor: Rgba в default-golden — шумная паника вместо маски «не клип» (дрейф дефолт-таблицы обязан падать); IC-наследование альф закреплено тестом (пер-темная пара skeleton реально различается); русификация приложения A. Тесты на каждый гард. --- crates/labcolors-core/src/config.rs | 56 ++++++++++----- crates/labcolors-core/src/config/tests.rs | 83 ++++++++++++++++++++++- crates/labcolors-core/src/ladder.rs | 18 ++++- crates/labcolors-core/src/semantic.rs | 48 +++++++++++-- docs/decisions/0001-config-boundary.md | 4 +- 5 files changed, 178 insertions(+), 31 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 78927f26..ca213ca6 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -863,7 +863,7 @@ impl ThemeConfig { hue_stiffness: self.neutral.tint.hue_stiffness, }; - Ok(NamedRoleTable::new(entries, chroma)) + Ok(NamedRoleTable::new(entries, self.aliases.clone(), chroma)) } /// Скомпилировать один рецепт в [`RoleSpec`]. Ladder/AlphaAnalog раскладывают @@ -920,7 +920,10 @@ impl ThemeConfig { field: format!("roles.{role} (источник лестницы)"), value: "<пер-темный якорь>".to_string(), })?; - Ok(LadderTint::new(quad)) + LadderTint::new(quad).map_err(|mode| ConfigError::InvalidHex { + field: format!("roles.{role} (тинт лестницы, режим {mode})"), + value: "<вне кодированного домена>".to_string(), + }) } /// Нейтральный якорь из [`NeutralConfig::anchors`] по [`NeutralPick`], @@ -970,7 +973,7 @@ impl ThemeConfig { })?; let fam = self.family_anchors(role, &cat.family)?.clone(); let brand = self.brand.anchors.clone(); - let s_perc_min = self.sentiment_s_perc_min(); + let s_perc_min = self.sentiment_s_perc_min()?; let solid_of = |anchor_hex: &str, brand_hex: &str| -> Result<[f64; 3], ConfigError> { let brand_hue = crate::accent::oklab_hue_of(brand_hex); @@ -995,30 +998,47 @@ impl ThemeConfig { }) }; - Ok(LadderTint::new([ + LadderTint::new([ solid_of(&fam.light, &brand.light)?, solid_of(&fam.dark, &brand.dark)?, solid_of(&fam.light_ic, &brand.light_ic)?, solid_of(&fam.dark_ic, &brand.dark_ic)?, - ])) + ]) + .map_err(|mode| ConfigError::InvalidHex { + field: format!("roles.{role} (сентимент-тинт, режим {mode})"), + value: "<вне кодированного домена>".to_string(), + }) } /// `S_PERC_MIN`, пересчитанный из Oklab-хром светлых якорей 4 (или скольких /// есть) сентимент-категорий конфига — закон `2·C_rep·sin(20°/2)` (поправка /// t2 №д). При labui-якорях == замороженная константа (тест-идентичность). - pub fn sentiment_s_perc_min(&self) -> f64 { - let chromas: Vec = self - .sentiments - .categories - .iter() - .filter_map(|c| self.palette.iter().find(|f| f.key == c.family)) - .filter_map(|f| crate::spaces::srgb::srgb_from_hex(&f.anchors.light).ok()) - .map(|lin| { - let lab = crate::spaces::oklab::srgb_linear_to_oklab(lin); - (lab[1] * lab[1] + lab[2] * lab[2]).sqrt() - }) - .collect(); - crate::sentiment::s_perc_min_from_chromas(&chromas) + /// # Errors + /// + /// `Err`, если категория ссылается на несуществующее семейство или якорь + /// семейства — невалидный hex: порог разделения, посчитанный по НЕПОЛНОМУ + /// набору категорий, был бы тихой математической ложью. + pub fn sentiment_s_perc_min(&self) -> Result { + let mut chromas = Vec::with_capacity(self.sentiments.categories.len()); + for c in &self.sentiments.categories { + let fam = self + .palette + .iter() + .find(|f| f.key == c.family) + .ok_or_else(|| ConfigError::UnknownFamily { + referenced_by: format!("sentiments.{}", c.name), + family: c.family.clone(), + })?; + let lin = crate::spaces::srgb::srgb_from_hex(&fam.anchors.light).map_err(|_| { + ConfigError::InvalidHex { + field: format!("palette.{}.anchors.light", fam.key), + value: fam.anchors.light.clone(), + } + })?; + let lab = crate::spaces::oklab::srgb_linear_to_oklab(lin); + chromas.push((lab[1] * lab[1] + lab[2] * lab[2]).sqrt()); + } + Ok(crate::sentiment::s_perc_min_from_chromas(&chromas)) } } diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 5297ce39..4ffe499b 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -757,7 +757,9 @@ fn consumed_roles_diff_is_empty_against_labui_contract() { /// случай при labui-якорях (поправка t2 №д). #[test] fn s_perc_min_recomputed_from_config_anchors_matches_frozen() { - let recomputed = labui_reference().sentiment_s_perc_min(); + let recomputed = labui_reference() + .sentiment_s_perc_min() + .expect("фикстура валидна"); let frozen = crate::sentiment::s_perc_min_frozen(); assert!( (recomputed - frozen).abs() < 1e-4, @@ -774,7 +776,9 @@ fn s_perc_min_recomputed_from_config_anchors_matches_frozen() { /// хрома) сдвигает `S_PERC_MIN` — иначе пересчёт был бы слеп к якорям. #[test] fn s_perc_min_recompute_bites_on_anchor_mutation() { - let base = labui_reference().sentiment_s_perc_min(); + let base = labui_reference() + .sentiment_s_perc_min() + .expect("фикстура валидна"); let mut cfg = labui_reference(); // Danger маппится на red; подменим red-якорь на серый (низкая хрома) → // C_rep падает → S_PERC_MIN падает. @@ -783,7 +787,9 @@ fn s_perc_min_recompute_bites_on_anchor_mutation() { fam.anchors.light = "#808080".to_string(); } } - let mutated = cfg.sentiment_s_perc_min(); + let mutated = cfg + .sentiment_s_perc_min() + .expect("мутация якоря сохраняет валидность"); assert!( (base - mutated).abs() > 1e-3, "RED-proof провален: подмена якоря НЕ сдвинула S_PERC_MIN ({base} vs {mutated})" @@ -1277,3 +1283,74 @@ fn validator_reference_errors_are_distinguishable() { .push(("probe-alias".to_string(), "nonexistent-role".to_string())); assert!(matches!(c.validate(), Err(ConfigError::UnknownRole { .. }))); } + +/// IC-наследование альф закреплено: позиция отдаёт альфу базовой темы и в +/// IC-режиме (IC меняет тинт, не прозрачность — стаб без ic-скоупов). +#[test] +fn ic_inherits_base_theme_alpha() { + use crate::spaces::vc::ViewingConditions; + let pos = crate::ladder::LadderPosition::SkeletonBase; + let light = ViewingConditions::srgb(); + let dark = ViewingConditions::dim_surround(); + let light_ic = ViewingConditions::srgb_high_contrast(); + let dark_ic = ViewingConditions::dim_surround_high_contrast(); + assert_eq!(pos.alpha_for_vc(&light), pos.alpha_for_vc(&light_ic)); + assert_eq!(pos.alpha_for_vc(&dark), pos.alpha_for_vc(&dark_ic)); + // Пер-темная пара реально различается (skeleton-base @8/@12). + assert!((pos.alpha_for_vc(&light) - pos.alpha_for_vc(&dark)).abs() > 1e-6); +} + +/// Алиасы переносятся в скомпилированную таблицу — без переноса алиасные роли +/// контракта терялись бы при эмиссии (major CodeRabbit r2). +#[test] +fn compiled_table_carries_aliases() { + let table = labui_reference() + .compile_named_role_table() + .expect("фикстура компилируется"); + let aliases = table.aliases(); + assert!(!aliases.is_empty(), "фикстура несёт алиасы"); + assert!( + aliases + .iter() + .any(|(a, t)| a == "fill-neutral-tinted" && t == "fill-primary"), + "алиас fill-neutral-tinted→fill-primary обязан пережить компиляцию" + ); +} + +/// Сборка RoleSpec в обход валидатора не даёт правдоподобного мусора: +/// невалидная α/тинт резолвятся в Unreachable, не в тихий кламп. +#[test] +fn rgba_resolve_rejects_out_of_domain_spec() { + use crate::semantic::{NamedRoleTable, Resolved, RoleChroma, RoleSpec, resolve_named_set}; + use crate::solve::BgInput; + use crate::spaces::vc::ViewingConditions; + let tint = crate::ladder::LadderTint::new([[0.5, 0.5, 0.5]; 4]).expect("валидный тинт"); + for bad_alpha in [f64::NAN, 0.0, 1.5] { + let table = NamedRoleTable::new( + vec![( + "probe".to_string(), + RoleSpec::Ladder { + tint, + alpha_light: bad_alpha, + alpha_dark: bad_alpha, + }, + )], + vec![], + RoleChroma::Neutral, + ); + let set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ); + assert!( + matches!(set[0].1, Resolved::Unreachable(_)), + "α={bad_alpha} обязана дать Unreachable, не цвет" + ); + } + // Мусорный quad отвергается конструктором тинта с именем режима. + assert_eq!( + crate::ladder::LadderTint::new([[2.0, 0.5, 0.5]; 4]).unwrap_err(), + "light" + ); +} diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index 0a04652a..525d4c1b 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -109,8 +109,20 @@ pub struct LadderTint { impl LadderTint { /// Собрать тинт из кодированной четвёрки режимов. - pub fn new(quad: [[f64; 3]; 4]) -> Self { - Self { quad } + /// + /// # Errors + /// + /// `Err` с именем битого режима, если любой канал не конечен или вне + /// `[0,1]` — тинт публично конструируем, и мусор на входе резолва дал бы + /// правдоподобно-неверный цвет (встроенный путь byte/255 валиден всегда). + pub fn new(quad: [[f64; 3]; 4]) -> Result { + const MODES: [&str; 4] = ["light", "dark", "light-ic", "dark-ic"]; + for (i, rgb) in quad.iter().enumerate() { + if !rgb.iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c)) { + return Err(MODES[i]); + } + } + Ok(Self { quad }) } /// Кодированный тинт под условия просмотра резолва (тот же выбор режима, что @@ -370,7 +382,7 @@ mod tests { light_ic: "#D70015".to_string(), dark_ic: "#FF6161".to_string(), }; - let tint = LadderTint::new(anchors.encoded_quad().unwrap()); + let tint = LadderTint::new(anchors.encoded_quad().unwrap()).unwrap(); for vc in [ ViewingConditions::srgb(), ViewingConditions::dim_surround(), diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 30925568..e6f3dd41 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1420,12 +1420,30 @@ fn resolve_spec_in( /// sRGB) — тот же путь, что Figma/браузер ([`crate::alpha`]). Контраст меряется /// на КОМПОЗИТЕ (солид-эквивалент), не на тинте: контраст полупрозрачной роли /// определён тем, во что она складывается на подложке. +/// Валидный кодированный вход rgba-пути: конечные каналы [0,1] и α в (0,1]. +/// RoleSpec публичен — спека, собранная в обход валидатора конфига, не должна +/// давать правдоподобный мусор: невалидный вход честно резолвится в +/// Unreachable, не в тихий кламп. +fn rgba_input_valid(tint_encoded: [f64; 3], alpha: f64) -> bool { + tint_encoded + .iter() + .all(|c| c.is_finite() && (0.0..=1.0).contains(c)) + && alpha.is_finite() + && alpha > 0.0 + && alpha <= 1.0 +} + fn resolve_rgba_direct( tint_encoded: [f64; 3], alpha: f64, bg: &BgInput, vc: &ViewingConditions, ) -> Resolved { + if !rgba_input_valid(tint_encoded, alpha) { + return Resolved::Unreachable(Unreachable::InvalidInput( + "rgba-спека вне домена (тинт [0,1], α (0,1]) — сборка в обход валидатора".into(), + )); + } let bg_encoded = bg.encoded_display(); let composite = crate::alpha::composite_over_encoded(tint_encoded, alpha, bg_encoded); finish_rgba(tint_encoded, alpha, composite, bg_encoded, vc) @@ -1684,6 +1702,7 @@ pub(crate) fn resolve_set_live( #[derive(Debug, Clone, PartialEq)] pub struct NamedRoleTable { entries: Vec<(String, RoleSpec)>, + aliases: Vec<(String, String)>, chroma: RoleChroma, } @@ -1692,8 +1711,23 @@ impl NamedRoleTable { /// policy. Names are the CSS contract downstream (`--lab-{name}`); this /// constructor does not validate them — the config validator /// ([`ThemeConfig::validate`](crate::config::ThemeConfig::validate)) owns that. - pub fn new(entries: Vec<(String, RoleSpec)>, chroma: RoleChroma) -> Self { - Self { entries, chroma } + pub fn new( + entries: Vec<(String, RoleSpec)>, + aliases: Vec<(String, String)>, + chroma: RoleChroma, + ) -> Self { + Self { + entries, + aliases, + chroma, + } + } + + /// Алиасы `(имя, цель)` — эмитируются потребителем как CSS-ссылка + /// `--lab-{имя}: var(--lab-{цель})` (одна истина значения, ноль копий); + /// без переноса сюда алиасные роли контракта терялись бы при компиляции. + pub fn aliases(&self) -> &[(String, String)] { + &self.aliases } /// The `(name, recipe)` entries, in declaration order. @@ -3028,9 +3062,13 @@ mod tests { } } Resolved::None => matches!(table.spec(*role), RoleSpec::Zero), - // Дефолтная таблица не несёт Ladder/AlphaAnalog — вариант тут - // недостижим; полупрозрачная роль в любом случае не «клип». - Resolved::Rgba(_) => true, + // Дефолтная таблица не несёт Ladder/AlphaAnalog — появление + // Rgba здесь означало бы дрейф default() и обязано падать + // шумно, а не маскироваться под «не клип». + Resolved::Rgba(_) => panic!( + "{bg_hex}: RoleTable::default() отдал Rgba для {:?} — дрейф дефолт-таблицы", + role + ), Resolved::Unreachable(_) => true, }); assert!( diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index c78487f3..fe41253e 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -205,8 +205,8 @@ Figma-примитивов замерены, не выведены. dark (нормализованный формат). Закрывает класс дефекта «роль в diff по имени, но эмитит не то значение». Исключены намеренно расходящиеся роли (с ссылкой): `*-info-*` (смещение солвером); `fx-focus-ring-neutral` (dark), `fx-glow-inverted`, -`fill-neutral` — задокументированные gap-и (пер-темный нейтральный край / -inverted-якоря / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`). +`fill-neutral` — задокументированные пробелы (gap) (пер-темный нейтральный край / +якоря инвертированных поверхностей / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`). ### Деривационная идентичность сентимента (честная находка t2) From 79ea1cf831036958b3e4c43fc5b9efab0de7aa4b Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 08:48:55 +0300 Subject: [PATCH 07/17] =?UTF-8?q?fix(config):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20CodeRabbit=20=D1=80-3=20=E2=80=94=20=D0=B0=D0=B3?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D1=82=D0=BE=D0=BD,=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20=D0=B7=D0=B0=D0=BC=D0=B5=D1=80,=20signum?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 major: (1) оттенок нейтрального подтона — из КОНФИГА: явная ручка hue_override_deg (labui несёт измеренную SSOT 286.0°) либо деривация из тёмного якоря нейтрали клиента (той же формулой, которой была получена константа: #101012→285.97°) — labui-константа для чужой нейтрали была дефектом агностичности; (2) контраст rgba-роли меряется по КВАНТОВАННОМУ композиту — тому же 8-битному hex, что уходит наружу (закон solve_dj; неквантованный замер расходился с отданным на LSB); (3) preferred_side нормализуется до ±1 на публичном входе солвера (множитель направления, не масштаб). Minor/trivial: rustdoc фикстуры под t1+t2, expect с обоснованием в ladder-тесте, валидатор hue_override [0,360). --- crates/labcolors-core/src/config.rs | 42 +++++++++++++++++++++------ crates/labcolors-core/src/ladder.rs | 7 ++++- crates/labcolors-core/src/semantic.rs | 14 ++++++--- 3 files changed, 49 insertions(+), 14 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index ca213ca6..d983d310 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -10,7 +10,7 @@ //! [`RoleChroma`], [`DjMagnitude`], [`TextAnchor`], [`NamedRoleTable`]), а ядро //! про конфиг не знает ничего. //! -//! # Что этот модуль делает (CH-02 t1) +//! # Что этот модуль делает (CH-02 t1+t2) //! //! - Несёт типы конфига без сериализации ([`ThemeConfig`] и вложенные) — JSON-парсинг //! это отдельная задача границы WASM (t3), не ядро. @@ -284,6 +284,11 @@ pub struct NeutralTint { pub target_mp: f64, /// Жёсткость прижатия оттенка к каноническому (v2-кривая): `≥ 0`. pub hue_stiffness: f64, + /// Явный оттенок подтона (градусы `[0, 360)`), если у потребителя есть + /// ИЗМЕРЕННАЯ величина (labui: SSOT 286.0°). `None` — движок выводит оттенок + /// из тёмного якоря нейтрали (та же деривация, которой была получена + /// labui-константа: `#101012` → 285.97°). + pub hue_override_deg: Option, } /// Нейтраль: тройка якорей + ручки подтона. @@ -692,6 +697,16 @@ impl ThemeConfig { } } + if let Some(hue) = self.neutral.tint.hue_override_deg + && !(hue.is_finite() && (0.0..360.0).contains(&hue)) + { + return Err(ConfigError::OutOfBounds { + handle: "neutral.tint.hue_override_deg".to_string(), + value: hue, + bound: "0 ≤ hue < 360 (явный оттенок подтона по модулю 360°)", + }); + } + // Дубликаты ключей всех словарей: повтор имени = неоднозначный lookup. fn check_unique<'a, I: Iterator>( dictionary: &'static str, @@ -851,14 +866,18 @@ impl ThemeConfig { entries.push((name.clone(), spec)); } - // Нейтраль-подтон: v2-кривая. Форма строго сверена с - // `semantic::RoleChroma::Curve` дефолтной таблицы (`neutral_curve()`): - // canonical_hue_deg = измеренный NEUTRAL_HUE_DEG (движок выводит оттенок из - // нейтральной тройки; для t1 берём измеренную SSOT-величину — вывод из hex - // это t2), target_mp / hue_stiffness — из конфиг-ручек. `ratio` в v2-кривую - // не входит (это поле v1 flat-пути), но валидируется как экспонированная ручка. + // Нейтраль-подтон: v2-кривая. Оттенок подтона — ИЗ КОНФИГА: явная ручка + // hue_override (labui несёт измеренную SSOT-величину 286.0°), иначе — + // деривация из ТЁМНОГО якоря нейтрали клиента (NEUTRAL_HUE_DEG сам был + // измерен по #101012 → 285.97°; labui-константа для чужой нейтрали была + // бы чужим подтоном — дефект агностичности, CodeRabbit р-3). `ratio` в + // v2-кривую не входит (поле v1 flat-пути), но валидируется как ручка. + let canonical_hue_deg = match self.neutral.tint.hue_override_deg { + Some(hue) => hue, + None => crate::accent::oklab_hue_of(&self.neutral.anchors.dark), + }; let chroma = RoleChroma::Curve { - canonical_hue_deg: semantic::NEUTRAL_HUE_DEG, + canonical_hue_deg, target_mp: self.neutral.tint.target_mp, hue_stiffness: self.neutral.tint.hue_stiffness, }; @@ -1046,7 +1065,9 @@ impl ThemeConfig { // Эталонная фикстура labui. // ───────────────────────────────────────────────────────────────────────────── -/// Эталонный конфиг labui для CH-02 t1 — покрывает сегодняшние 20 эмитируемых ролей. +/// Эталонный конфиг labui (CH-02 t1+t2) — 20 нейтральных ролей ядра (байт-в-байт +/// с [`RoleTable::default`](crate::RoleTable)) плюс акцент/сентимент/FX/альфа-роли +/// лестницы и алиасы — полное покрытие consumedRoles labui-контракта. /// /// Имена ролей = `Role::key()` сегодняшнего ядра; рецепты сняты 1:1 из /// [`RoleTable::default`](crate::RoleTable) (`semantic.rs`): текст-фракции с их @@ -1292,6 +1313,9 @@ pub fn labui_reference() -> ThemeConfig { ratio: semantic::NEUTRAL_TINT_RATIO, target_mp: semantic::TINT_TARGET_MP, hue_stiffness: semantic::TINT_HUE_STIFFNESS, + // Явный измеренный оттенок (SSOT NEUTRAL_HUE_DEG): labui несёт + // замер, деривация из тёмного якоря — путь клиентов без замера. + hue_override_deg: Some(semantic::NEUTRAL_HUE_DEG), }, }, // Палитра labui — 10 замеренных семейств, ПЕР-ТЕМНО ДОСЛОВНО из diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index 525d4c1b..613b1340 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -382,7 +382,12 @@ mod tests { light_ic: "#D70015".to_string(), dark_ic: "#FF6161".to_string(), }; - let tint = LadderTint::new(anchors.encoded_quad().unwrap()).unwrap(); + let tint = LadderTint::new( + anchors + .encoded_quad() + .expect("захардкоженные hex теста валидны"), + ) + .expect("byte/255 всегда в домене"); for vc in [ ViewingConditions::srgb(), ViewingConditions::dim_surround(), diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index e6f3dd41..18d297ac 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1489,7 +1489,13 @@ fn finish_rgba( bg_encoded: [f64; 3], vc: &ViewingConditions, ) -> Resolved { - use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_gamma_inv}; + use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_gamma_inv}; + // Замер идёт по КВАНТОВАННОМУ композиту — тому же 8-битному hex, который + // уходит наружу (закон движка: честный dJ' меряется на отданном цвете, + // как в solve_dj; неквантованный замер расходился бы с отданным на LSB). + let composite_hex = hex_from_srgb_encoded(composite_encoded); + let composite_q = + srgb_encoded_from_hex(&composite_hex).expect("hex собственного форматтера всегда валиден"); // Линейный свет из кодированного (per-channel gamma-декод) для перцептивного Lc. let decode = |e: [f64; 3]| { [ @@ -1498,14 +1504,14 @@ fn finish_rgba( srgb_gamma_inv(e[2]), ] }; - let composite_linear = decode(composite_encoded); + let composite_linear = decode(composite_q); let bg_linear = decode(bg_encoded); let (composite_lc, _) = measure_contrast(bg_linear, composite_linear, vc); - let composite_wcag = crate::wcag::contrast_ratio(composite_encoded, bg_encoded); + let composite_wcag = crate::wcag::contrast_ratio(composite_q, bg_encoded); Resolved::Rgba(RgbaResolved { tint_hex: hex_from_srgb_encoded(tint_encoded), alpha, - composite_hex: hex_from_srgb_encoded(composite_encoded), + composite_hex, composite_lc, composite_wcag, }) From 8e2cb3726d679a7e0ad82392600092edc29b2028 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 08:58:40 +0300 Subject: [PATCH 08/17] =?UTF-8?q?fix(config):=20CodeRabbit=20=D1=80-4=20?= =?UTF-8?q?=E2=80=94=20=D1=82=D0=B8=D0=BD=D1=82=20=D0=BA=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=82=D1=83=D0=B5=D1=82=D1=81=D1=8F=20=D0=B4=D0=BE=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=BC=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=B0,=20=D0=BD?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D0=B2=D1=82=D0=BE=D0=BB=D0=BE=D0=B3=D0=B8?= =?UTF-8?q?=D1=87=D0=BD=D1=8B=D0=B9=20WCAG-=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit major: наружу уходит 8-битный tint_hex и браузер композитит именно его — тинт теперь квантуется ДО композита в обоих rgba-путях (quantise_encoded, hex-roundtrip без строки); composite_hex/Lc/WCAG считаются из эмитируемого значения (завершение принципа «всё внешнее посчитано из внешнего»). Равенство композита солиду в alpha-analog держится в LSB-границе (#119). minor/trivial: expect с обоснованием на hex-парсингах ladder-тестов; тавтологичная проверка WCAG∈[1,21] заменена содержательной (композит @8 над белым обязан быть почти белым и вдвое контрастнее солидного тинта — доказывает замер по правильному цвету). «Различать код catch-all» — отклонено: арм уже помечен осознанным долгом t3, различение кодов там. --- crates/labcolors-core/src/config/tests.rs | 16 ++++++++++---- crates/labcolors-core/src/semantic.rs | 26 +++++++++++++++++------ 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 4ffe499b..86ec8eaf 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -916,11 +916,19 @@ fn ladder_emits_rgba_with_composite_over_bg() { // Композит #007AFF@0.078 над #FFFFFF — то, что реально красится. let want_composite = crate::alpha::composite_hex("#007AFF", 0.078, "#FFFFFF").unwrap(); assert_eq!(r.composite_hex(), want_composite, "композит на белом фоне"); - // Контраст меряется на композите (близок к нулю для очень прозрачной заливки). + // Контраст меряется на КОМПОЗИТЕ, не на тинте: у прозрачной заливки @8 над + // белым композит почти белый — WCAG близок к 1 и заведомо МЕНЬШЕ контраста + // солидного тинта (#007AFF на белом ≈ 4.0) — нетавтологичная проверка того, + // что замер идёт по правильному цвету. + let solid_wcag = crate::wcag::contrast_ratio( + crate::spaces::srgb::srgb_encoded_from_hex("#007AFF").expect("валидный hex"), + crate::spaces::srgb::srgb_encoded_from_hex("#FFFFFF").expect("валидный hex"), + ); assert!( - r.composite_wcag() >= 1.0 && r.composite_wcag() <= 21.0, - "WCAG композита вне [1,21]: {}", - r.composite_wcag() + r.composite_wcag() < 1.2 && r.composite_wcag() < solid_wcag / 2.0, + "WCAG обязан меряться по композиту (почти белому), не по тинту: composite={}, solid={}", + r.composite_wcag(), + solid_wcag ); } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 18d297ac..e4bb9faf 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1420,6 +1420,13 @@ fn resolve_spec_in( /// sRGB) — тот же путь, что Figma/браузер ([`crate::alpha`]). Контраст меряется /// на КОМПОЗИТЕ (солид-эквивалент), не на тинте: контраст полупрозрачной роли /// определён тем, во что она складывается на подложке. +/// Квантовать кодированный цвет до 8-битной сетки (hex-roundtrip без строки): +/// эмиссия и замер обязаны считаться из одного — отдаваемого — значения. +fn quantise_encoded(e: [f64; 3]) -> [f64; 3] { + let q = |c: f64| (c.clamp(0.0, 1.0) * 255.0).round() / 255.0; + [q(e[0]), q(e[1]), q(e[2])] +} + /// Валидный кодированный вход rgba-пути: конечные каналы [0,1] и α в (0,1]. /// RoleSpec публичен — спека, собранная в обход валидатора конфига, не должна /// давать правдоподобный мусор: невалидный вход честно резолвится в @@ -1445,8 +1452,12 @@ fn resolve_rgba_direct( )); } let bg_encoded = bg.encoded_display(); - let composite = crate::alpha::composite_over_encoded(tint_encoded, alpha, bg_encoded); - finish_rgba(tint_encoded, alpha, composite, bg_encoded, vc) + // Тинт квантуется ДО композита: наружу уходит 8-битный tint_hex, и браузер + // скомпозитит именно его — замер обязан считаться из эмитируемого значения, + // иначе composite_hex/Lc/WCAG расходились бы с CSS-результатом на LSB. + let tint_q = quantise_encoded(tint_encoded); + let composite = crate::alpha::composite_over_encoded(tint_q, alpha, bg_encoded); + finish_rgba(tint_q, alpha, composite, bg_encoded, vc) } /// Альфа-аналог: солид-цель `solid` (кодированный, по теме) на фоне резолва @@ -1470,10 +1481,13 @@ fn resolve_rgba_inverted( "alpha-analog source out of encoded sRGB domain".to_string(), )); }; - // Композит фактической пары == солид (теорема тождества, `alpha` #119), - // но считаем его явно — единый путь замера с прямой лестницей. - let composite = crate::alpha::composite_over_encoded(analog.tint, analog.alpha, bg_encoded); - finish_rgba(analog.tint, analog.alpha, composite, bg_encoded, vc) + // Тинт инверсии квантуется до композита (см. resolve_rgba_direct: замер из + // эмитируемого значения — браузер скомпозитит 8-битный tint_hex). Композит + // пересчитывается от квантованного тинта; равенство солиду держится в + // пределах LSB-границы квантования (#119). + let tint_q = quantise_encoded(analog.tint); + let composite = crate::alpha::composite_over_encoded(tint_q, analog.alpha, bg_encoded); + finish_rgba(tint_q, analog.alpha, composite, bg_encoded, vc) } /// Собрать [`Resolved::Rgba`] из тинта, альфы и композита: квантовать тинт и From 839df8a32257c43803e60decbf7e55bca04593c7 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 09:17:37 +0300 Subject: [PATCH 09/17] =?UTF-8?q?fix(config):=20CodeRabbit=20=D1=80-5=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B5=D1=80-=D1=82=D0=B5=D0=BC=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=BA=D1=80=D0=B0=D1=8F=20=D0=BD=D0=B5=D0=B9=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BB=D0=B8,=20=D1=87=D0=B8=D1=81=D1=82=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=B7=D0=B0=D0=BA=D0=BE=D0=BD=20=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D0=B3=D0=B0,=20=D0=B3=D0=B0=D1=80=D0=B4=D1=8B=20hue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 major: (1) fx-focus-ring-neutral / fx-glow-inverted — на пер-темных четвёрках конфига (neutral.edge #101012/#F6F8FA, neutral.inverted #B0B0B9/#3C3C43 — стаб дословно): дублирование одного края давало НЕВИДИМОЕ кольцо фокуса на тёмной теме; без поля pick — честная ошибка MissingNeutralAnchors, не выдумка; обе роли теперь в точном value-тесте обеих тем; (2) замороженный labui-S_PERC_MIN больше НЕ подмешивается в config-путь (s_min_deg на константе завышал порог низкохромным палитрам чужим законом — мог опустошить legal arc); (3) preferred_side нормализуется до ±1 (р-3 фикс МОЛЧА не применился: node-replace без assert + CRLF/LF-смесь в репо — CAPA: все replace с assert и EOL-автодетектом); (4) ахроматичные источники оттенка: серая нейтраль без hue_override — AchromaticHueSource; серый бренд — разведение сентиментов честно отключено (сентимент = сырой якорь; порог 1e-7 технический — числовая определённость atan2, не перцептивная политика). minor/trivial: expect на hex тестов лестницы; labui_reference — задокументирован как КАНОНИЧЕСКИЙ конфиг (переезд в пакет = PR-b); граничный тест α=1.0 vs 1.0+ε; тесты MissingNeutralAnchors/achromatic. --- crates/labcolors-core/src/config.rs | 156 ++++++++++++++++++---- crates/labcolors-core/src/config/tests.rs | 95 +++++++++++++ crates/labcolors-core/src/ladder.rs | 3 +- crates/labcolors-core/src/sentiment.rs | 16 ++- 4 files changed, 240 insertions(+), 30 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index d983d310..2a530726 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -182,6 +182,15 @@ pub enum ConfigError { dictionary: &'static str, key: String, }, + /// Роль требует пер-темной нейтральной четвёрки (edge/inverted), которой в + /// конфиге нет — дублирование одного края дало бы невидимую роль. + MissingNeutralAnchors { + referenced_by: String, + field: &'static str, + }, + /// Источник вывода оттенка ахроматичен (Oklab-хрома ≈ 0): hue математически + /// не определён — требуется явный hue_override_deg. + AchromaticHueSource { field: String }, /// Значение ручки вне допустимого предела. `handle` — путь до ручки, `bound` — /// человеко-читаемое описание нарушенного предела с обоснованием. OutOfBounds { @@ -219,6 +228,17 @@ impl std::fmt::Display for ConfigError { f, "`{referenced_by}` ссылается на роль `{role}`, которой нет в roles" ), + ConfigError::MissingNeutralAnchors { + referenced_by, + field, + } => write!( + f, + "`{referenced_by}` требует пер-темной нейтральной четвёрки `{field}`, которой нет в конфиге" + ), + ConfigError::AchromaticHueSource { field } => write!( + f, + "источник оттенка `{field}` ахроматичен — hue не определён, задай hue_override_deg" + ), ConfigError::DuplicateKey { dictionary, key } => write!( f, "дубликат ключа `{key}` в словаре `{dictionary}` — lookup был бы неоднозначным" @@ -298,6 +318,15 @@ pub struct NeutralConfig { pub anchors: NeutralAnchors, /// Ручки подтона. pub tint: NeutralTint, + /// Пер-темный «контурный» край нейтрали (контрастный теме: светлая тема — + /// тёмный контур, тёмная — почти белый; labui: #101012 / #F6F8FA). Нужен + /// ролям типа кольца фокуса; без поля [`NeutralPick::Edge`] даёт ошибку + /// конфига, не выдуманное значение. + pub edge: Option, + /// Пер-темный «инвертированный» средний тон (labui: #B0B0B9 / #3C3C43) — + /// для свечения на инвертированной поверхности; без поля + /// [`NeutralPick::Inverted`] даёт ошибку конфига. + pub inverted: Option, } /// Именованное семейство палитры: ключ + пер-темные якорные hex. @@ -465,6 +494,11 @@ pub enum LadderSource { pub enum NeutralPick { /// Средний якорь `neutral.anchors.mid` (`#787880`) — скелетон, нейтральные тинты. Mid, + /// Контурный край, контрастный теме ([`NeutralConfig::edge`]) — кольцо фокуса. + Edge, + /// Инвертированный средний тон ([`NeutralConfig::inverted`]) — свечение на + /// инвертированной поверхности. + Inverted, /// Светлый край `neutral.anchors.light` (`#FFFFFF`) — нейтральное свечение. Light, /// Тёмный край `neutral.anchors.dark` (`#101012`) — нейтральный фокус. @@ -503,6 +537,22 @@ fn is_valid_name(name: &str) -> bool { } /// Проверить, что hex парсится ядром (`#RGB` / `#RRGGBB`). +/// Технический порог числовой определённости оттенка: ниже него atan2 в +/// oklab_hue_of математически не определён (не перцептивная величина — +/// защита от произвольного 0°, не политика). +const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; + +/// Oklab-хрома hex-цвета (для гарда ахроматичности источников оттенка). +fn oklab_chroma_of_hex(hex: &str) -> f64 { + match crate::spaces::srgb::srgb_from_hex(hex) { + Ok(lin) => { + let lab = crate::spaces::oklab::srgb_linear_to_oklab(lin); + (lab[1] * lab[1] + lab[2] * lab[2]).sqrt() + } + Err(_) => 0.0, // невалидный hex ловится валидатором раньше + } +} + fn check_hex(field: &str, value: &str) -> Result<(), ConfigError> { crate::spaces::srgb::srgb_from_hex(value) .map(|_| ()) @@ -874,7 +924,17 @@ impl ThemeConfig { // v2-кривую не входит (поле v1 flat-пути), но валидируется как ручка. let canonical_hue_deg = match self.neutral.tint.hue_override_deg { Some(hue) => hue, - None => crate::accent::oklab_hue_of(&self.neutral.anchors.dark), + None => { + // Ахроматичный якорь не несёт оттенка: atan2(0,0) дал бы + // произвольный 0° — тихо чужой подтон. Порог технический + // (числовая определённость), не перцептивный. + if oklab_chroma_of_hex(&self.neutral.anchors.dark) < ACHROMATIC_CHROMA_EPS { + return Err(ConfigError::AchromaticHueSource { + field: "neutral.anchors.dark".to_string(), + }); + } + crate::accent::oklab_hue_of(&self.neutral.anchors.dark) + } }; let chroma = RoleChroma::Curve { canonical_hue_deg, @@ -931,7 +991,7 @@ impl ThemeConfig { LadderSource::Brand => self.brand.anchors.clone(), LadderSource::Family(key) => self.family_anchors(role, key)?.clone(), LadderSource::Sentiment(name) => return self.compile_sentiment_tint(role, name), - LadderSource::Neutral(pick) => self.neutral_anchors(*pick), + LadderSource::Neutral(pick) => self.neutral_anchors(role, *pick)?, }; let quad = anchors .encoded_quad() @@ -949,18 +1009,40 @@ impl ThemeConfig { /// продублированный на четыре режима (нейтральная шкала конфига несёт один /// hex на край, без пер-темных IC-вариантов). Заземление — стаб labui: /// `Neutral/Derivable` тинтуется этими краями (`#787880`/`#FFFFFF`/`#101012`). - fn neutral_anchors(&self, pick: NeutralPick) -> ThemeAnchors { - let hex = match pick { - NeutralPick::Mid => &self.neutral.anchors.mid, - NeutralPick::Light => &self.neutral.anchors.light, - NeutralPick::Dark => &self.neutral.anchors.dark, - }; - ThemeAnchors { - light: hex.clone(), - dark: hex.clone(), - light_ic: hex.clone(), - dark_ic: hex.clone(), - } + fn neutral_anchors(&self, role: &str, pick: NeutralPick) -> Result { + // Edge/Inverted — пер-темные четвёрки из конфига: дублирование одного + // hex дало бы невидимые роли (контур #101012 на тёмной теме); без поля + // pick честно падает ошибкой, не выдумкой. + let single = + match pick { + NeutralPick::Edge => { + return self + .neutral + .edge + .clone() + .ok_or(ConfigError::MissingNeutralAnchors { + referenced_by: format!("roles.{role}"), + field: "neutral.edge", + }); + } + NeutralPick::Inverted => { + return self.neutral.inverted.clone().ok_or( + ConfigError::MissingNeutralAnchors { + referenced_by: format!("roles.{role}"), + field: "neutral.inverted", + }, + ); + } + NeutralPick::Mid => &self.neutral.anchors.mid, + NeutralPick::Light => &self.neutral.anchors.light, + NeutralPick::Dark => &self.neutral.anchors.dark, + }; + Ok(ThemeAnchors { + light: single.clone(), + dark: single.clone(), + light_ic: single.clone(), + dark_ic: single.clone(), + }) } /// Пер-темные якоря семейства палитры по ключу (валидатор уже проверил @@ -995,6 +1077,17 @@ impl ThemeConfig { let s_perc_min = self.sentiment_s_perc_min()?; let solid_of = |anchor_hex: &str, brand_hex: &str| -> Result<[f64; 3], ConfigError> { + // Серый бренд не несёт оттенка — разведение по hue бессмысленно и + // численно не определено: сентимент честно остаётся сырым якорем + // семейства (ни с чем не сливается по оттенку). + if oklab_chroma_of_hex(brand_hex) < ACHROMATIC_CHROMA_EPS { + return crate::spaces::srgb::srgb_encoded_from_hex(anchor_hex).map_err(|_| { + ConfigError::InvalidHex { + field: format!("roles.{role} (якорь сентимента)"), + value: anchor_hex.to_string(), + } + }); + } let brand_hue = crate::accent::oklab_hue_of(brand_hex); let solid = crate::sentiment::resolve_config_sentiment_solid( anchor_hex, @@ -1065,6 +1158,10 @@ impl ThemeConfig { // Эталонная фикстура labui. // ───────────────────────────────────────────────────────────────────────────── +/// КАНОНИЧЕСКИЙ конфиг labui (не тестовая фикстура): значения = замеры +/// Figma/стаба; публичен намеренно — до PR-b это единственный носитель +/// labui-семантики у движка; в PR-b переезжает данными пакета @labpics/colors. +/// /// Эталонный конфиг labui (CH-02 t1+t2) — 20 нейтральных ролей ядра (байт-в-байт /// с [`RoleTable::default`](crate::RoleTable)) плюс акцент/сентимент/FX/альфа-роли /// лестницы и алиасы — полное покрытие consumedRoles labui-контракта. @@ -1200,12 +1297,12 @@ pub fn labui_reference() -> ThemeConfig { sent_pos("warning", LadderPosition::FocusRing), )); // Нейтральный фокус: тёмный край нейтрали, солид (стаб light rgb(16 16 18) = - // #101012). Пер-темный флип к near-white на тёмной теме стаб несёт литералом - // (#F6F8FA) — движок из тройки anchors его не выводит: исключён из точного - // value-теста, помечен как gap пер-темного нейтрального края. + // Контур нейтрали ПЕР-ТЕМНЫЙ (стаб: light #101012 / dark #F6F8FA) — едет + // на neutral.edge (дублирование одного края дало бы невидимое кольцо + // фокуса на тёмной теме). В точном value-тесте — обе темы. roles.push(( "fx-focus-ring-neutral".to_string(), - neutral_pos(NeutralPick::Dark, LadderPosition::FocusRing), + neutral_pos(NeutralPick::Edge, LadderPosition::FocusRing), )); roles.push(("fx-glow-brand".to_string(), brand_pos(LadderPosition::Glow))); roles.push(( @@ -1221,13 +1318,11 @@ pub fn labui_reference() -> ThemeConfig { "fx-glow-neutral".to_string(), neutral_pos(NeutralPick::Light, LadderPosition::Glow), )); - // Инвертированное свечение: нейтральный mid-тинт (стаб light #B0B0B9 / - // dark #3C3C43 — конкретные нейтральные литералы, не выводимые из тройки - // anchors). Приближено Neutral(Mid)@Glow; исключено из точного value-теста - // как известный gap (нужны отдельные inverted-якоря конфига). + // Инвертированное свечение — на neutral.inverted (пер-темная пара стаба + // #B0B0B9 / #3C3C43 дословно). В точном value-тесте — обе темы. roles.push(( "fx-glow-inverted".to_string(), - neutral_pos(NeutralPick::Mid, LadderPosition::Glow), + neutral_pos(NeutralPick::Inverted, LadderPosition::Glow), )); // Skeleton — нейтральный тинт #787880 (стаб rgb(120 120 128 / …)), ПЕР-ТЕМНАЯ // альфа: base light @8 / dark @12, highlight @4. Источник = Neutral(Mid). @@ -1317,6 +1412,21 @@ pub fn labui_reference() -> ThemeConfig { // замер, деривация из тёмного якоря — путь клиентов без замера. hue_override_deg: Some(semantic::NEUTRAL_HUE_DEG), }, + // Пер-темные края (стаб labui дословно; IC = дубль базовых — стаб + // без ic-скоупов, наследование как у альф): + // контур — light #101012 / dark #F6F8FA; инверт — #B0B0B9 / #3C3C43. + edge: Some(crate::ladder::ThemeAnchors { + light: "#101012".to_string(), + dark: "#F6F8FA".to_string(), + light_ic: "#101012".to_string(), + dark_ic: "#F6F8FA".to_string(), + }), + inverted: Some(crate::ladder::ThemeAnchors { + light: "#B0B0B9".to_string(), + dark: "#3C3C43".to_string(), + light_ic: "#B0B0B9".to_string(), + dark_ic: "#3C3C43".to_string(), + }), }, // Палитра labui — 10 замеренных семейств, ПЕР-ТЕМНО ДОСЛОВНО из // reference/labui-accent-primitives.md §2 (Figma `Accent/*`, все 4 режима, diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 86ec8eaf..6250b81c 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1139,6 +1139,13 @@ fn representative_roles_match_stub_values_light_and_dark() { "rgb(0 122 255 / 0.522)", "rgb(74 143 255 / 0.522)", ), + // Края нейтрали пер-темные: контур (edge) и инверт — из стаба дословно. + ("fx-focus-ring-neutral", "rgb(16 16 18)", "rgb(246 248 250)"), + ( + "fx-glow-inverted", + "rgb(176 176 185 / 0.522)", + "rgb(60 60 67 / 0.522)", + ), // Нейтральные: skeleton #787880 с ПЕР-ТЕМНОЙ альфой (base @8/@12), glow-neutral белый @52. ( "fx-skeleton-base", @@ -1362,3 +1369,91 @@ fn rgba_resolve_rejects_out_of_domain_spec() { "light" ); } + +/// Границы α AlphaAnalog: ровно 1.0 валидна, 1.0+ε — нет (RED-proof грани). +#[test] +fn alpha_analog_boundary_is_exact() { + let mut c = labui_reference(); + c.roles.push(( + "probe-alpha-boundary".to_string(), + RoleRecipe::AlphaAnalog { + of: LadderSource::Brand, + alpha: 1.0, + }, + )); + assert!(c.validate().is_ok(), "α=1.0 легальна"); + if let Some((_, RoleRecipe::AlphaAnalog { alpha, .. })) = c + .roles + .iter_mut() + .find(|(n, _)| n == "probe-alpha-boundary") + { + *alpha = 1.0 + 1e-9; + } + assert!( + matches!(c.validate(), Err(ConfigError::OutOfBounds { .. })), + "α чуть выше 1 обязана быть отвергнута" + ); +} + +/// Edge/Inverted без соответствующего поля конфига — честная ошибка, не выдумка. +#[test] +fn missing_neutral_quads_are_rejected() { + let mut c = labui_reference(); + c.neutral.edge = None; + assert!(matches!( + c.compile_named_role_table(), + Err(ConfigError::MissingNeutralAnchors { + field: "neutral.edge", + .. + }) + )); + let mut c = labui_reference(); + c.neutral.inverted = None; + assert!(matches!( + c.compile_named_role_table(), + Err(ConfigError::MissingNeutralAnchors { + field: "neutral.inverted", + .. + }) + )); +} + +/// Ахроматичные источники оттенка: серая нейтраль без override — ошибка; +/// серый бренд — сентимент честно равен сырому якорю (разведение отключено). +#[test] +fn achromatic_hue_sources_are_handled_honestly() { + let mut c = labui_reference(); + c.neutral.tint.hue_override_deg = None; + c.neutral.anchors.dark = "#101010".to_string(); // чистый серый: хрома ≈ 0 + assert!(matches!( + c.compile_named_role_table(), + Err(ConfigError::AchromaticHueSource { .. }) + )); + + let mut c = labui_reference(); + // Серый бренд: все четыре режима ахроматичны. + c.brand.anchors = crate::ladder::ThemeAnchors { + light: "#808080".to_string(), + dark: "#808080".to_string(), + light_ic: "#808080".to_string(), + dark_ic: "#808080".to_string(), + }; + let table = c.compile_named_role_table().expect("серый бренд легален"); + let set = crate::semantic::resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &crate::spaces::vc::ViewingConditions::srgb(), + ); + let (_, r) = set + .iter() + .find(|(n, _)| n == "label-danger-primary") + .expect("роль есть"); + let Resolved::Rgba(r) = r else { + panic!("ожидался Rgba"); + }; + assert_eq!( + r.tint_hex(), + "#FF3B30", + "при сером бренде сентимент = сырой якорь семейства (разведение отключено)" + ); +} diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index 613b1340..b3b4d33a 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -394,7 +394,8 @@ mod tests { ViewingConditions::srgb_high_contrast(), ViewingConditions::dim_surround_high_contrast(), ] { - let want = srgb_encoded_from_hex(anchors.for_vc(&vc)).unwrap(); + let want = srgb_encoded_from_hex(anchors.for_vc(&vc)) + .expect("якоря теста — валидные hex по построению"); assert_eq!(tint.for_vc(&vc), want, "тинт для vc разошёлся с якорем"); } } diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 0e3223d7..7a3af60c 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -492,12 +492,15 @@ pub fn resolve_smooth_hue_explicit( (1.0, params.p_high) } else { // Degenerate seam: brand exactly on the prototype. Pick the preferred side. - let p = if preferred_side >= 0.0 { + // Нормализация до ±1: публичный вход мог передать произвольный f64, а это + // МНОЖИТЕЛЬ направления, не масштаб (0/NaN трактуем как +1 — дефолт). + let side = if preferred_side < 0.0 { -1.0 } else { 1.0 }; + let p = if side >= 0.0 { params.p_high } else { params.p_low }; - (preferred_side, p) + (side, p) }; let s = smooth_separation(d, s_min, p); @@ -647,11 +650,12 @@ pub fn resolve_config_sentiment_solid( let prototype = oklab_hue_of(family_anchor_hex); let l_anchor = anchor_lab[0]; let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt(); - let s_min = s_min_deg(c_anchor); - // Порог разделения — max из перцептивного (от хромы якоря) и конфиг-порога: - // конфиг S_PERC_MIN задаёт минимум для КАТЕГОРИИ, s_min_deg — для этой хромы. + // Порог разделения — ТОЛЬКО из конфиг-порога (s_perc_min пересчитан из + // якорей клиента): подмешивание замороженного labui-S_PERC_MIN через + // s_min_deg() завышало бы угол низкохромным палитрам чужим порогом + // (могло опустошить legal arc) — закон обязан быть чистым по конфигу. let params = SentimentParams::uniform(hardness)?; - let effective_s_min = s_min.max(s_min_deg_from_chord(s_perc_min, c_anchor)); + let effective_s_min = s_min_deg_from_chord(s_perc_min, c_anchor); let resolved_hue = resolve_smooth_hue_explicit( preferred_side, hue_floor, From efb355cd48787aadcac552fe50f1b1a5f8cf7be1 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 09:51:45 +0300 Subject: [PATCH 10/17] =?UTF-8?q?fix(config):=20CodeRabbit=20=D1=80-6=20?= =?UTF-8?q?=E2=80=94=20validate()=20=3D=20=D0=BF=D0=BE=D0=BB=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20preflight=20=D0=BF=D0=BE=20=D0=BF=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B5=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Три из четырёх major — один класс (ложноположительный Ok preflight-а: ахроматичная нейтраль, отсутствующие edge/inverted, битый hex в заданных краях). Закрыт не заплатками, а по построению: validate() = компиляция с отброшенным результатом — второго списка проверок не существует, паритет validate/compile не может разъехаться; структурная фаза выделена в приватный validate_syntactic (компиляция зовёт её — не validate: рекурсия). Тест-паритет на корпусе деривационных ошибок сверяет обе ошибки байт-в-байт. Невозможный порог хорды (s_perc_min > 2C): клип-в-180° больше не скармливается p-норм солверу — smooth_separation ≥ s_min перелетает диаметраль, легальное множество вырождается в точку меры нуль и скан-сетка legalize_hue давала бы ложный «пустая дуга». Сатурация решается аналитически: диаметральный оттенок = максимум разведения («максимально приближенный приемлемый», директива владельца), пол-блокировка → граница пола; мусор-входы инверсии хорды — Err. Случай реален: приглушённый тёмный якорь при хромных светлых соседях. Домен alpha-analog: симметричный с прямым rgba-путём гард в resolve_rgba_inverted — недоменная α публичной RoleSpec резолвится в честный Unreachable, а не в правдоподобный hex через кламп резолвера инверсии (недоменный солид по построению невозможен: LadderTint::new валидирует квад). Сопутствующее: ахроматичный якорь СЕМЕЙСТВА → сырой якорь (тот же закон, что серый бренд: нет носителя оттенка — нет разведения); ACHROMATIC_CHROMA_EPS переехал к закону в sentiment.rs — реестр строка 38, GROUNDED с вычисленными границами (мин. 8-бит хрома 1.06e-3, f64-шум ≲1e-12); старый ассерт hue_floor=359.999 → Ok был ровно флагнутым ложноположительным preflight-ом — обновлён на честную семантику (в диапазоне, но дуга пуста). --- crates/labcolors-core/src/config.rs | 57 ++++++-- crates/labcolors-core/src/config/tests.rs | 112 +++++++++++++++- crates/labcolors-core/src/semantic.rs | 9 ++ crates/labcolors-core/src/sentiment.rs | 152 ++++++++++++++++++++-- docs/empirical-inventory.md | 1 + 5 files changed, 306 insertions(+), 25 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 2a530726..0fa7ef7f 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -536,11 +536,7 @@ fn is_valid_name(name: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } -/// Проверить, что hex парсится ядром (`#RGB` / `#RRGGBB`). -/// Технический порог числовой определённости оттенка: ниже него atan2 в -/// oklab_hue_of математически не определён (не перцептивная величина — -/// защита от произвольного 0°, не политика). -const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; +use crate::sentiment::ACHROMATIC_CHROMA_EPS; /// Oklab-хрома hex-цвета (для гарда ахроматичности источников оттенка). fn oklab_chroma_of_hex(hex: &str) -> f64 { @@ -553,6 +549,7 @@ fn oklab_chroma_of_hex(hex: &str) -> f64 { } } +/// Проверить, что hex парсится ядром (`#RGB` / `#RRGGBB`). fn check_hex(field: &str, value: &str) -> Result<(), ConfigError> { crate::spaces::srgb::srgb_from_hex(value) .map(|_| ()) @@ -658,12 +655,30 @@ fn check_ge( } impl ThemeConfig { - /// Провалидировать конфиг: hex, имена, ссылки на семейства/источники лестницы - /// и пределы каждой экспонируемой ручки. Первая найденная ошибка возвращается - /// сразу — клиент чинит по одной. Успех означает: - /// [`compile_named_role_table`](Self::compile_named_role_table) не упадёт на - /// неверном hex/имени/ссылке/пределе (все рецепты компилируются). + /// Провалидировать конфиг как ПОЛНЫЙ preflight: `Ok` гарантирует, что + /// [`compile_named_role_table`](Self::compile_named_role_table) вернёт `Ok`. + /// + /// Гарантия держится по построению: `validate` — это компиляция с + /// отброшенным результатом (единый код-путь; паритет validate/compile не + /// может разъехаться, потому что второго списка проверок не существует). + /// Ловится и структурное (hex, имена, ссылки, пределы ручек), и + /// деривационное (ахроматичный источник оттенка, отсутствующие + /// edge/inverted-четвёрки, пустая легальная дуга сентимента). Первая + /// найденная ошибка возвращается сразу — клиент чинит по одной. + /// + /// # Errors + /// + /// Та же [`ConfigError`], которую вернула бы компиляция. pub fn validate(&self) -> Result<(), ConfigError> { + self.compile_named_role_table().map(drop) + } + + /// Структурная фаза валидации: hex, имена, ссылки на семейства/источники + /// лестницы, дубликаты словарей и пределы каждой экспонируемой ручки. + /// НЕ полный preflight: деривационные ошибки (ахроматичность, пустая дуга + /// сентимента) всплывают только в фазе компиляции — снаружи полноту даёт + /// [`validate`](Self::validate). + fn validate_syntactic(&self) -> Result<(), ConfigError> { // Бренд: пер-темная четвёрка hex. check_theme_anchors("brand.anchors", &self.brand.anchors)?; @@ -672,6 +687,16 @@ impl ThemeConfig { check_hex("neutral.anchors.mid", &self.neutral.anchors.mid)?; check_hex("neutral.anchors.dark", &self.neutral.anchors.dark)?; + // Пер-темные края нейтрали: hex валидируется, если четвёрка задана — + // даже без ссылающихся ролей (задекларированные данные обязаны быть + // валидными: мёртвый битый hex всплыл бы позже дорогой загадкой). + if let Some(edge) = &self.neutral.edge { + check_theme_anchors("neutral.edge", edge)?; + } + if let Some(inverted) = &self.neutral.inverted { + check_theme_anchors("neutral.inverted", inverted)?; + } + // Нейтраль: ручки подтона. check_in_incl_incl( "neutral.tint.ratio", @@ -904,11 +929,15 @@ impl ThemeConfig { /// [`resolve_named_set`](crate::semantic::resolve_named_set) резолвит той же /// физикой, что и встроенную [`crate::RoleTable`]. /// - /// Валидирует конфиг ([`validate`](Self::validate)) перед компиляцией. - /// [`Ladder`](RoleRecipe::Ladder) раскладывает источник в пер-темный тинт, - /// [`AlphaAnalog`](RoleRecipe::AlphaAnalog) — солид-цель источника + альфа (t2). + /// Структурная фаза ([`validate_syntactic`](Self::validate_syntactic)) + /// выполняется первой; деривационные ошибки возвращаются по ходу компиляции + /// (снаружи обе фазы разом — [`validate`](Self::validate), который и есть + /// эта компиляция с отброшенным результатом — НЕ вызывать её отсюда: + /// рекурсия). [`Ladder`](RoleRecipe::Ladder) раскладывает источник в + /// пер-темный тинт, [`AlphaAnalog`](RoleRecipe::AlphaAnalog) — солид-цель + /// источника + альфа (t2). pub fn compile_named_role_table(&self) -> Result { - self.validate()?; + self.validate_syntactic()?; 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 6250b81c..38be33b8 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -370,13 +370,25 @@ fn hue_floor_out_of_range_is_rejected() { let mut neg = labui_reference(); neg.sentiments.categories[1].hue_floor_deg = Some(-1.0); assert!(neg.validate().is_err()); - // RED-proof: 0.0 валиден, чуть ниже 360 валиден. + // RED-proof: 0.0 валиден (ничего не исключает — компилируется). let mut lo = labui_reference(); lo.sentiments.categories[1].hue_floor_deg = Some(0.0); assert_eq!(lo.validate(), Ok(())); + // 359.999 проходит проверку ДИАПАЗОНА (не OutOfBounds), но полный + // preflight честно ловит деривационную коллизию: такой пол исключает + // почти весь круг (`h < f` нелегален) — легальная дуга сентимента пуста. + // Старый ассерт `Ok` был ровно тем ложноположительным preflight-ом, + // который закрыт р-6 (validate = компиляция по построению). let mut hi = labui_reference(); hi.sentiments.categories[1].hue_floor_deg = Some(359.999); - assert_eq!(hi.validate(), Ok(())); + assert!( + !matches!(hi.validate(), Err(ConfigError::OutOfBounds { .. })), + "359.999 внутри полуинтервала [0,360) — диапазонная проверка проходит" + ); + assert!( + hi.validate().is_err(), + "пол 359.999 опустошает легальную дугу — деривационная ошибка" + ); } // ───────────────────────────────────────────────────────────────────────────── @@ -1418,6 +1430,102 @@ fn missing_neutral_quads_are_rejected() { )); } +/// `validate()` — полный preflight ПО ПОСТРОЕНИЮ (компиляция с отброшенным +/// результатом): для любого конфига validate и compile дают одинаковый исход +/// и байт-в-байт одинаковую ошибку. Корпус — деривационные ошибки, которые +/// структурная фаза не видит (класс р-6: ложноположительный `Ok` preflight-а). +#[test] +fn validate_is_a_complete_preflight() { + let ok = labui_reference(); + assert!( + ok.validate().is_ok(), + "канонический конфиг проходит preflight" + ); + assert!(ok.compile_named_role_table().is_ok()); + + let assert_parity = |c: &ThemeConfig, want: &str| { + let v = c.validate().expect_err("validate обязан падать"); + let k = c + .compile_named_role_table() + .expect_err("compile обязан падать"); + assert_eq!( + format!("{v:?}"), + format!("{k:?}"), + "validate и compile разошлись — полнота preflight нарушена" + ); + let got = format!("{v:?}"); + assert!(got.contains(want), "ждали {want}, получено {got}"); + }; + + // Ахроматичная нейтраль без override — деривационная ошибка подтона. + let mut c = labui_reference(); + c.neutral.tint.hue_override_deg = None; + c.neutral.anchors.dark = "#101010".to_string(); + assert_parity(&c, "AchromaticHueSource"); + + // Edge-роль без четвёрки edge — деривационная ошибка края нейтрали. + let mut c = labui_reference(); + c.neutral.edge = None; + assert_parity(&c, "MissingNeutralAnchors"); + + // Битый hex в ЗАДАННОЙ, но никем не используемой четвёрке edge: + // задекларированные данные валидируются даже без ссылающихся ролей — + // мёртвый битый hex не должен ждать первую ссылку, чтобы всплыть. + let mut c = labui_reference(); + c.roles.retain(|(_, r)| { + !matches!( + r, + RoleRecipe::Ladder { + source: LadderSource::Neutral(NeutralPick::Edge), + .. + } | RoleRecipe::AlphaAnalog { + of: LadderSource::Neutral(NeutralPick::Edge), + .. + } + ) + }); + let kept: std::collections::BTreeSet<&str> = c.roles.iter().map(|(n, _)| n.as_str()).collect(); + c.aliases + .retain(|(_, target)| kept.contains(target.as_str())); + c.neutral.edge = Some(crate::ladder::ThemeAnchors { + light: "не-hex".to_string(), + dark: "#F6F8FA".to_string(), + light_ic: "#101012".to_string(), + dark_ic: "#F6F8FA".to_string(), + }); + assert_parity(&c, "InvalidHex"); +} + +/// `RoleSpec` публичен: alpha-analog-спека с недоменной α, собранная в обход +/// валидатора конфига, резолвится в честный `Unreachable`, а не в +/// правдоподобный hex через кламп резолвера инверсии. Недоменный СОЛИД по +/// построению невозможен ([`crate::ladder::LadderTint::new`] валидирует домен +/// квада) — гард по солиду остаётся глубинной защитой. +#[test] +fn alpha_analog_spec_bypassing_validator_is_rejected() { + use crate::ladder::LadderTint; + use crate::semantic::{NamedRoleTable, RoleChroma, RoleSpec}; + + let tint = LadderTint::new([[0.5, 0.5, 0.5]; 4]).expect("валидный квад"); + let bg = BgInput::solid("#FFFFFF").unwrap(); + for alpha in [1.0 + 1e-9, 0.0, -0.5, f64::NAN, f64::INFINITY] { + let table = NamedRoleTable::new( + vec![( + "probe".to_string(), + RoleSpec::AlphaAnalog { of: tint, alpha }, + )], + vec![], + RoleChroma::Neutral, + ); + let set = crate::semantic::resolve_named_set(&bg, &table, &ViewingConditions::srgb()); + let (_, r) = set.iter().find(|(n, _)| n == "probe").expect("роль есть"); + assert!( + matches!(r, Resolved::Unreachable(_)), + "α={alpha}: ждали Unreachable (честный отказ), получено {r:?}" + ); + } +} + /// Ахроматичные источники оттенка: серая нейтраль без override — ошибка; /// серый бренд — сентимент честно равен сырому якорю (разведение отключено). #[test] diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index e4bb9faf..2580352e 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1471,6 +1471,15 @@ fn resolve_rgba_inverted( bg: &BgInput, vc: &ViewingConditions, ) -> Resolved { + // Тот же домен-гард, что у прямого rgba-пути: RoleSpec публичен, а резолвер + // инверсии клампит запрошенную α — недоменная спека, собранная в обход + // валидатора конфига, стала бы правдоподобным hex вместо честного отказа. + if !rgba_input_valid(solid_encoded, requested_alpha) { + return Resolved::Unreachable(Unreachable::InvalidInput( + "alpha-analog-спека вне домена (солид [0,1], α (0,1]) — сборка в обход валидатора" + .into(), + )); + } let bg_encoded = bg.encoded_display(); let Some(analog) = crate::alpha::resolve_alpha_analog(solid_encoded, requested_alpha, bg_encoded) diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 7a3af60c..0b34dcb5 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -5,7 +5,9 @@ use crate::lcs::LcsColor; use crate::neutral::NeutralCurve; use crate::scale::{jp_to_oklab_l, max_chroma}; use crate::spaces::oklab::{oklab_to_srgb_linear, srgb_linear_to_oklab}; -use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex, srgb_to_xyz}; +use crate::spaces::srgb::{ + hex_from_srgb, hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_from_hex, srgb_to_xyz, +}; use crate::spaces::vc::ViewingConditions; /// Перцептивный минимум разделения между оттенком сентимента и брендовым @@ -620,6 +622,19 @@ pub fn s_perc_min_frozen() -> f64 { S_PERC_MIN } +/// Технический порог числовой определённости оттенка: ниже него atan2 в +/// [`oklab_hue_of`] математически не определён (не перцептивная величина — +/// защита от произвольного 0°, не политика). Дом константы — здесь, рядом с +/// законом «нет носителя оттенка → нет разведения»; конфиг-гарды +/// (`crate::config`) ссылаются сюда же. +/// +/// Провенанс ε: минимум ненулевой Oklab-хромы 8-битного цвета ≈ 1.1e-3 +/// (#FEFFFF; #808081 ≈ 1.5e-3), f64-шум конвейера sRGB→Oklab ≲ 1e-12; +/// 1e-7 лежит между ними с запасом ≥4 порядка в обе стороны — не может +/// переклассифицировать ни один представимый цвет. +// GROUNDED — арифметика представимости: мин. 8-бит хрома `1.06e-3` ≫ ε ≫ f64-шум `1e-12` (docs/empirical-inventory.md). +pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; + /// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом /// сентимент-солвером, при СОХРАНЁННЫХ светлоте и хроме якоря. /// @@ -632,8 +647,9 @@ pub fn s_perc_min_frozen() -> f64 { /// /// # Errors /// -/// `Err`, если якорь невалиден или легальный оттенок геометрически пуст -/// (см. [`resolve_smooth_hue_explicit`]). +/// `Err`, если якорь невалиден, легальный оттенок геометрически пуст +/// (см. [`resolve_smooth_hue_explicit`]) или порог `s_perc_min` не конечен +/// (см. [`s_min_deg_from_chord`]). pub fn resolve_config_sentiment_solid( family_anchor_hex: &str, brand_hue: f64, @@ -647,15 +663,40 @@ pub fn resolve_config_sentiment_solid( // chroma_fraction — ручка рампы SentimentCurve, не тинта; принимается для // единообразия сигнатуры конфига, но тинт держит фактическую хрому якоря. let anchor_lab = srgb_linear_to_oklab(srgb_from_hex(family_anchor_hex)?); - let prototype = oklab_hue_of(family_anchor_hex); let l_anchor = anchor_lab[0]; let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt(); + // Ахроматичный якорь не несёт оттенка (prototype = atan2(0,0) — числовой + // произвол), а хорда разведения 2·C·sin(Δh/2) при C≈0 перцептивно пуста: + // тот же закон, что для серого бренда (`config::compile_sentiment_tint`) — + // нет носителя оттенка → нет разведения, солид = сырой якорь + // (байт-в-байт, нормализованный через encoded-roundtrip). + if c_anchor < ACHROMATIC_CHROMA_EPS { + return Ok(hex_from_srgb_encoded(srgb_encoded_from_hex( + family_anchor_hex, + )?)); + } + let prototype = oklab_hue_of(family_anchor_hex); // Порог разделения — ТОЛЬКО из конфиг-порога (s_perc_min пересчитан из // якорей клиента): подмешивание замороженного labui-S_PERC_MIN через // s_min_deg() завышало бы угол низкохромным палитрам чужим порогом // (могло опустошить legal arc) — закон обязан быть чистым по конфигу. let params = SentimentParams::uniform(hardness)?; - let effective_s_min = s_min_deg_from_chord(s_perc_min, c_anchor); + let effective_s_min = s_min_deg_from_chord(s_perc_min, c_anchor)?; + // Сатурация порога (180° ⇔ chord ≥ 2C): требуемая хорда недостижима ни + // одним углом — ограничение вырождено, ответ аналитический: максимум + // разведения на легальной дуге («максимально приближенный приемлемый», + // не отказ). Пол, блокирующий диаметраль, даёт границу пола — ближайшую + // легальную точку к максимуму; супремум у открытого конца дуги (360⁻ при + // поле у верха круга и бренде напротив) сознательно не берётся: границе + // пола отдан детерминизм в вырожденной конфигурации. + if effective_s_min >= 180.0 { + let diametric = normalize_hue(brand_hue + 180.0); + let resolved = match hue_floor { + Some(floor) if diametric < floor => floor, + _ => diametric, + }; + return Ok(oklab_lc_to_hex(l_anchor, c_anchor, resolved)); + } let resolved_hue = resolve_smooth_hue_explicit( preferred_side, hue_floor, @@ -671,10 +712,33 @@ pub fn resolve_config_sentiment_solid( /// Перевести целевую хорду разделения `chord` в угол оттенка (градусы) при /// хроме `zone_chroma` — та же инверсия `2·C·sin(Δh/2)`, что [`s_min_deg`], но с /// произвольной хордой (для конфиг-`S_PERC_MIN`). -fn s_min_deg_from_chord(chord: f64, zone_chroma: f64) -> f64 { - let safe_chroma = zone_chroma.max(1e-6); - let ratio = (chord / (2.0 * safe_chroma)).clamp(0.0, 1.0); - 2.0 * ratio.asin().to_degrees() +/// +/// При `chord ≥ 2·zone_chroma` порог недостижим НИ ОДНИМ углом (хорда +/// окружности радиуса C ограничена диаметром 2C): возвращается ровно 180° — +/// маркер сатурации. Вызывающий ОБЯЗАН обработать 180° аналитически +/// (диаметральный оттенок = максимум разведения), НЕ передавая его p-норм +/// солверу: `smooth_separation ≥ s_min` перелетает диаметраль, легальное +/// множество вырождается в точку меры нуль, и скан-сетка `legalize_hue` +/// (шаг 0.05°) её не находит — получился бы ложный «пустая дуга». Случай +/// реален: приглушённый якорь (тёмная тема) при хромных соседях — средняя +/// хорда категорий превышает диаметр одного якоря. +/// +/// # Errors +/// +/// `Err` на неконечных/отрицательных входах и `zone_chroma ≤ 0` — вызывающий +/// обязан отсечь ахроматичную зону гардом [`ACHROMATIC_CHROMA_EPS`] до +/// инверсии хорды (asin от NaN-отношения дал бы NaN-градусы дальше по физике). +fn s_min_deg_from_chord(chord: f64, zone_chroma: f64) -> Result { + if !(chord.is_finite() && zone_chroma.is_finite() && chord >= 0.0 && zone_chroma > 0.0) { + return Err(format!( + "инверсия хорды вне домена: chord={chord}, zone_chroma={zone_chroma}" + )); + } + let ratio = chord / (2.0 * zone_chroma); + if ratio >= 1.0 { + return Ok(180.0); + } + Ok(2.0 * ratio.asin().to_degrees()) } /// The in-gamut sRGB hex at Oklab `(L, C, h)`, channels clamped to `[0, 1]`. @@ -1114,4 +1178,74 @@ mod tests { (S_PERC_MIN - derived).abs() ); } + + /// Инверсия хорды: достижимый порог строго внутри (0, 180°); сатурация + /// (chord ≥ 2C) — маркер ровно 180°; мусор-входы — честный Err, не + /// NaN-градусы дальше по физике. + #[test] + fn chord_inversion_saturates_and_rejects_garbage() { + let deg = s_min_deg_from_chord(0.05, 0.1).expect("достижимая хорда"); + assert!(deg > 0.0 && deg < 180.0, "0 < {deg} < 180"); + // Ровно диаметр и выше — маркер сатурации. + assert_eq!(s_min_deg_from_chord(0.2, 0.1).unwrap(), 180.0); + assert_eq!(s_min_deg_from_chord(0.5, 0.1).unwrap(), 180.0); + for (chord, zone) in [ + (f64::NAN, 0.1), + (0.1, f64::NAN), + (f64::INFINITY, 0.1), + (-0.1, 0.1), + (0.1, 0.0), + (0.1, -0.1), + ] { + assert!( + s_min_deg_from_chord(chord, zone).is_err(), + "({chord}, {zone}) обязана быть отвергнута" + ); + } + } + + /// Сатурированный порог (хорда недостижима ни одним углом) резолвится + /// аналитически в диаметральный оттенок — максимум разведения, не ложный + /// «пустая дуга» от скан-сетки и не тихое меньшее разведение. + #[test] + fn saturated_chord_resolves_to_diametric_hue() { + // Ассерт байт-в-байт против аналитического закона (солид = якорные L/C + // на целевом оттенке): угол ПОСЛЕ hex-эмиссии сравнивать нельзя — + // гамут-клип каналов и 8-бит квантование легитимно смещают его + // (пре-существующий контракт oklab_lc_to_hex). + let anchor = "#FF3B30"; + let brand_hue = 100.0; + let lab = srgb_linear_to_oklab(srgb_from_hex(anchor).unwrap()); + let (l, c) = (lab[0], (lab[1].powi(2) + lab[2].powi(2)).sqrt()); + + // s_perc_min = 1.0 — заведомо больше диаметра 2C любого sRGB-цвета. + let solid = resolve_config_sentiment_solid(anchor, brand_hue, 4.0, 1.0, None, 1.0, 1.0) + .expect("сатурация — не отказ"); + let diametric = normalize_hue(brand_hue + 180.0); + assert_eq!( + solid, + oklab_lc_to_hex(l, c, diametric), + "сатурация → диаметраль (максимум разведения) на якорных L/C" + ); + + // Пол, блокирующий диаметраль (brand 100° → диаметраль 280° < 300°): + // граница пола, не отказ и не нарушение пола. + let floored = + resolve_config_sentiment_solid(anchor, brand_hue, 4.0, 1.0, Some(300.0), 1.0, 1.0) + .expect("пол при сатурации — не отказ"); + assert_eq!( + floored, + oklab_lc_to_hex(l, c, 300.0), + "при полу 300° сатурация садится на границу пола" + ); + } + + /// Ахроматичный якорь семейства не несёт оттенка: разведение отключается + /// честно — солид равен сырому якорю (тот же закон, что серый бренд). + #[test] + fn achromatic_family_anchor_returns_raw_anchor() { + let solid = resolve_config_sentiment_solid("#808080", 28.0, 4.0, 1.0, None, 1.0, 0.06) + .expect("серый якорь легален"); + assert_eq!(solid, "#808080", "серый якорь возвращается байт-в-байт"); + } } diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index 2a19d34a..8b8a3a04 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -47,6 +47,7 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 35 | `IC_DECORATIVE_FLOOR_MIN` | `15.0` | `semantic.rs` | SSOT-TRACKED | Increased-contrast decorative floor (Lc), raised above `DECORATIVE_FLOOR_MIN` (7.5) to match the stronger perceptual requirements of the `-ic` themes. | | 36 | `HUE_SEARCH_HALF_WINDOW` | `30.0` | `scale.rs` | SSOT-TRACKED | Half-width (degrees) of the hue search window in `find_optimal_hue`; 30° spans the typical sRGB gamut ridge width around the canonical hue. | | 37 | `HUE_DRIFT_PENALTY_SLOPE` | `0.15` | `scale.rs` | SSOT-TRACKED | Наклон штрафа дрейфа оттенка в `find_optimal_hue` (`penalty_scale = slope/half_window`, `score = c − penalty_scale·drift`): баланс «максимум хромы против ухода от канонического оттенка». Калибровочный; ранее жил незадекларированным поле-литералом (найден инвентарём конфиг-границы 2026-07-02). Кандидат науки: вывести или обосновать датасетом. | +| 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | GROUNDED | Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. Вывод из арифметики представимости, а не подгонка: минимум ненулевой Oklab-хромы 8-битного sRGB-цвета ≈ 1.06e-3 (`#FEFFFF`; `#808081` ≈ 1.49e-3; расчёт конвейером sRGB→Oklab, Ottosson 2020), накопленный f64-шум того же конвейера ≲ 1e-12; 1e-7 лежит между границами с запасом ≥4 порядка в обе стороны, поэтому не может переклассифицировать ни один представимый цвет. | ## Muddiness Law constants — `cleanliness.rs` From c96901949edb87a7c7c617e1bcef15f40edbb1e2 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 10:09:38 +0300 Subject: [PATCH 11/17] =?UTF-8?q?fix(config):=20CodeRabbit=20=D1=80-7=20?= =?UTF-8?q?=E2=80=94=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B0=20=D1=81?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D0=BC=D0=B0=D0=BA=D1=81=D0=B8=D0=BC=D1=83=D0=BC=D1=83=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=B2=D0=B5=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F,?= =?UTF-8?q?=20=D0=BF=D0=B0=D0=B1=D0=BB=D0=B8=D0=BA-=D0=B3=D0=B0=D1=80?= =?UTF-8?q?=D0=B4=D1=8B=20=D1=83=D0=B3=D0=BB=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2 major: (1) при поле, блокирующем диаметраль, берётся граница легальной дуги [floor, 360°) с БОЛЬШИМ разведением от бренда (не всегда floor — прежний «детерминизм к полу» противоречил декларированному максимуму разведения); верхняя открытая граница — достижимая точка 360°−1e-9 (суб-LSB отступ); зеркальный тест-корпус на оба исхода; (2) домены α разводить НЕ стали — отклонено с основанием: контракт РОЛИ = (0,1] (α=0 — невидимая роль; тот же предел, что у конфиг-валидатора), α=0 принимает только библиотечный resolve_alpha_analog (вырожденный ответ tint=фон — его домен, не ролевой); починен реальный дефект — рассинхрон доков RoleSpec::AlphaAnalog ([0,1] → (0,1] с объяснением границы слоёв). minor: паблик-гарды resolve_config_sentiment_solid — неконечный brand_hue и недоменный hue_floor (вне [0,360)) в сатурированной ветке уходили мимо солвера прямо в oklab_lc_to_hex тихим неверным hex → честный Err + мусор-тесты; ACHROMATIC_CHROMA_EPS: GROUNDED → SSOT-TRACKED (внешнего стандарта не существует, деривация закрыта и живёт в таблице реестра — GROUNDED обещает цитируемый стандарт); PartialEq/NaN у ConfigError — отклонено с основанием (IEEE-семантика f64-полей, как у самого f64; дроп PartialEq сломал бы assert_eq всех потребителей) + doc-нота на enum. trivial: предикат коллапс-ролей ВЫВОДИТСЯ из деклараций COLLAPSED_ROLES (glob-сопоставитель + значенческий RED-proof гард) — второй вручную синхронизируемый список гнил молча; значенческая сверка со стабом: rgb побайтово + α числом с допуском 1e-12 — Display-сравнение f64 хрупко к хвостам представления. --- crates/labcolors-core/src/config.rs | 4 + crates/labcolors-core/src/config/tests.rs | 127 +++++++++++++++------- crates/labcolors-core/src/semantic.rs | 6 +- crates/labcolors-core/src/sentiment.rs | 81 ++++++++++++-- docs/empirical-inventory.md | 2 +- 5 files changed, 170 insertions(+), 50 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 0fa7ef7f..cbdfdb9c 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -155,6 +155,10 @@ const HUE_FLOOR_MAX_EXCLUSIVE: f64 = 360.0; /// «рецепт ещё не реализован». Реализована вручную (без `thiserror`) — крейт /// `labcolors-core` держит НОЛЬ runtime-зависимостей (issue #29); стиль `Display` /// повторяет ручные ошибки ядра. +/// +/// NB: `PartialEq` наследует IEEE-семантику `f64`-полей — +/// `OutOfBounds { value: NaN, .. }` НЕ равен самому себе (как и сам `f64`); +/// для сравнения NaN-ошибок матчитесь по варианту, не по равенству. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum ConfigError { diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 38be33b8..a47adef0 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -739,26 +739,56 @@ fn consumed_roles_diff_is_empty_against_labui_contract() { ); // Обратная сторона: фикстура не эмитит НИ ОДНОЙ коллапс-роли (иначе коллапс - // не исполнен). Проверяем по конкретным маркерам удаляемых семейств - // (`fx-glow-inverted` — легитимная FX-роль, НЕ инвертированный лейбл/бордер). + // не исполнен). Предикат ВЫВОДИТСЯ из деклараций COLLAPSED_ROLES — второй, + // вручную синхронизируемый список условий гнил бы молча (новый паттерн в + // декларации без правки предиката = тест перестаёт кусаться). for (name, _) in table.entries() { - let collapsed = name.contains("static") - || name.starts_with("label-inverted") - || name == "border-inverted" - || name.starts_with("label-on-") - || name.starts_with("bg-") - || name.starts_with("badge-") - || name == "control-bg" - || name.contains("material"); - assert!( - !collapsed, - "фикстура эмитит коллапс-роль `{name}` — коллапс контракта нарушен" - ); + if let Some((pattern, why)) = COLLAPSED_ROLES + .iter() + .find(|(p, _)| matches_collapsed_pattern(name, p)) + { + panic!( + "фикстура эмитит коллапс-роль `{name}` (паттерн `{pattern}`: {why}) — \ + коллапс контракта нарушен" + ); + } } + // Значенческий гард сопоставителя (RED-proof против немого предиката): + // коллапс-имена ловятся, легитимная FX-роль `fx-glow-inverted` — нет + // (она НЕ инвертированный лейбл/бордер). + let hits = |name: &str| { + COLLAPSED_ROLES + .iter() + .any(|(p, _)| matches_collapsed_pattern(name, p)) + }; + assert!(hits("label-on-accent") && hits("bg-material-thick") && hits("tint-static-dark-4")); + assert!(!hits("fx-glow-inverted") && !hits("label-danger-primary")); // COLLAPSED_ROLES не пуст — декларация причин присутствует. assert!(!COLLAPSED_ROLES.is_empty()); } +/// Glob-сопоставление паттернов [`COLLAPSED_ROLES`] (`*` — любая подстрока): +/// сегменты между `*` обязаны входить по порядку; без ведущей/замыкающей `*` +/// первый/последний сегмент заякорен на начало/конец имени. +fn matches_collapsed_pattern(name: &str, pattern: &str) -> bool { + let segments: Vec<&str> = pattern.split('*').collect(); + let mut pos = 0usize; + for (i, seg) in segments.iter().enumerate() { + if seg.is_empty() { + continue; + } + let Some(found) = name[pos..].find(seg) else { + return false; + }; + if i == 0 && found != 0 { + return false; // без ведущей `*` — якорь на начало + } + pos += found + seg.len(); + } + // Без замыкающей `*` — якорь на конец имени. + pattern.ends_with('*') || pos == name.len() +} + // ───────────────────────────────────────────────────────────────────────────── // t2 №д: S_PERC_MIN — деривационная идентичность из конфиг-якорей. // ───────────────────────────────────────────────────────────────────────────── @@ -1094,24 +1124,51 @@ fn alpha_analog_recipe_inverts_and_bites_on_alpha() { // стаба contract.css ПОБАЙТНО (нормализованный формат), в light И dark. // ───────────────────────────────────────────────────────────────────────────── -/// Нормализовать [`Resolved::Rgba`] в канонический `rgb(R G B / A)` (формат стаба -/// labui): тинт-hex → десятичные каналы, альфа как есть. Солид (α=1) → `rgb(R G B)`. -fn rgba_to_stub_string(res: &Resolved) -> String { +/// Нормализовать [`Resolved::Rgba`] в пару (rgb-строка, α): rgb сверяется со +/// стабом ПОБАЙТОВО, α — числом с допуском (Display-сравнение f64 хрупко: +/// хвост вида 0.07800000000000001 после будущего рефакторинга формулы уронил +/// бы тест по ФОРМАТУ, маскируя семантику — класс «строковое сравнение +/// плавающей точки», CodeRabbit р-7). +fn rgba_to_parts(res: &Resolved) -> (String, f64) { let r = res .rgba() .unwrap_or_else(|| panic!("ожидался Resolved::Rgba, получено {res:?}")); let rgb = crate::spaces::srgb::srgb_encoded_from_hex(r.tint_hex()).unwrap(); let ch = |v: f64| (v * 255.0).round() as u8; - let (rr, gg, bb) = (ch(rgb[0]), ch(rgb[1]), ch(rgb[2])); - if (r.alpha() - 1.0).abs() < 1e-9 { - format!("rgb({rr} {gg} {bb})") - } else { - // Стаб печатает альфу без ведущего нуля целой части и без хвостовых нулей - // (0.722, 0.2, 0.078…); {} по f64 это воспроизводит для наших величин. - format!("rgb({rr} {gg} {bb} / {})", r.alpha()) + ( + format!("rgb({} {} {})", ch(rgb[0]), ch(rgb[1]), ch(rgb[2])), + r.alpha(), + ) +} + +/// Разбить стаб-литерал `rgb(R G B / A)` / `rgb(R G B)` на (rgb-строка, α): +/// солид без слэша несёт α = 1. +fn split_stub_rgba(stub: &str) -> (String, f64) { + match stub.split_once(" / ") { + Some((rgb, a)) => ( + format!("{rgb})"), + a.trim_end_matches(')').parse().expect("α стаба — число"), + ), + None => (stub.to_string(), 1.0), } } +/// Сверить эмиссию роли со стаб-литералом: rgb побайтово, α с допуском 1e-12 +/// (тот же допуск, что у соседних численных сверок α). +#[track_caller] +fn assert_matches_stub(role: &str, theme: &str, got: &Resolved, want: &str) { + let (got_rgb, got_alpha) = rgba_to_parts(got); + let (want_rgb, want_alpha) = split_stub_rgba(want); + assert_eq!( + got_rgb, want_rgb, + "ЗНАЧЕНИЕ РАЗОШЛОСЬ ({theme}) `{role}`: rgb {got_rgb} != стаб {want_rgb}" + ); + assert!( + (got_alpha - want_alpha).abs() < 1e-12, + "ЗНАЧЕНИЕ РАЗОШЛОСЬ ({theme}) `{role}`: α {got_alpha} != стаб {want_alpha}" + ); +} + /// Значенческая сверка представителей групп против стаба labui в light И dark. /// Закрывает класс «имя есть, значение врёт»: skeleton = нейтраль #787880 с /// пер-темной альфой, glow-neutral = белый @52, акценты = пер-темный якорь. @@ -1179,16 +1236,10 @@ fn representative_roles_match_stub_values_light_and_dark() { for (role, want_light, want_dark) in cases { let set_l = resolve_named_set(&bg_light, &table, &ViewingConditions::srgb()); let set_d = resolve_named_set(&bg_dark, &table, &ViewingConditions::dim_surround()); - let got_l = rgba_to_stub_string(&set_l.iter().find(|(n, _)| n == role).unwrap().1); - let got_d = rgba_to_stub_string(&set_d.iter().find(|(n, _)| n == role).unwrap().1); - assert_eq!( - &got_l, want_light, - "ЗНАЧЕНИЕ РАЗОШЛОСЬ (light) `{role}`: эмиссия {got_l} != стаб {want_light}" - ); - assert_eq!( - &got_d, want_dark, - "ЗНАЧЕНИЕ РАЗОШЛОСЬ (dark) `{role}`: эмиссия {got_d} != стаб {want_dark}" - ); + let got_l = &set_l.iter().find(|(n, _)| n == role).unwrap().1; + let got_d = &set_d.iter().find(|(n, _)| n == role).unwrap().1; + assert_matches_stub(role, "light", got_l, want_light); + assert_matches_stub(role, "dark", got_d, want_dark); } } @@ -1209,9 +1260,11 @@ fn value_test_bites_on_alpha_mutation() { let table = cfg.compile_named_role_table().unwrap(); let bg_dark = BgInput::solid("#101012").unwrap(); let set = resolve_named_set(&bg_dark, &table, &ViewingConditions::dim_surround()); - let got = rgba_to_stub_string(&set.iter().find(|(n, _)| n == "fx-skeleton-base").unwrap().1); - assert_ne!( - got, "rgb(120 120 128 / 0.122)", + let (got_rgb, got_alpha) = + rgba_to_parts(&set.iter().find(|(n, _)| n == "fx-skeleton-base").unwrap().1); + assert_eq!(got_rgb, "rgb(120 120 128)", "мутация двигает ТОЛЬКО альфу"); + assert!( + (got_alpha - 0.122).abs() > 1e-9, "RED-proof значенческого теста провален: мутация альфы НЕ сдвинула эмиссию" ); } diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 2580352e..271dab30 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -544,7 +544,11 @@ pub enum RoleSpec { AlphaAnalog { /// Пер-темный кодированный солид-источник, чей альфа-аналог берётся. of: LadderTint, - /// Запрошенная альфа (`[0, 1]`; поднимается до `α_min`, если ниже). + /// Запрошенная альфа: `(0, 1]` — контракт РОЛИ (тот же предел, что у + /// конфиг-валидатора: α = 0 — невидимая роль, отказ честнее выдумки). + /// Уже: библиотечный [`crate::alpha::resolve_alpha_analog`] принимает + /// и `0.0` (вырожденный ответ tint=фон) — то его домен, не ролевой. + /// Поднимается до `α_min`, если запрошенная ниже разрешимой. alpha: f64, }, /// The zero token: resolves to [`Resolved::None`]. diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 0b34dcb5..d13bbb79 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -632,7 +632,7 @@ pub fn s_perc_min_frozen() -> f64 { /// (#FEFFFF; #808081 ≈ 1.5e-3), f64-шум конвейера sRGB→Oklab ≲ 1e-12; /// 1e-7 лежит между ними с запасом ≥4 порядка в обе стороны — не может /// переклассифицировать ни один представимый цвет. -// GROUNDED — арифметика представимости: мин. 8-бит хрома `1.06e-3` ≫ ε ≫ f64-шум `1e-12` (docs/empirical-inventory.md). +// SSOT-TRACKED — арифметика представимости (деривация закрыта, внешнего стандарта не существует): границы в docs/empirical-inventory.md. pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; /// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом @@ -647,9 +647,10 @@ pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; /// /// # Errors /// -/// `Err`, если якорь невалиден, легальный оттенок геометрически пуст -/// (см. [`resolve_smooth_hue_explicit`]) или порог `s_perc_min` не конечен -/// (см. [`s_min_deg_from_chord`]). +/// `Err`, если якорь невалиден, `brand_hue`/`hue_floor` вне домена (конечный +/// угол; пол — в `[0, 360)`, те же пределы, что у конфиг-валидатора), +/// легальный оттенок геометрически пуст (см. [`resolve_smooth_hue_explicit`]) +/// или порог `s_perc_min` не конечен (см. [`s_min_deg_from_chord`]). pub fn resolve_config_sentiment_solid( family_anchor_hex: &str, brand_hue: f64, @@ -662,6 +663,18 @@ pub fn resolve_config_sentiment_solid( let _ = chroma_fraction; // хрома тинта = хрома якоря (сохраняем солид якоря); // chroma_fraction — ручка рампы SentimentCurve, не тинта; принимается для // единообразия сигнатуры конфига, но тинт держит фактическую хрому якоря. + // + // Публичная граница: конфиг-путь передаёт сюда уже валидированные углы, но + // прямой вызов мог бы протащить NaN в сатурированную ветку мимо солвера — + // прямо в oklab_lc_to_hex, тихим неверным hex. + if !brand_hue.is_finite() { + return Err(format!("brand_hue вне домена (конечный угол): {brand_hue}")); + } + if let Some(floor) = hue_floor + && !(floor.is_finite() && (0.0..360.0).contains(&floor)) + { + return Err(format!("hue_floor вне домена [0, 360): {floor}")); + } let anchor_lab = srgb_linear_to_oklab(srgb_from_hex(family_anchor_hex)?); let l_anchor = anchor_lab[0]; let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt(); @@ -685,14 +698,21 @@ pub fn resolve_config_sentiment_solid( // Сатурация порога (180° ⇔ chord ≥ 2C): требуемая хорда недостижима ни // одним углом — ограничение вырождено, ответ аналитический: максимум // разведения на легальной дуге («максимально приближенный приемлемый», - // не отказ). Пол, блокирующий диаметраль, даёт границу пола — ближайшую - // легальную точку к максимуму; супремум у открытого конца дуги (360⁻ при - // поле у верха круга и бренде напротив) сознательно не берётся: границе - // пола отдан детерминизм в вырожденной конфигурации. + // не отказ). Пол, блокирующий диаметраль: легальная дуга [floor, 360°) — + // берётся её граница с БОЛЬШИМ разведением от бренда (не всегда floor); + // верхняя граница открыта, берётся достижимая точка 360⁻ на суб-LSB + // отступе (1e-9° много ниже углового разрешения 8-бит квантования hex). if effective_s_min >= 180.0 { let diametric = normalize_hue(brand_hue + 180.0); let resolved = match hue_floor { - Some(floor) if diametric < floor => floor, + Some(floor) if diametric < floor => { + let upper = 360.0 - 1e-9; + if angular_distance(upper, brand_hue) > angular_distance(floor, brand_hue) { + upper + } else { + floor + } + } _ => diametric, }; return Ok(oklab_lc_to_hex(l_anchor, c_anchor, resolved)); @@ -1229,15 +1249,54 @@ mod tests { ); // Пол, блокирующий диаметраль (brand 100° → диаметраль 280° < 300°): - // граница пола, не отказ и не нарушение пола. + // разведение у floor=300 (160°) больше, чем у 360⁻ (100°) → floor. let floored = resolve_config_sentiment_solid(anchor, brand_hue, 4.0, 1.0, Some(300.0), 1.0, 1.0) .expect("пол при сатурации — не отказ"); assert_eq!( floored, oklab_lc_to_hex(l, c, 300.0), - "при полу 300° сатурация садится на границу пола" + "при полу 300° максимум разведения — граница пола" ); + + // Зеркальный корпус: brand 185° → диаметраль 5° < пола 350°; разведение + // у 360⁻ (175°) БОЛЬШЕ, чем у floor=350 (165°) → верхняя граница дуги. + let upper = resolve_config_sentiment_solid(anchor, 185.0, 4.0, 1.0, Some(350.0), 1.0, 1.0) + .expect("пол при сатурации — не отказ"); + assert_eq!( + upper, + oklab_lc_to_hex(l, c, 360.0 - 1e-9), + "максимум разведения на дуге [350,360) — верхняя граница, не floor" + ); + } + + /// Публичная граница `resolve_config_sentiment_solid`: неконечный + /// `brand_hue` и недоменный `hue_floor` — честный Err, не тихий hex + /// (в сатурированной ветке NaN уходил бы прямо в oklab_lc_to_hex). + #[test] + fn config_sentiment_public_boundary_rejects_garbage_angles() { + for bad_brand in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert!( + resolve_config_sentiment_solid("#FF3B30", bad_brand, 4.0, 1.0, None, 1.0, 0.06) + .is_err(), + "brand_hue={bad_brand} обязан быть отвергнут" + ); + } + for bad_floor in [f64::NAN, -1.0, 360.0, f64::INFINITY] { + assert!( + resolve_config_sentiment_solid( + "#FF3B30", + 28.0, + 4.0, + 1.0, + Some(bad_floor), + 1.0, + 0.06 + ) + .is_err(), + "hue_floor={bad_floor} обязан быть отвергнут" + ); + } } /// Ахроматичный якорь семейства не несёт оттенка: разведение отключается diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index 8b8a3a04..d2845609 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -47,7 +47,7 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 35 | `IC_DECORATIVE_FLOOR_MIN` | `15.0` | `semantic.rs` | SSOT-TRACKED | Increased-contrast decorative floor (Lc), raised above `DECORATIVE_FLOOR_MIN` (7.5) to match the stronger perceptual requirements of the `-ic` themes. | | 36 | `HUE_SEARCH_HALF_WINDOW` | `30.0` | `scale.rs` | SSOT-TRACKED | Half-width (degrees) of the hue search window in `find_optimal_hue`; 30° spans the typical sRGB gamut ridge width around the canonical hue. | | 37 | `HUE_DRIFT_PENALTY_SLOPE` | `0.15` | `scale.rs` | SSOT-TRACKED | Наклон штрафа дрейфа оттенка в `find_optimal_hue` (`penalty_scale = slope/half_window`, `score = c − penalty_scale·drift`): баланс «максимум хромы против ухода от канонического оттенка». Калибровочный; ранее жил незадекларированным поле-литералом (найден инвентарём конфиг-границы 2026-07-02). Кандидат науки: вывести или обосновать датасетом. | -| 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | GROUNDED | Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. Вывод из арифметики представимости, а не подгонка: минимум ненулевой Oklab-хромы 8-битного sRGB-цвета ≈ 1.06e-3 (`#FEFFFF`; `#808081` ≈ 1.49e-3; расчёт конвейером sRGB→Oklab, Ottosson 2020), накопленный f64-шум того же конвейера ≲ 1e-12; 1e-7 лежит между границами с запасом ≥4 порядка в обе стороны, поэтому не может переклассифицировать ни один представимый цвет. | +| 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | SSOT-TRACKED | Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. Вывод из арифметики представимости, а не подгонка: минимум ненулевой Oklab-хромы 8-битного sRGB-цвета ≈ 1.06e-3 (`#FEFFFF`; `#808081` ≈ 1.49e-3; расчёт конвейером sRGB→Oklab, Ottosson 2020), накопленный f64-шум того же конвейера ≲ 1e-12; 1e-7 лежит между границами с запасом ≥4 порядка в обе стороны, поэтому не может переклассифицировать ни один представимый цвет. Деривация ЗАКРЫТА; SSOT-TRACKED (а не GROUNDED) потому, что цитируемого внешнего стандарта для этой величины не существует — таблица и есть её носитель. | ## Muddiness Law constants — `cleanliness.rs` From adecf6b2cf43e2582040b7704540cfe53f485dcf Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 10:14:25 +0300 Subject: [PATCH 12/17] =?UTF-8?q?docs(s2b):=20=D1=81=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=D0=BD=D1=82=D0=B8=D0=BA=D0=B0=20=D0=B0=D0=BD=D0=BA=D0=BE=D1=80?= =?UTF-8?q?=D0=B0=20baseline-=D0=B3=D0=B0=D1=80=D0=B4=D0=B0=20=E2=80=94=20?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D1=80=D0=B5-=D0=B0=D0=BD=D0=BA=D0=BE=D1=80=D1=83=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20t2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Разовая правка залоченного r3_byte_identity.rs (арм Resolved::Rgba) была конверсией enum в #[non_exhaustive]: с ней r3 закрыт к будущим расширениям Resolved (wildcard-паника — новый вариант не требует правки файла, но не проходит golden молча). Лок гарда = «чисто против HEAD», не вечная заморозка: осознанная правка становится новым анкором через коммит + PR-гейт. Байт-дрейфа эмиссии нет: r3 240-cell golden + споты зелёные, nested-прогон baseline_r3_byte_identity_tests_pass — ok. --- crates/labcolors-core/tests/s2b_baseline_guards.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/labcolors-core/tests/s2b_baseline_guards.rs b/crates/labcolors-core/tests/s2b_baseline_guards.rs index 48cbcf07..708261ef 100644 --- a/crates/labcolors-core/tests/s2b_baseline_guards.rs +++ b/crates/labcolors-core/tests/s2b_baseline_guards.rs @@ -256,6 +256,16 @@ fn run_cargo_check(args: &[&str]) -> (bool, String) { /// /// GREEN at birth (characterization). Bites on mutation: alter any byte in /// either file → the hash changes → assertion fails. +/// +/// Семантика анкора (решение владельца s2b, 2026-07-02, ре-анкор после t2 +/// ds-config-train): лок = «чисто против HEAD», НЕ вечная заморозка — ловит +/// незакоммиченный дрейф в течение сессии; осознанная правка проходит через +/// коммит + PR-гейт (CI + ревью) и СТАНОВИТСЯ новым анкором. Разовая правка +/// r3 при t2 (арм `Resolved::Rgba`) — конверсия enum в `#[non_exhaustive]`; +/// с тех пор r3 закрыт к расширениям `Resolved` (wildcard-паника в matсh — +/// будущий вариант НЕ требует правки файла, но не проходит golden молча). +/// Байт-дрейфа эмиссии при ре-анкоре не было: r3-тесты зелёные (240-cell +/// golden + споты), включая nested-прогон `baseline_r3_byte_identity_tests_pass`. #[test] fn baseline_r3_and_empirical_inventory_files_are_git_clean() { // Both files must show no modification in `git status`. From 9ff398dae36e823092b013385d5eb96a3b1d06d9 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 10:26:07 +0300 Subject: [PATCH 13/17] =?UTF-8?q?docs(comments):=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=E2=80=94=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=C2=AB=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B5=D0=BC=D1=83=C2=BB=20=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B5,?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=BE=D1=86=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=BD=D0=BE=D0=B9=20=D0=BD=D0=B0=D1=80=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ссылки на раунды ревью, задачи эпика (t1/t2/t3, №г/№д), «решение владельца» и даты процессов — разговор с ревьюером, а не ограничение кода: читателю пакета они не дают ничего и выглядят чужеродно. Каждый такой комментарий переписан до сухого «почему» (само основание везде сохранено); провенанс данных (даты замеров Figma/стаба) оставлен — он отвечает «с какого снимка истины взято значение». Ссылка на локальный путь эпика вне репо заменена самими источниками (Figma-файл + reference/ + стаб labui). Сообщение об ошибке NotYetImplemented больше не отсылает к внутренней задаче. --- crates/labcolors-core/src/config.rs | 48 +++++++++--------- crates/labcolors-core/src/config/tests.rs | 49 +++++++++---------- crates/labcolors-core/src/ladder.rs | 14 +++--- crates/labcolors-core/src/sentiment.rs | 6 +-- crates/labcolors-core/src/solve.rs | 2 +- .../labcolors-core/tests/r3_byte_identity.rs | 2 +- .../tests/s2b_baseline_guards.rs | 14 ++---- crates/labcolors-wasm/src/engine.rs | 6 +-- crates/labcolors-wasm/tests/wasm_parity.rs | 2 +- 9 files changed, 69 insertions(+), 74 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index cbdfdb9c..8703bb52 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -10,10 +10,10 @@ //! [`RoleChroma`], [`DjMagnitude`], [`TextAnchor`], [`NamedRoleTable`]), а ядро //! про конфиг не знает ничего. //! -//! # Что этот модуль делает (CH-02 t1+t2) +//! # Что этот модуль делает //! //! - Несёт типы конфига без сериализации ([`ThemeConfig`] и вложенные) — JSON-парсинг -//! это отдельная задача границы WASM (t3), не ядро. +//! это забота границы WASM, не ядра. //! - [`ThemeConfig::validate`] проверяет пределы КАЖДОЙ экспонируемой ручки: значение //! вне предела возвращает [`ConfigError`], а не тихо принимается. Клиент не может //! молча сломать различимость или WCAG-полы. @@ -21,7 +21,7 @@ //! которую [`crate::semantic::resolve_named_set`] резолвит той же физикой, что и //! встроенную [`crate::RoleTable`]. //! -//! # Рецепты лестницы и альфа-аналога (CH-02 t2) +//! # Рецепты лестницы и альфа-аналога //! //! [`RoleRecipe::Ladder`] (акцентная/сентимент/бренд-лестница, поглощает GAP #59) //! компилируется в [`RoleSpec::Ladder`]: источник раскладывается в пер-темный @@ -203,8 +203,8 @@ pub enum ConfigError { bound: &'static str, }, /// Рецепт объявлен в меню, но его компиляция ещё не реализована — честная - /// заглушка для БУДУЩИХ рецептов (в t2 все текущие рецепты компилируются; - /// вариант сохранён как сеам для расширения меню без ломающего изменения). + /// заглушка для БУДУЩИХ рецептов (все текущие компилируются; вариант + /// сохранён как сеам для расширения меню без ломающего изменения). NotYetImplemented { recipe: &'static str, role: String }, } @@ -261,7 +261,7 @@ impl std::fmt::Display for ConfigError { } => write!(f, "ручка `{handle}` = {value} вне предела: {bound}"), ConfigError::NotYetImplemented { recipe, role } => write!( f, - "рецепт `{recipe}` (роль `{role}`) ещё не реализован — задача t2" + "рецепт `{recipe}` (роль `{role}`) ещё не реализован ядром" ), } } @@ -270,7 +270,7 @@ impl std::fmt::Display for ConfigError { impl std::error::Error for ConfigError {} // ───────────────────────────────────────────────────────────────────────────── -// Типы конфига (без serde — JSON-парсинг это t3). +// Типы конфига (без serde — JSON-парсинг живёт на границе WASM). // ───────────────────────────────────────────────────────────────────────────── /// Бренд — вход, не роль: пер-темные якорные hex. Оттенок движок выводит физикой; @@ -413,8 +413,8 @@ pub struct ThemesConfig { /// Рецепт роли из ФИЗИЧЕСКОГО меню (типология из [`crate::semantic`]). /// -/// Все рецепты компилируются в [`RoleSpec`]: текст/dJ'/Lc/zero (t1) и -/// [`Ladder`](Self::Ladder) / [`AlphaAnalog`](Self::AlphaAnalog) (t2, rgba-эмиссия). +/// Все рецепты компилируются в [`RoleSpec`]: текст/dJ'/Lc/zero — солвер-роли, +/// [`Ladder`](Self::Ladder) / [`AlphaAnalog`](Self::AlphaAnalog) — rgba-эмиссия. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum RoleRecipe { @@ -509,7 +509,7 @@ pub enum NeutralPick { Dark, } -/// Полный конфиг темы потребителя (без сериализации — t3). +/// Полный конфиг темы потребителя (без сериализации — она на границе WASM). #[derive(Debug, Clone, PartialEq)] pub struct ThemeConfig { /// Бренд-вход. @@ -939,7 +939,7 @@ impl ThemeConfig { /// эта компиляция с отброшенным результатом — НЕ вызывать её отсюда: /// рекурсия). [`Ladder`](RoleRecipe::Ladder) раскладывает источник в /// пер-темный тинт, [`AlphaAnalog`](RoleRecipe::AlphaAnalog) — солид-цель - /// источника + альфа (t2). + /// источника + альфа. pub fn compile_named_role_table(&self) -> Result { self.validate_syntactic()?; @@ -953,8 +953,8 @@ impl ThemeConfig { // hue_override (labui несёт измеренную SSOT-величину 286.0°), иначе — // деривация из ТЁМНОГО якоря нейтрали клиента (NEUTRAL_HUE_DEG сам был // измерен по #101012 → 285.97°; labui-константа для чужой нейтрали была - // бы чужим подтоном — дефект агностичности, CodeRabbit р-3). `ratio` в - // v2-кривую не входит (поле v1 flat-пути), но валидируется как ручка. + // бы чужим подтоном — дефект агностичности). `ratio` в v2-кривую не + // входит (поле v1 flat-пути), но валидируется как ручка. let canonical_hue_deg = match self.neutral.tint.hue_override_deg { Some(hue) => hue, None => { @@ -1013,8 +1013,8 @@ impl ThemeConfig { /// - [`LadderSource::Brand`] / [`LadderSource::Family`]: сырая пер-темная /// четвёрка якорей (эмитится напрямую как `rgba`). /// - [`LadderSource::Sentiment`]: пер-темный СОЛИД, чей оттенок разведён с - /// брендом сентимент-солвером (`crate::sentiment`, поправка t2 №г); - /// светлота/хрома — исходного якоря семейства категории. + /// брендом сентимент-солвером (`crate::sentiment`); светлота/хрома — + /// исходного якоря семейства категории. fn compile_ladder_tint( &self, role: &str, @@ -1093,8 +1093,8 @@ impl ThemeConfig { /// Пер-темный сентимент-солид: для каждой темы взять якорь семейства /// категории, развести оттенок с пер-темным брендом сентимент-солвером, - /// сохранив светлоту/хрому якоря. `S_PERC_MIN` — пересчёт из хром 4 якорей - /// сентиментов конфига (поправка t2 №д). + /// сохранив светлоту/хрому якоря. `S_PERC_MIN` — пересчёт из хром якорей + /// сентиментов конфига (закон не зависит от labui-констант). fn compile_sentiment_tint(&self, role: &str, name: &str) -> Result { let cat = self .sentiments @@ -1156,8 +1156,8 @@ impl ThemeConfig { } /// `S_PERC_MIN`, пересчитанный из Oklab-хром светлых якорей 4 (или скольких - /// есть) сентимент-категорий конфига — закон `2·C_rep·sin(20°/2)` (поправка - /// t2 №д). При labui-якорях == замороженная константа (тест-идентичность). + /// есть) сентимент-категорий конфига — закон `2·C_rep·sin(20°/2)`. + /// При labui-якорях == замороженная константа (тест-идентичность). /// # Errors /// /// `Err`, если категория ссылается на несуществующее семейство или якорь @@ -1192,10 +1192,10 @@ impl ThemeConfig { // ───────────────────────────────────────────────────────────────────────────── /// КАНОНИЧЕСКИЙ конфиг labui (не тестовая фикстура): значения = замеры -/// Figma/стаба; публичен намеренно — до PR-b это единственный носитель -/// labui-семантики у движка; в PR-b переезжает данными пакета @labpics/colors. +/// Figma/стаба; публичен намеренно — до переезда данными пакета +/// `@labpics/colors` это единственный носитель labui-семантики у движка. /// -/// Эталонный конфиг labui (CH-02 t1+t2) — 20 нейтральных ролей ядра (байт-в-байт +/// Эталонный конфиг labui — 20 нейтральных ролей ядра (байт-в-байт /// с [`RoleTable::default`](crate::RoleTable)) плюс акцент/сентимент/FX/альфа-роли /// лестницы и алиасы — полное покрытие consumedRoles labui-контракта. /// @@ -1207,7 +1207,7 @@ impl ThemeConfig { /// /// Байт-в-байт тест доказывает: [`resolve_named_set`](crate::semantic::resolve_named_set) /// этой фикстуры эмитит идентично [`resolve_set`](crate::resolve_set) дефолтной -/// таблицы на всех 240 точках golden-грида. Акценты/сентименты/альфа/ladder — t2. +/// таблицы на всех 240 точках golden-грида (лестница/альфа — сверх этой таблицы). pub fn labui_reference() -> ThemeConfig { // Фракции и полы — 1:1 из RoleTable::default (semantic.rs), включая border-strong // = контракт label-primary. Рецепты собраны так, чтобы имя роли совпадало с @@ -1271,7 +1271,7 @@ pub fn labui_reference() -> ThemeConfig { ("none".to_string(), RoleRecipe::Zero), ]; - // ── Акцентная/сентимент/FX/альфа-лестница (t2, поглощает GAP #59) ────────── + // ── Акцентная/сентимент/FX/альфа-лестница (поглощает GAP #59) ───────────── // Имена = consumedRoles labui (roles.json) без префикса `--lab-`, минус // удаляемые по коллапсу (static-*/inverted-*/on-*/material-*, роли-от-фона). // Каждая семья (brand + 4 сентимента) несёт label×4 · fill×4 · border(strong/ diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index a47adef0..55cf4d03 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1,12 +1,12 @@ -//! Тесты границы конфига (CH-02 t1): +//! Тесты границы конфига: //! 1. Байт-в-байт: `resolve_named_set(labui_reference)` эмитит идентично //! `resolve_set(RoleTable::default)` по всем 240 точкам golden-грида. //! 2. RED-proof байт-в-байт: мутация одного рецепта фикстуры роняет тест. //! 3. Валидатор: за-предельное значение КАЖДОЙ ручки даёт `ConfigError` + //! RED-proof мутацией предела (валидный vs невалидный на границе). -//! 4. t2: Ladder/AlphaAnalog компилируются в rgba-специи; diff=пусто против -//! consumedRoles; S_PERC_MIN-идентичность; значенческая сверка со стабом -//! labui (light+dark) + RED-proof мутаций. +//! 4. Лестница/альфа: Ladder/AlphaAnalog компилируются в rgba-специи; +//! diff=пусто против consumedRoles; S_PERC_MIN-идентичность; значенческая +//! сверка со стабом labui (light+dark) + RED-proof мутаций. use super::*; use crate::ladder::LadderPosition; @@ -58,8 +58,8 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { .compile_named_role_table() .expect("эталонная фикстура labui обязана компилироваться"); - // Фикстура t2 несёт 20 сегодняшних ролей ПЛЮС акцентную/сентимент/FX/альфа - // лестницу (t2). Байт-в-байт гарантия — на 20 СЕГОДНЯШНИХ ролях (имена = + // Фикстура несёт 20 core-ролей ПЛЮС акцентную/сентимент/FX/альфа + // лестницу. Байт-в-байт гарантия — на 20 CORE-ролях (имена = // Role::key()): именно их пинит owner-approved golden. Проверяем, что каждая // из 20 присутствует и эмитит идентично дефолтной таблице на всех точках. let core_keys: Vec<&'static str> = Role::ALL.iter().map(|r| r.key()).collect(); @@ -377,8 +377,8 @@ fn hue_floor_out_of_range_is_rejected() { // 359.999 проходит проверку ДИАПАЗОНА (не OutOfBounds), но полный // preflight честно ловит деривационную коллизию: такой пол исключает // почти весь круг (`h < f` нелегален) — легальная дуга сентимента пуста. - // Старый ассерт `Ok` был ровно тем ложноположительным preflight-ом, - // который закрыт р-6 (validate = компиляция по построению). + // Ассерт `Ok` здесь был бы ложноположительным preflight-ом (validate = + // компиляция по построению, деривационные ошибки видит). let mut hi = labui_reference(); hi.sentiments.categories[1].hue_floor_deg = Some(359.999); assert!( @@ -452,12 +452,12 @@ fn alias_to_missing_role_is_rejected() { } // ───────────────────────────────────────────────────────────────────────────── -// 4. Честные заглушки t2. +// 4. Честные заглушки нереализованных рецептов. // ───────────────────────────────────────────────────────────────────────────── #[test] fn ladder_recipe_compiles_to_rgba_spec() { - // t2: Ladder больше не заглушка — компилируется в RoleSpec::Ladder. + // Ladder — не заглушка: компилируется в RoleSpec::Ladder. let cfg = with_role_recipe( "fill-primary", RoleRecipe::Ladder { @@ -550,7 +550,7 @@ fn config_error_display_is_russian_and_informative() { } // ───────────────────────────────────────────────────────────────────────────── -// t2: diff=пусто против consumedRoles labui. +// diff=пусто против consumedRoles labui. // ───────────────────────────────────────────────────────────────────────────── /// Полный контракт `--lab-*` labui из `packages/colors-stub/roles.json` @@ -561,7 +561,7 @@ fn config_error_display_is_russian_and_informative() { /// Имена без префикса `--lab-`. IC-режимы зарезервированы (в roles.json не /// перечислены), поэтому и здесь их нет. /// -/// Компромисс t2: это ЗЕРКАЛО roles.json, не живой файл. Класс дрейфа зеркала +/// Компромисс: это ЗЕРКАЛО roles.json, не живой файл. Класс дрейфа зеркала /// закрывается гардами поезда labui (consumed-contract против живой эмиссии) — /// там diff проверяется против фактического потребления, не против копии. const LABUI_CONSUMED_ROLES: &[&str] = &[ @@ -706,7 +706,7 @@ const COLLAPSED_ROLES: &[(&str, &str)] = &[ ]; /// diff = ПУСТО: каждая consumedRole labui (минус удаляемые по коллапсу) -/// эмитируется фикстурой. Это несущий тест t2 — поглощение акцентного GAP #59. +/// эмитируется фикстурой. Несущий тест поглощения акцентного GAP #59. /// /// Удаляемые перечислены явно с причиной ([`COLLAPSED_ROLES`]) — тест не «прощает» /// их молча, а декларирует, ПОЧЕМУ они не эмитируются (материал=флаг, роль от @@ -790,13 +790,13 @@ fn matches_collapsed_pattern(name: &str, pattern: &str) -> bool { } // ───────────────────────────────────────────────────────────────────────────── -// t2 №д: S_PERC_MIN — деривационная идентичность из конфиг-якорей. +// S_PERC_MIN — деривационная идентичность из конфиг-якорей. // ───────────────────────────────────────────────────────────────────────────── /// `S_PERC_MIN`, пересчитанный из хром 4 сентимент-якорей labui, совпадает с /// замороженной константой (`0.068_703_9`, допуск 1e-4) — закон /// `2·C_rep·sin(20°/2)` остаётся законом, сегодняшнее значение — его частный -/// случай при labui-якорях (поправка t2 №д). +/// случай при labui-якорях. #[test] fn s_perc_min_recomputed_from_config_anchors_matches_frozen() { let recomputed = labui_reference() @@ -839,11 +839,11 @@ fn s_perc_min_recompute_bites_on_anchor_mutation() { } // ───────────────────────────────────────────────────────────────────────────── -// t2 №г: сентимент — деривационная идентичность (тинт == сырой якорь при +// Сентимент — деривационная идентичность (тинт == сырой якорь при // labui-бренде). // ───────────────────────────────────────────────────────────────────────────── -/// Деривационная идентичность (поправка t2 №г): при бренде labui сентимент-тинт +/// Деривационная идентичность: при бренде labui сентимент-тинт /// совпадает с СЫРЫМ якорем семейства (по всем 4 темам) для сентиментов, /// ОТСТОЯЩИХ от бренда дальше перцептивного порога `s_min`. /// @@ -931,7 +931,7 @@ fn info_is_displaced_from_blue_brand_by_design() { } // ───────────────────────────────────────────────────────────────────────────── -// t2: rgba-эмиссия + RED-proof мутаций (позиция/семейство/альфа → RED). +// rgba-эмиссия + RED-proof мутаций (позиция/семейство/альфа → RED). // ───────────────────────────────────────────────────────────────────────────── /// Резолв Ladder-роли несёт rgba(тинт, α) + солид-композит на фоне резолва. @@ -1116,7 +1116,7 @@ fn alpha_analog_recipe_inverts_and_bites_on_alpha() { } // ───────────────────────────────────────────────────────────────────────────── -// t2 (класс «имена без значений»): значенческий тест фикстуры против стаба. +// Класс «имена без значений»: значенческий тест фикстуры против стаба. // // Класс дефекта: роль присутствует в diff-тесте по ИМЕНИ, но эмитит НЕ ТО // значение (напр. нейтральный skeleton, ошибочно взятый из семейства blue). @@ -1127,8 +1127,7 @@ fn alpha_analog_recipe_inverts_and_bites_on_alpha() { /// Нормализовать [`Resolved::Rgba`] в пару (rgb-строка, α): rgb сверяется со /// стабом ПОБАЙТОВО, α — числом с допуском (Display-сравнение f64 хрупко: /// хвост вида 0.07800000000000001 после будущего рефакторинга формулы уронил -/// бы тест по ФОРМАТУ, маскируя семантику — класс «строковое сравнение -/// плавающей точки», CodeRabbit р-7). +/// бы тест по ФОРМАТУ, маскируя семантику). fn rgba_to_parts(res: &Resolved) -> (String, f64) { let r = res .rgba() @@ -1269,8 +1268,8 @@ fn value_test_bites_on_alpha_mutation() { ); } -/// Валидатор CodeRabbit-раунда: дубликаты ключей всех словарей отвергаются -/// (повтор имени = неоднозначный lookup), включая алиас, затеняющий роль. +/// Дубликаты ключей всех словарей отвергаются (повтор имени = неоднозначный +/// lookup), включая алиас, затеняющий роль. #[test] fn validator_rejects_duplicate_dictionary_keys() { let mut c = labui_reference(); @@ -1381,7 +1380,7 @@ fn ic_inherits_base_theme_alpha() { } /// Алиасы переносятся в скомпилированную таблицу — без переноса алиасные роли -/// контракта терялись бы при эмиссии (major CodeRabbit r2). +/// контракта терялись бы при эмиссии. #[test] fn compiled_table_carries_aliases() { let table = labui_reference() @@ -1486,7 +1485,7 @@ fn missing_neutral_quads_are_rejected() { /// `validate()` — полный preflight ПО ПОСТРОЕНИЮ (компиляция с отброшенным /// результатом): для любого конфига validate и compile дают одинаковый исход /// и байт-в-байт одинаковую ошибку. Корпус — деривационные ошибки, которые -/// структурная фаза не видит (класс р-6: ложноположительный `Ok` preflight-а). +/// структурная фаза не видит (иначе `Ok` preflight-а был бы ложноположительным). #[test] fn validate_is_a_complete_preflight() { let ok = labui_reference(); diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index b3b4d33a..d5e0cc9f 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -1,7 +1,7 @@ //! Лестница акцента/сентимента/бренда/нейтрали как ДАННЫЕ: закрытое меню позиций //! (каждая несёт свою альфу Figma-рампы) + физика тинта источника по теме. //! -//! # Закон лестницы (заземление 2026-07-02) +//! # Закон лестницы //! //! Акцентная лестница labui устроена КАК нейтральная: **один тинт (якорный цвет //! источника, пер-темно) × закрытая рампа альф** — Figma-переменная @@ -12,9 +12,9 @@ //! Солид-эквивалент (композит тинта на фоне резолва, //! [`crate::alpha::composite_over_encoded`]) — для честного замера dJ'/WCAG //! контраст-корректности на подложке (фаза 1 AA: контраст меряется на композите). -//! Заземление: `reference/labui-accent-primitives.md` §2 (пер-темные якоря), -//! стаб labui `packages/colors-stub/contract.css` (@NN-рампа), -//! `.agents/epics/ds-config-train/chapters/ch02-engine-config-input/grounding-accent-roles-2026-07-02.md`. +//! Заземление: Figma «🧪Lab UI (v.1)» (переменные `Accent/Derivable/*`, обход +//! через figma-console, 2026-07-02), `reference/labui-accent-primitives.md` §2 +//! (пер-темные якоря), стаб labui `packages/colors-stub/contract.css` (@NN-рампа). //! //! # Провенанс альф позиций //! @@ -22,8 +22,8 @@ //! переменных), не выведенные величины: `@72 → 0.722`, `@52 → 0.522`, //! `@32 → 0.322`, `@20 → 0.2`, `@12 → 0.122`, `@8 → 0.078`, `@4 → 0.039`, //! `@2 → 0.02`; `primary`/`border-strong`/`focus-ring` — солид (α = 1.0). -//! Единый паттерн проверен на brand/danger/info/success (grounding §Закон -//! лестницы). Это данные позиций, а не POLICY-константы перцептивных модулей, +//! Единый паттерн проверен на brand/danger/info/success (см. «Закон лестницы» +//! выше). Это данные позиций, а не POLICY-константы перцептивных модулей, //! поэтому провенанс держится этой doc-строкой + тестом лестницы, а не строкой //! реестра (как якорные hex палитры, `accent.rs`). @@ -250,7 +250,7 @@ impl LadderPosition { if vc.is_dark_theme() { dark } else { light } } - /// Стабильный kebab-ключ позиции — для разбора рецепта из конфига (t3 JSON) + /// Стабильный kebab-ключ позиции — для разбора рецепта из JSON-конфига /// и для приложения A к ADR. Часть контракта имён; опечатка ловится тестом. pub fn key(self) -> &'static str { match self { diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index d13bbb79..d1a5ace7 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -594,7 +594,7 @@ fn angular_distance(a: f64, b: f64) -> f64 { /// Категориальный порог оттенка `S_PERC_MIN` (длина хорды Oklab a/b), /// пересчитанный из хром сентимент-якорей конфига по закону -/// `2·C_rep·sin(20°/2)`, где `C_rep` — среднее хром (поправка t2 №д). +/// `2·C_rep·sin(20°/2)`, где `C_rep` — среднее хром. /// /// `20°` — нижний предел категориального восприятия (Witzel & Gegenfurtner 2013, /// JOSA A 30(7):1501). При labui-якорях (хромы Red/Orange/Green/Blue) результат @@ -615,7 +615,7 @@ pub fn s_perc_min_from_chromas(chromas: &[f64]) -> f64 { 2.0 * c_rep * (20.0_f64.to_radians() / 2.0).sin() } -/// Замороженное значение `S_PERC_MIN` (для деривационной идентичности теста t2). +/// Замороженное значение `S_PERC_MIN` (для теста деривационной идентичности). /// Возвращается функцией (не `const`), чтобы не заводить второй POLICY-литерал в /// аудите реестра — это тот же derivation-identity, что [`S_PERC_MIN`]. pub fn s_perc_min_frozen() -> f64 { @@ -638,7 +638,7 @@ pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; /// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом /// сентимент-солвером, при СОХРАНЁННЫХ светлоте и хроме якоря. /// -/// Тинт лестницы сентимента (поправка t2 №г): берётся оттенок семейства, +/// Тинт лестницы сентимента: берётся оттенок семейства, /// смещённый от бренда через [`resolve_smooth_hue_explicit`] (тот же C¹-солвер, /// что у [`SentimentCurve`]), но светлота/хрома — исходного якоря. Когда /// смещение не нужно (`resolved_hue == prototype`, случай labui-бренда), солид diff --git a/crates/labcolors-core/src/solve.rs b/crates/labcolors-core/src/solve.rs index 990d75aa..84a34045 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -2167,7 +2167,7 @@ mod tests { for (bg, target) in [("#FFFFFF", t), ("#000000", -t)] { if let Ok((solved, measured)) = solve_and_measure(bg, target, &vc) { checked += 1; - // Symmetric budget — this is the guard CodeRabbit flagged: a + // Symmetric budget — the guard this protects: a // one-sided "not below floor" check would let an overshoot in. assert!( (measured - target).abs() <= TOL, diff --git a/crates/labcolors-core/tests/r3_byte_identity.rs b/crates/labcolors-core/tests/r3_byte_identity.rs index 1c422b56..2f1dfb45 100644 --- a/crates/labcolors-core/tests/r3_byte_identity.rs +++ b/crates/labcolors-core/tests/r3_byte_identity.rs @@ -193,7 +193,7 @@ fn r3_resolve_set_240_cell_representative_byte_identity() { Resolved::Unreachable(_) => "UNREACHABLE".to_string(), // Дефолтная `RoleTable` (Role-путь) не несёт Ladder/AlphaAnalog- // рецептов, поэтому rgba-роль здесь недостижима; арм обязателен - // из-за `#[non_exhaustive] Resolved` (t2 добавил вариант Rgba). + // из-за `#[non_exhaustive] Resolved`. Resolved::Rgba(_) => "RGBA".to_string(), // Будущий вариант Resolved не должен молча пройти golden: паника // делает его видимым (обязан быть переучтён вместе с golden). diff --git a/crates/labcolors-core/tests/s2b_baseline_guards.rs b/crates/labcolors-core/tests/s2b_baseline_guards.rs index 708261ef..6c207692 100644 --- a/crates/labcolors-core/tests/s2b_baseline_guards.rs +++ b/crates/labcolors-core/tests/s2b_baseline_guards.rs @@ -257,15 +257,11 @@ fn run_cargo_check(args: &[&str]) -> (bool, String) { /// GREEN at birth (characterization). Bites on mutation: alter any byte in /// either file → the hash changes → assertion fails. /// -/// Семантика анкора (решение владельца s2b, 2026-07-02, ре-анкор после t2 -/// ds-config-train): лок = «чисто против HEAD», НЕ вечная заморозка — ловит -/// незакоммиченный дрейф в течение сессии; осознанная правка проходит через -/// коммит + PR-гейт (CI + ревью) и СТАНОВИТСЯ новым анкором. Разовая правка -/// r3 при t2 (арм `Resolved::Rgba`) — конверсия enum в `#[non_exhaustive]`; -/// с тех пор r3 закрыт к расширениям `Resolved` (wildcard-паника в matсh — -/// будущий вариант НЕ требует правки файла, но не проходит golden молча). -/// Байт-дрейфа эмиссии при ре-анкоре не было: r3-тесты зелёные (240-cell -/// golden + споты), включая nested-прогон `baseline_r3_byte_identity_tests_pass`. +/// Семантика анкора: лок = «чисто против HEAD», НЕ вечная заморозка — ловит +/// незакоммиченный дрейф в течение сессии; осознанная правка проходит коммит + +/// PR-гейт (CI + ревью) и СТАНОВИТСЯ новым анкором. r3 закрыт к расширениям +/// enum `Resolved` (`#[non_exhaustive]` + wildcard-паника в матче r3: новый +/// вариант не требует правки залоченного файла, но не проходит golden молча). #[test] fn baseline_r3_and_empirical_inventory_files_are_git_clean() { // Both files must show no modification in `git status`. diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 3f732ddb..34ae8d4e 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -140,7 +140,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { // конфиг-пути (`resolve_named_set`), который ЭТА поверхность ещё не // экспортирует: `resolve_theme` идёт по встроенной `RoleTable`, где // Ladder/AlphaAnalog-рецептов нет, поэтому вариант здесь недостижим. - // rgba-форма границы WASM — задача t3; до неё маппим в стабильный код, + // rgba-форма границы WASM ещё не экспортирована; до неё маппим в стабильный код, // а не молчаливо роняем неверный цвет (`Resolved` теперь non_exhaustive). Resolved::Rgba(_) => RoleOutcome::Unreachable { code: "rgba_boundary_not_yet_exported", @@ -148,9 +148,9 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { (config path, task t3)" .to_string(), }, - // ОСОЗНАННЫЙ ДОЛГ t3: `Resolved` — `#[non_exhaustive]`, поэтому catch-all + // ОСОЗНАННЫЙ ДОЛГ: `Resolved` — `#[non_exhaustive]`, поэтому catch-all // обязателен для будущих вариантов ядра. Пока маппит в стабильный код, - // а не молча роняет неверный цвет; при экспорте rgba-границы (t3) каждый + // а не молча роняет неверный цвет; при экспорте rgba-границы каждый // новый вариант должен получить явный арм выше, а не оседать сюда. _ => RoleOutcome::Unreachable { code: "unreachable", diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 073b5d02..9fcac2fe 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -81,7 +81,7 @@ fn resolve_theme_matches_native_resolve_set() { Resolved::Unreachable(_) => { assert_eq!(kind, "unreachable", "{} should be unreachable", role.key()); } - // Rgba в дефолт-таблице не встречается (rgba-граница — долг t3); + // Rgba в дефолт-таблице не встречается (rgba-граница WASM — открытый долг); // будущий вариант обязан быть переучтён здесь шумно, не замаскирован. other => panic!( "неучтённый Resolved-вариант в wasm-парити ({}): {other:?}", From 401cfe128d1b5190f41f01e29a8acdd9df2a5acb Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 10:33:21 +0300 Subject: [PATCH 14/17] =?UTF-8?q?docs(comments):=20=D0=B4=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BD=D0=B0=D1=80=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BE=D0=B1=20=D0=BE=D1=88=D0=B8?= =?UTF-8?q?=D0=B1=D0=BA=D0=B5=20WASM,=20=D0=B0=D1=82=D1=80=D0=B8=D0=B1?= =?UTF-8?q?=D1=83=D1=86=D0=B8=D0=B8,=20ADR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сообщение RoleOutcome::Unreachable у rgba-ролей несло внутренний код задачи — а это наблюдаемая поверхность API, не комментарий; заменено описанием причины (solid-only surface). Атрибуции решений (owner-провизион, owner-approved) и коды задач в заголовках ADR убраны — основание остаётся, происхождение решения читателю кода не нужно. Ссылка ADR на grounding-файл вне репо заменена самим источником (переменные Figma + путь обхода). --- crates/labcolors-core/src/config.rs | 4 ++-- crates/labcolors-core/src/config/tests.rs | 2 +- crates/labcolors-core/tests/s2b_baseline_guards.rs | 10 +++++----- crates/labcolors-wasm/src/engine.rs | 2 +- docs/decisions/0001-config-boundary.md | 8 ++++---- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 8703bb52..4f8e690e 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -1382,8 +1382,8 @@ pub fn labui_reference() -> ThemeConfig { "fill-accent".to_string(), brand_pos(LadderPosition::LabelPrimary), )); - // fill-neutral — солид-литерал PROVISIONAL стаба (нет engine-деривации); - // приближено Neutral(Mid) солид, исключено из точного value-теста (owner-провизион). + // fill-neutral — солид-литерал стаба без engine-деривации; приближен + // солидом Neutral(Mid) и потому исключён из точного value-теста. roles.push(( "fill-neutral".to_string(), neutral_pos(NeutralPick::Mid, LadderPosition::LabelPrimary), diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 55cf4d03..2f2e8c7b 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -60,7 +60,7 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { // Фикстура несёт 20 core-ролей ПЛЮС акцентную/сентимент/FX/альфа // лестницу. Байт-в-байт гарантия — на 20 CORE-ролях (имена = - // Role::key()): именно их пинит owner-approved golden. Проверяем, что каждая + // Role::key()): именно их пинит golden-грид. Проверяем, что каждая // из 20 присутствует и эмитит идентично дефолтной таблице на всех точках. let core_keys: Vec<&'static str> = Role::ALL.iter().map(|r| r.key()).collect(); for key in &core_keys { diff --git a/crates/labcolors-core/tests/s2b_baseline_guards.rs b/crates/labcolors-core/tests/s2b_baseline_guards.rs index 6c207692..9b8103cc 100644 --- a/crates/labcolors-core/tests/s2b_baseline_guards.rs +++ b/crates/labcolors-core/tests/s2b_baseline_guards.rs @@ -257,11 +257,11 @@ fn run_cargo_check(args: &[&str]) -> (bool, String) { /// GREEN at birth (characterization). Bites on mutation: alter any byte in /// either file → the hash changes → assertion fails. /// -/// Семантика анкора: лок = «чисто против HEAD», НЕ вечная заморозка — ловит -/// незакоммиченный дрейф в течение сессии; осознанная правка проходит коммит + -/// PR-гейт (CI + ревью) и СТАНОВИТСЯ новым анкором. r3 закрыт к расширениям -/// enum `Resolved` (`#[non_exhaustive]` + wildcard-паника в матче r3: новый -/// вариант не требует правки залоченного файла, но не проходит golden молча). +/// Семантика анкора: лок = «чисто против HEAD», не вечная заморозка — ловит +/// незакоммиченный дрейф; закоммиченное состояние и есть анкор. r3 закрыт к +/// расширениям enum `Resolved` (`#[non_exhaustive]` + wildcard-паника в матче +/// r3: новый вариант не требует правки залоченного файла, но не проходит +/// golden молча). #[test] fn baseline_r3_and_empirical_inventory_files_are_git_clean() { // Both files must show no modification in `git status`. diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 34ae8d4e..eab856f8 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -145,7 +145,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { Resolved::Rgba(_) => RoleOutcome::Unreachable { code: "rgba_boundary_not_yet_exported", message: "semi-transparent ladder/alpha-analog role is not exported by resolve_theme \ - (config path, task t3)" + (solid-only surface)" .to_string(), }, // ОСОЗНАННЫЙ ДОЛГ: `Resolved` — `#[non_exhaustive]`, поэтому catch-all diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index fe41253e..253d02b6 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -128,7 +128,7 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch альфа-якоря (@1/@2/@4/@12 через alpha_analog) — отдельное научное решение, вне поезда. -## Приложение A. Закрытое меню позиций лестницы (t2) +## Приложение A. Закрытое меню позиций лестницы Фиксирует `position` рецепта `ladder(source, position)` (объявлено в разделе API). Меню закрыто: движок эмитит `rgba(тинт, α)` НАПРЯМУЮ (закон лестницы @@ -141,8 +141,8 @@ labui — композитит браузер), где тинт = якорь и Снято 2026-07-02 из живого потребления labui: стаб `packages/colors-stub/ contract.css` (несёт Figma-значения дословно) + `reference/labui-accent- -primitives.md` §2 (пер-темные якоря) + grounding-документ главы -(`chapters/ch02-engine-config-input/grounding-accent-roles-2026-07-02.md`). +primitives.md` §2 (пер-темные якоря) + переменные Figma «🧪Lab UI (v.1)» +(`Accent/Derivable/*`, обход через figma-console). Акцентная лестница устроена КАК нейтральная: один тинт-якорь × закрытая рампа альф Figma `Accent/Derivable//@NN`. Единый паттерн проверен на brand/danger/info/success. @@ -208,7 +208,7 @@ dark (нормализованный формат). Закрывает клас `fill-neutral` — задокументированные пробелы (gap) (пер-темный нейтральный край / якоря инвертированных поверхностей / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`). -### Деривационная идентичность сентимента (честная находка t2) +### Деривационная идентичность сентимента При бренде labui (`#007AFF`, Oklab h≈257.4°) сентимент-тинт совпадает с СЫРЫМ якорем семейства для сентиментов, отстоящих от бренда дальше порога разделения: From a4a84a73e6abe652a12fd5471252d9e934fabbb0 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 10:48:22 +0300 Subject: [PATCH 15/17] =?UTF-8?q?fix(config):=20=D1=87=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BD=D0=B0=D1=8F=20=D1=82=D0=B0=D0=BA=D1=81=D0=BE=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D1=8F=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA=20?= =?UTF-8?q?=D1=81=D0=B5=D0=BD=D1=82=D0=B8=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?+=20=D0=B1=D1=80=D0=BE=D0=BD=D1=8F=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D1=8B=D1=85=20=D0=B2=D1=85=D0=BE=D0=B4=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ошибка сентимент-солвера (пустая легальная дуга, недоменные углы) наружу — собственным вариантом ConfigError::SentimentResolution, а не маской InvalidHex: потребитель матчится по вариантам, и ошибка политики/геометрии под видом ошибки парсинга hex ломала бы различение. Валидация hardness/s_perc_min перенесена ДО ахроматичного fast path — недоменная ручка не может «повезти» в зависимости от цвета якоря. Публичный resolve_smooth_hue_explicit получил домен-гард (конечные углы, s_min в [0,180], пол в [0,360)) — NaN в signed_delta/smooth_separation давал бы NaN-оттенок вниз по физике, а скан legalize_hue на NaN не завершается осмысленно. Отклонено с основанием: #[non_exhaustive] на RoleSpec — атрибут уже стоит (semantic.rs); придирка к GROUNDED-маркеру ACHROMATIC_CHROMA_EPS — маркер уже SSOT-TRACKED. Доки пары альф позиции уточнены: пару несёт каждая позиция, расходятся значения только у скелетон-базы. --- crates/labcolors-core/src/config.rs | 22 ++++++- crates/labcolors-core/src/config/tests.rs | 16 +++++ crates/labcolors-core/src/ladder.rs | 7 ++- crates/labcolors-core/src/sentiment.rs | 74 ++++++++++++++++++++++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 4f8e690e..63fc4fcd 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -202,6 +202,16 @@ pub enum ConfigError { value: f64, bound: &'static str, }, + /// Сентимент-солвер не смог развести оттенок (пустая легальная дуга, + /// недоменные углы/пороги). Отдельный вариант, а не + /// [`InvalidHex`](Self::InvalidHex): ошибка политики/геометрии, + /// замаскированная под ошибку парсинга hex, ломала бы матчинг + /// потребителя по вариантам. + SentimentResolution { + role: String, + sentiment: String, + reason: String, + }, /// Рецепт объявлен в меню, но его компиляция ещё не реализована — честная /// заглушка для БУДУЩИХ рецептов (все текущие компилируются; вариант /// сохранён как сеам для расширения меню без ломающего изменения). @@ -259,6 +269,11 @@ impl std::fmt::Display for ConfigError { value, bound, } => write!(f, "ручка `{handle}` = {value} вне предела: {bound}"), + ConfigError::SentimentResolution { + role, + sentiment, + reason, + } => write!(f, "сентимент `{sentiment}` (роль `{role}`): {reason}"), ConfigError::NotYetImplemented { recipe, role } => write!( f, "рецепт `{recipe}` (роль `{role}`) ещё не реализован ядром" @@ -1131,9 +1146,10 @@ impl ThemeConfig { cat.preferred_side.map_or(1.0, f64::from), s_perc_min, ) - .map_err(|reason| ConfigError::InvalidHex { - field: format!("roles.{role} (сентимент `{name}`): {reason}"), - value: anchor_hex.to_string(), + .map_err(|reason| ConfigError::SentimentResolution { + role: role.to_string(), + sentiment: name.to_string(), + reason, })?; crate::spaces::srgb::srgb_encoded_from_hex(&solid).map_err(|_| { ConfigError::InvalidHex { diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 2f2e8c7b..605d4c79 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -1578,6 +1578,22 @@ fn alpha_analog_spec_bypassing_validator_is_rejected() { } } +/// Ошибка сентимент-солвера наружу — СВОИМ вариантом, не [`ConfigError::InvalidHex`]: +/// потребитель матчится по вариантам, и ошибка политики/геометрии под маской +/// ошибки парсинга hex ломала бы это различение. Пустая легальная дуга +/// (пол 359.999 у категории) — ровно такой случай. +#[test] +fn sentiment_solver_errors_surface_as_their_own_variant() { + let mut c = labui_reference(); + c.sentiments.categories[1].hue_floor_deg = Some(359.999); + match c.compile_named_role_table() { + Err(ConfigError::SentimentResolution { sentiment, .. }) => { + assert_eq!(sentiment, c.sentiments.categories[1].name); + } + other => panic!("ждали SentimentResolution, получено {other:?}"), + } +} + /// Ахроматичные источники оттенка: серая нейтраль без override — ошибка; /// серый бренд — сентимент честно равен сырому якорю (разведение отключено). #[test] diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index d5e0cc9f..a27d4401 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -211,9 +211,10 @@ impl LadderPosition { /// стаба labui `packages/colors-stub/contract.css` (light-scope `[data-theme= /// "light"]` и dark-scope `[data-theme="dark"]`, снято 2026-07-02). /// - /// Для акцентных позиций пара РАВНА (свет и тьма несут одну альфу @NN, меняется - /// только тинт по теме — стаб dark = копия light-альфы). Скелетон-база — - /// ЕДИНСТВЕННАЯ пер-темная альфа: light `@8` (0.078) / dark `@12` (0.122). + /// Пару `(light, dark)` несёт КАЖДАЯ позиция; у акцентных обе альфы равны + /// (меняется только тинт по теме — стаб dark = копия light-альфы). + /// Скелетон-база — единственная позиция, где значения пары РАСХОДЯТСЯ: + /// light `@8` (0.078) / dark `@12` (0.122). /// IC-темы: стаб не несёт отдельных ic-скоупов, поэтому light-ic берёт /// light-альфу, dark-ic — dark-альфу ([`alpha_for_vc`](Self::alpha_for_vc)). /// diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index d1a5ace7..4dc75a26 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -472,7 +472,9 @@ fn resolve_smooth_hue( /// # Errors /// /// `Err` if no hue satisfies both the floor and the separation invariant -/// (empty legal arc) — never a silent breach. +/// (empty legal arc), or if any angular input is outside its domain +/// (non-finite `prototype`/`brand_hue`, `s_min` outside `[0, 180]`, +/// `hue_floor` outside `[0, 360)`) — never a silent breach. pub fn resolve_smooth_hue_explicit( preferred_side: f64, hue_floor: Option, @@ -481,6 +483,22 @@ pub fn resolve_smooth_hue_explicit( params: SentimentParams, s_min: f64, ) -> Result { + // Домен публичного входа: NaN/inf в signed_delta/smooth_separation дали бы + // NaN-оттенок вниз по физике, а скан legalize_hue на NaN не завершается + // осмысленно — честный Err вместо тихого мусора. + if !(prototype.is_finite() && brand_hue.is_finite()) { + return Err(format!( + "углы вне домена (конечные): prototype={prototype}, brand_hue={brand_hue}" + )); + } + if !(s_min.is_finite() && (0.0..=180.0).contains(&s_min)) { + return Err(format!("s_min вне домена [0, 180]: {s_min}")); + } + if let Some(floor) = hue_floor + && !(floor.is_finite() && (0.0..360.0).contains(&floor)) + { + return Err(format!("hue_floor вне домена [0, 360): {floor}")); + } // Signed shortest delta from prototype to brand. Its sign tells us which side // of the brand the prototype sits on; we push the resolved hue out along that // same side, away from the brand. @@ -675,6 +693,16 @@ pub fn resolve_config_sentiment_solid( { return Err(format!("hue_floor вне домена [0, 360): {floor}")); } + // Валидация ручек — ДО любого раннего возврата: ахроматичный fast path + // ниже не решает по hardness/s_perc_min, но недоменная ручка остаётся + // ошибкой вызова, а не «повезло с серым якорем» (иначе один и тот же + // мусорный вход то принимался бы, то отвергался в зависимости от цвета). + let params = SentimentParams::uniform(hardness)?; + if !(s_perc_min.is_finite() && s_perc_min >= 0.0) { + return Err(format!( + "s_perc_min вне домена (конечный неотрицательный): {s_perc_min}" + )); + } let anchor_lab = srgb_linear_to_oklab(srgb_from_hex(family_anchor_hex)?); let l_anchor = anchor_lab[0]; let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt(); @@ -693,7 +721,6 @@ pub fn resolve_config_sentiment_solid( // якорей клиента): подмешивание замороженного labui-S_PERC_MIN через // s_min_deg() завышало бы угол низкохромным палитрам чужим порогом // (могло опустошить legal arc) — закон обязан быть чистым по конфигу. - let params = SentimentParams::uniform(hardness)?; let effective_s_min = s_min_deg_from_chord(s_perc_min, c_anchor)?; // Сатурация порога (180° ⇔ chord ≥ 2C): требуемая хорда недостижима ни // одним углом — ограничение вырождено, ответ аналитический: максимум @@ -1301,10 +1328,53 @@ mod tests { /// Ахроматичный якорь семейства не несёт оттенка: разведение отключается /// честно — солид равен сырому якорю (тот же закон, что серый бренд). + /// Fast path НЕ обходит валидацию ручек: недоменные hardness/s_perc_min + /// отвергаются и на сером якоре — мусорный вход не может «повезти» + /// в зависимости от цвета. #[test] fn achromatic_family_anchor_returns_raw_anchor() { let solid = resolve_config_sentiment_solid("#808080", 28.0, 4.0, 1.0, None, 1.0, 0.06) .expect("серый якорь легален"); assert_eq!(solid, "#808080", "серый якорь возвращается байт-в-байт"); + + for (hardness, s_perc_min) in [ + (0.5, 0.06), + (f64::NAN, 0.06), + (4.0, f64::NAN), + (4.0, -0.01), + (4.0, f64::INFINITY), + ] { + assert!( + resolve_config_sentiment_solid( + "#808080", 28.0, hardness, 1.0, None, 1.0, s_perc_min + ) + .is_err(), + "(hardness={hardness}, s_perc_min={s_perc_min}) обязаны отвергаться и на сером" + ); + } + } + + /// Домен публичного `resolve_smooth_hue_explicit`: неконечные углы, + /// `s_min` вне [0, 180] и пол вне [0, 360) — честный Err, не NaN-оттенок. + #[test] + fn smooth_hue_explicit_rejects_garbage_domain() { + let params = SentimentParams::uniform(4.0).unwrap(); + let cases: &[(f64, f64, Option, f64)] = &[ + (f64::NAN, 28.0, None, 10.0), + (68.0, f64::INFINITY, None, 10.0), + (68.0, 28.0, None, f64::NAN), + (68.0, 28.0, None, -1.0), + (68.0, 28.0, None, 180.0 + 1e-9), + (68.0, 28.0, Some(360.0), 10.0), + (68.0, 28.0, Some(f64::NAN), 10.0), + ]; + for &(prototype, brand, floor, s_min) in cases { + assert!( + resolve_smooth_hue_explicit(1.0, floor, prototype, brand, params, s_min).is_err(), + "(prototype={prototype}, brand={brand}, floor={floor:?}, s_min={s_min})" + ); + } + // Валидный вход по-прежнему резолвится. + assert!(resolve_smooth_hue_explicit(1.0, None, 68.0, 28.0, params, 10.0).is_ok()); } } From b9344cbd411935ee3aeadceb8bf0351f9c93e509 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 11:10:02 +0300 Subject: [PATCH 16/17] =?UTF-8?q?fix(config):=20=D0=B5=D0=B4=D0=B8=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20=D0=B4=D0=BE=D0=BC=D0=B5=D0=BD=20=D0=BE=D1=82?= =?UTF-8?q?=D1=82=D0=B5=D0=BD=D0=BA=D0=B0,=20IC-=D1=80=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D1=8F=20=D0=B0=D0=BB=D1=8C=D1=84,=20?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Канонический домен [0, 360) получил один дом (sentiment, рядом с законом) и один хелпер проверки check_hue_floor_domain для обоих публичных входов — два независимых литерала/блока одного домена в цветовом коде были классом тихого расхождения; config импортирует границы (направление зависимостей внутрь). Дока провенанса альф называет солидные позиции их реальными ключами контракта (label-primary, не primary — авторы конфигов парсят key()). Тест пары альф фиксирует наследование базовой темы IC-режимами — будущая ic-ветка в alpha_for_vc не проедет молча. --- crates/labcolors-core/src/config.rs | 16 +++++-------- crates/labcolors-core/src/ladder.rs | 14 ++++++++++- crates/labcolors-core/src/sentiment.rs | 33 ++++++++++++++++++-------- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 63fc4fcd..913b5472 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -135,14 +135,9 @@ const ALPHA_MIN_EXCLUSIVE: f64 = 0.0; /// Верхний предел запрошенной альфы (включительно; α = 1 = солид). const ALPHA_MAX_INCLUSIVE: f64 = 1.0; -/// Нижний предел `hue_floor` сентимент-политики (градусы): `[0, 360)`. -/// -/// `hue_floor_deg` — минимальный угол оттенка категории (напр. Warning ≥ 45°). -/// Оттенок — величина по модулю 360°; значение вне `[0, 360)` не является -/// каноническим углом. -const HUE_FLOOR_MIN_INCLUSIVE: f64 = 0.0; -/// Верхний предел `hue_floor` (исключительно; 360° ≡ 0°). -const HUE_FLOOR_MAX_EXCLUSIVE: f64 = 360.0; +// Канонический домен оттенка [0, 360) — единый дом в crate::sentiment +// (два независимых литерала одного домена = класс тихого расхождения). +use crate::sentiment::{HUE_DOMAIN_MAX_EXCLUSIVE, HUE_DOMAIN_MIN_INCLUSIVE}; // ───────────────────────────────────────────────────────────────────────────── // Ошибки валидации конфига. @@ -781,7 +776,7 @@ impl ThemeConfig { if let Some(hue) = cat.hue_floor_deg { let field = format!("sentiments.{}.hue_floor_deg", cat.name); // Полуинтервал `[0, 360)`: угол по модулю 360°, где 360° ≡ 0°. - if !(HUE_FLOOR_MIN_INCLUSIVE..HUE_FLOOR_MAX_EXCLUSIVE).contains(&hue) { + if !(HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&hue) { return Err(ConfigError::OutOfBounds { handle: field, value: hue, @@ -792,7 +787,8 @@ impl ThemeConfig { } if let Some(hue) = self.neutral.tint.hue_override_deg - && !(hue.is_finite() && (0.0..360.0).contains(&hue)) + && !(hue.is_finite() + && (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&hue)) { return Err(ConfigError::OutOfBounds { handle: "neutral.tint.hue_override_deg".to_string(), diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index a27d4401..c3653ab0 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -21,7 +21,8 @@ //! Альфы — ДАННЫЕ рампы Figma `@NN` (float32-квантование процентов из имён //! переменных), не выведенные величины: `@72 → 0.722`, `@52 → 0.522`, //! `@32 → 0.322`, `@20 → 0.2`, `@12 → 0.122`, `@8 → 0.078`, `@4 → 0.039`, -//! `@2 → 0.02`; `primary`/`border-strong`/`focus-ring` — солид (α = 1.0). +//! `@2 → 0.02`; `label-primary`/`border-strong`/`focus-ring` — солид (α = 1.0; +//! имена = [`LadderPosition::key`], контракт разбора конфига). //! Единый паттерн проверен на brand/danger/info/success (см. «Закон лестницы» //! выше). Это данные позиций, а не POLICY-константы перцептивных модулей, //! поэтому провенанс держится этой doc-строкой + тестом лестницы, а не строкой @@ -344,6 +345,17 @@ mod tests { // alpha_for_vc выбирает верный элемент пары по теме. assert_eq!(pos.alpha_for_vc(&ViewingConditions::srgb()), pair.0); assert_eq!(pos.alpha_for_vc(&ViewingConditions::dim_surround()), pair.1); + // IC-режимы НАМЕРЕННО наследуют альфу базовой темы (стаб не несёт + // отдельных ic-скоупов) — фиксируем, чтобы будущая ic-ветка в + // alpha_for_vc не проехала молча. + assert_eq!( + pos.alpha_for_vc(&ViewingConditions::srgb_high_contrast()), + pair.0 + ); + assert_eq!( + pos.alpha_for_vc(&ViewingConditions::dim_surround_high_contrast()), + pair.1 + ); } let all: Vec = LadderPosition::ALL.to_vec(); let listed: Vec = expected.iter().map(|(p, ..)| *p).collect(); diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 4dc75a26..5b3c6fe5 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -494,11 +494,7 @@ pub fn resolve_smooth_hue_explicit( if !(s_min.is_finite() && (0.0..=180.0).contains(&s_min)) { return Err(format!("s_min вне домена [0, 180]: {s_min}")); } - if let Some(floor) = hue_floor - && !(floor.is_finite() && (0.0..360.0).contains(&floor)) - { - return Err(format!("hue_floor вне домена [0, 360): {floor}")); - } + check_hue_floor_domain(hue_floor)?; // Signed shortest delta from prototype to brand. Its sign tells us which side // of the brand the prototype sits on; we push the resolved hue out along that // same side, away from the brand. @@ -597,6 +593,19 @@ fn is_legal_hue(h: f64, brand_hue: f64, floor: Option, s_min: f64) -> bool } /// Signed shortest delta from `from` to `h` in (-180, 180]. +/// Домен-гард пола оттенка: единая проверка для обоих публичных входов +/// (валидатор конфига и солвер) — дублированный блок разошёлся бы тихо, +/// ровно как разошлись бы независимые литералы одного домена. +fn check_hue_floor_domain(hue_floor: Option) -> Result<(), String> { + if let Some(floor) = hue_floor + && !(floor.is_finite() + && (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&floor)) + { + return Err(format!("hue_floor вне домена [0, 360): {floor}")); + } + Ok(()) +} + fn signed_delta(h: f64, from: f64) -> f64 { ((h - from + 180.0).rem_euclid(360.0)) - 180.0 } @@ -653,6 +662,14 @@ pub fn s_perc_min_frozen() -> f64 { // SSOT-TRACKED — арифметика представимости (деривация закрыта, внешнего стандарта не существует): границы в docs/empirical-inventory.md. pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; +/// Канонический домен угла оттенка (градусы): `[0, 360)` — угол по модулю +/// 360°, где 360° ≡ 0°. Единые границы для `hue_floor`/`hue_override` +/// валидатора конфига и гардов солвера: два независимых литерала одного +/// домена в цветовом коде — класс тихого расхождения пределов. +pub(crate) const HUE_DOMAIN_MIN_INCLUSIVE: f64 = 0.0; +/// Верхняя граница канонического домена оттенка (исключительно; 360° ≡ 0°). +pub(crate) const HUE_DOMAIN_MAX_EXCLUSIVE: f64 = 360.0; + /// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом /// сентимент-солвером, при СОХРАНЁННЫХ светлоте и хроме якоря. /// @@ -688,11 +705,7 @@ pub fn resolve_config_sentiment_solid( if !brand_hue.is_finite() { return Err(format!("brand_hue вне домена (конечный угол): {brand_hue}")); } - if let Some(floor) = hue_floor - && !(floor.is_finite() && (0.0..360.0).contains(&floor)) - { - return Err(format!("hue_floor вне домена [0, 360): {floor}")); - } + check_hue_floor_domain(hue_floor)?; // Валидация ручек — ДО любого раннего возврата: ахроматичный fast path // ниже не решает по hardness/s_perc_min, но недоменная ручка остаётся // ошибкой вызова, а не «повезло с серым якорем» (иначе один и тот же From 54c844c120f75e232aec4e91a4a214bafee22337 Mon Sep 17 00:00:00 2001 From: Daniel from Labpics Date: Thu, 2 Jul 2026 12:32:59 +0300 Subject: [PATCH 17/17] =?UTF-8?q?fix(registry):=20=D0=BC=D0=B0=D1=80=D0=BA?= =?UTF-8?q?=D0=B5=D1=80=D1=8B=20=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B5=D0=B5=D1=81=D1=82=D1=80=D0=B0=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B3=D1=80=D0=B0=D0=BD=D0=B8=D1=86=20=D0=B4?= =?UTF-8?q?=D0=BE=D0=BC=D0=B5=D0=BD=D0=B0=20=D0=BE=D1=82=D1=82=D0=B5=D0=BD?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Гейт-1 честно поймал немаркированные HUE_DOMAIN_*: каждая константа перцептивных модулей обязана нести paper-trail. Обе — определение домена (угол по модулю 360°), не политика: SSOT-TRACKED + строки 39/40 реестра. --- crates/labcolors-core/src/sentiment.rs | 2 ++ docs/empirical-inventory.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index 5b3c6fe5..9192c2a1 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -666,8 +666,10 @@ pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; /// 360°, где 360° ≡ 0°. Единые границы для `hue_floor`/`hue_override` /// валидатора конфига и гардов солвера: два независимых литерала одного /// домена в цветовом коде — класс тихого расхождения пределов. +// SSOT-TRACKED — определение домена: угол по модулю 360°, не перцептивная политика (реестр, строка 39). pub(crate) const HUE_DOMAIN_MIN_INCLUSIVE: f64 = 0.0; /// Верхняя граница канонического домена оттенка (исключительно; 360° ≡ 0°). +// SSOT-TRACKED — определение домена: полный оборот окружности, не перцептивная политика (реестр, строка 40). pub(crate) const HUE_DOMAIN_MAX_EXCLUSIVE: f64 = 360.0; /// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index d2845609..e1ad3347 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -48,6 +48,8 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 36 | `HUE_SEARCH_HALF_WINDOW` | `30.0` | `scale.rs` | SSOT-TRACKED | Half-width (degrees) of the hue search window in `find_optimal_hue`; 30° spans the typical sRGB gamut ridge width around the canonical hue. | | 37 | `HUE_DRIFT_PENALTY_SLOPE` | `0.15` | `scale.rs` | SSOT-TRACKED | Наклон штрафа дрейфа оттенка в `find_optimal_hue` (`penalty_scale = slope/half_window`, `score = c − penalty_scale·drift`): баланс «максимум хромы против ухода от канонического оттенка». Калибровочный; ранее жил незадекларированным поле-литералом (найден инвентарём конфиг-границы 2026-07-02). Кандидат науки: вывести или обосновать датасетом. | | 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | SSOT-TRACKED | Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. Вывод из арифметики представимости, а не подгонка: минимум ненулевой Oklab-хромы 8-битного sRGB-цвета ≈ 1.06e-3 (`#FEFFFF`; `#808081` ≈ 1.49e-3; расчёт конвейером sRGB→Oklab, Ottosson 2020), накопленный f64-шум того же конвейера ≲ 1e-12; 1e-7 лежит между границами с запасом ≥4 порядка в обе стороны, поэтому не может переклассифицировать ни один представимый цвет. Деривация ЗАКРЫТА; SSOT-TRACKED (а не GROUNDED) потому, что цитируемого внешнего стандарта для этой величины не существует — таблица и есть её носитель. | +| 39 | `HUE_DOMAIN_MIN_INCLUSIVE` | `0.0` | `sentiment.rs` | SSOT-TRACKED | Нижняя граница канонического домена оттенка `[0, 360)`: определение угла по модулю 360°, не перцептивная величина. Единый дом границ для валидатора конфига и гардов солвера (два литерала одного домена = класс тихого расхождения). | +| 40 | `HUE_DOMAIN_MAX_EXCLUSIVE` | `360.0` | `sentiment.rs` | SSOT-TRACKED | Верхняя (исключительная) граница того же домена: полный оборот окружности, 360° ≡ 0°. Определение, не политика; пара к строке 39. | ## Muddiness Law constants — `cleanliness.rs`