diff --git a/README.md b/README.md index 6bc1c8cf..fe2fa4a9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Компилятор цветовых систем. Как компилятор превращает исходник в детерминированный бинарь, Lab Colors превращает бренд и законы цвета в детерминированную цветовую систему — один и тот же вход всегда даёт байт-идентичный выход. -**Вход:** бренд (якорные цвета) · физика (перцептуальное пространство LCS, гамут sRGB, контраст) · психофизика (эффект Гельмгольца-Кольрауша, перцептуальная светлота, адаптация зрения к окружению, категориальные зоны хью у сентиментов). +**Вход:** бренд и клиентские anchors · физика (перцептуальное пространство LCS, гамут sRGB, контраст) · психофизика (эффект Гельмгольца-Кольрауша, перцептуальная светлота, адаптация зрения к окружению). Сентименты проверяются по фактической попарной геометрии anchors, без универсальных hue-зон. **Выход:** цветовая система целиком — таблица именованных ролей, темы, контраст с юридическим полом WCAG 2.1 AA. @@ -207,7 +207,7 @@ let config = ThemeConfig::new( edge: None, inverted: None, }, vec![], - SentimentsConfig { categories: vec![], hardness: 5.0, chroma_fraction: 0.88 }, + SentimentsConfig { categories: vec![] }, ThemesConfig { entries: vec![("light".into(), VcPreset::Srgb)] }, vec![( "label-primary".into(), @@ -358,6 +358,7 @@ crates/ packages/ └── colors — npm-пакет @labpics/colors поверх WASM docs/ +├── cleanliness-v2.md — научный контракт чистоты, область применимости и фальсифицируемость ├── whitepaper.md, empirical-inventory.md, decisions/, conformance/, … └── palette.svg ``` @@ -411,7 +412,8 @@ CI на каждом PR — пять джобов на Rust 1.96.0: - **lcs** — roundtrip под обеими VC, дрейф при чужой VC - **neutral** — монотонность J', непрерывность кривой, точность якорей, chroma envelope, hue-дрейф, dim-тема - **scale** — акцентная кривая: монотонность, попадание в гамут, dim-тема -- **sentiment** — покой сентимента на фокусе своей категориальной зоны +- **sentiment** — формула неравных радиусов, независимость от порядка, отказ на + недостижимой паре и финальная проверка всех попарных anchor-дистанций - **lpc** — полярность контраста, H-K-буст со сверкой против Hellwig 2022 - **golden_tests** — кросс-валидация CIECAM16 против colour-science с задокументированными допусками - **spaces** (oklab, vc) — Oklab hue и roundtrip, параметры окружения diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 590a4849..666919fd 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -27,7 +27,7 @@ //! | `ladders.json` | позиция лестницы → (α_light, α_dark) | `LadderPosition::alpha_pair` | //! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | `solve.json` | (bg, контракт, тема) → резолв или честный отказ | `solve` | -//! | `muddiness.json` | hex → мутность | `cleanliness::muddiness_from_hex` | +//! | `muddiness.json` | замороженная характеристика Закона Грязи V1 | `cleanliness::muddiness_from_hex` | //! | `manifest.json` | версии пака/ядра, дайджест, счётчики | — | //! //! Версия пака ([`PACK_VERSION`]) привязана к версии ядра ([`core_version`]): @@ -384,17 +384,17 @@ pub fn generate_solve() -> Vec { // Семейство: мутность // ───────────────────────────────────────────────────────────────────────────── -/// Один вектор мутности: hex и его оценка «грязи» `[0,1]`. +/// Один замороженный вектор воспроизводимости исторической модели V1. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MuddinessVector { /// Цвет, `#RRGGBB`. pub hex: String, - /// Оценка мутности `[0,1]` (0 — чистый, 1 — грязный). + /// Историческое значение V1 `[0,1]`; не вероятность наблюдателя. pub score: f64, } -/// Цвета для мутности: грязная олива, чистый серый, чистый бренд, грязный хаки. +/// Исторические стимулы V1; названия классов не являются научным оракулом. const MUDDINESS_CASES: [&str; 4] = ["#6B6B2E", "#808080", "#007AFF", "#8A7A50"]; /// Дериватор векторов мутности. diff --git a/crates/labcolors-core/src/accent.rs b/crates/labcolors-core/src/accent.rs index 79ada5f8..3ea96eb0 100644 --- a/crates/labcolors-core/src/accent.rs +++ b/crates/labcolors-core/src/accent.rs @@ -214,7 +214,7 @@ mod tests { (Accent::Red, "#FF3B30", "red", 28.6592), (Accent::Orange, "#FFA100", "orange", 68.6070), (Accent::Yellow, "#FFD000", "yellow", 92.2265), - (Accent::Green, "#34C759", "green", 147.4439), + (Accent::Green, "#34C759", "green", 147.4440), (Accent::Teal, "#5AC8FA", "teal", 230.8271), (Accent::Mint, "#00C7BE", "mint", 189.0284), (Accent::Blue, "#3E87FF", "blue", 259.8918), diff --git a/crates/labcolors-core/src/accent_balance.rs b/crates/labcolors-core/src/accent_balance.rs index 12deb05c..7e587b92 100644 --- a/crates/labcolors-core/src/accent_balance.rs +++ b/crates/labcolors-core/src/accent_balance.rs @@ -12,25 +12,23 @@ //! //! Разрешение конфликта одно: найти светлоту `L`, удовлетворяющую ФУНКЦИИ, и на //! ней взять **МАКСИМАЛЬНУЮ** хрому оттенка (`max_chroma` — стена гамута). -//! Тогда идентичность держится ровно так сильно, как позволяет гамут на нужной -//! светлоте, а не срезается произвольной долей. Если даже стена гамута на этой -//! `L` оставляет красочность ниже перцептивного пола — оттенок физически -//! вырожден, и это объявляется ЧЕСТНЫМ флагом [`BalancedAccent::hue_vanished`], -//! не молчанием. +//! Тогда этот примитив отвечает на узкий геометрический вопрос: какой максимум +//! данного направления допускает sRGB на требуемой светлоте. Он не утверждает, +//! что максимум красивее или лучше сохраняет конкретный клиентский якорь. +//! Потеря оттенка фиксируется только как точное состояние выходной решётки: +//! эмитированный `#RRGGBB` стал ахроматическим (`R = G = B`). Перцептивный порог +//! без эксперимента с наблюдателями здесь не выдумывается. //! //! # Строгая агностичность (frozen northInvariant) //! //! Ноль labui-имён ролей, ноль хардкод-оттенков, ноль резерваций //! (сентимент→оттенок): целевая светлота, оттенок и тема приходят ПАРАМЕТРАМИ. -//! Порог вырождения — НЕ новая ручка, а уже заземлённый перцептивный пол крейта -//! `TINT_PERCEPTIBLE_MP_FLOOR` (`M' = 1.5`, тот же, которым -//! [`crate::semantic`] объявляет `hue_vanished` у лейблов). Провенанс нового -//! кода: единственная новая величина — этот примитив — не несёт НИ ОДНОЙ новой -//! числовой константы (переиспользует `max_chroma` и существующий пол). +//! Модуль не содержит порога «видимости оттенка»: такой порог был бы свойством +//! психофизического протокола, а не геометрии гамута. Флаг ниже выводится из +//! конечного sRGB8-результата без коэффициента и epsilon. use crate::lcs::LcsColor; use crate::scale::{lcs_from_oklab_lch, max_chroma}; -use crate::semantic::TINT_PERCEPTIBLE_MP_FLOOR; use crate::spaces::vc::ViewingConditions; /// Результат применения закона баланса на требуемой светлоте. @@ -44,13 +42,22 @@ pub struct BalancedAccent { pub hue_deg: f64, /// Отрисованный цвет ([`LcsColor`] под переданными `vc`). pub color: LcsColor, - /// Честный флаг вырождения: на этой светлоте даже МАКСИМАЛЬНАЯ хрома - /// оставляет красочность `M'` ниже `TINT_PERCEPTIBLE_MP_FLOOR` — оттенок - /// физически потерян (near-white/near-black), функция задавила - /// идентичность. Вызывающий видит потерю цвета флагом, а не тихо. + /// Точный флаг вырождения на выходе: после реальной sRGB8-квантизации цвет + /// имеет одинаковые байты `R = G = B`, поэтому направления hue у + /// эмитируемого значения уже нет. Это не заявление о пороге восприятия. pub hue_vanished: bool, } +/// Проверяет потерю hue на том представлении, которое действительно получит +/// клиент. Сравнивать `M'` с придуманным epsilon нельзя: CAM16 может оставить +/// числовой opponent-шум у серого, тогда как равенство трёх байтов однозначно. +pub(crate) fn hue_vanished_on_srgb8(color: &LcsColor, vc: &ViewingConditions) -> bool { + let hex = color.to_hex_with_vc(vc); + let encoded = crate::spaces::srgb::srgb_encoded_from_hex(&hex) + .expect("hex, эмитированный LcsColor, обязан разбираться ядром"); + encoded[0] == encoded[1] && encoded[1] == encoded[2] +} + /// Закон баланса акцента: на требуемой Oklab-светлоте `target_l_ok` взять /// МАКСИМАЛЬНУЮ хрому оттенка `hue_deg` в гамуте sRGB. /// @@ -65,7 +72,7 @@ pub fn accent_balanced(target_l_ok: f64, hue_deg: f64, vc: &ViewingConditions) - // ЗАКОН: максимум хромы на требуемой светлоте (не фикс-доля источника). let c_ok = max_chroma(target_l_ok, hue_deg); let color = lcs_from_oklab_lch(target_l_ok, c_ok, hue_deg, vc); - let hue_vanished = color.mp() < TINT_PERCEPTIBLE_MP_FLOOR; + let hue_vanished = hue_vanished_on_srgb8(&color, vc); BalancedAccent { l_ok: target_l_ok, c_ok, @@ -78,13 +85,6 @@ pub fn accent_balanced(target_l_ok: f64, hue_deg: f64, vc: &ViewingConditions) - #[cfg(test)] mod tests { use super::*; - use crate::scale::jp_to_oklab_l; - - /// Функциональный пол яркости центра свечения из светлоты источника: полпути - /// к белому по J' (пересвет центра — та же деривация, что в [`crate::glow`]). - fn glow_core_l(src_jp: f64, vc: &ViewingConditions) -> f64 { - jp_to_oklab_l((src_jp + 100.0) * 0.5, vc) - } /// ЯКОРЬ ЗАКОНА (и RED-proof primary): на ЛЮБОЙ светлоте и оттенке баланс /// берёт РОВНО стену гамута — не долю. Мутация примитива к фикс-дельте @@ -109,51 +109,36 @@ mod tests { } } - /// ГЛАВНОЕ СВОЙСТВО (RED-proof): на ФУНКЦИОНАЛЬНОМ ПОЛУ glow-центра для - /// каждого оттенка баланс держит красочность выше порога вырождения — - /// оттенок НЕ вымывается. Пол намеренно яркий (пересвет центра), это - /// маргинальный режим: под фикс-дельтой (`0.5 × max_chroma`) красочность - /// падает ниже пола и `hue_vanished` краснит; под законом (полная стена) — - /// держится. Мутация примитива к доле → тест краснит. + /// Флаг обязан быть тождественен ахроматичности эмитированного sRGB8, а не + /// зависеть от CAM16-шума или скрытого порога. #[test] - fn hue_survives_at_glow_core_functional_floor() { + fn hue_flag_equals_emitted_byte_achromaticity() { for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { - // Умеренно-светлый источник (J'≈52) — типовой акцент; центр ≈ J'76. - let l = glow_core_l(52.0, &vc); - for h_step in 0..72 { - let h = f64::from(h_step) * 5.0; - let b = accent_balanced(l, h, &vc); - assert!( - !b.hue_vanished, - "оттенок {h}° вымылся на функциональном полу glow-центра \ - (M'={:.3} < {TINT_PERCEPTIBLE_MP_FLOOR}), L={l:.4}", - b.color.mp() - ); - assert!( - b.color.mp() >= TINT_PERCEPTIBLE_MP_FLOOR, - "красочность выше порога вырождения: оттенок {h}°" - ); + for l_step in 0..=10 { + let l = f64::from(l_step) / 10.0; + for h_step in 0..72 { + let h = f64::from(h_step) * 5.0; + let balanced = accent_balanced(l, h, &vc); + assert_eq!( + balanced.hue_vanished, + hue_vanished_on_srgb8(&balanced.color, &vc), + "L={l}, h={h}" + ); + } } } } - /// ЧЕСТНОСТЬ ФЛАГА (обе стороны): у самой стены белого даже МАКСИМАЛЬНАЯ - /// хрома оставляет цвет near-white — `hue_vanished` обязан краснеть (не - /// молчать). Ловит примитив, который никогда не выставляет флаг. + /// В точках чёрного и белого радиус гамута равен нулю, поэтому выходной hue + /// отсутствует точно, без понятия «почти исчез». #[test] - fn flag_fires_when_hue_physically_degenerates() { + fn flag_fires_at_exact_achromatic_endpoints() { let vc = ViewingConditions::srgb(); - let l = 0.9999; // у самой вершины гамута: стена хромы → 0, красочность гаснет - let mut any_vanished = false; - for h_step in 0..72 { - let h = f64::from(h_step) * 5.0; - if accent_balanced(l, h, &vc).hue_vanished { - any_vanished = true; + for l in [0.0, 1.0] { + for h_step in 0..72 { + let h = f64::from(h_step) * 5.0; + assert!(accent_balanced(l, h, &vc).hue_vanished, "L={l}, h={h}"); } } - assert!( - any_vanished, - "у стены белого (L={l}) оттенок физически вырожден — флаг обязан краснеть" - ); } } diff --git a/crates/labcolors-core/src/accent_golden_tests.rs b/crates/labcolors-core/src/accent_golden_tests.rs index 59b9d9f8..3d3e2a89 100644 --- a/crates/labcolors-core/src/accent_golden_tests.rs +++ b/crates/labcolors-core/src/accent_golden_tests.rs @@ -1,114 +1,81 @@ -//! Contract 7 — AccentCurve / SentimentCurve golden snapshots. +//! Контрактные тесты конечных AccentCurve и SentimentCurve. //! -//! BUG CLASS this guards: *silent value drift.* Every other test in this crate -//! checks a *property* — in-gamut, monotone J', non-negative saturation, hits a -//! contrast target. None of them pins the *actual emitted colours*. A change to -//! a curve coefficient, the chroma envelope, the hue-optimisation search, or the -//! CAM16-UCS rescaling could shift every swatch by a few bytes while keeping all -//! the properties true — and no test would notice. The Bracket-path LUT seam -//! (#50/#53) was exactly this shape: a value that moved without a property -//! breaking. This file freezes the exact byte output of two representative -//! curves, sampled at 13 stops. -//! -//! A failure here is NOT automatically a bug: a deliberate recalibration of a -//! curve is a legitimate, intentional change of the snapshot. The rule is that -//! it must be a *conscious* swap — read the diff, confirm the new ladder is the -//! intended one, and update the constant. Drift that nobody chose is the -//! regression; the snapshot makes the difference visible instead of invisible. -//! -//! Snapshots captured 2026-06-12 from the curves' own `sample_hex(13)` through -//! their inherited (srgb) viewing conditions. +//! Старые массивы байтов фиксировали одну непрерывную реализацию и краснели при +//! любом научно обоснованном изменении нейтрального скелета. Здесь фиксируется +//! более сильный инвариант: каждый байт должен быть детерминированным оптимумом +//! содержащей непрерывный iso-HK-идеал sRGB8-ячейки. +use crate::accent::oklab_hue_of; use crate::curve::ColorCurve; use crate::neutral::NeutralCurve; -use crate::scale::AccentCurve; +use crate::scale::{AccentCurve, iso_hk_identity_from_anchor, quantized_iso_hk_for_neutral}; use crate::sentiment::{Sentiment, SentimentCurve}; -/// The system neutral ladder all accent/sentiment curves are built on. fn neutral() -> NeutralCurve { NeutralCurve::new("#FFFFFF", "#787880", "#101012") - .expect("the canonical neutral anchors are valid") + .expect("канонические нейтральные якоря валидны") } -/// AccentCurve::new("#007AFF", neutral).sample_hex(13) — frozen. -/// Recalibration = a conscious, reviewed change to this constant. -const ACCENT_007AFF_GOLDEN: [&str; 13] = [ - "#FFFFFF", "#F4F8FF", "#DAE9FF", "#B6D4FF", "#88B9FF", "#4F98FF", "#0A6CFF", "#0060FC", - "#0C41FF", "#0500F9", "#0300C4", "#010089", "#000043", -]; - -/// SentimentCurve(Info, brand=200°, prototype "#3E87FF", neutral).sample_hex(13) -/// — frozen. -/// -/// СОЗНАТЕЛЬНЫЙ ДРЕЙФ Волны 1 (закон категориальных зон). Прежде brand=200° -/// смещал Info плавно-асимптотически до resolved_hue ≈ 259.96°. Под новым законом -/// бренд НЕ смещает сентимент — Info ОТДЫХАЕТ на своём синем фокусе 259.89° -/// (Figma `Accent/Blue`), поэтому рампа чуть сдвинулась. Это следствие закона (info -/// теперь покоится на синем фокусе), а не тихая регрессия; массив перегенерирован -/// из ФАКТИЧЕСКОГО вывода нового закона. -const SENTIMENT_INFO_GOLDEN: [&str; 13] = [ - "#FFFFFF", "#ECF3FD", "#CCDEFB", "#A1C2F8", "#6EA1F4", "#2F78F0", "#1858BD", "#1551B0", - "#114597", "#0B3579", "#052456", "#02112F", "#000108", -]; - #[test] -fn accent_curve_007af_sample_hex_13_matches_golden() { +fn accent_curve_matches_the_finite_srgb8_objective_at_every_sample() { let neutral = neutral(); - let accent = AccentCurve::new("#007AFF", &neutral).expect("#007AFF is a valid accent seed"); - let got = accent.sample_hex(13); - assert_eq!( - got, ACCENT_007AFF_GOLDEN, - "AccentCurve('#007AFF') ladder drifted from its golden snapshot. If this was a \ - deliberate recalibration, update ACCENT_007AFF_GOLDEN consciously; otherwise it is \ - a silent value regression." - ); + let curve = AccentCurve::new("#007AFF", &neutral).unwrap(); + let identity = iso_hk_identity_from_anchor("#007AFF", neutral.vc()).unwrap(); + let first = curve.sample_hex(13); + let second = AccentCurve::new("#007AFF", &neutral) + .unwrap() + .sample_hex(13); + assert_eq!(first, second, "одинаковые входы обязаны дать те же байты"); + + for (index, actual) in first.iter().enumerate() { + let t = index as f64 / 12.0; + let expected = quantized_iso_hk_for_neutral( + &neutral.at(t), + identity.h_cam, + identity.chroma_ratio, + neutral.vc(), + ) + .unwrap() + .color + .to_hex_with_vc(neutral.vc()); + assert_eq!( + actual, &expected, + "ступень {index} обошла конечный objective" + ); + } + assert_eq!(first[0], "#FFFFFF"); } #[test] -fn sentiment_info_curve_sample_hex_13_matches_golden() { +fn sentiment_curve_uses_only_its_anchor_and_the_same_srgb8_objective() { let neutral = neutral(); - // prototype_hex = Figma Accent/Blue (#3E87FF), Oklab-оттенок 259.89°. - // СОЗНАТЕЛЬНЫЙ ДРЕЙФ Волны 1: бренд (200°) больше НЕ смещает Info — сентимент - // ОТДЫХАЕТ на своём синем фокусе 259.89° (прежде brand-displacement уводил его - // до ≈259.96°). Следствие закона категориальных зон, не тихая регрессия. - let curve = SentimentCurve::from_sentiment(Sentiment::Info, 200.0, "#3E87FF", &neutral) - .expect("Info sentiment resolves (brand ignored by the categorical-zone law)"); - // Info покоится: смещения от прототипа нет (was_displaced == false, Δ ≈ 0). - assert!( - !curve.was_displaced && curve.displacement < 1e-6, - "Info должен ОТДЫХАТЬ на фокусе (бренд игнорируется): displaced={}, Δ={}", - curve.was_displaced, - curve.displacement - ); - assert!( - (curve.resolved_hue - 259.89).abs() < 0.1, - "Info resolved hue должен быть ~259.89° (синий фокус Figma Accent/Blue; \ - бренд не смещает): {}", - curve.resolved_hue - ); - let got = curve.sample_hex(13); + let from_anchor = SentimentCurve::from_anchor("#3E87FF", &neutral).unwrap(); + let legacy_a = + SentimentCurve::from_sentiment(Sentiment::Info, 200.0, "#3E87FF", &neutral).unwrap(); + let legacy_b = + SentimentCurve::from_sentiment(Sentiment::Info, 33.5, "#3E87FF", &neutral).unwrap(); + + assert_eq!(from_anchor.sample_hex(13), legacy_a.sample_hex(13)); + assert_eq!(legacy_a.sample_hex(13), legacy_b.sample_hex(13)); assert_eq!( - got, SENTIMENT_INFO_GOLDEN, - "SentimentCurve(Info) ladder drifted from its golden snapshot. Golden перегенерирован \ - под закон Волны 1 (Info покоится на синем фокусе); будущий дрейф = регрессия." + from_anchor.resolved_hue.to_bits(), + oklab_hue_of("#3E87FF").to_bits() ); -} + assert!(!from_anchor.was_displaced); + assert_eq!(from_anchor.displacement, 0.0); -#[test] -fn golden_endpoints_anchor_to_white_and_near_black() { - // A cheap structural guard so an accidental wholesale replacement of the - // golden constants (e.g. all-white) can't pass: both ladders start at pure - // white and descend to a dark near-black, monotonically darkening overall. - assert_eq!(ACCENT_007AFF_GOLDEN[0], "#FFFFFF"); - assert_eq!(SENTIMENT_INFO_GOLDEN[0], "#FFFFFF"); - for golden in [&ACCENT_007AFF_GOLDEN, &SENTIMENT_INFO_GOLDEN] { - let luma = |hex: &str| -> u32 { - let v = u32::from_str_radix(hex.trim_start_matches('#'), 16).unwrap(); - ((v >> 16) & 0xFF) + ((v >> 8) & 0xFF) + (v & 0xFF) - }; - assert!( - luma(golden[0]) > luma(golden[12]), - "golden ladder must darken from first to last stop" - ); + let identity = iso_hk_identity_from_anchor("#3E87FF", neutral.vc()).unwrap(); + for index in 0..13 { + let t = index as f64 / 12.0; + let expected = quantized_iso_hk_for_neutral( + &neutral.at(t), + identity.h_cam, + identity.chroma_ratio, + neutral.vc(), + ) + .unwrap() + .color + .to_hex_with_vc(neutral.vc()); + assert_eq!(from_anchor.sample_hex(13)[index], expected); } } diff --git a/crates/labcolors-core/src/accent_surface.rs b/crates/labcolors-core/src/accent_surface.rs index 88e4c063..beac24b4 100644 --- a/crates/labcolors-core/src/accent_surface.rs +++ b/crates/labcolors-core/src/accent_surface.rs @@ -1,46 +1,16 @@ -//! Агностичный примитив акцентных **Background**-рамп (первый вертикальный -//! под-срез EC6+EC7). +//! Акцентные поверхности на конечной sRGB8-лестнице. //! -//! ЗАКОН ОДНОУРОВНЕВОСТИ. Из нейтральной surface-рампы Backgrounds, акцентного -//! оттенка, темы (viewing conditions) и материала выводится акцентная -//! Background-рампа, КАЖДАЯ ступень которой сидит на ТОЙ ЖЕ перцептивной -//! светлоте (CAM16-UCS J'), что одноимённая нейтральная ступень. Светлота не -//! решается заново — она наследуется из нейтрального скелета, поэтому -//! пер-уровневые ШАГИ светлоты акцентной рампы по построению равны шагам -//! нейтрали (`property_accent_surface.rs` доказывает это на ≥20 случайных -//! оттенках в обеих темах). -//! -//! # Строгая агностичность (frozen northInvariant) -//! -//! Ноль labui-имён ролей, ноль хардкод-оттенков, ноль резерваций -//! (сентимент→оттенок) в ядре: оттенок, нейтральная рампа и тема приходят -//! ПАРАМЕТРАМИ. Порядок выходной рампы = порядок входной (иерархия -//! Primary..Tertiary — семантика ПОТРЕБИТЕЛЯ, ядро лишь сохраняет порядок). -//! Гейты `agnostic_cleanliness` это стерегут. -//! -//! # Хрома — ЕДИНЫЙ закон баланса, не второй рецепт (DRY) -//! -//! Тинт красится на ФИКСИРОВАННОЙ Oklab-светлоте нейтральной ступени; хрома — -//! НЕ отдельная ручка-доля `доля × max_chroma`, а ЕДИНЫЙ агностичный -//! примитив [`crate::accent_balance::accent_balanced`]: на этой светлоте берётся -//! СТЕНА ГАМУТА (`max_chroma`). Субтильность фона — следствие -//! ГЕОМЕТРИИ: у белого/чёрного гамут узок, поэтому near-white/near-black — не -//! молчаливая доля, а честный итог закона; когда даже стена оставляет оттенок -//! ниже перцептивного пола, примитив ставит [`BalancedAccent::hue_vanished`], а -//! не гасит цвет тихо. Стена гамута В ГАМУТЕ по построению ⇒ эмиссия не клипует -//! каналы и светлота не «съезжает» — тот класс дефекта, что ловит property-тест. -//! -//! # Материал — флаг, не ветка -//! -//! Деривация одна (солид на нужной светлоте); [`SurfaceMaterial`] лишь выбирает -//! ПРЕДСТАВЛЕНИЕ. `Alpha` переиспользует обратный ход [`crate::alpha`]: композит -//! `(tint, α)` над базой темы ПОБАЙТНО равен солиду, значит J' — а с ним -//! одноуровневость — наследуется алгеброй, а не переизобретается. +//! Единственный клиентский anchor задаёт обе координаты идентичности: CAM16 hue +//! и долю физического iso-HK-радиуса. Для каждого уже эмитируемого нейтрального +//! hex строится непрерывный идеал, после чего выбирается лучшая вершина содержащей +//! его sRGB8-ячейки. Поэтому одноуровневость измеряется после квантования, а +//! Oklab hue никогда не передаётся в формулу, ожидающую CAM16 hue. -use crate::accent_balance::{BalancedAccent, accent_balanced}; use crate::alpha::resolve_alpha_analog_hex; use crate::lcs::LcsColor; -use crate::scale::jp_to_oklab_l; +use crate::scale::{IsoHkIdentity, iso_hk_identity_from_anchor, quantized_iso_hk_for_neutral}; +use crate::spaces::oklab::srgb_linear_to_oklab; +use crate::spaces::srgb::srgb_from_hex; use crate::spaces::vc::ViewingConditions; /// Материал выдачи ступени акцентного фона. Флаг представления — деривация @@ -62,40 +32,71 @@ pub enum AccentSurface { Alpha { tint_hex: String, alpha: f64 }, } -/// Акцентный фон ОДНОЙ ступени: баланс акцента `hue_deg` на перцептивной -/// светлоте `neutral_level` (та же J') — через ЕДИНЫЙ примитив -/// [`accent_balanced`] (стена гамута [`crate::scale::max_chroma`], НЕ доля). -/// -/// Зеркалит светлотную часть [`crate::accent::AccentCurve::at`], но оттенок -/// ФИКСИРОВАН (фоны не дрейфуют оттенком, в отличие от акцентных лестниц). -/// Возвращает [`BalancedAccent`], а не голый цвет: у краёв гамута оттенок может -/// физически выродиться — вызывающий видит это флагом [`BalancedAccent::hue_vanished`] -/// примитива, а не молчаливым near-white (см. модульную доку). -fn accent_level(neutral_level: &LcsColor, hue_deg: f64, vc: &ViewingConditions) -> BalancedAccent { - // Светлота наследуется из нейтральной ступени (одноуровневость по построению). - let l_ok = jp_to_oklab_l(neutral_level.jp, vc); - // ЕДИНЫЙ закон баланса: стена гамута на этой светлоте — без второго рецепта доли. - accent_balanced(l_ok, hue_deg, vc) +/// Фактически эмитируемая ступень акцентной поверхности. +#[derive(Debug, Clone, PartialEq)] +pub struct AccentSurfaceLevel { + /// Oklab L декодированного выходного hex, а не непрерывного идеала. + pub l_ok: f64, + /// Oklab C декодированного выходного hex. + pub c_ok: f64, + /// Oklab hue выходного hex; это отчётная координата, не вход CAM16-решателя. + pub hue_deg: f64, + /// Цвет построен повторным декодированием выбранного `#RRGGBB`. + pub color: LcsColor, + /// Точное состояние: эмитированный sRGB8 ахроматичен (`R = G = B`). + /// Здесь нет универсального «порога различимости» без наблюдательских данных. + pub hue_vanished: bool, + /// H-K-уровень фактически эмитированной нейтральной ступени. + pub target_hk: f64, + /// H-K-уровень фактически выбранного акцентного hex. + pub achieved_hk: f64, + /// Неизменная доля физического iso-HK-радиуса клиентского anchor. + pub chroma_ratio: f64, +} + +fn accent_level( + neutral_level: &LcsColor, + identity: IsoHkIdentity, + vc: &ViewingConditions, +) -> Result { + let resolved = + quantized_iso_hk_for_neutral(neutral_level, identity.h_cam, identity.chroma_ratio, vc) + .map_err(|error| format!("акцентный H-K-уровень недостижим: {error}"))?; + let hex = resolved.color.to_hex_with_vc(vc); + let rgb = srgb_from_hex(&hex)?; + let lab = srgb_linear_to_oklab(rgb); + let c_ok = lab[1].hypot(lab[2]); + let hue_deg = if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + 0.0 + } else { + lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0) + }; + Ok(AccentSurfaceLevel { + l_ok: lab[0], + c_ok, + hue_deg, + hue_vanished: rgb[0] == rgb[1] && rgb[1] == rgb[2], + color: resolved.color, + target_hk: resolved.target_hk, + achieved_hk: resolved.achieved_hk, + chroma_ratio: resolved.chroma_ratio, + }) } /// Вывести акцентную Background-рампу из нейтральной surface-рампы. /// -/// Для каждой нейтральной ступени — акцентный фон на ТОЙ ЖЕ перцептивной -/// светлоте; порядок сохраняется (иерархия входа). Пер-уровневые шаги светлоты -/// выхода по построению равны шагам нейтрали — ЗАКОН ОДНОУРОВНЕВОСТИ. Хрома — -/// ЕДИНЫЙ закон баланса ([`accent_balanced`]), НЕ отдельная ручка; каждый -/// [`BalancedAccent`] несёт свой `hue_vanished` (честность вырождения у краёв). -/// -/// `hue_deg` — акцентный оттенок (градусы, параметр); `vc` — тема (srgb=светлая, -/// dim=тёмная). +/// `anchor_hex` — единственный источник hue и относительной хромы. Функция +/// fallible, потому неверный anchor или несогласованные условия просмотра нельзя +/// превращать в частично построенную рампу. pub fn derive_accent_surface_ramp( neutral: &[LcsColor], - hue_deg: f64, + anchor_hex: &str, vc: &ViewingConditions, -) -> Vec { +) -> Result, String> { + let identity = iso_hk_identity_from_anchor(anchor_hex, vc)?; neutral .iter() - .map(|n| accent_level(n, hue_deg, vc)) + .map(|level| accent_level(level, identity, vc)) .collect() } @@ -150,19 +151,27 @@ mod tests { .collect() } - /// GREEN: выведенная акцентная рампа держит светлотные шаги нейтрали. + /// Метаданные одноуровневости пересчитываются из реально эмитированных hex. #[test] - fn accent_ramp_inherits_neutral_lightness_steps() { + fn accent_ramp_measures_the_emitted_srgb8_levels() { let vc = ViewingConditions::srgb(); let neutral = neutral_ramp(&vc); - let accent = derive_accent_surface_ramp(&neutral, 264.0, &vc); + let accent = derive_accent_surface_ramp(&neutral, "#007AFF", &vc).unwrap(); assert_eq!(accent.len(), neutral.len()); - for i in 0..neutral.len() - 1 { - let dn = neutral[i + 1].jp - neutral[i].jp; - let da = accent[i + 1].color.jp - accent[i].color.jp; - assert!( - (da - dn).abs() <= 1.0, - "шаг {i}: акцент Δ{da:.3} против нейтрали Δ{dn:.3}" + for (neutral, accent) in neutral.iter().zip(&accent) { + let neutral_hex = neutral.to_hex_with_vc(&vc); + let accent_hex = accent.color.to_hex_with_vc(&vc); + assert_eq!( + accent.target_hk.to_bits(), + crate::scale::emitted_perceived_lightness(&neutral_hex, &vc) + .unwrap() + .to_bits() + ); + assert_eq!( + accent.achieved_hk.to_bits(), + crate::scale::emitted_perceived_lightness(&accent_hex, &vc) + .unwrap() + .to_bits() ); } } @@ -172,25 +181,40 @@ mod tests { fn accent_ramp_stays_in_gamut() { let vc = ViewingConditions::srgb(); let neutral = neutral_ramp(&vc); - for hue in [12.0, 140.0, 264.0] { - let accent = derive_accent_surface_ramp(&neutral, hue, &vc); + for anchor in ["#FF3B30", "#34C759", "#007AFF"] { + let accent = derive_accent_surface_ramp(&neutral, anchor, &vc).unwrap(); for c in &accent { let rgb = crate::spaces::srgb::srgb_from_hex(&c.color.to_hex_with_vc(&vc)).unwrap(); assert!( - rgb.iter().all(|&x| (-0.01..=1.01).contains(&x)), - "вне гамута на hue={hue}" + rgb.iter().all(|&x| (0.0..=1.0).contains(&x)), + "вне гамута для anchor={anchor}" ); } } } + #[test] + fn surface_preserves_anchor_radius_fraction_instead_of_forcing_the_wall() { + let vc = ViewingConditions::srgb(); + let neutral = neutral_ramp(&vc); + let saturated = derive_accent_surface_ramp(&neutral, "#FF3B30", &vc).unwrap(); + let muted = derive_accent_surface_ramp(&neutral, "#B36A65", &vc).unwrap(); + assert!(saturated[0].chroma_ratio > muted[0].chroma_ratio); + assert!(muted[0].chroma_ratio < 1.0); + assert!( + muted + .iter() + .all(|level| level.chroma_ratio.to_bits() == muted[0].chroma_ratio.to_bits()) + ); + } + /// Материал — флаг: Alpha-аналог композитится обратно в солид (одноуровневость /// наследуется алгеброй альфы). #[test] fn alpha_material_composites_back_to_solid() { let vc = ViewingConditions::srgb(); let neutral = neutral_ramp(&vc); - let accent = derive_accent_surface_ramp(&neutral, 264.0, &vc); + let accent = derive_accent_surface_ramp(&neutral, "#007AFF", &vc).unwrap(); let base_hex = "#FFFFFF"; for surface in &accent { let color = &surface.color; diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index 454bf875..278de53a 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -288,11 +288,7 @@ fn acme_config() -> ThemeConfig { categories: vec![SentimentCategory { name: "alert".to_string(), family: "crimson".to_string(), - hue_floor_deg: None, - preferred_side: None, }], - hardness: 5.0, - chroma_fraction: 0.88, }, themes: ThemesConfig { entries: vec![ diff --git a/crates/labcolors-core/src/cleanliness.rs b/crates/labcolors-core/src/cleanliness.rs index b3dedce3..213b230f 100644 --- a/crates/labcolors-core/src/cleanliness.rs +++ b/crates/labcolors-core/src/cleanliness.rs @@ -1,1282 +1,17 @@ -//! Порт финального «Закона Грязи» (Muddiness Law) системы `labcolors`. +//! Замороженная совместимая поверхность исторического «Закона Грязи V1». //! -//! # Уравнения +//! Модуль сохраняет только старые функции и константы с прежней семантикой для +//! воспроизводимости conformance-векторов. Новый код не должен использовать их +//! как универсальную шкалу качества, человеческий verdict или основание для +//! автоматического изменения цвета. //! -//! ```text -//! raw = neutral_gate(C) * hue_weight(h) * depth_mod(L, C, h) -//! mud = raw ← параметр-свободная монотонная нормировка (Zone B, 2026-06-30) -//! ``` -//! -//! Platt-звено (логарифм raw + eps → sigmoid с двумя подогнанными скалярами) **удалено полностью** -//! в Zone B slice 3: оба скаляра (M-07/M-08) подогнаны на авторском датасете 738 меток -//! (наблюдатель-фит) и нарушали инвариант «ZERO observer-fit» (North). mud = raw_chromatic напрямую: -//! - параметр-свободно (нет новых констант); -//! - монотонно (произведение монотонных сигмоидных факторов ∈ \[0,1\]); -//! - ограничено \[0,1\] точно (каждый множитель ∈ \[0,1\]); -//! - JND-относительно по конструкции (gate N(C) выровнен по M-02 JND). -//! -//! Бывший light-escape порог M-03 (DECLARED-CALIBRATION скаляр) и его escape-член -//! в `neutral_gate` **удалены целиком** (Zone B, 2026-07-01): escape-член протекал -//! ось L в функцию, документированную как чисто-хроматический hue-agnostic гейт — -//! нарушение независимости осей. `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)`. -//! -//! # Инвентарь параметров (раздел Muddiness Law) -//! -//! | const | mud-id | статус | значение | -//! |-----------------|--------|--------------------------------|-----------------------------| -//! | `C0` | M-01 | cited-and-kept | 0.0395 (граница серого sRGB; Evans/Xie-Fairchild yellow zero-grayness frontier) | -//! | `JND` | M-02 | cited-and-kept | 0.01228 (Oklab chroma JND; Oklab perceptual measurement) | -//! | `B0` | M-04 | cited-measured | 0.036 (центр диапазона [0.030, 0.044]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | -//! | `BW` | M-05 | cited-measured | 0.017 (центр диапазона [0.013, 0.020]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | -//! | ~~`CAL_EPS`~~ | M-06 | УДАЛЁН (Zone B slice 3) | был 0.01; log-регуляризатор для Platt — более не нужен | -//! | ~~`CAL_T`~~ | M-07 | УДАЛЁН (Zone B slice 3) | был 2.356978; Platt-скаляр, подогнанный на 738 авторских метках — нарушал ZERO observer-fit | -//! | ~~`CAL_B`~~ | M-08 | УДАЛЁН (Zone B slice 3) | был 6.445168; Platt-смещение — нарушало ZERO observer-fit | -//! | ~~`M_W`~~ | M-09 | УДАЛЁН (Front B, 2026-07-04) | был 0.181527; полуширина доверительного поля confidence-слоя — субъективная калибровка владельца без модели надёжности, вне пути эмиссии — удалён целиком | -//! | ~~`KAPPA_CORE`~~ | M-10 | УДАЛЁН (Front B, 2026-07-04) | был 0.34; потолок уверенности ядра — та же субъективная калибровка — удалён вместе с confidence-слоем | -//! | ~~`KAPPA_INTERIOR`~~ | M-11 | УДАЛЁН (Front B, 2026-07-04) | был 0.10; пол уверенности спорной полосы — удалён вместе с confidence-слоем | -//! | `H_Y_DEG` | M-12 | cited-derived (Zone B slice 5) | 90.4011° — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65, XYZ→Oklab напрямую); hue_weight = (1+cos(h−H_Y))/2 | -//! | ~~`W_HUE[8]`~~ | M-12 | УДАЛЁН (Zone B slice 4) | был: подогнанный K=3 Fourier-вектор логистической регрессии — нарушал ZERO observer-fit | -//! | `CUSP_L_TABLE` | M-13 | cited-and-kept | (чистая геометрия гамута Oklab, верифицирована до f64 — kept as-is) | -//! | ~~`CEIL_N_TABLE`~~ | M-14 | УДАЛЕНА (Zone B slice 4) | была: Fourier CEIL_N, подогнана на датасете v3 — более не нужна после BB-замены | - -#![allow(clippy::excessive_precision)] - -pub(crate) static CUSP_L_TABLE: [f64; 361] = [ - 0.64774951, 0.64579256, 0.64579256, 0.64383562, 0.64383562, 0.64187867, 0.64187867, 0.64187867, - 0.63992172, 0.63992172, 0.63796477, 0.63796477, 0.63796477, 0.63600783, 0.63600783, 0.63600783, - 0.63405088, 0.63405088, 0.63405088, 0.63209393, 0.63209393, 0.63209393, 0.63013699, 0.63013699, - 0.63013699, 0.63013699, 0.62818004, 0.62818004, 0.62818004, 0.62818004, 0.63209393, 0.63796477, - 0.64187867, 0.64774951, 0.65362035, 0.65753425, 0.66340509, 0.66731898, 0.67123288, 0.67710372, - 0.68101761, 0.68493151, 0.68884540, 0.69275930, 0.69667319, 0.70058708, 0.70450098, 0.70841487, - 0.71232877, 0.71624266, 0.72015656, 0.72407045, 0.72798434, 0.73189824, 0.73581213, 0.73776908, - 0.74168297, 0.74559687, 0.74951076, 0.75342466, 0.75538160, 0.75929550, 0.76320939, 0.76516634, - 0.76908023, 0.77299413, 0.77690802, 0.77886497, 0.78277886, 0.78669276, 0.79060665, 0.79256360, - 0.79647750, 0.80039139, 0.80430528, 0.80626223, 0.81017613, 0.81409002, 0.81800391, 0.82191781, - 0.82387476, 0.82778865, 0.83170254, 0.83561644, 0.83953033, 0.84344423, 0.84735812, 0.85127202, - 0.85518591, 0.85909980, 0.86301370, 0.86692759, 0.87084149, 0.87475538, 0.88062622, 0.88454012, - 0.88845401, 0.89432485, 0.89823875, 0.90410959, 0.90802348, 0.91389432, 0.91976517, 0.92563601, - 0.92954990, 0.93542074, 0.94129159, 0.94911937, 0.95499022, 0.96086106, 0.96673190, 0.96281800, - 0.96086106, 0.95890411, 0.95499022, 0.95303327, 0.94911937, 0.94716243, 0.94520548, 0.94129159, - 0.93933464, 0.93542074, 0.93346380, 0.92954990, 0.92759295, 0.92367906, 0.92172211, 0.91780822, - 0.91585127, 0.91193738, 0.90998043, 0.90606654, 0.90215264, 0.90019569, 0.89628180, 0.89236791, - 0.89041096, 0.88649706, 0.88258317, 0.87866928, 0.87475538, 0.87084149, 0.86692759, 0.86692759, - 0.86692759, 0.86888454, 0.86888454, 0.87084149, 0.87084149, 0.87279843, 0.87279843, 0.87475538, - 0.87475538, 0.87671233, 0.87671233, 0.87671233, 0.87866928, 0.87866928, 0.88062622, 0.88062622, - 0.88062622, 0.88258317, 0.88258317, 0.88454012, 0.88454012, 0.88454012, 0.88649706, 0.88649706, - 0.88649706, 0.88845401, 0.88845401, 0.88845401, 0.89041096, 0.89041096, 0.89041096, 0.89236791, - 0.89236791, 0.89236791, 0.89432485, 0.89432485, 0.89432485, 0.89628180, 0.89628180, 0.89628180, - 0.89823875, 0.89823875, 0.89823875, 0.90019569, 0.90019569, 0.90019569, 0.90215264, 0.90215264, - 0.90215264, 0.90410959, 0.90410959, 0.90410959, 0.89823875, 0.89432485, 0.89041096, 0.88649706, - 0.88062622, 0.87671233, 0.87279843, 0.86888454, 0.86497065, 0.86105675, 0.85714286, 0.85322896, - 0.84931507, 0.84540117, 0.84148728, 0.83757339, 0.83365949, 0.82974560, 0.82583170, 0.82191781, - 0.81800391, 0.81409002, 0.81017613, 0.80626223, 0.80234834, 0.79843444, 0.79452055, 0.79060665, - 0.78669276, 0.78277886, 0.77886497, 0.77495108, 0.76908023, 0.76516634, 0.76125245, 0.75733855, - 0.75342466, 0.74951076, 0.74559687, 0.73972603, 0.73581213, 0.73189824, 0.72602740, 0.72211350, - 0.71624266, 0.71232877, 0.70645793, 0.70254403, 0.69667319, 0.69080235, 0.68493151, 0.67906067, - 0.67318982, 0.66731898, 0.66144814, 0.65362035, 0.64774951, 0.63992172, 0.63209393, 0.62426614, - 0.61643836, 0.60665362, 0.59686888, 0.58512720, 0.57338552, 0.56164384, 0.54403131, 0.52446184, - 0.49315068, 0.45401174, 0.45596869, 0.45792564, 0.45988258, 0.46183953, 0.46575342, 0.46771037, - 0.46966732, 0.47162427, 0.47358121, 0.47749511, 0.47945205, 0.48140900, 0.48336595, 0.48727984, - 0.48923679, 0.49119374, 0.49510763, 0.49706458, 0.49902153, 0.50293542, 0.50489237, 0.50880626, - 0.51076321, 0.51467710, 0.51663405, 0.52054795, 0.52446184, 0.52641879, 0.53033268, 0.53424658, - 0.53816047, 0.54011742, 0.54403131, 0.54794521, 0.55185910, 0.55577299, 0.55968689, 0.56360078, - 0.56751468, 0.57142857, 0.57729941, 0.58121331, 0.58512720, 0.58904110, 0.59491194, 0.59882583, - 0.60469667, 0.60861057, 0.61448141, 0.62035225, 0.62426614, 0.63013699, 0.63600783, 0.64187867, - 0.64774951, 0.65362035, 0.65949119, 0.66536204, 0.67123288, 0.67906067, 0.68493151, 0.69275930, - 0.69863014, 0.69863014, 0.69667319, 0.69471624, 0.69080235, 0.68884540, 0.68688845, 0.68493151, - 0.68297456, 0.68101761, 0.67906067, 0.67710372, 0.67514677, 0.67318982, 0.67123288, 0.66927593, - 0.66731898, 0.66536204, 0.66536204, 0.66340509, 0.66144814, 0.65949119, 0.65949119, 0.65753425, - 0.65557730, 0.65557730, 0.65362035, 0.65166341, 0.65166341, 0.64970646, 0.64970646, 0.64774951, - 0.64774951, -]; - -// CEIL_N_TABLE удалена (Zone B slice 4, 2026-06-30): -// K=3 Fourier-базис CEIL_N использовался в hue_basis() для dot-product c W_HUE. -// После замены hue_weight на Hanning-окно BB ни hue_basis, ни CEIL_N_TABLE -// не нужны в продуктивном коде. - -// High-precision frozen parameter constants -pub const C0: f64 = 0.0395000000000000; -/// JND reuse rationale: this constant is the cited Oklab chroma just-noticeable-difference -/// (Oklab perceptual measurement, M-02 cited-and-kept). It is used as the sigmoid gate width -/// throughout the module because JND is the natural perceptual scale for a logistic boundary — -/// a ±1 JND band captures the transition from sub-threshold to supra-threshold chroma presence. -/// No new fit is performed; this is the same cited value reused as a scale parameter. -/// The applicability of a chroma JND as the gate width is a design assumption; if a future -/// study finds a different gate width is warranted, M-02 should be split into separate cited -/// values (one for detection threshold, one for gate width). Flagged OPEN as an assumption. -pub const JND: f64 = 0.0122779190541810; -// M-03 (former light-escape calibration threshold, DECLARED-CALIBRATION) removed -// entirely (Zone B, 2026-07-01): its escape term leaked the lightness axis into -// what is documented as a hue-agnostic, chroma-only gate (see `neutral_gate` above). -pub const B0: f64 = 0.036; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.030, 0.044] -pub const BW: f64 = 0.017; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.013, 0.020] -// CAL_EPS / CAL_T / CAL_B удалены (Zone B slice 3, 2026-06-30): -// Platt-звено sigmoid(CAL_T*ln(raw+eps)+CAL_B) было observer-fit на 738 авторских метках -// (нарушение ZERO observer-fit, North). Заменено параметр-свободным mud = raw_chromatic. -// Confidence-слой (M_W / KAPPA_CORE / KAPPA_INTERIOR) удалён целиком (Front B, 2026-07-04): -// это была субъективная калибровка владельца без модели надёжности — вне пути эмиссии -// (solve/semantic/resolve его не звали), вне агностичного контракта движка. Снос не -// двигает ни байта эмиссии. См. removed-строки M-09/M-10/M-11 в docs/empirical-inventory.md. - -// W_HUE[8] удалён (Zone B slice 4, 2026-06-30): -// Подогнанный вектор K=3 Фурье-регрессии (логистическая регрессия на 738 авторских -// метках, M-12) заменён выведенным Hanning-окном Бецольда-Брюкке. -// Формула: hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 -// Провенанс: инвариантная точка жёлтого BB 578nm (Purdy 1937; Jacobs & Wascher 1967, -// J. Opt. Soc. Am. 57, 1155–1156) × Oklab (Ottosson 2020). -// Инварианты: hue_weight(H_Y_DEG) = 1.0 точно; hue_weight(H_Y_DEG ± 180°) = 0.0 точно; -// строго монотонное убывание при |h − H_Y_DEG| растёт от 0 до 180°. - -/// Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° наблюдатель, D65). -/// -/// Вывод (Zone B slice 5, 2026-07-03 — исправление ошибочной деривации slice 4): -/// 1. CMF при 578nm — линейная интерп. официальной CIE 1931 2° 5нм-таблицы: -/// 575nm (x̄=0.8425, ȳ=0.9154, z̄=0.0018); 580nm (x̄=0.9163, ȳ=0.8700, z̄=0.00165); -/// t=0.6 → (x̄=0.886780, ȳ=0.888160, z̄=0.001710). -/// 2. Нормируем к Y=1: XYZ = (0.998446, 1.000000, 0.001925). -/// 3. XYZ → Oklab НАПРЯМУЮ через M1/M2 (Ottosson 2020). Проекция в sRGB не нужна: -/// hue инвариантен к абсолютной радиансности стимула — равномерное -/// масштабирование LMS′ множит a и b ОДИНАКОВО (линейность M2), отношение -/// в atan2 не меняется; отдельный верный факт: нулевые суммы строк a,b -/// матрицы M2 дают a = b = 0 на ахромате (L′=M′=S′). -/// 4. h = atan2(b, a) = 90.4011°. -/// -/// История slice 4 (значение 96.9172 — ОТОЗВАНО): деривация содержала две ошибки. -/// (а) CMF-значения были спутаны между строками таблицы (заявленные ȳ=0.7070/0.7570 -/// для 570/580нм — на деле ȳ(570)=0.9520, ȳ(580)=0.8700; 0.7570 — это ȳ(590нм)), -/// что давало ложный XYZ=(1.207, 1, 0) вместо (0.998, 1, 0.002). -/// (б) Перед взятием hue цвет клэмпился к гамуту sRGB — покомпонентный клэмп НЕ -/// сохраняет оттенок: без клэмпа тот же XYZ давал h=69.7°, после клэмпа 96.9°. -/// Значение 96.9172° было артефактом клэмпа поверх неверного XYZ. -/// -/// Выбор λ=578nm: инвариантная точка жёлтого Бецольда-Брюкке (Purdy 1937, -/// Am. J. Psychol. 49, 313–315; подтверждено Jacobs & Wascher 1967, -/// J. Opt. Soc. Am. 57, 1155–1156). Прежняя цитата «Parry (1967) JOSA 57, -/// 1130–1134» не существует в литературе и удалена. -/// -/// Использование: центр Hanning-окна `hue_weight(h)` (M-12). -/// Константа выводная, не подогнана; воспроизводится скриптом деривации из CMF. -pub const H_Y_DEG: f64 = 90.4011; - -#[inline] -fn sigmoid(x: f64) -> f64 { - 1.0 / (1.0 + (-x).exp()) -} - -/// Compute the Oklab yellow-blue opponent b axis coordinate: b = C * sin(h) -#[inline] -pub fn b_of(c: f64, h_deg: f64) -> f64 { - c * h_deg.to_radians().sin() -} - -/// Precomputed cusp lightness as a function of hue (Oklab degree) with linear interpolation. -pub fn cusp_l_of(h_deg: f64) -> f64 { - let h = h_deg.rem_euclid(360.0); - let idx = h.floor() as usize; - let fract = h - h.floor(); - let next_idx = if idx >= 360 { 0 } else { idx + 1 }; - let y0 = CUSP_L_TABLE[idx]; - let y1 = CUSP_L_TABLE[next_idx]; - y0 + fract * (y1 - y0) -} - -/// Depth term: how far below the hue's clean cusp the colour sits, normalized by cusp L. -/// Ranges from 0.0 (at/above cusp) to 1.0 (deep in the basement). -pub fn depth_term(l: f64, h_deg: f64) -> f64 { - let cusp_l = cusp_l_of(h_deg); - let below = (cusp_l - l) / cusp_l.max(1e-6); - below.clamp(0.0, 1.0) -} - -/// Hue-agnostic chroma-confidence gate: checks if the color is chromatic at all, or reads grey/beige. -/// -/// `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)` — a pure JND-relative -/// chroma-presence gate. The former light-escape term (a product of two -/// sigmoids gated on lightness and chroma) depended on a DECLARED-CALIBRATION -/// threshold (M-03, a Platt-fit scalar on the v3 dataset) and leaked the -/// lightness axis into a function documented as hue-agnostic and chroma-only — -/// removed entirely (Zone B, 2026-07-01) per North's ZERO observer-fit invariant. -pub fn neutral_gate(c: f64, c0: f64, jnd: f64) -> f64 { - sigmoid((c - c0) / jnd) -} - -/// Opponent b-gate (warm/clean filter): gates the geometric depth term. Warm -> 1.0, cool -> 0.0. -pub fn depth_mod(l: f64, c: f64, h_deg: f64, b0: f64, bw: f64) -> f64 { - let dp = depth_term(l, h_deg); - let s = sigmoid((b_of(c, h_deg) - b0) / bw); - dp * s -} - -/// Весовой множитель оттенка — Hanning-окно Бецольда-Брюкке (Zone B slice 4, 2026-06-30). -/// -/// Заменяет подогнанный вектор W_HUE\[8\] (M-12, удалён в Zone B slice 4) на выводную формулу. -/// -/// # Вывод формулы -/// -/// Эффект Бецольда-Брюкке — реальный психофизический феномен (уникальный жёлтый и его -/// инвариантная точка заземлены в этом же файле через Purdy 1937, см. [`H_Y_DEG`]): при -/// росте яркости оттенки смещаются к уникальному жёлтому (h_Y) и уникальному синему. -/// Косинусная модель плотности конвергенции ниже — ЭВРИСТИКА (параметрическая форма -/// Hanning-окна); железный первоисточник ИМЕННО ЭТОЙ формы не установлен. Прежняя ссылка -/// «Parry (1967) JOSA 57, 1130–1134» не существует в литературе (отозвана у [`H_Y_DEG`]) -/// и удалена. Производная смещения по Oklab-оттенку: -/// -/// dΔH_BB/dh = A_BB · cos(h − h_Y) -/// -/// — плотность конвергенции оттенков к h_Y. Максимальна при h = h_Y (все ближние оттенки -/// сходятся к уникальному жёлтому, зона максимальной «грязи»); обнуляется при |h − h_Y| = 90°; -/// минимальна при |h − h_Y| = 180° (противоположное направление). -/// -/// Нормируем в [0, 1]: -/// -/// hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 [Hanning-окно] -/// -/// # Провенанс констант -/// -/// - `H_Y_DEG = 90.4011°` — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65). -/// Вывод: CMF → XYZ → Oklab напрямую (Ottosson 2020) → atan2(b, a); без sRGB-клэмпа -/// (клэмп не сохраняет hue — см. историю slice 4 в доке H_Y_DEG). -/// Цитата: Purdy (1937) — инвариантная точка жёлтого 578nm; Jacobs & Wascher (1967) -/// J. Opt. Soc. Am. 57, 1155–1156. -/// -/// # Инварианты (проверены тестами) -/// -/// - `hue_weight(H_Y_DEG) == 1.0` точно (cos(0) = 1) -/// - `hue_weight(H_Y_DEG + 180°) == 0.0` точно (cos(π) = -1) -/// - строго монотонное убывание при |h − H_Y_DEG| растёт от 0° до 180° -/// - симметрия: hue_weight(h_Y + δ) == hue_weight(h_Y − δ) -/// -/// # Независимость осей -/// -/// Эта функция НЕ вызывается внутри `drab` или `n_pure` (вторая ось mud/drab). -/// Единственная точка входа — `raw_chromatic`, которая и так гейтирована b-axis (depth_mod). -#[inline] -pub fn hue_weight(h_deg: f64) -> f64 { - // Hanning-окно: (1 + cos(δ)) / 2, где δ = h − H_Y_DEG нормирован в [0°, 360°). - // - // Граничные случаи обрабатываются явно, чтобы гарантировать платформенно-точные - // значения 1.0 и 0.0 независимо от FP-реализации cos(0) / cos(π): - // δ == 0.0° → cos(0) = 1 → hw = 1.0 точно - // δ == 180.0° → cos(π) = −1 → hw = 0.0 точно - // Остальные углы вычисляются через cos как обычно. - let delta = (h_deg - H_Y_DEG).rem_euclid(360.0); - if delta == 0.0 { - 1.0 - } else if delta == 180.0 { - 0.0 - } else { - (1.0 + delta.to_radians().cos()) / 2.0 - } -} - -/// Сырой хроматический счёт грязи (без масштабирования). -/// -/// raw = N(C) × hue_weight_BB(h) × depth_mod(L, C, h) -/// -/// Все три множителя ∈ \[0,1\], поэтому raw ∈ \[0,1\] строго. -pub fn raw_chromatic(l: f64, c: f64, h_deg: f64) -> f64 { - let cc = neutral_gate(c, C0, JND); - let hw = hue_weight(h_deg); - let dpm = depth_mod(l, c, h_deg, B0, BW); - cc * hw * dpm -} - -/// Параметр-свободная монотонная цена грязи (Zone B slices 3+4, 2026-06-30). -/// -/// mud = raw_chromatic(l, c, h) — прямое произведение трёх перцептивно-обоснованных -/// факторов из геометрии Oklab и поворота Бецольда-Брюкке: -/// N(C) = sigmoid((C - C0) / JND) — JND-взвешенное присутствие хромы (M-01/M-02) -/// hue_weight(h) = (1+cos(h−H_Y_DEG))/2 — Hanning-окно BB (M-12, Zone B slice 4) -/// depth_mod(L, C, h) — глубина под cusp-L, взвешенная b-гейтом (M-04/M-05) -/// -/// Свойства: -/// - Выводных констант: только H_Y_DEG = 90.4° (CIE D65 unique yellow 578nm, Purdy 1937) -/// - Нет подогнанных скаляров: W_HUE/CEIL_N_TABLE/CAL_T/CAL_B — все удалены -/// - raw ∈ \[0,1\] точно (произведение ∈ \[0,1\] множителей) -/// - Монотонно в C только на ТЁПЛОЙ полуплоскости (h∈[0°,180°], sin h ≥ 0): -/// N(C) растёт, b-гейт depth_mod не убывает. На холодных (sin h < 0) b-гейт -/// `sigmoid((c·sin h − B0)/BW)` убывает в C → НЕ глобально монотонно -/// (запинено `property_invariants`: warm-инвариант + cool-characterization) -/// - JND-нормировано по конструкции через M-02 -/// - .clamp(0.0, 1.0) — safety no-op при f64-округлении -pub fn muddiness_oklch(l: f64, c: f64, h_deg: f64) -> f64 { - raw_chromatic(l, c, h_deg).clamp(0.0, 1.0) -} - -/// Compute muddiness from linear sRGB values [r, g, b] in [0, 1]. -pub fn muddiness_from_linear_srgb(rgb: [f64; 3]) -> f64 { - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let l = lab[0]; - let c = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - let h = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); - muddiness_oklch(l, c, h) -} - -/// Compute muddiness from an sRGB hex color string like "#6B6B2E". -pub fn muddiness_from_hex(hex: &str) -> Result { - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - Ok(muddiness_from_linear_srgb(rgb)) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Drab defect head (Zone B slice 2 — second INDEPENDENT output) -// -// `n_pure` and `drab` are a SEPARATE defect axis from mud/muddiness_oklch. -// They share ONLY the cited gate constants C0 / JND as INPUTS. -// They are NEVER called inside `raw_chromatic`, `muddiness_oklch`, or any -// other mud code path — the two axes are provably independent (the curl / -// integrability two-axes result from the paradigm North). -// -// drab(C) = sigmoid((C0 - C) / JND) -// = 1 - sigmoid((C - C0) / JND) [= 1 - n_pure(C)] -// -// Constants reused (zero new parameters): -// C0 = 0.0395 (cited-and-kept, M-01, Evans/Xie-Fairchild yellow zero-grayness frontier) -// JND = 0.0122779190541810 (cited-and-kept, M-02, Oklab chroma JND) -// -// Статус (актуально на 2026-07-01): W_HUE[8] удалён и заменён выведенным Hanning-окном -// Бецольда-Брюкке (Zone B slice 4, M-12 cited-derived, см. таблицу инвентаря выше); -// Platt CAL_T/CAL_B удалены (Zone B slice 3). Открытых наблюдатель-фит параметров -// в этом блоке не осталось. -// ───────────────────────────────────────────────────────────────────────────── - -/// Pure chroma-presence gate: N_pure(C) = sigmoid((C - C0) / JND). -/// -/// This is the shared gate that enters both mud (as `neutral_gate`) and drab -/// (as 1 - N_pure). It is kept as a standalone `pub fn` so callers can -/// compose the two heads independently without re-implementing the gate. -/// -/// Parameters: cited-and-kept C0 (M-01) and JND (M-02). Zero new parameters. -#[inline] -pub fn n_pure(c: f64) -> f64 { - sigmoid((c - C0) / JND) -} - -/// Drab defect head: D(C) = 1 − N_pure(C). -/// -/// Measures chroma ABSENCE — the complement of the cited chroma-presence gate. -/// Returns 1.0 for achromatic colours (C ≈ 0, deeply grey/beige) and 0.0 for -/// strongly chromatic colours (C >> C0). -/// -/// Implementation: `1.0 - n_pure(c)` — exact f64 arithmetic complement. -/// This guarantees `drab(C) + n_pure(C) == 1.0` exactly in IEEE 754, which -/// the property tests verify. The equivalent closed form sigmoid((C0 - C) / JND) -/// would produce values that sum to 1.0 only up to ±1 ULP due to independent -/// exp() calls; the subtraction form is exact by construction. -/// -/// Invariants guaranteed by construction (independently property-tested): -/// `drab(C) + n_pure(C) == 1.0` exact in f64 -/// `drab` is strictly monotone-decreasing in C -/// `drab(C0) == 0.5` (sigmoid(0) == 0.5 exactly, since n_pure(C0) == 0.5) -/// -/// This function is a SECOND DISTINCT output — it is NEVER called inside -/// `raw_chromatic`, `muddiness_oklch`, or any mud path. The only coupling -/// with the mud head is the shared C0/JND constants as a shared INPUT gate. -#[inline] -pub fn drab(c: f64) -> f64 { - 1.0 - n_pure(c) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Surround-aware оценка дефектов (Zone G) -// -// Тема снаружи (light / dark / light-ic / dark-ic) маппируется на ViewingConditions -// по таблице CIECAM16 (Li et al. 2017, Table 1): -// light → average surround: F=1.0, c=0.69, Nc=1.0 -// dark → dim surround: F=0.9, c=0.59, Nc=0.9 -// light-ic → average + increased contrast flag -// dark-ic → dim + increased contrast flag -// -// Фон bg_hex даёт яркость Yb (Y-компонент фона в % от D65-белого), которая -// подставляется в ViewingConditions::srgb_with_yb / dim_surround_with_yb. -// Это единственный канал влияния фона на дефект — через CAM16 J (apparent lightness) -// цвета под данным Yb+surround. -// -// mud и drab считаются на (l_app=J/100, C_oklab, h_oklab): -// - C_oklab, h_oklab — Oklab-координаты исходного цвета (не меняются от surround) -// - l_app = J/100 — CAM16 apparent lightness под фоном+темой, нормирован в [0,1] -// -// Это заменяет Oklab L в depth_term(l, h) (определяет положение цвета под cusp), -// делая оценку surround-aware: цвет на тёмном фоне «воспринимается светлее», -// лежит ближе к cusp-L и получает меньший depth_term → меньшую грязь. -// -// Ноль новых параметров: все VC-параметры — таблица CIECAM16 (Li et al. 2017). -// ───────────────────────────────────────────────────────────────────────────── - -/// Тема просмотра — определяет условия окружения (surround) для оценки дефектов. -/// -/// Соответствие CIECAM16 (Li et al. 2017, Table 1): -/// - `Light` → average surround (F=1.0, c=0.69, Nc=1.0) -/// - `Dark` → dim surround (F=0.9, c=0.59, Nc=0.9) -/// - `LightIc` / `DarkIc` — то же, но с флагом повышенного контраста -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Theme { - /// Светлая тема: average surround (CIECAM16 Table 1). - Light, - /// Тёмная тема: dim surround (CIECAM16 Table 1). - Dark, - /// Светлая тема с повышенным контрастом (IC). - LightIc, - /// Тёмная тема с повышенным контрастом (IC). - DarkIc, -} - -impl Theme { - /// Разобрать стабильный kebab-контракт границы (`"light"` / `"dark"` / - /// `"light-ic"` / `"dark-ic"`). Неизвестная строка — ошибка вызывающего, - /// возвращается как есть (граница оборачивает в свой тип ошибки), никогда - /// не коэрсится в тему по умолчанию. - /// - /// # Errors - /// - /// `Err` с непринятой строкой. - pub fn parse(raw: &str) -> Result { - match raw { - "light" => Ok(Theme::Light), - "dark" => Ok(Theme::Dark), - "light-ic" => Ok(Theme::LightIc), - "dark-ic" => Ok(Theme::DarkIc), - other => Err(other.to_string()), - } - } - - /// Стабильный kebab-ключ темы — обратная к [`parse`](Self::parse). - pub fn key(self) -> &'static str { - match self { - Theme::Light => "light", - Theme::Dark => "dark", - Theme::LightIc => "light-ic", - Theme::DarkIc => "dark-ic", - } - } - - /// Условия просмотра, под которыми ядро резолвит эту тему: та же карта - /// surround-ов, что у `vc_for_context` (Light → average, Dark → dim, - /// IC-темы → high-contrast двойники), но с дефолтным Yb — вход границы, - /// где фон ещё неизвестен. - pub fn viewing_conditions(self) -> crate::spaces::vc::ViewingConditions { - self.vc_by( - crate::spaces::vc::ViewingConditions::srgb, - crate::spaces::vc::ViewingConditions::dim_surround, - ) - } - - /// ЕДИНАЯ карта тема → surround: light-темы берут average-конструктор, - /// dark-темы — dim, IC-темы поднимают флаг повышенного контраста. Обе - /// точки входа (дефолтный Yb на границе, Yb-от-фона в контексте дефектов) - /// обязаны выбирать surround здесь — вторая копия карты в цветовом коде - /// расходилась бы тихо при добавлении темы. - fn vc_by( - self, - srgb: impl FnOnce() -> crate::spaces::vc::ViewingConditions, - dim: impl FnOnce() -> crate::spaces::vc::ViewingConditions, - ) -> crate::spaces::vc::ViewingConditions { - let (mut vc, ic) = match self { - Theme::Light => (srgb(), false), - Theme::Dark => (dim(), false), - Theme::LightIc => (srgb(), true), - Theme::DarkIc => (dim(), true), - }; - vc.high_contrast = vc.high_contrast || ic; - vc - } -} - -/// Контекст просмотра для surround-aware оценки дефектов. -/// -/// Сочетает фон (hex-строка, задаёт яркость Yb) и тему (определяет surround). -/// Передаётся в `muddiness_in_context` / `drab_in_context`. -#[derive(Debug, Clone, Copy)] -pub struct DefectContext<'a> { - /// Фоновый цвет в hex (`#RRGGBB`). Задаёт яркость фона Yb для CIECAM16. - pub bg_hex: &'a str, - /// Тема просмотра. - pub theme: Theme, -} - -/// Y-компонент (относительная яркость) hex-цвета в % от D65-белого. -/// -/// Формула: Y = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin (IEC 61966-2-1 D65), -/// затем умножаем на 100 для CIECAM16 (где Yb задаётся в %). -/// -/// Диапазон результата: [0.0, 100.0]. -fn y_pct_from_hex(hex: &str) -> Result { - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - // Строка srgb_from_hex возвращает ЛИНЕЙНЫЙ sRGB (без гаммы). - // Y (IEC 61966-2-1, D65): Y = 0.2126 R + 0.7152 G + 0.0722 B - let y = 0.212_639_005_871_510_27 * rgb[0] - + 0.715_168_678_767_756 * rgb[1] - + 0.072_192_315_360_733_71 * rgb[2]; - Ok(y * 100.0) -} - -/// Viewing conditions для заданной темы и яркости фона Yb (в %). -/// -/// Параметры surround — CIECAM16 Table 1 (Li et al. 2017). Ноль новых констант. -fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingConditions { - theme.vc_by( - || crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct), - || crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct), - ) -} - -/// Surround-aware оценка грязи цвета в заданном контексте просмотра. -/// -/// # Алгоритм -/// -/// 1. Из `hex` получаем Oklab `(L, C, h)` для геометрии (C, h не зависят от surround). -/// 2. Из `ctx.bg_hex` получаем Yb — яркость фона в % от D65-белого. -/// 3. Строим `ViewingConditions` для темы `ctx.theme` с данным Yb (CIECAM16 Table 1). -/// 4. Из `hex` → XYZ → CAM16 `J` (apparent lightness под фоном+temой). -/// 5. `l_app = J / 100` — нормированный apparent lightness ∈ [0, 1]. -/// 6. mud = `raw_chromatic(l_app, C_oklab, h_oklab)` — формула без изменений, -/// но `l_app` учитывает surround и фон вместо Oklab L. -/// -/// # Провенанс -/// -/// VC-параметры: CIECAM16, Li et al. 2017, DOI 10.1002/col.22131, Table 1. -/// Формула mud — без изменений (Zone B slices 3+4; H_Y_DEG, B0, BW цитированы). -pub fn muddiness_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { - // Oklab-координаты: C и h не зависят от viewing conditions - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - let h_oklab = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); - - // Apparent lightness J через CAM16 под фоном+surround - let y_b_pct = y_pct_from_hex(ctx.bg_hex)?; - let vc = vc_for_context(ctx.theme, y_b_pct); - let xyz = crate::spaces::srgb::srgb_to_xyz(rgb); - let (j, _m, _h_cam) = crate::spaces::cam16::forward(xyz, &vc); - let l_app = (j / 100.0).clamp(0.0, 1.0); - - Ok(raw_chromatic(l_app, c_oklab, h_oklab).clamp(0.0, 1.0)) -} - -/// Surround-aware оценка тусклости цвета в заданном контексте просмотра. -/// -/// Тусклость `drab(C) = 1 − N_pure(C)` не зависит от lightness — только от -/// Oklab chroma C, которая инвариантна к surround. Тем не менее функция принимает -/// контекст для симметрии API и будущего расширения (например, gamut-aware chroma). -/// -/// Возвращает то же значение, что `drab(C_oklab)` — surround-aware путь mud -/// не меняет drab, только grязь через depth_term зависит от J. -pub fn drab_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { - // drab зависит только от C_oklab — surround не меняет chroma в Oklab - let _ = ctx; // принимается для симметрии API - let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - Ok(drab(c_oklab)) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ─── Smoke / class-membership reference values ──────────────────────────── - // - // Обновлено в Zone B slice 4 (2026-06-30): W_HUE заменён выведенным Hanning-окном - // Бецольда-Брюкке. Диапазон mud значительно расширился: [0, ~0.41] вместо [0.00..0.14]. - // Это ожидаемо — BB-формула даёт hue_weight ≈ 1.0 в широком тёплом секторе вместо - // узкого sigmoid-пика из логистической регрессии W_HUE. - // - // Ранговый порядок грязности: olive > babypoop > gold2 > gold1 > puke - // (прим.: gold2 > gold1 — артефакт подогнанного W_HUE исчез; теперь глубина - // под cusp-L побеждает, что физически более верно). - // - // Класс теста: class A (unit + reference), Фаулер. - // TDD: RED до коммита 2 (W_HUE → BB), GREEN после. - #[test] - fn test_muddiness_v3_reference_values() { - // Olive #6B6B2E (Наиболее грязный — максимальный raw) - // Провенанс golden: mud = N(C) * hue_weight_BB(h) * depth_mod(L, C, h), - // hue_weight_BB(109°) = (1 + cos(109° - 90.4°)) / 2 ≈ 0.974. - let olive_mud = muddiness_from_hex("#6B6B2E").unwrap(); - assert!( - olive_mud > 0.30, - "olive mud is {olive_mud:.6}, expected > 0.30 (BB-диапазон)" - ); - assert!( - (olive_mud - 0.40640072).abs() < 1e-5, - "olive mud {olive_mud:.8} != 0.40640072 (Zone B slice 5 H_Y golden)" - ); - - // Babypoop #937C00 (Очень грязный) - // hue_weight_BB(96°) = (1 + cos(96° - 90.4°)) / 2 ≈ 0.998. - let babypoop_mud = muddiness_from_hex("#937C00").unwrap(); - assert!( - babypoop_mud > 0.20, - "babypoop mud is {babypoop_mud:.6}, expected > 0.20" - ); - assert!( - (babypoop_mud - 0.33220155).abs() < 1e-5, - "babypoop mud {babypoop_mud:.8} != 0.33220155" - ); - - // Puke #9AAE07 (Умеренно грязный) - let puke_mud = muddiness_from_hex("#9AAE07").unwrap(); - assert!( - (puke_mud - 0.23449461).abs() < 1e-5, - "puke mud {puke_mud:.8} != 0.23449461" - ); - - // Золотые (средний диапазон) - let gold1_mud = muddiness_from_hex("#9e6c00").unwrap(); - assert!( - (gold1_mud - 0.29296999).abs() < 1e-5, - "gold1 mud {gold1_mud:.8} != 0.29296999" - ); - - let gold2_mud = muddiness_from_hex("#8f6424").unwrap(); - assert!( - (gold2_mud - 0.30971375).abs() < 1e-5, - "gold2 mud {gold2_mud:.8} != 0.30971375" - ); - - // Ранговый порядок: olive > babypoop > gold2 > gold1 > puke >> achromatic - // Примечание: gold2 > gold1 (BB-формула; прежний порядок gold1 > gold2 был - // артефактом подогнанного W_HUE, не геометрическим свойством Oklab). - assert!( - olive_mud > babypoop_mud, - "olive должен быть грязнее babypoop: {olive_mud:.6} vs {babypoop_mud:.6}" - ); - assert!( - babypoop_mud > gold1_mud && babypoop_mud > gold2_mud, - "babypoop должен быть грязнее золотых: {babypoop_mud:.6} vs gold1={gold1_mud:.6} gold2={gold2_mud:.6}" - ); - assert!( - gold2_mud > gold1_mud, - "gold2 должен быть грязнее gold1 по BB-формуле: gold2={gold2_mud:.8} vs gold1={gold1_mud:.8}" - ); - assert!( - gold1_mud > puke_mud && gold2_mud > puke_mud, - "золотые должны быть грязнее puke: gold1={gold1_mud:.6} gold2={gold2_mud:.6} vs puke={puke_mud:.6}" - ); - - // Pure grey #808080 (Чистый — achromatic, N(C) → 0) - let grey_mud = muddiness_from_hex("#808080").unwrap(); - assert!( - grey_mud < 0.005, - "grey mud is {grey_mud:.6}, expected < 0.005 (N(C) подавляет)" - ); - - // Teal #008080 (прохладный — b < B0, depth_mod → почти 0) - let teal_mud = muddiness_from_hex("#008080").unwrap(); - assert!( - teal_mud < 0.01, - "teal mud is {teal_mud:.6}, expected < 0.01 (depth_mod b-гейт)" - ); - assert!( - (teal_mud - 0.00430967).abs() < 1e-5, - "teal mud {teal_mud:.8} != 0.00430967 (Zone B slice 5 H_Y golden)" - ); - - // Navy Blue #000080 (Чистый — синий, depth_mod → 0 через b-гейт) - let navy_mud = muddiness_from_hex("#000080").unwrap(); - assert!( - navy_mud < 0.001, - "navy mud is {navy_mud:.8}, expected < 0.001" - ); - } - - // ─── Characterization golden test (Zone B slice 4, Fowler class B) ───────── - // - // Фиксирует таблицу mud(L,C,h) после замены W_HUE на Hanning-окно BB. - // mud = N(C) * hue_weight_BB(h) * depth_mod(L, C, h) - // где hue_weight_BB(h) = (1 + cos(h − H_Y_DEG)) / 2 - // - // Golden-значения получены из фактического кода после коммита 2 (Zone B slice 4) - // и зафиксированы как regression anchor. Tolerance: ±5e-6 (точность f64). - // - // Класс теста: B (characterization/golden, Фаулер) — якорь регрессии. - // История: Zone B slice 3 (Platt-удаление) имела диапазон [0.00..0.14]; - // Zone B slice 4 (BB-замена) расширила до [0.00..0.41] — ожидаемо - // (hue_weight_BB ≈ 1.0 шире, чем узкий sigmoid-пик W_HUE). - #[test] - fn characterization_mud_golden_cited() { - struct Case { - hex: &'static str, - label: &'static str, - expected: f64, - } - // Значения = muddiness_oklch(l,c,h) (BB-путь, Zone B slice 4) - let cases = [ - Case { - hex: "#6B6B2E", - label: "olive", - expected: 0.40640072, - }, - Case { - hex: "#937C00", - label: "babypoop", - expected: 0.33220155, - }, - Case { - hex: "#9AAE07", - label: "puke", - expected: 0.23449461, - }, - Case { - hex: "#9e6c00", - label: "gold1", - expected: 0.29296999, - }, - Case { - hex: "#8f6424", - label: "gold2", - expected: 0.30971375, - }, - Case { - hex: "#808080", - label: "grey", - expected: 0.00125988, - }, - Case { - hex: "#008080", - label: "teal", - expected: 0.00430967, - }, - Case { - hex: "#000080", - label: "navy", - expected: 0.00000000, - }, - Case { - hex: "#FF0000", - label: "red", - expected: 0.00133634, - }, - Case { - hex: "#0000FF", - label: "blue", - expected: 0.00000000, - }, - ]; - for c in &cases { - let got = muddiness_from_hex(c.hex).unwrap(); - assert!( - (got - c.expected).abs() < 5e-6, - "characterization_mud_golden_cited: {} {}: got={:.8} expected={:.8} delta={:.2e}", - c.label, - c.hex, - got, - c.expected, - (got - c.expected).abs() - ); - } - } - - // ─── CAL_T / CAL_B absence guard (Zone B slice 3, Fowler class Д) ────────── - // - // Проверяет, что `pub const CAL_T` и `pub const CAL_B` больше не определены, - // а `muddiness_oklch` не содержит вызовов CAL_T/CAL_B вне комментариев. - // - // Стратегия: ищем конкретные паттерны объявления (pub const CAL_T / pub const CAL_B) - // и паттерн употребления (CAL_T * / * CAL_B). Комментарии, описывающие удаление, - // допустимы — North: «комментарии могут только описывать удаление по-русски». - // - // RED на Platt-дереве (pub const CAL_T/CAL_B существуют и используются в muddiness_oklch). - // GREEN после коммита 2 (определения и употребления удалены). - #[test] - fn cal_t_cal_b_absent_from_shipping_code() { - let source = include_str!("cleanliness.rs"); - // Продуктивный код — всё до маркера тест-модуля - let prod_code = source.split("#[cfg(test)]").next().unwrap_or(source); - - // Проверяем отсутствие ОБЪЯВЛЕНИЙ (pub const CAL_T / pub const CAL_B) - assert!( - !prod_code.contains("pub const CAL_T"), - "pub const CAL_T найден в продуктивном коде — Platt-скаляр не удалён (M-07)." - ); - assert!( - !prod_code.contains("pub const CAL_B"), - "pub const CAL_B найден в продуктивном коде — Platt-скаляр не удалён (M-08)." - ); - - // Проверяем отсутствие УПОТРЕБЛЕНИЙ в продуктивных выражениях - // (паттерны: CAL_T * z и + CAL_B) - assert!( - !prod_code.contains("CAL_T *"), - "CAL_T * найден в продуктивном коде — muddiness_oklch всё ещё использует Platt." - ); - assert!( - !prod_code.contains("+ CAL_B"), - "+ CAL_B найден в продуктивном коде — muddiness_oklch всё ещё использует Platt." - ); - assert!( - !prod_code.contains("pub const CAL_EPS"), - "pub const CAL_EPS найден в продуктивном коде — log-регуляризатор Platt не удалён (M-06)." - ); - } - - // ─── Light-escape term removal guard (Zone B, Fowler class Д) ────────────── - // - // Проверяет, что `neutral_gate` — теперь 3-арная функция от (c, c0, jnd) — - // не содержит скрытого escape-члена, зависящего от L (former M-03 - // DECLARED-CALIBRATION threshold). Закрывает класс «скрытый калибровочный - // порог + протечка оси L в чисто-хроматический гейт». - // - // Стратегия (сильнее grep-по-имени): арность сигнатуры проверяется на - // этапе КОМПИЛЯЦИИ (вызов ровно с 3 аргументами не скомпилируется, если - // функция всё ещё принимает L/escape-параметр — RED был подтверждён до - // правки: 5-арный вызов не собирался с 3 аргументами). Плюс узкий - // grep-guard на паттерн употребления (`let escape`) и property-sweep, - // доказывающий БИТОВОЕ равенство sigmoid((c-c0)/jnd) на широком диапазоне - // C — если бы escape-член вернулся под другим именем, sweep поймал бы - // отклонение на большинстве точек. - // - // RED на дереве с escape-членом: 3-арный вызов не компилировался (см. - // историю коммита). GREEN после удаления: точное битовое равенство. - #[test] - fn light_escape_term_absent_from_shipping_code() { - let source = include_str!("cleanliness.rs"); - let prod_code = source.split("#[cfg(test)]").next().unwrap_or(source); - - assert!( - !prod_code.contains("let escape"), - "`let escape` найден в продуктивном коде — escape-член neutral_gate не удалён." - ); - - // neutral_gate теперь чистый sigmoid gate: sigmoid((c-c0)/jnd), без L и без escape. - // Sweep C от 0.0 до 1.0 (включая бледный тёплый диапазон, где раньше срабатывал - // escape-член и подавлял cc) — каждая точка должна ТОЧНО совпадать с sigmoid. - let steps = 1001usize; - for i in 0..=steps { - let c = (i as f64) / (steps as f64); - let expected = sigmoid((c - C0) / JND); - let got = neutral_gate(c, C0, JND); - assert_eq!( - got, expected, - "neutral_gate(c={c:.6}, C0, JND) должен быть РОВНО sigmoid((c-C0)/JND) без \ - escape-члена; got={got:.18} expected={expected:.18}" - ); - } - } - - // ─── Drab head property tests (Zone B slice 2, Fowler class A) ────────── - // - // Three properties that MUST hold for drab(C) = sigmoid((C0 - C) / JND) - // (the chroma-absence complement of the cited gate N_pure(C)): - // - // (a) D(C) + N_pure(C) == 1.0 exact in f64 across a chroma sweep - // — they are complements by construction: sigmoid(x) + sigmoid(-x) == 1. - // (b) D'(C) < 0 — drab strictly DECREASES as chroma C rises - // (finite-difference monotonicity over the sweep). - // (c) D(C0) == 0.5 exactly — sigmoid(0) == 0.5 by definition. - // - // These tests reference `drab` and `n_pure`, which do NOT exist at this - // commit (commit 1 of 2). They compile-fail at this HEAD, proving - // TDD RED-first. Verified independently in an isolated worktree. - #[test] - fn drab_plus_n_pure_equals_one_sweep() { - // Sweep C from 0.0 to 0.3 in 1001 steps; verify D + N == 1.0 exact. - let steps = 1001usize; - for i in 0..=steps { - let c = (i as f64) * 0.3 / (steps as f64); - let d = drab(c); - let n = n_pure(c); - assert_eq!( - d + n, - 1.0, - "drab + n_pure != 1.0 at C={c:.6}: drab={d:.18} n_pure={n:.18}" - ); - } - } - - #[test] - fn drab_strictly_decreasing_in_chroma() { - // Finite-difference monotonicity: drab(C+delta) < drab(C) for all C. - let steps = 1001usize; - let delta = 0.3 / (steps as f64); - for i in 0..steps { - let c0v = (i as f64) * 0.3 / (steps as f64); - let c1 = c0v + delta; - let d0 = drab(c0v); - let d1 = drab(c1); - assert!( - d1 < d0, - "drab not strictly decreasing: drab({c1:.6})={d1:.18} >= drab({c0v:.6})={d0:.18}" - ); - } - } - - #[test] - fn drab_at_c0_is_half() { - // D(C0) must equal exactly 0.5 because sigmoid(0) == 0.5. - let d = drab(C0); - assert_eq!( - d, 0.5, - "drab(C0) should be exactly 0.5 (sigmoid(0)); got {d:.18}" - ); - } - - // ─── Zone B slice 4: W_HUE absent + Бецольд-Брюкке инварианты (TDD RED-first) ── - // - // Три теста закрывают КЛАСС дефектов: «подогнанный вектор вместо выведенной формулы». - // - // (1) w_hue_fitted_vector_absent_from_shipping — RED: `pub static W_HUE` ещё - // существует в продуктивном коде (и hue_basis, hue_weight ещё опираются на него). - // GREEN после коммита 2: W_HUE убран, hue_weight переписан. - // - // (2) bezold_brucke_at_unique_yellow_is_one — RED: текущий hue_weight(97°) ≈ 0.2 - // (sigmoid(dot(W_HUE, basis(97°))) ≠ 1.0). GREEN после коммита 2: - // выведенная формула (1 + cos(0)) / 2 == 1.0 точно. - // - // (3) bezold_brucke_at_opposite_is_zero — RED: текущий hue_weight(277°) ≈ 0.015 - // (sigmoid(dot) ≠ 0.0). GREEN после коммита 2: - // (1 + cos(π)) / 2 == 0.0 точно. - // - // (4) bezold_brucke_monotone_from_unique_yellow — RED: текущая формула не является - // Hanning-окном и нарушает монотонное убывание от h_Y по обе стороны. - // GREEN после коммита 2: cos(δ) монотонно убывает при |δ| растёт от 0 до π. - // - // Класс багов: fitted-вектор не имеет гарантированных инвариантов hue_weight(h_Y) = 1, - // hue_weight(h_Y + 180°) = 0 и монотонности; выведенная формула доказывает их аналитически. - // - // Провенанс: - // H_Y_DEG = 90.4011° — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° наблюдатель, - // D65). Derivation (slice 5): CMF при 578nm — линейная интерполяция CIE 5нм-таблицы - // (575nm: 0.8425/0.9154/0.0018; 580nm: 0.9163/0.8700/0.00165) → (0.886780, 0.888160, - // 0.001710), → XYZ/Y = (0.998446, 1, 0.001925), → Oklab напрямую (Ottosson 2020, M1/M2; - // hue инвариантен к радиансности, sRGB-клэмп не нужен и не hue-сохраняющ), - // → atan2(b, a) = 90.4011°. Неопределённость 5нм-интерполяции: ±0.01° - // (1нм-таблица CIE даёт 90.3925° — расхождение 0.009°, на 4 порядка ниже - // ширины Hanning-окна; центр объявлен по официальной 5нм-таблице). - // Формула (1 + cos(h − h_Y)) / 2 — Hanning-окно, выведенное из производной - // поворота Бецольда-Брюкке: dΔH_BB/dh = A_BB · cos(h − h_Y), нормированная - // в [0, 1]. Цитаты: Purdy (1937) Am. J. Psychol. 49, 313–315 (инвариант 578nm); - // Jacobs & Wascher (1967) J. Opt. Soc. Am. 57, 1155–1156. - - #[test] - fn w_hue_fitted_vector_absent_from_shipping() { - // RED пока pub static W_HUE присутствует в продуктивном коде. - // GREEN после замены на выведенную BB-формулу. - let source = include_str!("cleanliness.rs"); - let prod_code = source.split("#[cfg(test)]").next().unwrap_or(source); - assert!( - !prod_code.contains("pub static W_HUE"), - "pub static W_HUE найден в продуктивном коде — подогнанный вектор (M-12) не удалён. \ - Ожидается выведенная формула Бецольда-Брюкке." - ); - // Дополнительно: hue_basis (вспомогательная функция для dot-product) тоже должна уйти - assert!( - !prod_code.contains("fn hue_basis("), - "fn hue_basis( найдена в продуктивном коде — K=3 Фурье-базис больше не нужен \ - после перехода на BB-формулу." - ); - } - - #[test] - fn bezold_brucke_at_unique_yellow_is_one() { - // Hanning-окно: (1 + cos(h − h_Y)) / 2 == 1.0 точно при h == h_Y. - // RED: текущий sigmoid(dot(W_HUE, basis(H_Y_DEG))) ≠ 1.0. - // GREEN: выведенная BB-формула гарантирует (1 + cos(0)) / 2 == 1.0. - // - // Значение H_Y_DEG определяется из CIE 1931 2° (D65) → derivation в - // комментарии к тест-блоку выше. Мы тестируем через публичный `hue_weight`: - // если формула выведена верно, hue_weight(H_Y_DEG) == 1.0 ТОЧНО (не ≈). - let hw = hue_weight(H_Y_DEG); - assert_eq!( - hw, 1.0, - "hue_weight(H_Y_DEG={H_Y_DEG}) должен быть ровно 1.0 \ - (Hanning-окно: (1 + cos(0)) / 2 = 1.0 точно); получено {hw:.18}" - ); - } - - #[test] - fn bezold_brucke_at_opposite_is_zero() { - // Hanning-окно: (1 + cos(π)) / 2 == 0.0 ТОЧНО при h == h_Y + 180°. - // RED: текущий sigmoid(dot) ≈ 0.015, не равен 0.0. - // GREEN: выведенная формула гарантирует нулевой вес на противоположном оттенке. - let hw = hue_weight(H_Y_DEG + 180.0); - assert_eq!( - hw, - 0.0, - "hue_weight(H_Y_DEG + 180 = {}) должен быть ровно 0.0 \ - (Hanning-окно: (1 + cos(π)) / 2 = 0.0 точно); получено {hw:.18}", - H_Y_DEG + 180.0 - ); - } - - #[test] - fn bezold_brucke_monotone_from_unique_yellow() { - // Hanning-окно строго монотонно убывает при |δ| растёт от 0 до π. - // RED: текущая sigmoid(dot(W_HUE,...)) — не монотонное Hanning-окно. - // GREEN: (1 + cos(δ)) / 2 монотонно убывает по обе стороны от h_Y. - // - // Тест: для 360 равномерных шагов δ от 0 до 180° включительно, - // hue_weight(h_Y + δ) должен строго убывать. - let steps = 360usize; - let mut prev = hue_weight(H_Y_DEG); - assert!( - (prev - 1.0).abs() < 1e-15, - "hue_weight(h_Y) должен быть 1.0; получено {prev}" - ); - for i in 1..=steps { - let delta = (i as f64) * 180.0 / (steps as f64); - let hw = hue_weight(H_Y_DEG + delta); - assert!( - hw < prev, - "hue_weight не строго монотонно убывает (плато): hw(h_Y + {delta:.2}) = {hw:.8} \ - >= hw(h_Y + {:.2}) = {prev:.8}", - (i - 1) as f64 * 180.0 / (steps as f64) - ); - prev = hw; - } - // Симметрично: hw(h_Y - δ) == hw(h_Y + δ) для Hanning-окна - for i in 1..=steps { - let delta = (i as f64) * 180.0 / (steps as f64); - let hw_pos = hue_weight(H_Y_DEG + delta); - let hw_neg = hue_weight(H_Y_DEG - delta); - assert!( - (hw_pos - hw_neg).abs() < 1e-12, - "hue_weight не симметрично: hw(h_Y + {delta:.2}) = {hw_pos:.12} \ - != hw(h_Y - {delta:.2}) = {hw_neg:.12}" - ); - } - } - - // ─── Provenance-range guard (Zone B, Fowler class A — property test) ───── - // - // Asserts B0 lies in [0.030, 0.044] and BW lies in [0.013, 0.020] — the - // cited-measured ranges (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 - // PNAS; Boynton 1975). This test was RED against the pre-swap fitted - // values (B0=0.02869 < 0.030, BW=0.02024 > 0.020) and turned GREEN after - // the swap — proving it bites (TDD RED-first; verified in git history as - // the compile-failing state at commit 1 HEAD). - // - // Uses `const { assert!(..) }` so the range check is enforced at COMPILE - // TIME (clippy::assertions_on_constants, Rust ≥1.96). A future constant - // edit outside the cited range will be caught at `cargo build`, not just - // at test time. - #[test] - fn provenance_range_guard_b0_bw() { - const { - assert!( - B0 >= 0.030 && B0 <= 0.044, - "B0 is outside the cited range [0.030, 0.044] \ - (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). \ - M-04 must be cited-measured." - ) - }; - const { - assert!( - BW >= 0.013 && BW <= 0.020, - "BW is outside the cited range [0.013, 0.020] \ - (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). \ - M-05 must be cited-measured." - ) - }; - } - - // ─── Zone G: surround-aware кейс-тесты (Fowler class A, TDD RED-first) ──── - // - // Условие успеха (Зона G exit-criteria): - // (1) Серый (#808080) на пастельном фоне (#FFE4E1) даёт более высокую грязь, - // чем тот же серый на нейтральном (#808080) — разница строго по знаку. - // (2) Тёмный розовый (#C2185B) на чёрном фоне (#000000) даёт более низкую грязь, - // чем тот же цвет на белом фоне (#FFFFFF) — направление дельты строго. - // - // Почему тест кусается (mutation-bite): - // Если заменить l_app = J/100 на l_app = Oklab-L (т.е. убрать surround-aware путь), - // оба теста провалятся: без учёта Yb фона CAM16 J не меняется при смене bg_hex, - // поэтому muddiness_in_context вернёт одинаковое значение для обоих фонов. - // - // TDD RED-first: до добавления `muddiness_in_context` в файл эти тесты не - // компилировались (функция не существовала) — RED доказан структурно. - // - // Провенанс: параметры CAM16 — Li et al. 2017 Table 1 (cited); mud-формула — - // Zone B slices 3+4 (BB Hanning, B0/BW, C0/JND — все cited). - - use super::{DefectContext, Theme, drab_in_context, muddiness_in_context}; - - /// Серый на пастельном фоне грязнее, чем тот же серый на нейтральном. - /// - /// Физика: пастельный фон (#FFE4E1, Yb≈82%) повышает адаптацию → сдвигает - /// CAM16 J серого (#808080, Yb≈22%) вниз от cusp-L → depth_term растёт → - /// mud растёт. На нейтральном фоне (#808080, Yb≈22%) адаптация стандартная. - /// - /// Направление дельты: mud_on_pastel > mud_on_neutral — строго. - #[test] - fn grey_on_pastel_is_muddier_than_grey_on_neutral() { - let grey = "#808080"; - let pastel_bg = "#FFE4E1"; // розово-белёсый, Yb≈82% - let neutral_bg = "#808080"; // нейтральный серый, Yb≈22% - - let mud_on_pastel = muddiness_in_context( - grey, - DefectContext { - bg_hex: pastel_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - let mud_on_neutral = muddiness_in_context( - grey, - DefectContext { - bg_hex: neutral_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - assert!( - mud_on_pastel > mud_on_neutral, - "Серый на пастельном фоне должен быть грязнее, чем на нейтральном: \ - mud_on_pastel={mud_on_pastel:.6} должно быть > mud_on_neutral={mud_on_neutral:.6}. \ - Если равны — surround-aware путь не работает (J не учитывает Yb фона)." - ); - } - - /// Тёмный розовый чист (низкая грязь) на чёрном и грязнее на белом. - /// - /// Физика: на белом фоне (#FFFFFF, Yb≈100%) CAM16 J тёмного розового (#C2185B) - /// ниже, чем на чёрном (#000000, Yb≈0.5%), — он выглядит тёмным относительно - /// светлого окружения, глубже под cusp-L → depth_term растёт → mud растёт. - /// На чёрном фоне адаптация к темноте, J цвета выше → depth_term меньше → mud меньше. - /// - /// Направление дельты: mud_on_black < mud_on_white — строго. - #[test] - fn dark_pink_is_cleaner_on_black_than_on_white() { - let dark_pink = "#C2185B"; // тёмно-розовый (Material Design Pink 800) - let black_bg = "#000000"; - let white_bg = "#FFFFFF"; - - let mud_on_black = muddiness_in_context( - dark_pink, - DefectContext { - bg_hex: black_bg, - theme: Theme::Dark, - }, - ) - .unwrap(); - - let mud_on_white = muddiness_in_context( - dark_pink, - DefectContext { - bg_hex: white_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - assert!( - mud_on_black < mud_on_white, - "Тёмный розовый должен быть чище (меньше грязи) на чёрном фоне, чем на белом: \ - mud_on_black={mud_on_black:.6} должно быть < mud_on_white={mud_on_white:.6}. \ - Если равны — surround-aware путь не работает." - ); - } - - /// Mutation-bite: подмена l_app на фиксированное значение (убираем surround-aware путь) - /// должна РОНЯТЬ первый кейс-тест. Проверяем здесь что тест различает два значения. - /// - /// Реализация: вычисляем mud дважды — с пастельным и нейтральным фоном. - /// Разница строго ненулевая → epsilon-тест подтверждает укус. - #[test] - fn surround_aware_mud_differs_across_backgrounds_epsilon() { - let grey = "#808080"; - let pastel_bg = "#FFE4E1"; - let neutral_bg = "#808080"; - - let mud_pastel = muddiness_in_context( - grey, - DefectContext { - bg_hex: pastel_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - let mud_neutral = muddiness_in_context( - grey, - DefectContext { - bg_hex: neutral_bg, - theme: Theme::Light, - }, - ) - .unwrap(); - - // Разница должна быть не менее 1e-4 — иначе тест не кусается - assert!( - (mud_pastel - mud_neutral).abs() > 1e-4, - "surround-aware mud разница < 1e-4: pastel={mud_pastel:.8} neutral={mud_neutral:.8} \ - delta={:.2e} — тест не кусается (mutation-bite провален).", - (mud_pastel - mud_neutral).abs() - ); - } - - /// API-test: drab_in_context принимает контекст и возвращает то же значение, что drab(C). - /// - /// Тест закрывает класс: drab не должен молча игнорировать контекст и возвращать ноль. - #[test] - fn drab_in_context_matches_bare_drab() { - use super::n_pure; - let hex = "#937C00"; // babypoop — умеренная хрома - - let ctx_light = DefectContext { - bg_hex: "#FFFFFF", - theme: Theme::Light, - }; - let ctx_dark = DefectContext { - bg_hex: "#000000", - theme: Theme::Dark, - }; - - let d_light = drab_in_context(hex, ctx_light).unwrap(); - let d_dark = drab_in_context(hex, ctx_dark).unwrap(); - - // drab зависит только от C_oklab — должно быть идентично для обоих контекстов - assert_eq!( - d_light, d_dark, - "drab_in_context должен возвращать одинаковый результат \ - независимо от контекста (drab зависит только от C_oklab): \ - light={d_light:.8} dark={d_dark:.8}" - ); - - // Проверяем, что D + N = 1 выполняется и через context-путь - let rgb = crate::spaces::srgb::srgb_from_hex(hex).unwrap(); - let lab = crate::spaces::oklab::srgb_linear_to_oklab(rgb); - let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - assert_eq!( - d_light + n_pure(c_oklab), - 1.0, - "drab_in_context(babypoop) + n_pure должно быть ровно 1.0 (D+N=1 инвариант)" - ); - // babypoop (#937C00) — сильно хроматический цвет: C >> C0=0.0395, - // поэтому drab(C) = 1 - N_pure(C) ≈ 0 (т.е. < 0.1 — почти нет тусклости). - assert!( - d_light < 0.1, - "drab_in_context(babypoop) = {d_light:.6} ожидается < 0.1 \ - (babypoop сильно хроматичен, drab → 0)" - ); - } - - /// IC-тема компилируется и возвращает корректный результат. - #[test] - fn ic_themes_compile_and_return_finite_value() { - let hex = "#6B6B2E"; // olive - let ctx_lic = DefectContext { - bg_hex: "#FFFFFF", - theme: Theme::LightIc, - }; - let ctx_dic = DefectContext { - bg_hex: "#000000", - theme: Theme::DarkIc, - }; +//! Screen-native анализ без `Clean/Dirty` находится в [`crate::color_quality`]. - let m_lic = muddiness_in_context(hex, ctx_lic).unwrap(); - let m_dic = muddiness_in_context(hex, ctx_dic).unwrap(); +#[path = "cleanliness_legacy.rs"] +mod legacy_v1; - assert!( - m_lic.is_finite() && (0.0..=1.0).contains(&m_lic), - "muddiness_in_context(LightIc) = {m_lic} вне [0,1]" - ); - assert!( - m_dic.is_finite() && (0.0..=1.0).contains(&m_dic), - "muddiness_in_context(DarkIc) = {m_dic} вне [0,1]" - ); - } -} +pub use legacy_v1::{ + B0, BW, C0, DefectContext, H_Y_DEG, JND, Theme, b_of, cusp_l_of, depth_mod, depth_term, drab, + drab_in_context, hue_weight, muddiness_from_hex, muddiness_from_linear_srgb, + muddiness_in_context, muddiness_oklch, n_pure, neutral_gate, raw_chromatic, +}; diff --git a/crates/labcolors-core/src/cleanliness_legacy.rs b/crates/labcolors-core/src/cleanliness_legacy.rs new file mode 100644 index 00000000..426ccec5 --- /dev/null +++ b/crates/labcolors-core/src/cleanliness_legacy.rs @@ -0,0 +1,600 @@ +//! Замороженная совместимая модель «Закон Грязи Labpics V1». +//! +//! Исторические уравнения и результаты сохранены точно, чтобы native/WASM/FFI +//! потребители и conformance-векторы не меняли смысл молча. V1 — +//! охарактеризованная гипотеза Labpics, а не валидированная вероятность для +//! популяции и не универсальное психофизическое определение чистого/грязного. +//! Новый код должен использовать явно версионированный отчёт V2 из родительского +//! модуля `cleanliness`. +//! +//! # Уравнения +//! +//! ```text +//! raw = neutral_gate(C) * hue_weight(h) * depth_mod(L, C, h) +//! mud = raw ← параметр-свободная монотонная нормировка (Zone B, 2026-06-30) +//! ``` +//! +//! Platt-звено (логарифм raw + eps → sigmoid с двумя подогнанными скалярами) **удалено полностью** +//! в Zone B slice 3: оба скаляра (M-07/M-08) подогнаны на авторском датасете 738 меток +//! (наблюдатель-фит) и нарушали инвариант «ZERO observer-fit» (North). mud = raw_chromatic напрямую: +//! - параметр-свободно (нет новых констант); +//! - монотонно (произведение монотонных сигмоидных факторов ∈ \[0,1\]); +//! - ограничено \[0,1\] точно (каждый множитель ∈ \[0,1\]); +//! - JND-относительно по конструкции (gate N(C) выровнен по M-02 JND). +//! +//! Бывший light-escape порог M-03 (DECLARED-CALIBRATION скаляр) и его escape-член +//! в `neutral_gate` **удалены целиком** (Zone B, 2026-07-01): escape-член протекал +//! ось L в функцию, документированную как чисто-хроматический hue-agnostic гейт — +//! нарушение независимости осей. `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)`. +//! +//! # Инвентарь параметров (раздел Muddiness Law) +//! +//! | const | mud-id | статус | значение | +//! |-----------------|--------|--------------------------------|-----------------------------| +//! | `C0` | M-01 | cited-and-kept | 0.0395 (граница серого sRGB; Evans/Xie-Fairchild yellow zero-grayness frontier) | +//! | `JND` | M-02 | cited-and-kept | 0.01228 (Oklab chroma JND; Oklab perceptual measurement) | +//! | `B0` | M-04 | cited-measured | 0.036 (центр диапазона [0.030, 0.044]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | +//! | `BW` | M-05 | cited-measured | 0.017 (центр диапазона [0.013, 0.020]; Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975) | +//! | ~~`CAL_EPS`~~ | M-06 | УДАЛЁН (Zone B slice 3) | был 0.01; log-регуляризатор для Platt — более не нужен | +//! | ~~`CAL_T`~~ | M-07 | УДАЛЁН (Zone B slice 3) | был 2.356978; Platt-скаляр, подогнанный на 738 авторских метках — нарушал ZERO observer-fit | +//! | ~~`CAL_B`~~ | M-08 | УДАЛЁН (Zone B slice 3) | был 6.445168; Platt-смещение — нарушало ZERO observer-fit | +//! | ~~`M_W`~~ | M-09 | УДАЛЁН (Front B, 2026-07-04) | был 0.181527; полуширина доверительного поля confidence-слоя — субъективная калибровка владельца без модели надёжности, вне пути эмиссии — удалён целиком | +//! | ~~`KAPPA_CORE`~~ | M-10 | УДАЛЁН (Front B, 2026-07-04) | был 0.34; потолок уверенности ядра — та же субъективная калибровка — удалён вместе с confidence-слоем | +//! | ~~`KAPPA_INTERIOR`~~ | M-11 | УДАЛЁН (Front B, 2026-07-04) | был 0.10; пол уверенности спорной полосы — удалён вместе с confidence-слоем | +//! | `H_Y_DEG` | M-12 | cited-derived (Zone B slice 5) | 90.4011° — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65, XYZ→Oklab напрямую); hue_weight = (1+cos(h−H_Y))/2 | +//! | ~~`W_HUE[8]`~~ | M-12 | УДАЛЁН (Zone B slice 4) | был: подогнанный K=3 Fourier-вектор логистической регрессии — нарушал ZERO observer-fit | +//! | `CUSP_L_TABLE` | M-13 | cited-and-kept | (чистая геометрия гамута Oklab, верифицирована до f64 — kept as-is) | +//! | ~~`CEIL_N_TABLE`~~ | M-14 | УДАЛЕНА (Zone B slice 4) | была: Fourier CEIL_N, подогнана на датасете v3 — более не нужна после BB-замены | + +#![allow(clippy::excessive_precision)] + +/// Исторический sRGB → Oklab-конвейер, на котором были рассчитаны V1-векторы. +/// +/// V1 обязан замораживать не только собственную формулу, но и её координатную +/// реализацию. Вызов живого `spaces::oklab` незаметно изменил бы сотни старых +/// отметок при каждом уточнении матриц, аналитической ахроматической ветви или +/// порядка операций. Коэффициенты и порядок ниже дословно повторяют прежний +/// binary64-путь lab-colors; новый код вне V1 использовать его не должен. +fn legacy_srgb_linear_to_oklab([r, g, b]: [f64; 3]) -> [f64; 3] { + let l = 0.412_221_470_8 * r + 0.536_332_536_3 * g + 0.051_445_992_9 * b; + let m = 0.211_903_498_2 * r + 0.680_699_545_1 * g + 0.107_396_956_6 * b; + let s = 0.088_302_461_9 * r + 0.281_718_837_6 * g + 0.629_978_700_5 * b; + let l_ = l.cbrt(); + let m_ = m.cbrt(); + let s_ = s.cbrt(); + [ + 0.210_454_255_3 * l_ + 0.793_617_785_0 * m_ - 0.004_072_046_8 * s_, + 1.977_998_495_1 * l_ - 2.428_592_205_0 * m_ + 0.450_593_709_9 * s_, + 0.025_904_037_1 * l_ + 0.782_771_766_2 * m_ - 0.808_675_766_0 * s_, + ] +} + +pub(crate) static CUSP_L_TABLE: [f64; 361] = [ + 0.64774951, 0.64579256, 0.64579256, 0.64383562, 0.64383562, 0.64187867, 0.64187867, 0.64187867, + 0.63992172, 0.63992172, 0.63796477, 0.63796477, 0.63796477, 0.63600783, 0.63600783, 0.63600783, + 0.63405088, 0.63405088, 0.63405088, 0.63209393, 0.63209393, 0.63209393, 0.63013699, 0.63013699, + 0.63013699, 0.63013699, 0.62818004, 0.62818004, 0.62818004, 0.62818004, 0.63209393, 0.63796477, + 0.64187867, 0.64774951, 0.65362035, 0.65753425, 0.66340509, 0.66731898, 0.67123288, 0.67710372, + 0.68101761, 0.68493151, 0.68884540, 0.69275930, 0.69667319, 0.70058708, 0.70450098, 0.70841487, + 0.71232877, 0.71624266, 0.72015656, 0.72407045, 0.72798434, 0.73189824, 0.73581213, 0.73776908, + 0.74168297, 0.74559687, 0.74951076, 0.75342466, 0.75538160, 0.75929550, 0.76320939, 0.76516634, + 0.76908023, 0.77299413, 0.77690802, 0.77886497, 0.78277886, 0.78669276, 0.79060665, 0.79256360, + 0.79647750, 0.80039139, 0.80430528, 0.80626223, 0.81017613, 0.81409002, 0.81800391, 0.82191781, + 0.82387476, 0.82778865, 0.83170254, 0.83561644, 0.83953033, 0.84344423, 0.84735812, 0.85127202, + 0.85518591, 0.85909980, 0.86301370, 0.86692759, 0.87084149, 0.87475538, 0.88062622, 0.88454012, + 0.88845401, 0.89432485, 0.89823875, 0.90410959, 0.90802348, 0.91389432, 0.91976517, 0.92563601, + 0.92954990, 0.93542074, 0.94129159, 0.94911937, 0.95499022, 0.96086106, 0.96673190, 0.96281800, + 0.96086106, 0.95890411, 0.95499022, 0.95303327, 0.94911937, 0.94716243, 0.94520548, 0.94129159, + 0.93933464, 0.93542074, 0.93346380, 0.92954990, 0.92759295, 0.92367906, 0.92172211, 0.91780822, + 0.91585127, 0.91193738, 0.90998043, 0.90606654, 0.90215264, 0.90019569, 0.89628180, 0.89236791, + 0.89041096, 0.88649706, 0.88258317, 0.87866928, 0.87475538, 0.87084149, 0.86692759, 0.86692759, + 0.86692759, 0.86888454, 0.86888454, 0.87084149, 0.87084149, 0.87279843, 0.87279843, 0.87475538, + 0.87475538, 0.87671233, 0.87671233, 0.87671233, 0.87866928, 0.87866928, 0.88062622, 0.88062622, + 0.88062622, 0.88258317, 0.88258317, 0.88454012, 0.88454012, 0.88454012, 0.88649706, 0.88649706, + 0.88649706, 0.88845401, 0.88845401, 0.88845401, 0.89041096, 0.89041096, 0.89041096, 0.89236791, + 0.89236791, 0.89236791, 0.89432485, 0.89432485, 0.89432485, 0.89628180, 0.89628180, 0.89628180, + 0.89823875, 0.89823875, 0.89823875, 0.90019569, 0.90019569, 0.90019569, 0.90215264, 0.90215264, + 0.90215264, 0.90410959, 0.90410959, 0.90410959, 0.89823875, 0.89432485, 0.89041096, 0.88649706, + 0.88062622, 0.87671233, 0.87279843, 0.86888454, 0.86497065, 0.86105675, 0.85714286, 0.85322896, + 0.84931507, 0.84540117, 0.84148728, 0.83757339, 0.83365949, 0.82974560, 0.82583170, 0.82191781, + 0.81800391, 0.81409002, 0.81017613, 0.80626223, 0.80234834, 0.79843444, 0.79452055, 0.79060665, + 0.78669276, 0.78277886, 0.77886497, 0.77495108, 0.76908023, 0.76516634, 0.76125245, 0.75733855, + 0.75342466, 0.74951076, 0.74559687, 0.73972603, 0.73581213, 0.73189824, 0.72602740, 0.72211350, + 0.71624266, 0.71232877, 0.70645793, 0.70254403, 0.69667319, 0.69080235, 0.68493151, 0.67906067, + 0.67318982, 0.66731898, 0.66144814, 0.65362035, 0.64774951, 0.63992172, 0.63209393, 0.62426614, + 0.61643836, 0.60665362, 0.59686888, 0.58512720, 0.57338552, 0.56164384, 0.54403131, 0.52446184, + 0.49315068, 0.45401174, 0.45596869, 0.45792564, 0.45988258, 0.46183953, 0.46575342, 0.46771037, + 0.46966732, 0.47162427, 0.47358121, 0.47749511, 0.47945205, 0.48140900, 0.48336595, 0.48727984, + 0.48923679, 0.49119374, 0.49510763, 0.49706458, 0.49902153, 0.50293542, 0.50489237, 0.50880626, + 0.51076321, 0.51467710, 0.51663405, 0.52054795, 0.52446184, 0.52641879, 0.53033268, 0.53424658, + 0.53816047, 0.54011742, 0.54403131, 0.54794521, 0.55185910, 0.55577299, 0.55968689, 0.56360078, + 0.56751468, 0.57142857, 0.57729941, 0.58121331, 0.58512720, 0.58904110, 0.59491194, 0.59882583, + 0.60469667, 0.60861057, 0.61448141, 0.62035225, 0.62426614, 0.63013699, 0.63600783, 0.64187867, + 0.64774951, 0.65362035, 0.65949119, 0.66536204, 0.67123288, 0.67906067, 0.68493151, 0.69275930, + 0.69863014, 0.69863014, 0.69667319, 0.69471624, 0.69080235, 0.68884540, 0.68688845, 0.68493151, + 0.68297456, 0.68101761, 0.67906067, 0.67710372, 0.67514677, 0.67318982, 0.67123288, 0.66927593, + 0.66731898, 0.66536204, 0.66536204, 0.66340509, 0.66144814, 0.65949119, 0.65949119, 0.65753425, + 0.65557730, 0.65557730, 0.65362035, 0.65166341, 0.65166341, 0.64970646, 0.64970646, 0.64774951, + 0.64774951, +]; + +// CEIL_N_TABLE удалена (Zone B slice 4, 2026-06-30): +// K=3 Fourier-базис CEIL_N использовался в hue_basis() для dot-product c W_HUE. +// После замены hue_weight на Hanning-окно BB ни hue_basis, ни CEIL_N_TABLE +// не нужны в продуктивном коде. + +// High-precision frozen parameter constants +pub const C0: f64 = 0.0395000000000000; +/// JND reuse rationale: this constant is the cited Oklab chroma just-noticeable-difference +/// (Oklab perceptual measurement, M-02 cited-and-kept). It is used as the sigmoid gate width +/// throughout the module because JND is the natural perceptual scale for a logistic boundary — +/// a ±1 JND band captures the transition from sub-threshold to supra-threshold chroma presence. +/// No new fit is performed; this is the same cited value reused as a scale parameter. +/// The applicability of a chroma JND as the gate width is a design assumption; if a future +/// study finds a different gate width is warranted, M-02 should be split into separate cited +/// values (one for detection threshold, one for gate width). Flagged OPEN as an assumption. +pub const JND: f64 = 0.0122779190541810; +// M-03 (former light-escape calibration threshold, DECLARED-CALIBRATION) removed +// entirely (Zone B, 2026-07-01): its escape term leaked the lightness axis into +// what is documented as a hue-agnostic, chroma-only gate (see `neutral_gate` above). +pub const B0: f64 = 0.036; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.030, 0.044] +pub const BW: f64 = 0.017; // cited-measured central (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975); range [0.013, 0.020] +// CAL_EPS / CAL_T / CAL_B удалены (Zone B slice 3, 2026-06-30): +// Platt-звено sigmoid(CAL_T*ln(raw+eps)+CAL_B) было observer-fit на 738 авторских метках +// (нарушение ZERO observer-fit, North). Заменено параметр-свободным mud = raw_chromatic. +// Confidence-слой (M_W / KAPPA_CORE / KAPPA_INTERIOR) удалён целиком (Front B, 2026-07-04): +// это была субъективная калибровка владельца без модели надёжности — вне пути эмиссии +// (solve/semantic/resolve его не звали), вне агностичного контракта движка. Снос не +// двигает ни байта эмиссии. См. removed-строки M-09/M-10/M-11 в docs/empirical-inventory.md. + +// W_HUE[8] удалён (Zone B slice 4, 2026-06-30): +// Подогнанный вектор K=3 Фурье-регрессии (логистическая регрессия на 738 авторских +// метках, M-12) заменён выведенным Hanning-окном Бецольда-Брюкке. +// Формула: hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 +// Провенанс: инвариантная точка жёлтого BB 578nm (Purdy 1937; Jacobs & Wascher 1967, +// J. Opt. Soc. Am. 57, 1155–1156) × Oklab (Ottosson 2020). +// Инварианты: hue_weight(H_Y_DEG) = 1.0 точно; hue_weight(H_Y_DEG ± 180°) = 0.0 точно; +// строго монотонное убывание при |h − H_Y_DEG| растёт от 0 до 180°. + +/// Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° наблюдатель, D65). +/// +/// Вывод (Zone B slice 5, 2026-07-03 — исправление ошибочной деривации slice 4): +/// 1. CMF при 578nm — линейная интерп. официальной CIE 1931 2° 5нм-таблицы: +/// 575nm (x̄=0.8425, ȳ=0.9154, z̄=0.0018); 580nm (x̄=0.9163, ȳ=0.8700, z̄=0.00165); +/// t=0.6 → (x̄=0.886780, ȳ=0.888160, z̄=0.001710). +/// 2. Нормируем к Y=1: XYZ = (0.998446, 1.000000, 0.001925). +/// 3. XYZ → Oklab НАПРЯМУЮ через M1/M2 (Ottosson 2020). Проекция в sRGB не нужна: +/// hue инвариантен к абсолютной радиансности стимула — равномерное +/// масштабирование LMS′ множит a и b ОДИНАКОВО (линейность M2), отношение +/// в atan2 не меняется; отдельный верный факт: нулевые суммы строк a,b +/// матрицы M2 дают a = b = 0 на ахромате (L′=M′=S′). +/// 4. h = atan2(b, a) = 90.4011°. +/// +/// История slice 4 (значение 96.9172 — ОТОЗВАНО): деривация содержала две ошибки. +/// (а) CMF-значения были спутаны между строками таблицы (заявленные ȳ=0.7070/0.7570 +/// для 570/580нм — на деле ȳ(570)=0.9520, ȳ(580)=0.8700; 0.7570 — это ȳ(590нм)), +/// что давало ложный XYZ=(1.207, 1, 0) вместо (0.998, 1, 0.002). +/// (б) Перед взятием hue цвет клэмпился к гамуту sRGB — покомпонентный клэмп НЕ +/// сохраняет оттенок: без клэмпа тот же XYZ давал h=69.7°, после клэмпа 96.9°. +/// Значение 96.9172° было артефактом клэмпа поверх неверного XYZ. +/// +/// Выбор λ=578nm: инвариантная точка жёлтого Бецольда-Брюкке (Purdy 1937, +/// Am. J. Psychol. 49, 313–315; подтверждено Jacobs & Wascher 1967, +/// J. Opt. Soc. Am. 57, 1155–1156). Прежняя цитата «Parry (1967) JOSA 57, +/// 1130–1134» не существует в литературе и удалена. +/// +/// Использование: центр Hanning-окна `hue_weight(h)` (M-12). +/// Константа выводная, не подогнана; воспроизводится скриптом деривации из CMF. +pub const H_Y_DEG: f64 = 90.4011; + +#[inline] +fn sigmoid(x: f64) -> f64 { + 1.0 / (1.0 + (-x).exp()) +} + +/// Compute the Oklab yellow-blue opponent b axis coordinate: b = C * sin(h) +#[inline] +pub fn b_of(c: f64, h_deg: f64) -> f64 { + c * h_deg.to_radians().sin() +} + +/// Precomputed cusp lightness as a function of hue (Oklab degree) with linear interpolation. +pub fn cusp_l_of(h_deg: f64) -> f64 { + let h = h_deg.rem_euclid(360.0); + let idx = h.floor() as usize; + let fract = h - h.floor(); + let next_idx = if idx >= 360 { 0 } else { idx + 1 }; + let y0 = CUSP_L_TABLE[idx]; + let y1 = CUSP_L_TABLE[next_idx]; + y0 + fract * (y1 - y0) +} + +/// Depth term: how far below the hue's clean cusp the colour sits, normalized by cusp L. +/// Ranges from 0.0 (at/above cusp) to 1.0 (deep in the basement). +pub fn depth_term(l: f64, h_deg: f64) -> f64 { + let cusp_l = cusp_l_of(h_deg); + let below = (cusp_l - l) / cusp_l.max(1e-6); + below.clamp(0.0, 1.0) +} + +/// Hue-agnostic chroma-confidence gate: checks if the color is chromatic at all, or reads grey/beige. +/// +/// `neutral_gate(c, c0, jnd) = sigmoid((c - c0) / jnd)` — a pure JND-relative +/// chroma-presence gate. The former light-escape term (a product of two +/// sigmoids gated on lightness and chroma) depended on a DECLARED-CALIBRATION +/// threshold (M-03, a Platt-fit scalar on the v3 dataset) and leaked the +/// lightness axis into a function documented as hue-agnostic and chroma-only — +/// removed entirely (Zone B, 2026-07-01) per North's ZERO observer-fit invariant. +pub fn neutral_gate(c: f64, c0: f64, jnd: f64) -> f64 { + sigmoid((c - c0) / jnd) +} + +/// Opponent b-gate (warm/clean filter): gates the geometric depth term. Warm -> 1.0, cool -> 0.0. +pub fn depth_mod(l: f64, c: f64, h_deg: f64, b0: f64, bw: f64) -> f64 { + let dp = depth_term(l, h_deg); + let s = sigmoid((b_of(c, h_deg) - b0) / bw); + dp * s +} + +/// Весовой множитель оттенка — Hanning-окно Бецольда-Брюкке (Zone B slice 4, 2026-06-30). +/// +/// Заменяет подогнанный вектор W_HUE\[8\] (M-12, удалён в Zone B slice 4) на выводную формулу. +/// +/// # Вывод формулы +/// +/// Эффект Бецольда-Брюкке — реальный психофизический феномен (уникальный жёлтый и его +/// инвариантная точка заземлены в этом же файле через Purdy 1937, см. [`H_Y_DEG`]): при +/// росте яркости оттенки смещаются к уникальному жёлтому (h_Y) и уникальному синему. +/// Косинусная модель плотности конвергенции ниже — ЭВРИСТИКА (параметрическая форма +/// Hanning-окна); железный первоисточник ИМЕННО ЭТОЙ формы не установлен. Прежняя ссылка +/// «Parry (1967) JOSA 57, 1130–1134» не существует в литературе (отозвана у [`H_Y_DEG`]) +/// и удалена. Производная смещения по Oklab-оттенку: +/// +/// dΔH_BB/dh = A_BB · cos(h − h_Y) +/// +/// — плотность конвергенции оттенков к h_Y. Максимальна при h = h_Y (все ближние оттенки +/// сходятся к уникальному жёлтому, зона максимальной «грязи»); обнуляется при |h − h_Y| = 90°; +/// минимальна при |h − h_Y| = 180° (противоположное направление). +/// +/// Нормируем в [0, 1]: +/// +/// hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2 [Hanning-окно] +/// +/// # Провенанс констант +/// +/// - `H_Y_DEG = 90.4011°` — Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65). +/// Вывод: CMF → XYZ → Oklab напрямую (Ottosson 2020) → atan2(b, a); без sRGB-клэмпа +/// (клэмп не сохраняет hue — см. историю slice 4 в доке H_Y_DEG). +/// Цитата: Purdy (1937) — инвариантная точка жёлтого 578nm; Jacobs & Wascher (1967) +/// J. Opt. Soc. Am. 57, 1155–1156. +/// +/// # Инварианты (проверены тестами) +/// +/// - `hue_weight(H_Y_DEG) == 1.0` точно (cos(0) = 1) +/// - `hue_weight(H_Y_DEG + 180°) == 0.0` точно (cos(π) = -1) +/// - строго монотонное убывание при |h − H_Y_DEG| растёт от 0° до 180° +/// - симметрия: hue_weight(h_Y + δ) == hue_weight(h_Y − δ) +/// +/// # Независимость осей +/// +/// Эта функция НЕ вызывается внутри `drab` или `n_pure` (вторая ось mud/drab). +/// Единственная точка входа — `raw_chromatic`, которая и так гейтирована b-axis (depth_mod). +#[inline] +pub fn hue_weight(h_deg: f64) -> f64 { + // Hanning-окно: (1 + cos(δ)) / 2, где δ = h − H_Y_DEG нормирован в [0°, 360°). + // + // Граничные случаи обрабатываются явно, чтобы гарантировать платформенно-точные + // значения 1.0 и 0.0 независимо от FP-реализации cos(0) / cos(π): + // δ == 0.0° → cos(0) = 1 → hw = 1.0 точно + // δ == 180.0° → cos(π) = −1 → hw = 0.0 точно + // Остальные углы вычисляются через cos как обычно. + let delta = (h_deg - H_Y_DEG).rem_euclid(360.0); + if delta == 0.0 { + 1.0 + } else if delta == 180.0 { + 0.0 + } else { + (1.0 + delta.to_radians().cos()) / 2.0 + } +} + +/// Сырой хроматический счёт грязи (без масштабирования). +/// +/// raw = N(C) × hue_weight_BB(h) × depth_mod(L, C, h) +/// +/// Все три множителя ∈ \[0,1\], поэтому raw ∈ \[0,1\] строго. +pub fn raw_chromatic(l: f64, c: f64, h_deg: f64) -> f64 { + let cc = neutral_gate(c, C0, JND); + let hw = hue_weight(h_deg); + let dpm = depth_mod(l, c, h_deg, B0, BW); + cc * hw * dpm +} + +/// Параметр-свободная монотонная цена грязи (Zone B slices 3+4, 2026-06-30). +/// +/// mud = raw_chromatic(l, c, h) — прямое произведение трёх перцептивно-обоснованных +/// факторов из геометрии Oklab и поворота Бецольда-Брюкке: +/// N(C) = sigmoid((C - C0) / JND) — JND-взвешенное присутствие хромы (M-01/M-02) +/// hue_weight(h) = (1+cos(h−H_Y_DEG))/2 — Hanning-окно BB (M-12, Zone B slice 4) +/// depth_mod(L, C, h) — глубина под cusp-L, взвешенная b-гейтом (M-04/M-05) +/// +/// Свойства: +/// - Выводных констант: только H_Y_DEG = 90.4° (CIE D65 unique yellow 578nm, Purdy 1937) +/// - Нет подогнанных скаляров: W_HUE/CEIL_N_TABLE/CAL_T/CAL_B — все удалены +/// - raw ∈ \[0,1\] точно (произведение ∈ \[0,1\] множителей) +/// - Монотонно в C только на ТЁПЛОЙ полуплоскости (h∈[0°,180°], sin h ≥ 0): +/// N(C) растёт, b-гейт depth_mod не убывает. На холодных (sin h < 0) b-гейт +/// `sigmoid((c·sin h − B0)/BW)` убывает в C → НЕ глобально монотонно +/// (запинено `property_invariants`: warm-инвариант + cool-characterization) +/// - JND-нормировано по конструкции через M-02 +/// - .clamp(0.0, 1.0) — safety no-op при f64-округлении +pub fn muddiness_oklch(l: f64, c: f64, h_deg: f64) -> f64 { + raw_chromatic(l, c, h_deg).clamp(0.0, 1.0) +} + +/// Compute muddiness from linear sRGB values [r, g, b] in [0, 1]. +pub fn muddiness_from_linear_srgb(rgb: [f64; 3]) -> f64 { + let lab = legacy_srgb_linear_to_oklab(rgb); + let l = lab[0]; + let c = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); + let h = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); + muddiness_oklch(l, c, h) +} + +/// Compute muddiness from an sRGB hex color string like "#6B6B2E". +pub fn muddiness_from_hex(hex: &str) -> Result { + let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; + Ok(muddiness_from_linear_srgb(rgb)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Drab defect head (Zone B slice 2 — second INDEPENDENT output) +// +// `n_pure` and `drab` are a SEPARATE defect axis from mud/muddiness_oklch. +// They share ONLY the cited gate constants C0 / JND as INPUTS. +// They are NEVER called inside `raw_chromatic`, `muddiness_oklch`, or any +// other mud code path — the two axes are provably independent (the curl / +// integrability two-axes result from the paradigm North). +// +// drab(C) = sigmoid((C0 - C) / JND) +// = 1 - sigmoid((C - C0) / JND) [= 1 - n_pure(C)] +// +// Constants reused (zero new parameters): +// C0 = 0.0395 (cited-and-kept, M-01, Evans/Xie-Fairchild yellow zero-grayness frontier) +// JND = 0.0122779190541810 (cited-and-kept, M-02, Oklab chroma JND) +// +// Статус (актуально на 2026-07-01): W_HUE[8] удалён и заменён выведенным Hanning-окном +// Бецольда-Брюкке (Zone B slice 4, M-12 cited-derived, см. таблицу инвентаря выше); +// Platt CAL_T/CAL_B удалены (Zone B slice 3). Открытых наблюдатель-фит параметров +// в этом блоке не осталось. +// ───────────────────────────────────────────────────────────────────────────── + +/// Pure chroma-presence gate: N_pure(C) = sigmoid((C - C0) / JND). +/// +/// This is the shared gate that enters both mud (as `neutral_gate`) and drab +/// (as 1 - N_pure). It is kept as a standalone `pub fn` so callers can +/// compose the two heads independently without re-implementing the gate. +/// +/// Parameters: cited-and-kept C0 (M-01) and JND (M-02). Zero new parameters. +#[inline] +pub fn n_pure(c: f64) -> f64 { + sigmoid((c - C0) / JND) +} + +/// Drab defect head: D(C) = 1 − N_pure(C). +/// +/// Measures chroma ABSENCE — the complement of the cited chroma-presence gate. +/// Returns 1.0 for achromatic colours (C ≈ 0, deeply grey/beige) and 0.0 for +/// strongly chromatic colours (C >> C0). +/// +/// Implementation: `1.0 - n_pure(c)` — exact f64 arithmetic complement. +/// This guarantees `drab(C) + n_pure(C) == 1.0` exactly in IEEE 754, which +/// the property tests verify. The equivalent closed form sigmoid((C0 - C) / JND) +/// would produce values that sum to 1.0 only up to ±1 ULP due to independent +/// exp() calls; the subtraction form is exact by construction. +/// +/// Invariants guaranteed by construction (independently property-tested): +/// `drab(C) + n_pure(C) == 1.0` exact in f64 +/// `drab` is strictly monotone-decreasing in C +/// `drab(C0) == 0.5` (sigmoid(0) == 0.5 exactly, since n_pure(C0) == 0.5) +/// +/// This function is a SECOND DISTINCT output — it is NEVER called inside +/// `raw_chromatic`, `muddiness_oklch`, or any mud path. The only coupling +/// with the mud head is the shared C0/JND constants as a shared INPUT gate. +#[inline] +pub fn drab(c: f64) -> f64 { + 1.0 - n_pure(c) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Surround-aware оценка дефектов (Zone G) +// +// Тема снаружи (light / dark / light-ic / dark-ic) маппируется на ViewingConditions +// по таблице CIECAM16 (Li et al. 2017, Table 1): +// light → average surround: F=1.0, c=0.69, Nc=1.0 +// dark → dim surround: F=0.9, c=0.59, Nc=0.9 +// light-ic → average + increased contrast flag +// dark-ic → dim + increased contrast flag +// +// Фон bg_hex даёт яркость Yb (Y-компонент фона в % от D65-белого), которая +// подставляется в ViewingConditions::srgb_with_yb / dim_surround_with_yb. +// Это единственный канал влияния фона на дефект — через CAM16 J (apparent lightness) +// цвета под данным Yb+surround. +// +// mud и drab считаются на (l_app=J/100, C_oklab, h_oklab): +// - C_oklab, h_oklab — Oklab-координаты исходного цвета (не меняются от surround) +// - l_app = J/100 — CAM16 apparent lightness под фоном+темой, нормирован в [0,1] +// +// Это заменяет Oklab L в depth_term(l, h) (определяет положение цвета под cusp), +// делая оценку surround-aware: цвет на тёмном фоне «воспринимается светлее», +// лежит ближе к cusp-L и получает меньший depth_term → меньшую грязь. +// +// Ноль новых параметров: все VC-параметры — таблица CIECAM16 (Li et al. 2017). +// ───────────────────────────────────────────────────────────────────────────── + +/// Тема просмотра — определяет условия окружения (surround) для оценки дефектов. +/// +/// Соответствие CIECAM16 (Li et al. 2017, Table 1): +/// - `Light` → average surround (F=1.0, c=0.69, Nc=1.0) +/// - `Dark` → dim surround (F=0.9, c=0.59, Nc=0.9) +/// - `LightIc` / `DarkIc` — то же, но с флагом повышенного контраста +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Theme { + /// Светлая тема: average surround (CIECAM16 Table 1). + Light, + /// Тёмная тема: dim surround (CIECAM16 Table 1). + Dark, + /// Светлая тема с повышенным контрастом (IC). + LightIc, + /// Тёмная тема с повышенным контрастом (IC). + DarkIc, +} + +impl Theme { + /// Разобрать стабильный kebab-контракт границы (`"light"` / `"dark"` / + /// `"light-ic"` / `"dark-ic"`). Неизвестная строка — ошибка вызывающего, + /// возвращается как есть (граница оборачивает в свой тип ошибки), никогда + /// не коэрсится в тему по умолчанию. + /// + /// # Errors + /// + /// `Err` с непринятой строкой. + pub fn parse(raw: &str) -> Result { + match raw { + "light" => Ok(Theme::Light), + "dark" => Ok(Theme::Dark), + "light-ic" => Ok(Theme::LightIc), + "dark-ic" => Ok(Theme::DarkIc), + other => Err(other.to_string()), + } + } + + /// Стабильный kebab-ключ темы — обратная к [`parse`](Self::parse). + pub fn key(self) -> &'static str { + match self { + Theme::Light => "light", + Theme::Dark => "dark", + Theme::LightIc => "light-ic", + Theme::DarkIc => "dark-ic", + } + } + + /// Условия просмотра, под которыми ядро резолвит эту тему: та же карта + /// surround-ов, что у `vc_for_context` (Light → average, Dark → dim, + /// IC-темы → high-contrast двойники), но с дефолтным Yb — вход границы, + /// где фон ещё неизвестен. + pub fn viewing_conditions(self) -> crate::spaces::vc::ViewingConditions { + self.vc_by( + crate::spaces::vc::ViewingConditions::srgb, + crate::spaces::vc::ViewingConditions::dim_surround, + ) + } + + /// ЕДИНАЯ карта тема → surround: light-темы берут average-конструктор, + /// dark-темы — dim, IC-темы поднимают флаг повышенного контраста. Обе + /// точки входа (дефолтный Yb на границе, Yb-от-фона в контексте дефектов) + /// обязаны выбирать surround здесь — вторая копия карты в цветовом коде + /// расходилась бы тихо при добавлении темы. + fn vc_by( + self, + srgb: impl FnOnce() -> crate::spaces::vc::ViewingConditions, + dim: impl FnOnce() -> crate::spaces::vc::ViewingConditions, + ) -> crate::spaces::vc::ViewingConditions { + let (mut vc, ic) = match self { + Theme::Light => (srgb(), false), + Theme::Dark => (dim(), false), + Theme::LightIc => (srgb(), true), + Theme::DarkIc => (dim(), true), + }; + vc.high_contrast = vc.high_contrast || ic; + vc + } +} + +/// Контекст просмотра для surround-aware оценки дефектов. +/// +/// Сочетает фон (hex-строка, задаёт яркость Yb) и тему (определяет surround). +/// Передаётся в `muddiness_in_context` / `drab_in_context`. +#[derive(Debug, Clone, Copy)] +pub struct DefectContext<'a> { + /// Фоновый цвет в hex (`#RRGGBB`). Задаёт яркость фона Yb для CIECAM16. + pub bg_hex: &'a str, + /// Тема просмотра. + pub theme: Theme, +} + +/// Y-компонент (относительная яркость) hex-цвета в % от D65-белого. +/// +/// Формула: Y = 0.2126 R_lin + 0.7152 G_lin + 0.0722 B_lin (IEC 61966-2-1 D65), +/// затем умножаем на 100 для CIECAM16 (где Yb задаётся в %). +/// +/// Диапазон результата: [0.0, 100.0]. +fn y_pct_from_hex(hex: &str) -> Result { + let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; + // Строка srgb_from_hex возвращает ЛИНЕЙНЫЙ sRGB (без гаммы). + // Y (IEC 61966-2-1, D65): Y = 0.2126 R + 0.7152 G + 0.0722 B + let y = 0.212_639_005_871_510_27 * rgb[0] + + 0.715_168_678_767_756 * rgb[1] + + 0.072_192_315_360_733_71 * rgb[2]; + Ok(y * 100.0) +} + +/// Viewing conditions для заданной темы и яркости фона Yb (в %). +/// +/// Параметры surround — CIECAM16 Table 1 (Li et al. 2017). Ноль новых констант. +fn vc_for_context(theme: Theme, y_b_pct: f64) -> crate::spaces::vc::ViewingConditions { + theme.vc_by( + || crate::spaces::vc::ViewingConditions::srgb_with_yb(y_b_pct), + || crate::spaces::vc::ViewingConditions::dim_surround_with_yb(y_b_pct), + ) +} + +/// Surround-aware оценка грязи цвета в заданном контексте просмотра. +/// +/// # Алгоритм +/// +/// 1. Из `hex` получаем Oklab `(L, C, h)` для геометрии (C, h не зависят от surround). +/// 2. Из `ctx.bg_hex` получаем Yb — яркость фона в % от D65-белого. +/// 3. Строим `ViewingConditions` для темы `ctx.theme` с данным Yb (CIECAM16 Table 1). +/// 4. Из `hex` → XYZ → CAM16 `J` (apparent lightness под фоном+temой). +/// 5. `l_app = J / 100` — нормированный apparent lightness ∈ [0, 1]. +/// 6. mud = `raw_chromatic(l_app, C_oklab, h_oklab)` — формула без изменений, +/// но `l_app` учитывает surround и фон вместо Oklab L. +/// +/// # Провенанс +/// +/// VC-параметры: CIECAM16, Li et al. 2017, DOI 10.1002/col.22131, Table 1. +/// Формула mud — без изменений (Zone B slices 3+4; H_Y_DEG, B0, BW цитированы). +pub fn muddiness_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { + // Oklab-координаты: C и h не зависят от viewing conditions + let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; + let lab = legacy_srgb_linear_to_oklab(rgb); + let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); + let h_oklab = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); + + // Apparent lightness J через CAM16 под фоном+surround + let y_b_pct = y_pct_from_hex(ctx.bg_hex)?; + let vc = vc_for_context(ctx.theme, y_b_pct); + let xyz = crate::spaces::srgb::srgb_to_xyz(rgb); + let (j, _m, _h_cam) = crate::spaces::cam16::forward(xyz, &vc); + let l_app = (j / 100.0).clamp(0.0, 1.0); + + Ok(raw_chromatic(l_app, c_oklab, h_oklab).clamp(0.0, 1.0)) +} + +/// Surround-aware оценка тусклости цвета в заданном контексте просмотра. +/// +/// Тусклость `drab(C) = 1 − N_pure(C)` не зависит от lightness — только от +/// Oklab chroma C, которая инвариантна к surround. Тем не менее функция принимает +/// контекст для симметрии API и будущего расширения (например, gamut-aware chroma). +/// +/// Возвращает то же значение, что `drab(C_oklab)` — surround-aware путь mud +/// не меняет drab, только grязь через depth_term зависит от J. +pub fn drab_in_context(hex: &str, ctx: DefectContext<'_>) -> Result { + // drab зависит только от C_oklab — surround не меняет chroma в Oklab + let _ = ctx; // принимается для симметрии API + let rgb = crate::spaces::srgb::srgb_from_hex(hex)?; + let lab = legacy_srgb_linear_to_oklab(rgb); + let c_oklab = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); + Ok(drab(c_oklab)) +} diff --git a/crates/labcolors-core/src/color_quality.rs b/crates/labcolors-core/src/color_quality.rs new file mode 100644 index 00000000..8d6fefb8 --- /dev/null +++ b/crates/labcolors-core/src/color_quality.rs @@ -0,0 +1,752 @@ +//! Типизированный анализ качества экранного цвета без универсального verdict. +//! +//! Модуль разделяет четыре разных вида знания: +//! +//! - координаты опубликованных моделей цветового восприятия; +//! - математически выведенную геометрию конечного sRGB; +//! - относительное сравнение с явно переданным семейным якорем; +//! - проверяемые гипотезы Labpics, которые не выдаются за ответы наблюдателя. +//! +//! Здесь намеренно нет оси `clean ↔ dirty`, общего числового score и изменения +//! цвета. Проекция является отдельной задачей #217 и может опираться на этот +//! отчёт только после screen-native валидации #232. + +use crate::cleanliness::{DefectContext, Theme}; +use crate::scale::max_chroma; +use crate::spaces::oklab::srgb_linear_to_oklab; +use crate::spaces::srgb::{srgb_from_hex, srgb_to_xyz}; +use crate::spaces::vc::ViewingConditions; + +/// Версия агрегированного отчёта, который не изменяет исходный цвет. +pub const COLOR_QUALITY_AUDIT_MODEL_V1: &str = "lab-screen-color-quality-audit-v1"; + +/// Замороженная версия исследовательского взаимодействия +/// «положительная жёлтая компонента × относительное ослабление». +/// +/// Формула сохранена для фальсификации и сравнения с будущими данными. Идентификатор +/// не содержит слова `dirt`: модель не является человеческой шкалой чистоты. +pub const WARM_DARK_INTERACTION_MODEL_V2: &str = "lab-warm-dark-interaction-cam16ucs-v2"; + +/// Версия относительного сравнения хроматичности с семейным якорем. +pub const MUTEDNESS_MODEL_V2: &str = "lab-relative-mutedness-cam16ucs-v2"; + +/// Происхождение численной величины или вывода. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ModelProvenance { + /// Координаты или уравнение опубликованной модели/стандарта. + PublishedModel, + /// Точное математическое следствие объявленных координат и конечного домена. + MathematicalDerivation, + /// Фальсифицируемая формула Labpics без population-validation. + LabpicsHypothesis, +} + +/// Режим, в котором наблюдатель интерпретирует экранный стимул. +/// +/// Значение никогда не выводится из RGB или имени роли: один и тот же пиксель +/// может быть самостоятельным светящимся сигналом или изображением поверхности. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum AppearanceMode { + EmissiveUi, + SurfaceLike, + MaterialLike, + SpatialEffect, + ImageContent, +} + +/// Измеренные физические условия дисплея. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct DisplayConditionsV1 { + pub white_luminance_cd_m2: f64, + pub black_luminance_cd_m2: f64, + pub ambient_luminance_cd_m2: f64, +} + +impl DisplayConditionsV1 { + fn validate(self) -> Result<(), String> { + if !(self.white_luminance_cd_m2.is_finite() && self.white_luminance_cd_m2 > 0.0) { + return Err("яркость белого дисплея должна быть конечной и положительной".into()); + } + if !(self.black_luminance_cd_m2.is_finite() && self.black_luminance_cd_m2 >= 0.0) { + return Err("яркость чёрного дисплея должна быть конечной и неотрицательной".into()); + } + if self.black_luminance_cd_m2 >= self.white_luminance_cd_m2 { + return Err("яркость чёрного дисплея должна быть ниже яркости белого".into()); + } + if !(self.ambient_luminance_cd_m2.is_finite() && self.ambient_luminance_cd_m2 >= 0.0) { + return Err("окружающая яркость должна быть конечной и неотрицательной".into()); + } + Ok(()) + } +} + +/// Известная пространственная часть экранного стимула. +/// +/// `None` означает отсутствие измерения, а не нулевое значение. Поэтому неполный +/// контекст можно вернуть как `InsufficientContext`, не подменяя его пресетом. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +#[non_exhaustive] +pub struct SpatialContextV1 { + pub adjacent_fraction: Option, + pub angular_size_deg: Option, + pub viewing_duration_ms: Option, +} + +impl SpatialContextV1 { + fn validate(self) -> Result<(), String> { + if self + .adjacent_fraction + .is_some_and(|value| !value.is_finite() || !(0.0..=1.0).contains(&value)) + { + return Err("доля прилегающего окружения должна лежать внутри [0, 1]".into()); + } + if self + .angular_size_deg + .is_some_and(|value| !value.is_finite() || value <= 0.0) + { + return Err("угловой размер должен быть конечным и положительным".into()); + } + if self + .viewing_duration_ms + .is_some_and(|value| !value.is_finite() || value <= 0.0) + { + return Err("длительность просмотра должна быть конечной и положительной".into()); + } + Ok(()) + } +} + +/// Явный контекст appearance-модели. +#[derive(Debug, Clone, Copy)] +#[non_exhaustive] +pub struct AppearanceContextV1 { + pub mode: AppearanceMode, + pub viewing_conditions: ViewingConditions, + /// `None` означает номинальный, а не измеренный дисплей. + pub display: Option, + /// `None` означает, что пространственная геометрия неизвестна. + pub spatial: Option, +} + +impl AppearanceContextV1 { + /// Номинальный контекст сохраняет mode и VC, но не выдаёт отсутствующие + /// измерения дисплея и геометрии за известные значения. + pub fn nominal(mode: AppearanceMode, viewing_conditions: ViewingConditions) -> Self { + Self { + mode, + viewing_conditions, + display: None, + spatial: None, + } + } + + /// Измеренный контекст проверяет физические величины до анализа цвета. + pub fn measured( + mode: AppearanceMode, + viewing_conditions: ViewingConditions, + display: DisplayConditionsV1, + spatial: SpatialContextV1, + ) -> Result { + let context = Self { + mode, + viewing_conditions, + display: Some(display), + spatial: Some(spatial), + }; + context.validate()?; + Ok(context) + } + + fn validate(&self) -> Result<(), String> { + self.viewing_conditions.validate()?; + if let Some(display) = self.display { + display.validate()?; + } + if let Some(spatial) = self.spatial { + spatial.validate()?; + } + Ok(()) + } +} + +/// Воспроизводимая геометрия одного цвета относительно конечного гамута sRGB. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct SrgbGamutGeometryV1 { + pub l_ok: f64, + pub c_ok: f64, + pub h_ok: Option, + pub max_srgb_chroma: Option, + pub gamut_radius_fraction: Option, + pub remaining_chroma_radius: Option, + pub in_srgb_gamut: bool, +} + +/// Геометрия гамута рядом с CAM16-коррелятами старой тематической обёртки. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct ContextualSrgbGamutAnalysisV1 { + pub gamut: SrgbGamutGeometryV1, + pub cam16_j: f64, + pub cam16_m: f64, + pub cam16_h: f64, + pub background_y: f64, + pub high_contrast: bool, +} + +/// Опубликованные CAM16-UCS-координаты и выведенная геометрия sRGB. +/// +/// Полей `cleanliness`, `preference`, `vividness` или `blackness` здесь нет: +/// соответствующие observer-модели добавляются только после воспроизведения +/// формул и проверки applicability matrix #230. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct AppearanceProfileV1 { + pub coordinate_provenance: ModelProvenance, + pub gamut_provenance: ModelProvenance, + pub jp: f64, + pub ap: f64, + pub bp: f64, + pub mp: f64, + /// Евклидово расстояние CAM16-UCS от чёрного `hypot(J′, M′)`. + pub radius_from_black: f64, + /// `atan2(M′, J′)`, без превращения в абсолютный класс приглушённости. + pub chroma_angle: f64, + pub gamut: SrgbGamutGeometryV1, +} + +/// Отношение кандидата к явно переданному семейному якорю. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum MutednessRelationV2 { + Preserved, + MoreMuted, + LessMuted, + CandidateAchromatic, + ReferenceAchromatic, + BothAchromatic, +} + +/// Отчёт относительной потери/роста хроматичности без абсолютного порога. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct MutednessComparisonV2 { + pub model: &'static str, + pub reference_chroma_angle: f64, + pub candidate_chroma_angle: f64, + pub log_relative_chroma_loss: Option, + pub relation: MutednessRelationV2, +} + +/// Причина точного нуля исследовательского взаимодействия. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WarmDarkZeroReasonV2 { + ExactBlackForeground, + AchromaticForeground, + NonPositiveWarmComponent, + NoRelativeDarkening, +} + +/// Статус исследовательской формулы без человеческого verdict `Clean/Dirty`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum WarmDarkInteractionStatusV2 { + InteractionZero(WarmDarkZeroReasonV2), + InteractionPositive, + InsufficientContext, + NotApplicable(AppearanceMode), + NumericallyIndeterminate, +} + +/// Замороженный отчёт гипотезы `−e·y·r·ln(r)`. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct WarmDarkInteractionReportV2 { + pub model: &'static str, + pub provenance: ModelProvenance, + pub appearance_mode: AppearanceMode, + pub status: WarmDarkInteractionStatusV2, + pub yellow_direction_cosine: f64, + pub foreground_background_ratio: Option, + pub interaction_potential: Option, +} + +/// Немутабельный агрегат независимых сигналов. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct ColorQualityAuditV1 { + pub model: &'static str, + pub context: AppearanceContextV1, + pub foreground_hex: String, + pub background_hex: String, + pub appearance: AppearanceProfileV1, + pub relative_mutedness: Option, + pub warm_dark_interaction: WarmDarkInteractionReportV2, +} + +#[derive(Debug, Clone, Copy)] +struct UcsAppearance { + jp: f64, + ap: f64, + bp: f64, + mp: f64, + radius: f64, +} + +fn ucs_appearance(rgb: [f64; 3], vc: &ViewingConditions) -> UcsAppearance { + if rgb == [0.0, 0.0, 0.0] { + return UcsAppearance { + jp: 0.0, + ap: 0.0, + bp: 0.0, + mp: 0.0, + radius: 0.0, + }; + } + + let (j, m, h) = crate::spaces::cam16::forward(srgb_to_xyz(rgb), vc); + let jp = crate::spaces::cam16::ucs_j(j); + let (mp, ap, bp) = if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + (0.0, 0.0, 0.0) + } else { + let mp = crate::spaces::cam16::ucs_m(m); + let hue = h.to_radians(); + (mp, mp * hue.cos(), mp * hue.sin()) + }; + + UcsAppearance { + jp, + ap, + bp, + mp, + radius: jp.hypot(mp), + } +} + +fn compare_ucs_mutedness( + candidate: UcsAppearance, + reference: UcsAppearance, +) -> MutednessComparisonV2 { + let reference_chroma_angle = reference.mp.atan2(reference.jp); + let candidate_chroma_angle = candidate.mp.atan2(candidate.jp); + + let (log_relative_chroma_loss, relation) = match (reference.mp == 0.0, candidate.mp == 0.0) { + (true, true) => (None, MutednessRelationV2::BothAchromatic), + (true, false) => (None, MutednessRelationV2::ReferenceAchromatic), + (false, true) => (None, MutednessRelationV2::CandidateAchromatic), + (false, false) => { + let reference_ratio = reference.mp / reference.jp; + let candidate_ratio = candidate.mp / candidate.jp; + let loss = (reference_ratio / candidate_ratio).ln(); + let relation = if loss > 0.0 { + MutednessRelationV2::MoreMuted + } else if loss < 0.0 { + MutednessRelationV2::LessMuted + } else { + MutednessRelationV2::Preserved + }; + (Some(loss), relation) + } + }; + + MutednessComparisonV2 { + model: MUTEDNESS_MODEL_V2, + reference_chroma_angle, + candidate_chroma_angle, + log_relative_chroma_loss, + relation, + } +} + +/// Сравнивает candidate с явно переданным family anchor. +pub fn compare_mutedness_v2( + candidate_hex: &str, + reference_hex: &str, + vc: &ViewingConditions, +) -> Result { + vc.validate()?; + let candidate = ucs_appearance(srgb_from_hex(candidate_hex)?, vc); + let reference = ucs_appearance(srgb_from_hex(reference_hex)?, vc); + Ok(compare_ucs_mutedness(candidate, reference)) +} + +fn warm_dark_interaction(yellow_direction_cosine: f64, ratio: f64) -> f64 { + if ratio == 0.0 || ratio == 1.0 || yellow_direction_cosine == 0.0 { + 0.0 + } else { + (-std::f64::consts::E * yellow_direction_cosine * ratio * ratio.ln()) + .clamp(0.0, yellow_direction_cosine) + } +} + +fn warm_dark_report_from_rgb( + foreground_rgb: [f64; 3], + background_rgb: [f64; 3], + context: &AppearanceContextV1, +) -> WarmDarkInteractionReportV2 { + if matches!( + context.mode, + AppearanceMode::SpatialEffect | AppearanceMode::ImageContent + ) { + return WarmDarkInteractionReportV2 { + model: WARM_DARK_INTERACTION_MODEL_V2, + provenance: ModelProvenance::LabpicsHypothesis, + appearance_mode: context.mode, + status: WarmDarkInteractionStatusV2::NotApplicable(context.mode), + yellow_direction_cosine: 0.0, + foreground_background_ratio: None, + interaction_potential: None, + }; + } + + if context.mode == AppearanceMode::MaterialLike + && (context.display.is_none() || context.spatial.is_none()) + { + return WarmDarkInteractionReportV2 { + model: WARM_DARK_INTERACTION_MODEL_V2, + provenance: ModelProvenance::LabpicsHypothesis, + appearance_mode: context.mode, + status: WarmDarkInteractionStatusV2::InsufficientContext, + yellow_direction_cosine: 0.0, + foreground_background_ratio: None, + interaction_potential: None, + }; + } + + let foreground = ucs_appearance(foreground_rgb, &context.viewing_conditions); + let background = ucs_appearance(background_rgb, &context.viewing_conditions); + if ![ + foreground.jp, + foreground.ap, + foreground.bp, + foreground.mp, + foreground.radius, + background.radius, + ] + .into_iter() + .all(f64::is_finite) + { + return WarmDarkInteractionReportV2 { + model: WARM_DARK_INTERACTION_MODEL_V2, + provenance: ModelProvenance::LabpicsHypothesis, + appearance_mode: context.mode, + status: WarmDarkInteractionStatusV2::NumericallyIndeterminate, + yellow_direction_cosine: 0.0, + foreground_background_ratio: None, + interaction_potential: None, + }; + } + + let yellow_direction_cosine = if foreground.radius == 0.0 { + 0.0 + } else { + (foreground.bp / foreground.radius).max(0.0) + }; + + let (status, ratio, potential) = if foreground.radius == 0.0 { + ( + WarmDarkInteractionStatusV2::InteractionZero( + WarmDarkZeroReasonV2::ExactBlackForeground, + ), + Some(0.0), + Some(0.0), + ) + } else if foreground.mp == 0.0 { + ( + WarmDarkInteractionStatusV2::InteractionZero( + WarmDarkZeroReasonV2::AchromaticForeground, + ), + (background.radius > 0.0).then_some((foreground.radius / background.radius).min(1.0)), + Some(0.0), + ) + } else if yellow_direction_cosine == 0.0 { + ( + WarmDarkInteractionStatusV2::InteractionZero( + WarmDarkZeroReasonV2::NonPositiveWarmComponent, + ), + (background.radius > 0.0).then_some((foreground.radius / background.radius).min(1.0)), + Some(0.0), + ) + } else if background.radius == 0.0 { + (WarmDarkInteractionStatusV2::InsufficientContext, None, None) + } else if foreground.radius >= background.radius { + ( + WarmDarkInteractionStatusV2::InteractionZero(WarmDarkZeroReasonV2::NoRelativeDarkening), + Some(1.0), + Some(0.0), + ) + } else { + let ratio = foreground.radius / background.radius; + let potential = warm_dark_interaction(yellow_direction_cosine, ratio); + ( + WarmDarkInteractionStatusV2::InteractionPositive, + Some(ratio), + Some(potential), + ) + }; + + WarmDarkInteractionReportV2 { + model: WARM_DARK_INTERACTION_MODEL_V2, + provenance: ModelProvenance::LabpicsHypothesis, + appearance_mode: context.mode, + status, + yellow_direction_cosine, + foreground_background_ratio: ratio, + interaction_potential: potential, + } +} + +/// Вычисляет только замороженный research baseline; результат не является +/// человеческой классификацией или разрешением изменить цвет. +pub fn analyze_warm_dark_interaction_v2( + foreground_hex: &str, + background_hex: &str, + context: &AppearanceContextV1, +) -> Result { + context.validate()?; + let foreground_rgb = srgb_from_hex(foreground_hex)?; + let background_rgb = srgb_from_hex(background_hex)?; + Ok(warm_dark_report_from_rgb( + foreground_rgb, + background_rgb, + context, + )) +} + +fn appearance_profile_from_rgb( + rgb: [f64; 3], + context: &AppearanceContextV1, +) -> Result { + let appearance = ucs_appearance(rgb, &context.viewing_conditions); + if ![ + appearance.jp, + appearance.ap, + appearance.bp, + appearance.mp, + appearance.radius, + ] + .into_iter() + .all(f64::is_finite) + { + return Err("CAM16-UCS не определён для переданного стимула".into()); + } + + Ok(AppearanceProfileV1 { + coordinate_provenance: ModelProvenance::PublishedModel, + gamut_provenance: ModelProvenance::MathematicalDerivation, + jp: appearance.jp, + ap: appearance.ap, + bp: appearance.bp, + mp: appearance.mp, + radius_from_black: appearance.radius, + chroma_angle: appearance.mp.atan2(appearance.jp), + gamut: analyze_srgb_gamut_linear_v1(rgb)?, + }) +} + +/// Собирает независимые screen-color signals и не изменяет ни один output byte. +pub fn audit_color_quality_v1( + foreground_hex: &str, + background_hex: &str, + reference_hex: Option<&str>, + context: &AppearanceContextV1, +) -> Result { + context.validate()?; + let foreground_rgb = srgb_from_hex(foreground_hex)?; + let background_rgb = srgb_from_hex(background_hex)?; + let appearance = appearance_profile_from_rgb(foreground_rgb, context)?; + let relative_mutedness = reference_hex + .map(|reference| { + let reference_rgb = srgb_from_hex(reference)?; + Ok::<_, String>(compare_ucs_mutedness( + ucs_appearance(foreground_rgb, &context.viewing_conditions), + ucs_appearance(reference_rgb, &context.viewing_conditions), + )) + }) + .transpose()?; + let warm_dark_interaction = warm_dark_report_from_rgb(foreground_rgb, background_rgb, context); + + Ok(ColorQualityAuditV1 { + model: COLOR_QUALITY_AUDIT_MODEL_V1, + context: *context, + foreground_hex: foreground_hex.to_ascii_uppercase(), + background_hex: background_hex.to_ascii_uppercase(), + appearance, + relative_mutedness, + warm_dark_interaction, + }) +} + +/// Анализирует Oklch относительно той же радиальной границы sRGB, что и генератор. +pub fn analyze_srgb_gamut_oklch_v1( + l_ok: f64, + c_ok: f64, + h_ok: f64, +) -> Result { + if !(l_ok.is_finite() && (0.0..=1.0).contains(&l_ok)) { + return Err(format!( + "Oklab L должна быть конечной и лежать внутри [0, 1]: {l_ok}" + )); + } + if !(c_ok.is_finite() && c_ok >= 0.0) { + return Err(format!( + "хрома Oklab должна быть конечной и неотрицательной: {c_ok}" + )); + } + if !h_ok.is_finite() { + return Err(format!("оттенок Oklab должен быть конечным: {h_ok}")); + } + + if c_ok == 0.0 { + return Ok(SrgbGamutGeometryV1 { + l_ok, + c_ok, + h_ok: None, + max_srgb_chroma: None, + gamut_radius_fraction: None, + remaining_chroma_radius: None, + in_srgb_gamut: true, + }); + } + + let h_ok = h_ok.rem_euclid(360.0); + let max_srgb_chroma = max_chroma(l_ok, h_ok); + Ok(SrgbGamutGeometryV1 { + l_ok, + c_ok, + h_ok: Some(h_ok), + max_srgb_chroma: Some(max_srgb_chroma), + gamut_radius_fraction: (max_srgb_chroma > 0.0).then_some(c_ok / max_srgb_chroma), + remaining_chroma_radius: Some((max_srgb_chroma - c_ok).max(0.0)), + in_srgb_gamut: c_ok <= max_srgb_chroma, + }) +} + +/// Анализирует linear sRGB без clipping. +pub fn analyze_srgb_gamut_linear_v1(rgb: [f64; 3]) -> Result { + if !rgb + .into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) + { + return Err(format!( + "каналы линейного sRGB должны быть конечными и лежать внутри [0, 1]: {rgb:?}" + )); + } + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + let l_ok = srgb_linear_to_oklab(rgb)[0]; + return analyze_srgb_gamut_oklch_v1(l_ok, 0.0, 0.0); + } + + let lab = srgb_linear_to_oklab(rgb); + let c_ok = lab[1].hypot(lab[2]); + let h_ok = if c_ok == 0.0 { + 0.0 + } else { + lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0) + }; + analyze_srgb_gamut_oklch_v1(lab[0], c_ok, h_ok) +} + +/// Анализирует кодированный цвет `#RRGGBB`. +pub fn analyze_srgb_gamut_hex_v1(hex: &str) -> Result { + analyze_srgb_gamut_linear_v1(srgb_from_hex(hex)?) +} + +fn background_y(hex: &str) -> Result { + Ok(srgb_to_xyz(srgb_from_hex(hex)?)[1]) +} + +fn contextual_vc(theme: Theme, y: f64) -> ViewingConditions { + let (mut vc, high_contrast) = match theme { + Theme::Light => (ViewingConditions::srgb_with_yb(y * 100.0), false), + Theme::Dark => (ViewingConditions::dim_surround_with_yb(y * 100.0), false), + Theme::LightIc => (ViewingConditions::srgb_with_yb(y * 100.0), true), + Theme::DarkIc => (ViewingConditions::dim_surround_with_yb(y * 100.0), true), + }; + vc.high_contrast = high_contrast; + vc +} + +/// Совместимый отчёт CAM16/gamut для исторического `DefectContext`. +pub fn analyze_srgb_gamut_hex_in_context_v1( + hex: &str, + ctx: DefectContext<'_>, +) -> Result { + let rgb = srgb_from_hex(hex)?; + let gamut = analyze_srgb_gamut_linear_v1(rgb)?; + let requested_background_y = background_y(ctx.bg_hex)?; + let vc = contextual_vc(ctx.theme, requested_background_y); + let (cam16_j, cam16_m, cam16_h) = crate::spaces::cam16::forward(srgb_to_xyz(rgb), &vc); + Ok(ContextualSrgbGamutAnalysisV1 { + gamut, + cam16_j, + cam16_m, + cam16_h, + background_y: vc.n, + high_contrast: vc.high_contrast, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_encoded_grays_have_no_warm_dark_interaction() { + let context = + AppearanceContextV1::nominal(AppearanceMode::SurfaceLike, ViewingConditions::srgb()); + for byte in 0_u8..=u8::MAX { + let hex = format!("#{byte:02X}{byte:02X}{byte:02X}"); + let report = analyze_warm_dark_interaction_v2(&hex, "#FFFFFF", &context).unwrap(); + assert_eq!(report.yellow_direction_cosine, 0.0, "{hex}"); + assert_eq!(report.interaction_potential, Some(0.0), "{hex}"); + assert!(matches!( + report.status, + WarmDarkInteractionStatusV2::InteractionZero( + WarmDarkZeroReasonV2::AchromaticForeground + | WarmDarkZeroReasonV2::ExactBlackForeground + ) + )); + } + } + + #[test] + fn frozen_interaction_has_derived_endpoints_and_peak() { + assert_eq!(warm_dark_interaction(1.0, 0.0), 0.0); + assert_eq!(warm_dark_interaction(1.0, 1.0), 0.0); + let peak_ratio = 1.0 / std::f64::consts::E; + assert_eq!(warm_dark_interaction(1.0, peak_ratio), 1.0); + assert!(warm_dark_interaction(1.0, peak_ratio * 0.5) < 1.0); + assert!(warm_dark_interaction(1.0, peak_ratio * 2.0) < 1.0); + } + + #[test] + fn measured_context_rejects_nonphysical_display_values() { + let error = AppearanceContextV1::measured( + AppearanceMode::EmissiveUi, + ViewingConditions::srgb(), + DisplayConditionsV1 { + white_luminance_cd_m2: 100.0, + black_luminance_cd_m2: 100.0, + ambient_luminance_cd_m2: 0.0, + }, + SpatialContextV1::default(), + ) + .unwrap_err(); + assert!(error.contains("чёрного дисплея")); + } + + #[test] + fn audit_keeps_reference_relative_and_optional() { + let context = + AppearanceContextV1::nominal(AppearanceMode::EmissiveUi, ViewingConditions::srgb()); + let without = audit_color_quality_v1("#6B6B2E", "#FFFFFF", None, &context).unwrap(); + let with = audit_color_quality_v1("#6B6B2E", "#FFFFFF", Some("#FFD60A"), &context).unwrap(); + assert!(without.relative_mutedness.is_none()); + assert!(with.relative_mutedness.is_some()); + } +} diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index e4a4a706..eb56a937 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -115,33 +115,6 @@ const TARGET_MP_MIN_EXCLUSIVE: f64 = 0.0; /// нефизично. const HUE_STIFFNESS_MIN_INCLUSIVE: f64 = 0.0; -/// Нижняя граница валидации поля `sentiments.hardness` (`≥ 1`). -/// -/// ⚠️ VESTIGIAL после Волны 1: поле `hardness` задавало p-норму brand-displacement -/// (Sticky Potential Well, #55), но закон категориальных зон снёс этот механизм — -/// поле больше НЕ влияет на выход (см. док `SentimentsConfig.hardness`). Граница -/// `≥ 1` сохранена как контракт схемы конфига: значение всё ещё валидируется -/// (мусор отвергается), но законом оттенка не потребляется. Историческая -/// семантика: `p < 1` выводило p-норму из корректной области. -const HARDNESS_MIN_INCLUSIVE: f64 = 1.0; - -/// Доля хромы сентимент-цвета (`sentiments.chroma_fraction`) обязана лежать в -/// `(0, 1]`. -/// -/// `≤ 0` — обесцвеченный (не сентимент), `> 1` — за стеной гамута (неон/недостижимо). -/// Дефолт реестра — `0.88` (держится `< 1`, чтобы сидеть внутри стены гамута, не -/// читаясь как неон). -/// -/// СЕМАНТИКА (применена 2026-07-03; тем самым закрыт слайс «инертной ручки» -/// аудита): в продакшн-тинте (`resolve_config_sentiment_solid`) ручка — -/// АНТИ-НЕОНОВЫЙ ПОТОЛОК: `c = min(c_якоря, f · C_max(L, h_решённый))`. -/// Хрома якоря клиента — авторитет идентичности и сохраняется, пока не -/// упирается в долю гамутного максимума; усечение идёт по оси хромы -/// (оттенок сохранён — прежний канальный клип sRGB искажал оттенок). -const CHROMA_FRACTION_MIN_EXCLUSIVE: f64 = 0.0; -/// Верхний предел доли хромы сентимента (включительно). -const CHROMA_FRACTION_MAX_INCLUSIVE: f64 = 1.0; - /// Запрошенная альфа альфа-аналога (`roles.*.alpha`) обязана лежать в `(0, 1]`. /// /// `≤ 0` — невидимая роль (вырождение), `> 1` — не альфа. Резолвер поднимает @@ -390,48 +363,24 @@ pub struct PaletteFamily { pub anchors: ThemeAnchors, } -/// Политика одной семантической категории потребителя: маппинг на семейство -/// палитры + категориальные ручки. +/// Одна семантическая категория: имя и клиентское семейство-якорь. +/// +/// В типе намеренно нет hue-пола и направления смещения. Имя категории не +/// определяет универсальную цветовую область; физику несут только anchors +/// выбранного семейства. #[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`), если задана. - /// - /// # МЁРТВАЯ РУЧКА после Волны 1 (закон категориальных зон) - /// - /// Прежде применялась в вырожденном шве `brand == prototype` p-норм-резолвера. - /// Волна 1 убрала brand-displacement ЦЕЛИКОМ, поэтому шва больше НЕ существует - /// и ручка ничего не смещает. Поле СОХРАНЕНО ради стабильности схемы конфига - /// (течёт в wasm-DTO, JSON-фикстуры, JS-golden) и по-прежнему ВАЛИДИРУЕТСЯ - /// (`preferred_side ∈ {-1, +1}`) как контракт границы — но законом оттенка - /// БОЛЬШЕ НЕ ПОТРЕБЛЯЕТСЯ (прецедент «задокументированной мёртвой ручки» - /// сохранён). Вайринг/удаление — отдельное решение оркестратора. - pub preferred_side: Option, } -/// Конфиг сентиментов: категории + общие ручки различимости. +/// Конфиг сентиментов V2: только категории и их клиентские anchors. #[derive(Debug, Clone, PartialEq)] pub struct SentimentsConfig { /// Категории потребителя. pub categories: Vec, - /// Жёсткость p-нормы Sticky Potential Well (`≥ 1`). - /// - /// # МЁРТВАЯ РУЧКА после Волны 1 (закон категориальных зон) - /// - /// p-норма настраивала brand-displacement — смещение сентимента ОТ бренда. - /// Категориальный закон убрал brand-displacement ЦЕЛИКОМ (сентимент отдыхает на - /// фокусе своей категории), поэтому ручка БОЛЬШЕ НЕ ПОТРЕБЛЯЕТСЯ законом - /// оттенка. Поле СОХРАНЕНО ради стабильности схемы конфига (течёт в wasm-DTO, - /// JSON-фикстуры, JS-golden) и по-прежнему ВАЛИДИРУЕТСЯ (`≥ 1`) как контракт - /// границы — но эмитируемые байты больше не двигает. - pub hardness: f64, - /// Доля хромы сентимент-цвета от максимума гамута (`(0, 1]`). - pub chroma_fraction: f64, } /// Пресет условий просмотра из ЗАКРЫТОГО физического меню движка. @@ -900,20 +849,9 @@ impl ThemeConfig { check_theme_anchors(&anchors_field, &fam.anchors)?; } - // Сентименты: ручки + категории (маппинг на существующее семейство). - check_ge( - "sentiments.hardness", - self.sentiments.hardness, - HARDNESS_MIN_INCLUSIVE, - "hardness ≥ 1 (vestigial-контракт схемы; прежде p-норма brand-displacement, снесена Волной 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 = за стеной гамута)", - )?; + // Сентименты: имена и связи с существующими клиентскими семействами. + // Универсальных hue-зон и числовых ручек здесь нет: pairwise-контракт + // выводится из фактических anchors при компиляции каждого режима. for cat in &self.sentiments.categories { let name_field = format!("sentiments.{}.name", cat.name); check_name(&name_field, &cat.name)?; @@ -923,27 +861,6 @@ 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°. - if !(HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&hue) { - return Err(ConfigError::OutOfBounds { - handle: field, - value: hue, - bound: "0 ≤ hue_floor_deg < 360 (угол оттенка по модулю 360°)", - }); - } - } } if let Some(hue) = self.neutral.tint.hue_override_deg @@ -1386,11 +1303,12 @@ impl ThemeConfig { }) } - /// Пер-темный сентимент-солид: для каждой темы взять якорь семейства категории - /// и разрешить его оттенок законом категориальных зон (покой на прототипе; - /// пол/зоны — исключения), сохранив светлоту/хрому якоря. Бренд оттенок больше - /// НЕ смещает (Волна 1). `s_perc_min` — пересчёт из хром якорей сентиментов - /// конфига (питает попарные зоны фазы-2, не зависит от labui-констант). + /// Пер-темный сентимент-солид из клиентского anchor семейства. + /// + /// Для каждого режима весь набор категорий проверяется одним симметричным + /// pairwise-контрактом. Имя сентимента, бренд и порядок массива не двигают + /// цвет; различимость задаётся отдельной исходной `a/b`-дистанцией каждой + /// пары клиентских anchors. fn compile_sentiment_tint(&self, role: &str, name: &str) -> Result { // Существование категории проверяется до пофазного резолва, чтобы // неизвестное имя дало свою ошибку, а не «семья не найдена». @@ -1413,175 +1331,52 @@ impl ThemeConfig { }) } - /// Солид сентимента `name` в режиме `mode_idx` (0 light / 1 dark / - /// 2 light-ic / 3 dark-ic) под законом категориальных зон (Волна 1). + /// Солид сентимента `name` в одном режиме темы. /// - /// Оркестрация ДВУХФАЗНАЯ (сохранена), но БРЕНД-СВОБОДНАЯ — бренд оттенок - /// больше не смещает; двигать его может только пол и попарные зоны соседей: - /// - /// 1. каждая категория решается на СВОЁМ прототипе с полом (без бренда, - /// `resolve_config_sentiment_solid_among` с пустыми зонами); - /// ПОКОЯЩИЕСЯ (решённый оттенок = оттенок прототипа, ≤ 0.5°) объявляются - /// неподвижными оккупантами — деривационная идентичность якорей клиента - /// не нарушается по построению (для labui-якорей ВСЕ покоятся); - /// 2. СМЕЩЁННЫЕ (пол клампанул оттенок) перерешиваются по порядку конфига с - /// зонами оккупантов: угловой отступ от каждой зоны — инверсия хорды - /// `s_perc_min` при средней хроме пары (тот же закон различимости - /// сентиментов между собой; СОХРАНЁН). Решённый смещённый сам становится - /// оккупантом для следующих. - /// - /// Ахроматичные оккупанты (C < ε) зон не несут — у серого нет оттенка. - /// Ахроматичный ЯКОРЬ обрабатывается внутри солвера (сырой якорь). + /// Все категории передаются наборному V2-резолверу одновременно. Он + /// индексирует их по имени и финально перепроверяет каждую неупорядоченную + /// пару, поэтому перестановка массива конфига не может изменить результат. + /// Отдельный вызов для цели после жадного размещения соседей запрещён именно + /// здесь: он и создавал порядок-зависимые столкновения. fn sentiment_solid_for_mode( &self, role: &str, name: &str, mode_idx: usize, ) -> Result<[f64; 3], ConfigError> { - // s_perc_min нужен ТОЛЬКО для попарных зон фазы-2 (различимость - // сентиментов между собой) — brand-разведение убрано Волной 1. - let s_perc_min = self.sentiment_s_perc_min()?; - let pick = |a: &ThemeAnchors| -> String { + let pick = |anchors: &ThemeAnchors| -> String { match mode_idx { - 0 => a.light.clone(), - 1 => a.dark.clone(), - 2 => a.light_ic.clone(), - _ => a.dark_ic.clone(), + 0 => anchors.light.clone(), + 1 => anchors.dark.clone(), + 2 => anchors.light_ic.clone(), + _ => anchors.dark_ic.clone(), } }; - - let anchor_of = |cat: &SentimentCategory| -> Result { - Ok(pick(self.family_anchors(role, &cat.family)?)) - }; - - // Закон категориальных зон: оттенок семейства ОТДЫХАЕТ на своём прототипе, - // бренд его не смещает (ахроматичный якорь обрабатывается внутри солвера). - // Пол/зоны — единственные исключения. - let solve = |cat: &SentimentCategory, - anchor_hex: &str, - zones: &[crate::sentiment::NeighborZone]| - -> Result { - crate::sentiment::resolve_config_sentiment_solid_among( - anchor_hex, - self.sentiments.chroma_fraction, - cat.hue_floor_deg, - zones, - ) - .map_err(|reason| ConfigError::SentimentResolution { + let mut anchors = Vec::with_capacity(self.sentiments.categories.len()); + for category in &self.sentiments.categories { + anchors.push(( + category.name.clone(), + pick(self.family_anchors(role, &category.family)?), + )); + } + let resolved = crate::sentiment::resolve_anchor_palette_v2(&anchors).map_err(|reason| { + ConfigError::SentimentResolution { role: role.to_string(), - sentiment: cat.name.clone(), + sentiment: name.to_string(), reason, - }) - }; - - // Фаза 1: резолв всех категорий на прототипе с полом; классификация покоя. - // (hue, chroma) занятых зон меряются на РЕШЁННОМ солиде — честный - // оккупант, не идеал. - let mut occupied: Vec<(f64, f64)> = Vec::new(); - let mut displaced: Vec<(usize, String)> = Vec::new(); // (index, anchor) - let mut target_phase1: Option = None; - for (i, cat) in self.sentiments.categories.iter().enumerate() { - let anchor_hex = anchor_of(cat)?; - let solid = solve(cat, &anchor_hex, &[])?; - let proto_hue = crate::accent::oklab_hue_of(&anchor_hex); - let solid_hue = crate::accent::oklab_hue_of(&solid); - let solid_chroma = oklab_chroma_of_hex(&solid); - let rested = crate::sentiment::angular_distance(solid_hue, proto_hue) <= 0.5 - || solid_chroma < ACHROMATIC_CHROMA_EPS; - if rested { - if solid_chroma >= ACHROMATIC_CHROMA_EPS { - occupied.push((solid_hue, solid_chroma)); - } - if cat.name == name { - target_phase1 = Some(solid); - } - } else { - displaced.push((i, anchor_hex)); - } - } - if let Some(solid) = target_phase1 { - // Покоящаяся цель неподвижна по построению — байт-в-байт фаза 1. - return crate::spaces::srgb::srgb_encoded_from_hex(&solid).map_err(|_| { - ConfigError::InvalidHex { - field: format!("roles.{role} (сентимент-солид)"), - value: solid, - } - }); - } - - // Фаза 2: смещённые перерешиваются с зонами оккупантов, по порядку. - for (i, anchor_hex) in displaced { - let cat = &self.sentiments.categories[i]; - let c_self = oklab_chroma_of_hex(&anchor_hex); - let mut zones = Vec::with_capacity(occupied.len()); - for &(hue, c_other) in &occupied { - let pair_chroma = (c_self + c_other) / 2.0; - let min_sep = crate::sentiment::s_min_deg_from_chord(s_perc_min, pair_chroma) - .map_err(|reason| ConfigError::SentimentResolution { - role: role.to_string(), - sentiment: cat.name.clone(), - reason, - })?; - // Сатурация (маркер 180° из s_min_deg_from_chord): пара так - // приглушена, что категориальная хорда недостижима при любом угле — - // перцептивно НЕРАЗДЕЛИМА. Отступ бессмыслен, зону пропускаем: иначе - // legalize искал бы точный антипод (мера нуль) и вернул ложный - // «пустая дуга». Дормантно для labui (все сентименты хромны). - if min_sep >= 180.0 { - continue; - } - zones.push(crate::sentiment::NeighborZone { - hue_deg: hue, - min_sep_deg: min_sep, - }); } - let solid = solve(cat, &anchor_hex, &zones)?; - if cat.name == name { - return crate::spaces::srgb::srgb_encoded_from_hex(&solid).map_err(|_| { - ConfigError::InvalidHex { - field: format!("roles.{role} (сентимент-солид)"), - value: solid, - } - }); - } - let solid_hue = crate::accent::oklab_hue_of(&solid); - let solid_chroma = oklab_chroma_of_hex(&solid); - if solid_chroma >= ACHROMATIC_CHROMA_EPS { - occupied.push((solid_hue, solid_chroma)); - } - } - unreachable!("категория `{name}` обязана быть покоящейся или смещённой") - } - - /// `S_PERC_MIN`, пересчитанный из Oklab-хром светлых якорей 4 (или скольких - /// есть) сентимент-категорий конфига — закон `2·C_rep·sin(20°/2)`. - /// При labui-якорях == замороженная константа (тест-идентичность). - /// # 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 solid = resolved + .into_iter() + .find_map(|(category, hex)| (category == name).then_some(hex)) + .ok_or_else(|| ConfigError::UnknownSentiment { + referenced_by: format!("roles.{role}"), + sentiment: name.to_string(), })?; - 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)) + crate::spaces::srgb::srgb_encoded_from_hex(&solid).map_err(|_| ConfigError::InvalidHex { + field: format!("roles.{role} (сентимент-солид)"), + value: solid, + }) } } diff --git a/crates/labcolors-core/src/config/fixture.rs b/crates/labcolors-core/src/config/fixture.rs index 5e01ddfc..af65827c 100644 --- a/crates/labcolors-core/src/config/fixture.rs +++ b/crates/labcolors-core/src/config/fixture.rs @@ -72,22 +72,11 @@ pub fn labui_reference() -> ThemeConfig { ], sentiments: SentimentsConfig { categories: vec![ - sentiment("danger", "red", None, None), - sentiment( - "warning", - "orange", - Some(crate::sentiment::WARNING_HUE_FLOOR_DEG), - Some(1), - ), - sentiment("success", "green", None, None), - sentiment("info", "blue", None, None), + sentiment("danger", "red"), + sentiment("warning", "orange"), + sentiment("success", "green"), + sentiment("info", "blue"), ], - hardness: 5.0, - // 1.0 = потолок на чистой стене гамута: якоря labui — авторитет - // идентичности (Figma-калибровка, danger #FF3B30 сидит ВЫШЕ - // 0.88·C_max — доля 0.88 съедала бы клиентский красный). - // Реестровый дефолт для клиентов без якорной калибровки — 0.88. - chroma_fraction: 1.0, }, themes: ThemesConfig { entries: vec![ @@ -121,16 +110,9 @@ fn fam(key: &str, light: &str, dark: &str, light_ic: &str, dark_ic: &str) -> Pal } /// Краткий конструктор сентимент-категории для фикстуры. -fn sentiment( - name: &str, - family: &str, - hue_floor_deg: Option, - preferred_side: Option, -) -> SentimentCategory { +fn sentiment(name: &str, family: &str) -> SentimentCategory { SentimentCategory { name: name.to_string(), family: family.to_string(), - hue_floor_deg, - preferred_side, } } diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index 209ea7ff..d0ef892a 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -5,7 +5,7 @@ //! 3. Валидатор: за-предельное значение КАЖДОЙ ручки даёт `ConfigError` + //! RED-proof мутацией предела (валидный vs невалидный на границе). //! 4. Лестница/альфа: Ladder/AlphaAnalog компилируются в полупрозрачные специи; -//! diff=пусто против consumedRoles; S_PERC_MIN-идентичность; значенческая +//! diff=пусто против consumedRoles; попарный anchor-контракт; значенческая //! сверка со стабом labui (light+dark) + RED-proof мутаций. use super::fixture::labui_reference; @@ -373,129 +373,6 @@ fn hue_stiffness_negative_is_rejected() { 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(())); -} - -/// Class-B differential-регресс: живость ПРОВОДКИ `sentiments.chroma_fraction`. -/// -/// # Закрываемая дыра (диагноз debugger 2026-07-04) -/// -/// Поле `config.sentiments.chroma_fraction` ЖИВОЕ — долетает до эмитируемого -/// байта: `resolve_config_sentiment_solid_among` передаёт его в -/// `sentiment::capped_chroma` = `min(c_якоря, f · C_max(L, h))` (анти-неоновый -/// потолок). Но валидатор (`chroma_fraction_out_of_bounds_is_rejected`) проверяет -/// лишь ГРАНИЦЫ, а байт-в-байт golden упражняет фикстуру с `chroma_fraction = 1.0`, -/// при котором на якорях labui потолок ВЫРОЖДАЕТСЯ в no-op (`min(c, 1·C_max) = c`) -/// → эффект ручки не эмитируется. Значит регресс к ИНЕРТНОСТИ (константа вместо -/// поля в проводке ИЛИ игнор параметра `fraction` в `capped_chroma`) прошёл бы -/// ВСЕ существующие гейты зелёным. Этот тест пинает саму проводку. -/// -/// # Differential-механизм -/// -/// Один паспорт (`labui_reference`), одна НАСЫЩЕННАЯ сентимент-роль — danger, -/// якорь `#FF3B30` (`c_якоря > 0.88·C_max`, потолок реально кусается), три -/// значения `chroma_fraction`. Доля меняет ТОЛЬКО ось хромы: `resolved_hue` и -/// `L` от неё не зависят, поэтому `C_max(L, h)` общий для всех трёх прогонов → -/// потолок обязан дать строгий порядок `chroma(0.5) < chroma(0.88)` и no-op на -/// границе `chroma(1.0) = c_якоря`. Инертная проводка уравнивает три хромы — -/// строгие `<` падают (см. RED-proof в PR-описании). -#[test] -fn chroma_fraction_wiring_bites_on_a_saturated_sentiment_anchor() { - // Насыщенный danger-солид под условия светлой темы (якорь #FF3B30). - let vc = ViewingConditions::srgb(); - let danger_chroma_at = |fraction: f64| -> f64 { - let mut cfg = labui_reference(); - cfg.sentiments.chroma_fraction = fraction; - let tint = cfg - .compile_sentiment_tint("diff-probe", "danger") - .expect("danger компилируется при валидной доле хромы"); - let hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); - oklab_chroma_of_hex(&hex) - }; - - let c_050 = danger_chroma_at(0.50); - let c_088 = danger_chroma_at(0.88); - let c_100 = danger_chroma_at(1.00); - - // Ручка кусается: строго меньшая доля → строго меньшая выходная хрома. - assert!( - c_050 < c_088, - "проводка chroma_fraction ИНЕРТНА: доля 0.50 дала хрому {c_050:.4}, \ - не строго меньше доли 0.88 ({c_088:.4}) — потолок не долетел до выхода" - ); - assert!( - c_088 < c_100, - "проводка chroma_fraction ИНЕРТНА: доля 0.88 дала хрому {c_088:.4}, \ - не строго меньше доли 1.00 ({c_100:.4})" - ); - - // Граница: доля 1.0 = no-op (потолок на стене гамута) ⇒ выходная хрома == - // хроме сырого якоря #FF3B30 (в пределах 8-бит квантизации эмиссии). - let anchor_chroma = oklab_chroma_of_hex("#FF3B30"); - assert!( - (c_100 - anchor_chroma).abs() < 0.02 * anchor_chroma, - "доля 1.0 обязана быть no-op: выходная хрома {c_100:.4} разошлась с \ - сырым якорем #FF3B30 ({anchor_chroma:.4}) больше 2%" - ); -} - -#[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 валиден (ничего не исключает — компилируется). - 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-ом (validate = - // компиляция по построению, деривационные ошибки видит). - let mut hi = labui_reference(); - hi.sentiments.categories[1].hue_floor_deg = Some(359.999); - assert!( - !matches!(hi.validate(), Err(ConfigError::OutOfBounds { .. })), - "359.999 внутри полуинтервала [0,360) — диапазонная проверка проходит" - ); - assert!( - hi.validate().is_err(), - "пол 359.999 опустошает легальную дугу — деривационная ошибка" - ); -} - // ───────────────────────────────────────────────────────────────────────────── // Валидатор: hex / имена / ссылки. // ───────────────────────────────────────────────────────────────────────────── @@ -933,79 +810,18 @@ fn matches_collapsed_pattern(name: &str, pattern: &str) -> bool { } // ───────────────────────────────────────────────────────────────────────────── -// S_PERC_MIN — деривационная идентичность из конфиг-якорей. -// ───────────────────────────────────────────────────────────────────────────── - -/// `S_PERC_MIN`, пересчитанный из хром 4 сентимент-якорей labui, совпадает с -/// замороженной константой (`0.068_703_9`, допуск 1e-4) — закон -/// `2·C_rep·sin(20°/2)` остаётся законом, сегодняшнее значение — его частный -/// случай при labui-якорях. -#[test] -fn s_perc_min_recomputed_from_config_anchors_matches_frozen() { - let recomputed = labui_reference() - .sentiment_s_perc_min() - .expect("фикстура валидна"); - 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() - .expect("фикстура валидна"); - 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() - .expect("мутация якоря сохраняет валидность"); - assert!( - (base - mutated).abs() > 1e-3, - "RED-proof провален: подмена якоря НЕ сдвинула S_PERC_MIN ({base} vs {mutated})" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Сентимент — деривационная идентичность (тинт == сырой якорь при -// labui-бренде). +// Сентимент V2 — идентичность клиентских anchors. // ───────────────────────────────────────────────────────────────────────────── -/// Деривационная идентичность: при бренде labui сентимент-тинт совпадает с СЫРЫМ -/// якорем семейства — под законом категориальных зон (Волна 1) это держится для -/// ВСЕХ сентиментов, ВКЛЮЧАЯ Info, ПОТОМУ ЧТО бренд оттенок больше не смещает -/// (сентимент отдыхает на фокусе своей категории). -/// -/// ИСТОРИЯ (Волна 1 перевернула это): прежде для Danger/Success/Warning -/// идентичность держалась (их семейства далеки от синего бренда), а Info НЕ -/// держался — brand-separation уводил его в пурпур, т.к. Info→Blue (h≈259.9°) был -/// лишь ≈2.5° от синего бренда labui. ИМЕННО ЭТО и был баг. Категориальный закон -/// убрал brand-separation — теперь Info ТОЖЕ отдыхает на сыром синем якоре (включён -/// в кейсы ниже; пер-темный «покой» пинит [`labui_info_sentiment_rests_on_blue_focus`]). +/// Имя категории и оттенок бренда не являются геометрическими входами. +/// Поэтому каждый сентимент-тинт обязан совпадать с сырым anchor назначенного +/// клиентом семейства, включая Info рядом с брендовым синим. #[test] -fn sentiment_tint_is_raw_family_anchor_under_categorical_zones() { +fn sentiment_tint_preserves_raw_family_anchor() { let cfg = labui_reference(); let table = cfg.compile_named_role_table().unwrap(); - // Под законом зон бренд оттенок НЕ смещает — ВСЕ сентименты (включая Info) - // отдыхают на сыром якоре семейства. Проверяем на светлой теме (канонический - // кейс, бренд labui = светлый `#007AFF`); пер-темные режимы покрыты - // `labui_info_sentiment_rests_on_blue_focus` и байт-гейтом эмиссии. + // Проверяем светлый режим; остальные режимы покрывает следующий тест. let cases: &[(&str, &str)] = &[ ("fill-danger-primary", "red"), ("fill-success-primary", "green"), @@ -1033,20 +849,11 @@ fn sentiment_tint_is_raw_family_anchor_under_categorical_zones() { } } -/// ФИКС, ВИДИМЫЙ ВЛАДЕЛЬЦУ (РЕФРЕЙМ Волны 1). Прежний тест -/// `info_is_displaced_from_blue_brand_by_design` УТВЕРЖДАЛ обратное — что Info -/// смещён от синего бренда (`assert_ne!(got, "#3E87FF")`). ИМЕННО ЭТО и был баг: -/// info-заливка labui уезжала в пурпур (#7579FE и т.п.) вместо своего синего -/// якоря. Закон категориальных зон (Волна 1) его убрал — совпадение info с -/// брендовым синим внутри синей категории легитимно (Kay & McDaniel 1978). -/// -/// Проверяем через ПОЛНЫЙ config-путь (`fill-info-primary` Ladder-тинт) во всех -/// 4 режимах: эмитируемый info-тинт обязан ОТДЫХАТЬ на синем якоре СВОЕГО режима -/// (деривационная идентичность — бренд оттенок не смещает), а его Oklab-hue — -/// в пределах 2° от синего фокуса. Для light-режима это 259.89° (Figma Accent/Blue -/// `#3E87FF`), как заявил владелец. +/// Полный config-путь сохраняет blue-anchor Info отдельно во всех четырёх +/// режимах. Проверка точного hex ниже первична; угловая проверка остаётся лишь +/// диагностикой того же равенства, а не универсальной «синей зоны». #[test] -fn labui_info_sentiment_rests_on_blue_focus() { +fn labui_info_sentiment_preserves_blue_anchor_in_every_mode() { let cfg = labui_reference(); let table = cfg.compile_named_role_table().unwrap(); let (_, spec) = table @@ -1082,25 +889,11 @@ fn labui_info_sentiment_rests_on_blue_focus() { ]; for (name, vc, anchor_hex) in modes { let got_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); - let got_hue = crate::accent::oklab_hue_of(&got_hex); - let focus = crate::accent::oklab_hue_of(anchor_hex); - assert!( - crate::sentiment::angular_distance(got_hue, focus) <= 2.0, - "режим {name}: Info-тинт {got_hex} (h={got_hue:.2}°) обязан ОТДЫХАТЬ на синем \ - фокусе {anchor_hex} (h={focus:.2}°) — бренд оттенок не смещает (Волна 1); \ - отклонение {:.2}° > 2°", - crate::sentiment::angular_distance(got_hue, focus) + assert_eq!( + got_hex, anchor_hex, + "режим {name}: Info-тинт обязан сохранить клиентский blue-anchor" ); } - // Light-режим — заявленный владельцем синий фокус 259.89°. - let light_hex = - crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&ViewingConditions::srgb())); - let light_hue = crate::accent::oklab_hue_of(&light_hex); - assert!( - (light_hue - 259.89).abs() <= 2.0, - "light Info-тинт h={light_hue:.2}° обязан лежать в пределах 2° от синего фокуса 259.89° \ - ({light_hex})" - ); } // ───────────────────────────────────────────────────────────────────────────── @@ -1577,22 +1370,6 @@ fn validator_rejects_duplicate_dictionary_keys() { )); } -/// 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() { @@ -1853,22 +1630,6 @@ 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] @@ -2044,30 +1805,16 @@ fn glow_roles_resolve_screen_layers() { } } -/// Попарная различимость сентиментов между СОБОЙ (аудит 2026-07-03). -/// -/// Модель разводит каждый сентимент от БРЕНДА (`s_min` — хорда до бренда); -/// попарные дистанции сентиментов между собой ею прямо не гарантируются -/// (Warning↔Danger держит только категориальный пол). Этот тест ЗАМЕРЯЕТ -/// попарные Oklab-ab-дистанции решённых labui-солидов по всем четырём -/// режимам против конфиг-порога `s_perc_min` — того же порога перцептивной -/// различимости, что закон применяет к бренду. -/// -/// ИСТОРИЯ: находка S-02 (2026-07-03) — light-ic Warning↔Success слипались -/// (ab ≈ 0.042 < 0.0687): flip-ветка при ДАЛЁКОМ бренде зеркалила Warning -/// через полкруга в зелень к Success. ВЫЛЕЧЕНО тем же днём многотельной -/// легальностью (двухфазная оккупация, `sentiment_solid_for_mode`): -/// покоящиеся сентименты — неподвижные оккупанты (идентичность якорей цела), -/// смещённые держат выведенный угловой отступ от их зон — Warning light-ic -/// ложится в янтарную дугу у пола вместо зелени. Тест держит закон: -/// ВСЕ пары ≥ порога во всех режимах, без исключений. +/// V2 не навязывает сентиментам универсальный порог или именованные зоны. +/// Для каждой пары её минимальная допустимая Oklab-ab-дистанция равна +/// дистанции именно её клиентских anchors в том же режиме. Финальная эмиссия +/// не вправе уменьшить ни одну из этих попарных дистанций. #[test] -fn labui_sentiment_solids_keep_pairwise_ab_distance() { +fn sentiment_v2_preserves_every_pair_specific_anchor_distance() { use crate::spaces::oklab::srgb_linear_to_oklab; use crate::spaces::srgb::srgb_gamma_inv; let cfg = labui_reference(); - let s_perc_min = cfg.sentiment_s_perc_min().expect("порог из якорей labui"); let vcs = [ crate::spaces::vc::ViewingConditions::srgb(), crate::spaces::vc::ViewingConditions::dim_surround(), @@ -2075,58 +1822,63 @@ fn labui_sentiment_solids_keep_pairwise_ab_distance() { crate::spaces::vc::ViewingConditions::dim_surround_high_contrast(), ]; for (mode_idx, vc) in vcs.iter().enumerate() { - let mut solids: Vec<(String, [f64; 3])> = Vec::new(); + let to_lab = |encoded: [f64; 3]| { + srgb_linear_to_oklab([ + srgb_gamma_inv(encoded[0]), + srgb_gamma_inv(encoded[1]), + srgb_gamma_inv(encoded[2]), + ]) + }; + let mut pairs: Vec<(String, [f64; 3], [f64; 3])> = Vec::new(); for cat in &cfg.sentiments.categories { + let family = cfg + .palette + .iter() + .find(|family| family.key == cat.family) + .expect("семейство категории существует"); + let source = to_lab( + crate::spaces::srgb::srgb_encoded_from_hex(family.anchors.for_vc(vc)) + .expect("anchor валиден"), + ); let tint = cfg .compile_sentiment_tint("pairwise-probe", &cat.name) .expect("labui-сентимент компилируется"); - let e = tint.for_vc(vc); - let lab = srgb_linear_to_oklab([ - srgb_gamma_inv(e[0]), - srgb_gamma_inv(e[1]), - srgb_gamma_inv(e[2]), - ]); - solids.push((cat.name.clone(), lab)); + let resolved = to_lab(tint.for_vc(vc)); + pairs.push((cat.name.clone(), source, resolved)); } - for i in 0..solids.len() { - for j in (i + 1)..solids.len() { - let (na, a) = &solids[i]; - let (nb, b) = &solids[j]; - let d_ab = ((a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt(); + + for i in 0..pairs.len() { + for j in (i + 1)..pairs.len() { + let (name_a, source_a, resolved_a) = &pairs[i]; + let (name_b, source_b, resolved_b) = &pairs[j]; + let source_d2 = + (source_a[1] - source_b[1]).powi(2) + (source_a[2] - source_b[2]).powi(2); + let resolved_d2 = (resolved_a[1] - resolved_b[1]).powi(2) + + (resolved_a[2] - resolved_b[2]).powi(2); assert!( - d_ab >= s_perc_min, - "режим {mode_idx}: сентименты `{na}` и `{nb}` перцептивно слиплись: \ - ab-дистанция {d_ab:.4} < порога {s_perc_min:.4}" + resolved_d2 >= source_d2, + "режим {mode_idx}: пара `{name_a}`/`{name_b}` потеряла дистанцию: \ + D²_out={resolved_d2:.12} < D²_anchor={source_d2:.12}" ); } } } } -/// Warning light-ic лежит в янтарной дуге, а не в зелени. +/// Имя `warning` не перекрашивает клиентский light-ic anchor. /// -/// РЕФРЕЙМ Волны 1 (закон категориальных зон). Прежде это был RED-proof лечения -/// S-02: брендоцентричный однотельный закон зеркалил IC-Warning далёким брендом в -/// зелень (~127°), а многотельный лечил в янтарь. Под новым законом -/// brand-displacement УБРАН целиком — Warning всегда ОТДЫХАЕТ на своём оранжевом -/// прототипе (для light-ic якорь orange `#C93400`), поэтому зелени неоткуда взяться -/// и однотельного контр-примера больше не существует. Инвариант «warning light-ic -/// в янтаре» ОСТАЁТСЯ валидным следствием закона и сохранён как страж; мёртвый -/// RED-proof (однотельный→зелень) удалён. +/// Раньше тест навязывал универсальную янтарную дугу от 45°. V2 проверяет более +/// сильный и объективный инвариант: эмитируется именно anchor семейства, а его +/// попарная различимость проверяется относительно остальных anchors режима. #[test] -fn warning_light_ic_rests_in_amber_arc() { +fn warning_light_ic_preserves_the_client_anchor() { let cfg = labui_reference(); let tint = cfg .compile_sentiment_tint("warning-amber-probe", "warning") .expect("warning компилируется"); let vc_ic = crate::spaces::vc::ViewingConditions::srgb_high_contrast(); - let healed = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc_ic)); - let healed_hue = crate::accent::oklab_hue_of(&healed); - // Янтарная дуга: над полом Warning (45°), заведомо ниже зелени (< 90°). - assert!( - (45.0..90.0).contains(&healed_hue), - "warning light-ic обязан лечь в янтарь [45°, 90°), получено {healed_hue:.2}° ({healed})" - ); + let resolved = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc_ic)); + assert_eq!(resolved, "#C93400"); } // ───────────────────────────────────────────────────────────────────────────── // 5. Пустой контракт отклоняется на загрузке. diff --git a/crates/labcolors-core/src/curve.rs b/crates/labcolors-core/src/curve.rs index eb0af853..c138d5de 100644 --- a/crates/labcolors-core/src/curve.rs +++ b/crates/labcolors-core/src/curve.rs @@ -87,11 +87,14 @@ mod tests { &vc, ) .expect("NeutralCurve::with_vc should succeed for valid dim-surround anchors"); + let base_t = curve.base_position(); let curve: &dyn ColorCurve = &curve; let hexes = curve.sample_hex(13); assert_eq!(hexes[0].to_uppercase(), "#FFFFFF"); - assert_eq!(hexes[6].to_uppercase(), "#787880"); assert_eq!(hexes[12].to_uppercase(), "#101012"); + // База конечной sRGB8-полилинии выводится из фактических длин её рёбер + // и потому не обязана совпадать с арифметической серединой `t = 0.5`. + assert_eq!(curve.at(base_t).to_hex_with_vc(curve.vc()), "#787880"); } } diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 4c10a341..668036b4 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -21,59 +21,56 @@ //! гаснет; на тёмном цветёт. Среда свечения — тёмная тема (закон //! асимметрии ADR-0002 §1). //! -//! # Контрактные ступени (зеркальная деривация) +//! # Клиентские измерения LabUI //! //! **glow-k (dark) := фактический композит-шаг shadow-k (light)** — свечение //! на тёмном обязано давать тот же перцептивный шаг |ΔJ'|, что тень на -//! светлом. Значения ИЗМЕРЕНЫ от -//! владельческих альф стека теней; отображение subtle:=minor, base:=ambient, -//! bloom:=major (penumbra — анатомическая ступень именно тени, у излучения -//! света полутени нет). Ноль придуманных чисел. +//! светлом в конкретном клиентском пресете LabUI. Значения `GLOW_*` измерены +//! от его альф стека теней; это не универсальная шкала свечения библиотеки. +//! Для другого клиента целевой |ΔJ'| передаётся прямо в +//! [`solve_screen_alpha_for_dj`]. //! //! # Анатомия (двухслойный bloom) //! -//! core — малый радиус, светлота поднята к белому (пересвет центра — сигнатура -//! реального света); halo — большой радиус, оттенок источника на его светлоте. -//! Оттенок оба слоя наследуют от источника: свечение не имеет собственного -//! цвета. +//! halo — большой радиус и точный цвет источника. core — малый пересвеченный +//! радиус в точном белом пределе sRGB8. Из формулы screen приращение канала по +//! `G` равно `α·(1−bg) ≥ 0`; поэтому `[255, 255, 255]` — единственный +//! универсальный покомпонентный максимум конечной сетки, а не эстетическая +//! смесь со свободным коэффициентом. Целевой |ΔJ'| задаёт не цвет core, а +//! явный солвер прозрачности. //! -//! Центр подчинён ЕДИНОМУ ЗАКОНУ БАЛАНСА ([`crate::accent_balance`]): его -//! светлота — функциональный пол (полпути к белому по J'), а хрома на этой -//! светлоте — НЕ фикс-доля источника, а МАКСИМАЛЬНАЯ в гамуте -//! (`max_chroma`). Прежняя фикс-дельта «половина M'» размывала -//! центр к серому у пересвета (оттенок терялся); закон держит идентичность так -//! сильно, как позволяет гамут, а вырождение у стены белого честно флагуется -//! примитивом. Ноль новых констант: пол яркости — та же деривация «полпути к -//! белому», хрома — существующий `max_chroma`. +//! У точного серого источника hue отсутствует как состояние, а не хранится +//! фиктивным углом. У хроматического источника белый предел честно отмечается +//! как коллапс hue в структурированном отчёте. use crate::lcs::LcsColor; use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; use crate::spaces::vc::ViewingConditions; -/// Контрактный шаг glow-subtle (|ΔJ'| композита от фона): зеркало -/// стек-композита fx-shadow-minor на светлом якоре labui. -// SSOT-TRACKED — зеркальная деривация от владельческих альф теней. +/// Клиентское измерение LabUI для glow-subtle: |ΔJ'| зеркала +/// fx-shadow-minor на светлом якоре этого пресета. +// Значение принадлежит пресету LabUI; универсальный API принимает явный target_dj. pub const GLOW_SUBTLE_DJ: f64 = 0.8563; -/// Контрактный шаг glow-base: зеркало стек-композита fx-shadow-ambient. -// SSOT-TRACKED — зеркальная деривация от владельческих альф теней. +/// Клиентское измерение LabUI для glow-base: зеркало fx-shadow-ambient. +// Значение принадлежит пресету LabUI; универсальный API принимает явный target_dj. pub const GLOW_BASE_DJ: f64 = 2.3006; -/// Контрактный шаг glow-bloom: зеркало стек-композита fx-shadow-major. -// SSOT-TRACKED — зеркальная деривация от владельческих альф теней. +/// Клиентское измерение LabUI для glow-bloom: зеркало fx-shadow-major. +// Значение принадлежит пресету LabUI; универсальный API принимает явный target_dj. pub const GLOW_BLOOM_DJ: f64 = 13.3251; -/// Ступень контрактного стека свечения (зеркальная деривация, см. шапку). +/// Ступень клиентского пресета LabUI (зеркальная деривация, см. шапку). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GlowStep { - /// subtle := зеркало fx-shadow-minor. + /// LabUI subtle := зеркало fx-shadow-minor. Subtle, - /// base := зеркало fx-shadow-ambient. + /// LabUI base := зеркало fx-shadow-ambient. Base, - /// bloom := зеркало fx-shadow-major. + /// LabUI bloom := зеркало fx-shadow-major. Bloom, } impl GlowStep { - /// Целевой перцептивный шаг |ΔJ'| композита ступени. + /// Измеренный для LabUI перцептивный шаг |ΔJ'| композита ступени. pub fn target_dj(self) -> f64 { match self { GlowStep::Subtle => GLOW_SUBTLE_DJ, @@ -195,25 +192,57 @@ pub fn solve_screen_alpha_for_dj( }) } -/// Двухслойная анатомия свечения от источника: `(core_hex, halo_hex)`. +/// Точный покомпонентный максимум конечной сетки sRGB8 для screen-эмиссии. +const EMISSIVE_CLIP_CORE_HEX: &str = "#FFFFFF"; + +/// Структурированный отчёт о двух слоях свечения. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GlowLayers { + /// Пересвеченный центр: точный эмиссионный предел sRGB8. + pub core_hex: String, + /// Большой ореол: исходный цвет без перекодирования и дрейфа байтов. + pub halo_hex: String, + /// `true`, только если у источника был hue, а белый core его утратил. + /// Для серого источника `false`: отсутствовавший hue не мог коллапсировать. + pub hue_collapsed: bool, +} + +/// Строит структурированный отчёт о слоях свечения от источника. /// -/// halo — сам источник (его оттенок на его светлоте); core — переэкспонирование -/// по ЗАКОНУ БАЛАНСА: светлота = функциональный пол «полпути к белому по J'» -/// (`J'core = (J'src + 100)/2`), а хрома на ней — МАКСИМАЛЬНАЯ в гамуте для -/// оттенка источника ([`crate::accent_balance::accent_balanced`]), не фикс-доля. -/// Оттенок (h_ok) — источника: свечение не имеет собственного цвета. +/// Core равен точному белому пределу: на конечной сетке sRGB8 он максимизирует +/// каждый множитель `G` в screen-формуле и тем самым не прячет произвольную +/// долю яркости. Требуемый |ΔJ'| решается отдельно через прозрачность. +pub fn glow_layers_report_from_source( + source_hex: &str, + vc: &ViewingConditions, +) -> Result { + let encoded = srgb_encoded_from_hex(source_hex)?; + // Равенство точное: все три значения получены из целых байтов делением на + // один знаменатель. Поэтому серый код не нуждается ни в epsilon, ни в hue. + let source_hue_deg = if encoded[0] == encoded[1] && encoded[1] == encoded[2] { + None + } else { + Some(LcsColor::from_hex_with_vc(source_hex, vc)?.h_ok) + }; + + Ok(GlowLayers { + core_hex: EMISSIVE_CLIP_CORE_HEX.to_string(), + halo_hex: source_hex.to_string(), + // Белый core ахроматичен по построению; коллапс возможен только тогда, + // когда у исходного стимула действительно существовало направление hue. + hue_collapsed: source_hue_deg.is_some(), + }) +} + +/// Совместимый адаптер для производственного вызывающего кода, который +/// исторически принимает кортеж. Новый код должен брать [`GlowLayers`] через +/// [`glow_layers_report_from_source`], чтобы не терять состояние оттенка. pub fn glow_layers_from_source( source_hex: &str, vc: &ViewingConditions, ) -> Result<(String, String), String> { - let src = LcsColor::from_hex_with_vc(source_hex, vc)?; - // Функциональный пол яркости центра: полпути к белому по J' (пересвет). - let jp_core = (src.jp + 100.0) * 0.5; - let l_core = crate::scale::jp_to_oklab_l(jp_core, vc); - // ЗАКОН БАЛАНСА: на этой яркости — максимум хромы оттенка источника (не ×0.5, - // размывавшая центр к серому). Идентичность держится, вырождение — флагом. - let core = crate::accent_balance::accent_balanced(l_core, src.h_ok, vc).color; - Ok((core.to_hex_with_vc(vc), source_hex.to_string())) + let report = glow_layers_report_from_source(source_hex, vc)?; + Ok((report.core_hex, report.halo_hex)) } #[cfg(test)] @@ -291,41 +320,59 @@ mod tests { assert_eq!(g.composite_hex, "#FFFFFF", "screen над белым — тождество"); } - /// Анатомия core по ЗАКОНУ БАЛАНСА: светлее источника (пересвет), оттенок - /// унаследован, цвет центра ВЗЯТ ИЗ примитива баланса (не своя фикс-дельта), - /// насыщенный источник не вырождается, и хрома центра ПО ПОСТРОЕНИЮ в гамуте - /// (стена гамута — без тихого клипа, класс бага прежней ×0.5-доли). + /// Пересвеченный core — точный белый предел sRGB8, а не эстетическая смесь. + /// Хроматический источник честно сообщает о потере hue в этом пределе. #[test] - fn core_is_balanced_overexposed_source() { + fn core_is_exact_emissive_endpoint_and_reports_hue_collapse() { let vc = ViewingConditions::dim_surround(); - let (core_hex, halo_hex) = glow_layers_from_source("#FF3B30", &vc).unwrap(); - assert_eq!(halo_hex, "#FF3B30", "halo — сам источник"); - let src = LcsColor::from_hex_with_vc("#FF3B30", &vc).unwrap(); - let core = LcsColor::from_hex_with_vc(&core_hex, &vc).unwrap(); - assert!(core.jp > src.jp, "core светлее источника (пересвет)"); - let dh = (core.h_ok - src.h_ok + 180.0).rem_euclid(360.0) - 180.0; - assert!(dh.abs() < 6.0, "оттенок унаследован: Δh = {dh:.2}°"); + let report = glow_layers_report_from_source("#FF3B30", &vc).unwrap(); - // Центр ВЗЯТ из примитива баланса — доказательство wiring'а. - let l_core = crate::scale::jp_to_oklab_l((src.jp + 100.0) * 0.5, &vc); - let balanced = crate::accent_balance::accent_balanced(l_core, src.h_ok, &vc); - assert_eq!( - core_hex, - balanced.color.to_hex_with_vc(&vc), - "центр glow обязан быть цветом примитива баланса" - ); + assert_eq!(report.core_hex, "#FFFFFF", "core — предел эмиссии sRGB8"); + assert_eq!(report.halo_hex, "#FF3B30", "halo — сам источник"); assert!( - !balanced.hue_vanished, - "насыщенный источник не вырождается в центре" + report.hue_collapsed, + "у хроматического источника hue исчезает в белой точке" ); - // Хрома баланса = стена гамута ⇒ эмиссия в гамуте, без тихого клипа - // (прежняя ×0.5-доля могла запросить недостижимую красочность и молча - // срезаться в to_hex). Round-trip красочности центра стабилен. - let core_reparsed = LcsColor::from_hex_with_vc(&core_hex, &vc).unwrap(); - assert!( - (core_reparsed.mp() - core.mp()).abs() < 0.5, - "центр в гамуте: round-trip красочности стабилен (без тихого клипа)" + let compatible = glow_layers_from_source("#FF3B30", &vc).unwrap(); + assert_eq!( + compatible, + (report.core_hex, report.halo_hex), + "совместимый tuple-адаптер обязан сохранять оба цвета отчёта" ); } + + /// Любой точный серый код идёт по ахроматической ветви: угол hue для него + /// не создаётся, а белый core остаётся ахроматическим на всей сетке sRGB8. + #[test] + fn every_gray_source_stays_achromatic_without_fictitious_hue() { + let vc = ViewingConditions::dim_surround(); + + for byte in 0_u8..=u8::MAX { + let source_hex = format!("#{byte:02X}{byte:02X}{byte:02X}"); + let report = glow_layers_report_from_source(&source_hex, &vc).unwrap(); + let core = srgb_encoded_from_hex(&report.core_hex).unwrap(); + + assert_eq!( + report.core_hex, "#FFFFFF", + "серый {source_hex}: неверный core" + ); + assert_eq!( + report.halo_hex, source_hex, + "halo обязан сохранить источник" + ); + assert!( + !report.hue_collapsed, + "серый {source_hex} не имел hue, поэтому коллапс невозможен" + ); + assert_eq!( + core[0], core[1], + "серый {source_hex}: core получил цветность" + ); + assert_eq!( + core[1], core[2], + "серый {source_hex}: core получил цветность" + ); + } + } } diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index d85e0d4e..2c5fe6ed 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -31,8 +31,7 @@ //! поэтому провенанс держится этой 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::srgb::srgb_encoded_from_hex; use crate::spaces::vc::ViewingConditions; /// Пер-темная четвёрка якорных hex (`light` / `dark` / `light-ic` / `dark-ic`). @@ -134,21 +133,6 @@ impl LadderTint { pub fn for_vc(&self, vc: &ViewingConditions) -> [f64; 3] { self.quad[vc_slot(vc)] } - - /// 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-рампы. diff --git a/crates/labcolors-core/src/lcs.rs b/crates/labcolors-core/src/lcs.rs index 119d9300..f0edbf37 100644 --- a/crates/labcolors-core/src/lcs.rs +++ b/crates/labcolors-core/src/lcs.rs @@ -1,31 +1,32 @@ use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex, srgb_to_xyz, xyz_to_srgb}; use crate::spaces::{cam16, cat16, oklab, vc::ViewingConditions}; -/// All hue fields (`h_ok`, `h_cam`) are stored in **degrees** `[0, 360)`. -/// Convert to radians only at trigonometric call sites — never store radians. +/// Все поля hue (`h_ok`, `h_cam`) хранятся в **градусах** `[0, 360)`. +/// Радианы создаются только в месте тригонометрического вызова, чтобы единицы +/// нельзя было спутать в состоянии цвета. #[derive(Debug, Clone, Copy, PartialEq)] pub struct LcsColor { pub jp: f64, pub h_ok: f64, - /// Internal reparameterisation of CAM16-UCS colourfulness `M′`: + /// Внутренняя репараметризация красочности CAM16-UCS `M′`: /// `s = M′ / (J′ + 1)`. The `+ 1` is a regulariser against division by zero - /// as `J′ → 0`; it is lossless — `LcsColor::mp` recovers `M′` exactly as + /// при `J′ → 0`; преобразование обратимо — `LcsColor::mp` восстанавливает `M′` как /// `s · (J′ + 1)`. This is NOT the CAM16 saturation correlate. pub s: f64, h_cam: f64, } impl LcsColor { - /// Parse from hex using standard sRGB viewing conditions (average surround). + /// Декодирует hex в стандартных условиях sRGB со средним окружением. pub fn from_hex(hex: &str) -> Result { Self::from_hex_with_vc(hex, &ViewingConditions::srgb()) } - /// Parse from hex using the given viewing conditions. + /// Декодирует hex при заданных условиях просмотра. /// - /// The resulting J', saturation, and CAM16 hue reflect perception under - /// the provided VC (e.g. [`ViewingConditions::dim_surround`] for dark - /// themes). + /// Результат зависит от условий просмотра: тёмная тема, например, должна + /// передать [`ViewingConditions::dim_surround`], а не переиспользовать + /// координаты светлого окружения. pub fn from_hex_with_vc(hex: &str, vc: &ViewingConditions) -> Result { let rgb = srgb_from_hex(hex)?; let xyz = srgb_to_xyz(rgb); @@ -33,68 +34,113 @@ impl LcsColor { Ok(Self::from_xyz_with_hok(xyz, h_ok, vc)) } - /// Convert to hex using standard sRGB viewing conditions. + /// Кодирует в hex в стандартных условиях просмотра sRGB. pub fn to_hex(&self) -> String { self.to_hex_with_vc(&ViewingConditions::srgb()) } - /// Convert to hex using the given viewing conditions. + /// Кодирует в hex при заданных условиях просмотра. /// - /// Must use the same VC that was used to construct this colour, otherwise - /// the round-trip will introduce drift. + /// Условия должны совпадать с условиями построения цвета, иначе round-trip + /// закономерно представит другой воспринимаемый стимул. pub fn to_hex_with_vc(&self, vc: &ViewingConditions) -> String { let xyz = self.to_xyz(vc); let rgb = xyz_to_srgb(xyz); hex_from_srgb(rgb) } - /// Raw constructor from already-valid coordinates (curves, solver). - /// No validation here: inputs come from our own maths, not user input. + /// Конструктор для уже проверенных координат кривых и решателя. + /// Пользовательский ввод сюда не попадает, поэтому повторная валидация не нужна. pub(crate) fn new(jp: f64, h_ok: f64, s: f64, h_cam: f64) -> Self { Self { jp, h_ok, s, h_cam } } - /// CAM16-UCS colourfulness `M'`, recovered losslessly from the stored - /// reparameterisation (see the `s` field doc). + /// Декартовы координаты CAM16-UCS `[J′, a′, b′]`. + /// + /// Евклидова метрика CAM16-UCS определена через + /// `a′ = M′ cos(h_cam)` and `b′ = M′ sin(h_cam)`. In particular, the + /// Направление обязано быть CAM16 hue, сопряжённым с величиной CAM16-UCS. + /// Сочетание `M′` с Oklab hue не принадлежит ни одной из моделей и не имеет + /// корректной интерпретации евклидова расстояния. + pub fn ucs_cartesian(&self) -> [f64; 3] { + let h = self.h_cam.to_radians(); + let mp = self.mp(); + [self.jp, mp * h.cos(), mp * h.sin()] + } + + /// Стандартная евклидова цветовая разность CAM16-UCS до `other`. + pub fn delta_e_ucs(&self, other: &Self) -> f64 { + let a = self.ucs_cartesian(); + let b = other.ucs_cartesian(); + let dj = a[0] - b[0]; + let da = a[1] - b[1]; + let db = a[2] - b[2]; + dj.hypot(da).hypot(db) + } + + /// Красочность CAM16-UCS `M'`, обратимо восстановленная из `s`. pub(crate) fn mp(&self) -> f64 { self.s * (self.jp + 1.0) } - /// The CAM16-UCS colourfulness `M'` of an in-gamut **linear** sRGB colour, - /// computed straight through the forward CAM16 path with no hex round-trip. + /// Красочность CAM16-UCS `M'` допустимого **линейного** sRGB без hex round-trip. /// - /// `M'` does not depend on the Oklab hue carried alongside it, so this skips - /// the `oklab_hue` step too: it is purely `rgb → XYZ → CAM16 → M'`. It is the - /// allocation-free equivalent of `from_hex_with_vc(hex_from_srgb(rgb))?.mp()` - /// for callers that have already quantised `rgb` to the display grid. + /// `M'` не зависит от сопровождающего Oklab hue, поэтому путь ограничен + /// `rgb → XYZ → CAM16 → M'`. Это безаллокационный эквивалент hex-round-trip + /// для уже квантованного `rgb`. pub(crate) fn mp_of_linear_srgb(rgb: [f64; 3], vc: &ViewingConditions) -> f64 { let xyz = srgb_to_xyz(rgb); - // h_ok is irrelevant to M'; pass 0.0 to avoid the oklab_hue computation. + // `h_ok` не влияет на M′, поэтому ноль исключает лишний расчёт Oklab hue. Self::from_xyz_with_hok(xyz, 0.0, vc).mp() } - /// CAM16 hue in degrees. Field is private (accessor-only) so the two hue - /// spaces can't be mixed up: `h_ok` (Oklab) is the geometric hue, `h_cam` - /// feeds only CAM16 inverse/appearance maths. + /// CAM16 hue в градусах. Приватность поля не даёт случайно смешать его с + /// `h_ok`: первый обслуживает CAM16, второй — геометрию Oklab. pub(crate) fn h_cam(&self) -> f64 { self.h_cam } + /// Строит согласованное LCS-значение из полярных координат CAM16-UCS. + /// + /// `h_ok` выводится из результата обратного CAM16. Так вызывающий код не + /// сможет независимо интерполировать два hue-трека и создать состояние, где + /// публичный геометрический hue описывает не тот цвет, что [`Self::to_xyz`]. + pub(crate) fn from_ucs_polar(jp: f64, mp: f64, h_cam: f64, vc: &ViewingConditions) -> Self { + let jp = jp.max(0.0); + let mp = mp.max(0.0); + let h_cam = h_cam.rem_euclid(360.0); + let s = if jp + 1.0 > 0.0 { mp / (jp + 1.0) } else { 0.0 }; + let mut out = Self { + jp, + h_ok: 0.0, + s, + h_cam, + }; + if mp > 0.0 && jp > 0.0 { + let rgb = xyz_to_srgb(out.to_xyz(vc)); + out.h_ok = oklab::oklab_hue(rgb); + } + out + } + + /// Линейные координаты sRGB без clipping, представленные этим значением. + pub(crate) fn to_linear_srgb(self, vc: &ViewingConditions) -> [f64; 3] { + xyz_to_srgb(self.to_xyz(vc)) + } + pub(crate) fn from_xyz_with_hok(xyz: [f64; 3], h_ok: f64, vc: &ViewingConditions) -> Self { - // Single shared CIECAM16 forward pass (issue #19); the UCS rescale is the - // only step `lcs` adds on top of it. + // Единый прямой проход CIECAM16 не даёт двум потребителям разойтись в + // формулах; LCS добавляет только UCS-масштабирование. let (j, m, h) = cam16::forward(xyz, vc); Self::from_cam16(j, m, h, h_ok) } - /// Build from already-computed CIECAM16 correlates `(J, M, h_cam)` plus the - /// Oklab hue. The UCS rescale is the only work here — no forward pass — so a - /// caller that already ran [`cam16::forward`] (e.g. [`crate::solve`]'s - /// `finish`) reuses that result instead of recomputing it. + /// Строит цвет из готовых коррелятов CIECAM16 `(J, M, h_cam)` и Oklab hue. + /// Повторный forward не выполняется, чтобы решатель переиспользовал один и + /// тот же физический расчёт без численного расхождения. pub(crate) fn from_cam16(j: f64, m: f64, h_cam: f64, h_ok: f64) -> Self { - // CAM16-UCS rescaling (Li et al. 2017, DOI 10.1002/col.22131): maps raw - // CIECAM16 J/M onto perceptually uniform J'/M' (J'=50 reads as - // half-lightness). Inverse in `to_xyz` via the same helpers. + // Масштабирование CAM16-UCS (Li et al. 2017, DOI 10.1002/col.22131) + // вынесено в общие функции, чтобы прямой и обратный пути не расходились. let jp = cam16::ucs_j(j); let mp = cam16::ucs_m(m); let s = mp / (jp + 1.0); @@ -103,10 +149,20 @@ impl LcsColor { } pub(crate) fn to_xyz(self, vc: &ViewingConditions) -> [f64; 3] { - // Inverse CAM16-UCS rescaling (Li et al. 2017, DOI 10.1002/col.22131), - // single source of truth in `cam16`. + // Обратное масштабирование берётся из того же SSOT в `cam16`. let j = cam16::ucs_j_inv(self.jp); let m = cam16::ucs_m_inv(self.mp()); + + // В физическом чёрном J=M=0, а общая обратная формула содержит + // `M / sqrt(J)`. Аналитическая ветвь исключает искусственный 0/0 и + // последующий NaN-clipping; M>0 при J=0 лежал бы вне цветового тела. + if j <= 0.0 { + debug_assert!( + m == 0.0, + "positive CAM16 colourfulness at J=0 is unrealizable" + ); + return [0.0, 0.0, 0.0]; + } let hr = self.h_cam.to_radians(); // `hr.cos()` / `hr.sin()` ниже вычислялись дважды каждый; считаем один раз // и переиспользуем — байт-идентичный CSE (тот же аргумент, тот же diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 523373be..40c9b347 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod accent_balance; pub mod accent_surface; pub mod alpha; pub mod cleanliness; +pub mod color_quality; pub mod config; pub mod glow; pub mod hash; @@ -76,6 +77,15 @@ pub use cleanliness::{ DefectContext, Theme, drab, drab_in_context, muddiness_from_hex, muddiness_from_linear_srgb, muddiness_in_context, muddiness_oklch, n_pure, }; +pub use color_quality::{ + AppearanceContextV1, AppearanceMode, AppearanceProfileV1, COLOR_QUALITY_AUDIT_MODEL_V1, + ColorQualityAuditV1, ContextualSrgbGamutAnalysisV1, DisplayConditionsV1, MUTEDNESS_MODEL_V2, + ModelProvenance, MutednessComparisonV2, MutednessRelationV2, SpatialContextV1, + SrgbGamutGeometryV1, WARM_DARK_INTERACTION_MODEL_V2, WarmDarkInteractionReportV2, + WarmDarkInteractionStatusV2, WarmDarkZeroReasonV2, analyze_srgb_gamut_hex_in_context_v1, + analyze_srgb_gamut_hex_v1, analyze_srgb_gamut_linear_v1, analyze_srgb_gamut_oklch_v1, + analyze_warm_dark_interaction_v2, audit_color_quality_v1, compare_mutedness_v2, +}; pub use config::{ Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, @@ -83,8 +93,8 @@ pub use config::{ }; pub use curve::ColorCurve; pub use glow::{ - GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_SUBTLE_DJ, GlowSolve, glow_layers_from_source, - screen_layer_over_encoded, solve_screen_alpha_for_dj, + GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_SUBTLE_DJ, GlowLayers, GlowSolve, glow_layers_from_source, + glow_layers_report_from_source, screen_layer_over_encoded, solve_screen_alpha_for_dj, }; pub use hash::fnv1a_32; pub use ladder::{LadderPosition, LadderTint, ThemeAnchors}; @@ -108,7 +118,9 @@ pub use solve::{ BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveJob, Solved, TypographicContext, Unreachable, solve, solve_many, }; -pub use spaces::oklch::{oklch_css_from_hex, oklch_from_hex}; +pub use spaces::oklch::{ + OKLCH_POWERLESS_CHROMA, OklchCoordinates, oklch_css_from_hex, oklch_from_hex, +}; pub use spaces::p3::{p3_css_from_hex, p3_from_hex}; pub use spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex}; pub use spaces::vc::ViewingConditions; diff --git a/crates/labcolors-core/src/lpc.rs b/crates/labcolors-core/src/lpc.rs index 8e294b14..6eb32391 100644 --- a/crates/labcolors-core/src/lpc.rs +++ b/crates/labcolors-core/src/lpc.rs @@ -372,14 +372,12 @@ pub(crate) fn j_hk_from_cam16(j: f64, m: f64, h: f64, vc: &ViewingConditions) -> j + hk_coeff(h) * chroma.powf(HK_CHROMA_EXPONENT) } -fn hex_to_y_hk(hex: &str, vc: &ViewingConditions) -> f64 { - // Невалидный hex деградирует к чёрному, а не к Err: публичный LPC-API - // (`lpc`, `lpc_with_vc`) намеренно infallible (f64, не Result) — hex - // валидируются на границах (config/wasm), здесь только детерминированный - // фолбэк вместо паники. - let rgb = srgb_from_hex(hex).unwrap_or([0.0, 0.0, 0.0]); +fn hex_to_y_hk(hex: &str, vc: &ViewingConditions) -> Result { + // Невалидный текст нельзя переинтерпретировать как чёрный: это превращает + // ошибку границы в правдоподобный, но ложный контрастный результат. + let rgb = srgb_from_hex(hex)?; let xyz = srgb_to_xyz(rgb); - y_hk(j_hk_from_xyz(xyz, vc).max(0.0), vc) + Ok(y_hk(j_hk_from_xyz(xyz, vc).max(0.0), vc)) } /// Perceptual-contrast (LPC) between a foreground and background hex colour @@ -390,7 +388,13 @@ fn hex_to_y_hk(hex: &str, vc: &ViewingConditions) -> f64 { /// [`ViewingConditions::dim_surround`] (dim surround компенсирует эффект /// Бартлесона–Бренемана; параметры окружения — CIE 159:2004 Table 1 / CIE 248:2022). pub fn lpc(fg_hex: &str, bg_hex: &str) -> f64 { - lpc_with_vc(fg_hex, bg_hex, &ViewingConditions::srgb()) + try_lpc(fg_hex, bg_hex) + .expect("lpc получил невалидный цвет; на границе ввода используйте try_lpc") +} + +/// Проверяемый перцептивный контраст в стандартных условиях просмотра. +pub fn try_lpc(fg_hex: &str, bg_hex: &str) -> Result { + try_lpc_with_vc(fg_hex, bg_hex, &ViewingConditions::srgb()) } /// Perceptual-contrast (LPC) between a foreground and background hex colour @@ -409,9 +413,15 @@ pub fn lpc(fg_hex: &str, bg_hex: &str) -> f64 { /// NOT DOI 10.1002/col.22793, which is the Helmholtz–Kohlrausch paper cited by /// `hk_coeff` — that DOI was previously misattached to the surround params.) pub fn lpc_with_vc(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> f64 { - let y_fg = hex_to_y_hk(fg_hex, vc); - let y_bg = hex_to_y_hk(bg_hex, vc); - contrast_core(y_fg, y_bg) + try_lpc_with_vc(fg_hex, bg_hex, vc) + .expect("lpc_with_vc получил невалидный цвет; используйте try_lpc_with_vc") +} + +/// Проверяемый перцептивный контраст при явных условиях просмотра. +pub fn try_lpc_with_vc(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> Result { + let y_fg = hex_to_y_hk(fg_hex, vc)?; + let y_bg = hex_to_y_hk(bg_hex, vc)?; + Ok(contrast_core(y_fg, y_bg)) } /// LPC surface distance between two hex colours under standard sRGB viewing @@ -420,7 +430,13 @@ pub fn lpc_with_vc(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> f64 { /// Shortcut for [`lpc_surface_with_vc`] with [`ViewingConditions::srgb`]; use /// [`ViewingConditions::dim_surround`] for dark themes. pub fn lpc_surface(c1_hex: &str, c2_hex: &str) -> f64 { - lpc_surface_with_vc(c1_hex, c2_hex, &ViewingConditions::srgb()) + try_lpc_surface(c1_hex, c2_hex) + .expect("lpc_surface получил невалидный цвет; используйте try_lpc_surface") +} + +/// Проверяемое расстояние поверхности CAM16-UCS в стандартных условиях. +pub fn try_lpc_surface(c1_hex: &str, c2_hex: &str) -> Result { + try_lpc_surface_with_vc(c1_hex, c2_hex, &ViewingConditions::srgb()) } /// LPC surface distance between two hex colours under the given viewing @@ -430,18 +446,19 @@ pub fn lpc_surface(c1_hex: &str, c2_hex: &str) -> f64 { /// the dim-surround space. Use [`ViewingConditions::srgb`] for light themes and /// [`ViewingConditions::dim_surround`] for dark themes. pub fn lpc_surface_with_vc(c1_hex: &str, c2_hex: &str, vc: &ViewingConditions) -> f64 { - // `.expect()` on the parse is intentionally left in place here: the - // fallible-public-API redesign is tracked separately (issue #41). - let c1 = crate::lcs::LcsColor::from_hex_with_vc(c1_hex, vc).expect("invalid hex"); - let c2 = crate::lcs::LcsColor::from_hex_with_vc(c2_hex, vc).expect("invalid hex"); - let dj = c1.jp - c2.jp; - let m1 = c1.s * (c1.jp + 1.0); - let m2 = c2.s * (c2.jp + 1.0); - let h1 = c1.h_ok.to_radians(); - let h2 = c2.h_ok.to_radians(); - let da = m1 * h1.cos() - m2 * h2.cos(); - let db = m1 * h1.sin() - m2 * h2.sin(); - (dj * dj + da * da + db * db).sqrt() + try_lpc_surface_with_vc(c1_hex, c2_hex, vc) + .expect("lpc_surface_with_vc получил невалидный цвет; используйте try_lpc_surface_with_vc") +} + +/// Проверяемое расстояние поверхности CAM16-UCS при явных условиях просмотра. +pub fn try_lpc_surface_with_vc( + c1_hex: &str, + c2_hex: &str, + vc: &ViewingConditions, +) -> Result { + let c1 = crate::lcs::LcsColor::from_hex_with_vc(c1_hex, vc)?; + let c2 = crate::lcs::LcsColor::from_hex_with_vc(c2_hex, vc)?; + Ok(c1.delta_e_ucs(&c2)) } /// LPC contrast between two [`crate::lcs::LcsColor`] values under standard sRGB diff --git a/crates/labcolors-core/src/neutral.rs b/crates/labcolors-core/src/neutral.rs index beefdda2..607d588c 100644 --- a/crates/labcolors-core/src/neutral.rs +++ b/crates/labcolors-core/src/neutral.rs @@ -1,151 +1,63 @@ +use std::sync::Arc; + use crate::lcs::LcsColor; +use crate::spaces::srgb::{quantise_srgb, srgb_encoded_from_hex, srgb_from_hex, srgb_gamma_inv}; use crate::spaces::vc::ViewingConditions; -/// Порог M' (CAM16-UCS), ниже которого якорь считается ахроматическим и его -/// шумный `h_ok` замещается базовым оттенком. CAM16 даёт ненулевой M' даже для -/// номинально ахроматических стимулов (mp ≈ 1.5 для белого, ≈ 2.3 для near-black), -/// поэтому 5.0 ловит модельный шум, сохраняя подлинно хроматические якоря. -/// -/// Терминал **(c) INTERVAL-INSENSITIVE**: порог сидит в ШИРОКОМ пустом зазоре -/// между потолком ахроматического `M'`-шума серых и полом хромы подлинно -/// цветных якорей; партиция achromatic↔chromatic инвариантна для ЛЮБОГО θ в -/// этом зазоре (`achromatic_threshold_sits_in_empty_noise_to_chroma_gap`). -/// Экспозиция — доля sRGB-гаммы, где точное значение флипает партицию, — -/// **1.99%** (`exposure_achromatic_mp_threshold`). Значение не меняется. -// SSOT-TRACKED — порог ахроматичности M' (модельный шум CAM16), терминал (c) interval-insensitive (exposure 1.99%), см. docs/empirical-inventory.md. -const ACHROMATIC_MP_THRESHOLD: f64 = 5.0; - -/// Множитель опорной хромы для нормировки чистоты оттенка: `mp_ref = 1.5 × M'` -/// базового якоря, чтобы база сохраняла почти весь свой оттенок, а -/// near-ахроматические якоря сильно корректировались. Терминал (e) DESIGN-CHOICE: -/// магнитуда — свободная ручка (опубликованного значения не существует), -/// робастность доказана хроматическим инвариантом (`purity == 1` при -/// `mp ≥ mp_ref` при ЛЮБОМ множителе); ПРОВЕНАНС ФОРМЫ — см. -/// [`HUE_PURITY_EXPONENT`]. Легальный диапазон **> 1** (множитель обязан -/// поднимать опорную хрому над хромой базы, иначе база сама корректируется); -/// практический вкус [1.2, 2.0]. -// SSOT-TRACKED — множитель опорной хромы, терминал (e) design-choice (форма мотивирована Abney; > 1), см. docs/empirical-inventory.md. -const HUE_PURITY_MP_REF_RATIO: f64 = 1.5; - -/// Показатель степени кривой чистоты оттенка `(mp/mp_ref)^0.6`: агрессивная -/// коррекция оттенка для сильно десатурированных цветов, плавно отпускаемая с -/// ростом хромы. -/// -/// ПРОВЕНАНС ФОРМЫ (не значения). «Не доверяй тону у нейтрали» мотивировано двумя -/// СХОДЯЩИМИСЯ причинами: -/// 1. ЧИСЛЕННОЙ — `atan2(b, a)` ill-conditioned у серой оси, оттенок почти-серых -/// построен из шума (см. [`hue_purity`]); -/// 2. ПЕРЦЕПТИВНОЙ — эффект Abney: воспринимаемый тон монохроматического стимула -/// СДВИГАЕТСЯ при разбавлении белым (падении чистоты). Abney (1909) Proc. R. -/// Soc. Lond. A 83, 120–127 (DOI 10.1098/rspa.1909.0085); величина сдвига -/// растёт с падением колориметрической чистоты — Kurtenbach, Sternheim & -/// Spillmann (1984) JOSA A 1(4), 365–372 (DOI 10.1364/JOSAA.1.000365). -/// -/// ⚠️ Перцептивный Abney — ОТДЕЛЬНОЕ явление от численного atan2-шума; конкретные -/// `1.5`/`0.6` — инженерная калибровка формы, НЕ выведены из данных Abney (эта -/// кривая эффект Abney не моделирует, issue #27 — `abney_correct`). +/// Маркер бескоэффициентного построения нейтральной кривой. /// -/// Терминал (e) DESIGN-CHOICE (решение владельца 2026-07-07): фит магнитуд к -/// данным Abney дал бы ДРУГУЮ кривую — маркировать их (b) GROUNDED было бы -/// подлогом (ADR-0002); честный терминал — задекларированные свободные ручки с -/// доказанной робастностью. Sensitivity: хроматические якоря (`mp ≥ mp_ref`) -/// инвариантны к значению (`purity == 1`), константы двигают ТОЛЬКО оттенок -/// near-нейтралей (и так atan2-шум); свип показателя [0.4, 0.9] даёт -/// max|Δpurity| = 0.148 — непрерывный ограниченный дрейф, не флип. Легальный -/// диапазон **(0, 1]** (показатель < 1 = вогнутая кривая, агрессивная коррекция -/// near-нейтралей; практический вкус [0.4, 0.9]). Протокол «объективизации»: -/// 2AFC hue-shift на десатурированных стимулах (величина Abney-сдвига vs чистота) -/// стал бы кандидатом-ВЫВОДОМ (замер → сравнение → решение), не обязательным -/// экспериментом. Локи `hue_purity_curve_shape_is_pinned`, `exposure_hue_purity_curve`; -/// docs/empirical-inventory.md. -// SSOT-TRACKED — показатель кривой чистоты, терминал (e) design-choice (форма мотивирована Abney; (0,1]), см. docs/empirical-inventory.md. -const HUE_PURITY_EXPONENT: f64 = 0.6; - -/// Форм-параметры нейтральной кривой: гаммы светлотных ветвей и позиция пика -/// хромы. Раздельные гаммы — потому что восприятие шага светлоты асимметрично -/// относительно базы: светлая ветвь требует более плотного шага у якоря -/// (мотивирует НАПРАВЛЕНИЕ `gamma_light > gamma_dark`, не магнитуды). -/// -/// Все три поля — терминал **(e) DESIGN-CHOICE** (Волна 2 «объективизация»): -/// генуинные свободные ручки формы, дефолты для конфигурируемого `with_params`. -/// Sensitivity на дефолтной шкале `#FFFFFF→#787880→#101012` (лок -/// `curve_params_sensitivity_is_bounded`, max ΔE_ok эмитируемых 13 ступеней): -/// * `gamma_light` свип [1.3, 2.2] → **0.0466** (>1 JND, материальна); -/// * `gamma_dark` свип [1.2, 1.9] → **0.0325** (>1 JND, материальна); -/// * `chroma_peak_t` свип [0.2, 0.5] → **0.0042** (< ½ JND — самая низкая -/// чувствительность: на near-серой шкале пик огибающей хромы почти не двигает -/// выход; на хроматической базе был бы материальнее, поэтому (e), не (c)). -/// -/// Легальные диапазоны: гаммы `> 0` (степень powf; практический вкус -/// [1.2, 2.2]), `chroma_peak_t ∈ (0, 0.5]` (клампится в `chroma_envelope`). -/// Протокол «объективизации»: замерить перцептивную равномерность шага рампы -/// (ΔE соседних ступеней должен быть ~констан­тен для глаза) и выбрать гаммы, -/// минимизирующие дисперсию воспринимаемого шага — измерение стало бы -/// кандидатом-выводом (замер → сравнение → решение), не обязательным экспериментом. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct CurveParams { - pub gamma_light: f64, - pub gamma_dark: f64, - pub chroma_peak_t: f64, +/// Нулевой размер сохраняет совместимость вызовов `with_params`/`with_vc`, но не +/// оставляет скрытых gamma/chroma/hue-ручек. Геометрию полностью задают три +/// якоря, стандартная sRGB-квантизация и выбранные условия просмотра. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[non_exhaustive] +pub struct CurveParams; + +/// Одно фактически представимое состояние пути и пройденная до него длина. +#[derive(Debug, Clone, Copy)] +struct QuantizedState { + color: LcsColor, + cumulative_ucs: f64, + #[cfg(test)] + rgb8: [u8; 3], } -impl Default for CurveParams { - fn default() -> Self { - Self { - // Показатель степени, которым гамма-кривая отображает t на светлотный - // интервал [светлый якорь, базовый], при t <= 0.5. - // SSOT-TRACKED — гамма светлой ветви нейтральной кривой, (e) design-choice (max ΔE_ok 0.0466 по [1.3,2.2]). - gamma_light: 1.75, - // Показатель степени для тёмной ветви (t > 0.5): интервал [базовый, - // тёмный якорь]. - // SSOT-TRACKED — гамма тёмной ветви нейтральной кривой, (e) design-choice (max ΔE_ok 0.0325 по [1.2,1.9]). - gamma_dark: 1.5, - // Позиция вдоль параметра кривой t, где огибающая хромы достигает пика. - // SSOT-TRACKED — положение пика хромы, (e) design-choice (max ΔE_ok 0.0042 по [0.2,0.5], низшая чувствительность). - chroma_peak_t: 0.35, - } - } -} - -/// Нейтральная (серо-осевая) кривая по трём якорям: светлый → базовый → тёмный. +/// Конечная цветовая полилиния через светлый, базовый и тёмный якоря. +/// +/// Между соседними якорями строится выпуклый отрезок в **линейном свете sRGB**. +/// Затем перечисляются все состояния, которые фактически выдаёт общий +/// `encode → clamp → round → decode`-квантизатор sRGB8. Для каждого следующего +/// кода канала ищется первое представимое `f64 t`, на котором переключается +/// production-квантование; аналитическая half-byte граница не считается +/// эквивалентной живому пути из-за округления и `libm`. +/// +/// Длина пути — сумма CAM16-UCS расстояний между реальными `#RRGGBB`, а не длина +/// непрерывной кривой до квантования. Побитовая межплатформенная идентичность +/// трансцендентного encode-пути отдельно сертифицируется задачей #223. /// -/// Светлота ведётся гамма-ветвями (`CurveParams`), хрома — C1-огибающей -/// (`chroma_envelope`), оттенок — покоем у базы с purity-коррекцией шумных -/// концов (`hue_purity`). `at()` — примитив каждого шага построения лестницы, -/// поэтому все t-инвариантные величины предвычислены в конструкторе. +/// Поэтому [`Self::at`] — ступенчатая функция и близкие значения `t` закономерно +/// могут вернуть один цвет. «Равномерность» здесь означает только ближайшее по +/// накопленной CAM16-UCS длине состояние данной конечной sRGB8-полилинии. Это не +/// утверждение об универсальной психофизической равномерности CAM16-UCS и не +/// аппроксимация непрерывной float-кривой. #[derive(Debug, Clone)] pub struct NeutralCurve { a_light: LcsColor, a_base: LcsColor, a_dark: LcsColor, - h_ok_base: f64, - h_cam_base: f64, - // Purity-скорректированные оттенки концов кривой. Они не зависят от t, - // а их пересчёт в каждом `at()` стоил 4×powf на сэмпл (hue_purity для - // двух якорей в двух hue-пространствах). Предвычисление в with_vc даёт - // бит-идентичный результат: те же операции над теми же входами, один раз. - h_ok_light_eff: f64, - h_ok_dark_eff: f64, - h_cam_light_eff: f64, - h_cam_dark_eff: f64, - params: CurveParams, + states: Arc<[QuantizedState]>, + total_ucs: f64, + base_t: f64, vc: ViewingConditions, } impl NeutralCurve { - /// Build a neutral curve using standard sRGB viewing conditions (average surround). + /// Строит кривую для стандартного среднего окружения sRGB. pub fn new(light: &str, base: &str, dark: &str) -> Result { - Self::with_vc( - light, - base, - dark, - &CurveParams::default(), - &ViewingConditions::srgb(), - ) + Self::with_vc(light, base, dark, &CurveParams, &ViewingConditions::srgb()) } - /// Как [`NeutralCurve::new`], но с нестандартными форм-параметрами - /// (например, из `labui.config`), viewing conditions — стандартные sRGB. + /// Совместимая форма вызова для бескоэффициентного построения. pub fn with_params( light: &str, base: &str, @@ -155,128 +67,159 @@ impl NeutralCurve { Self::with_vc(light, base, dark, ¶ms, &ViewingConditions::srgb()) } - /// Build a neutral curve for the given viewing conditions. + /// Строит кривую при явно заданных условиях просмотра. /// - /// Anchor colours are parsed through `vc`, so J' and saturation reflect - /// the perceptual environment (e.g. dim-surround for dark themes). - /// Use [`ViewingConditions::srgb()`] for light themes and - /// [`ViewingConditions::dim_surround()`] for dark themes. + /// Все состояния декодируются из конечных sRGB8-байтов в одних условиях. + /// Это гарантирует допустимость гаммы по построению: метод `at` никогда не + /// создаёт CAM16-точку, которую затем пришлось бы клиппить или радиально + /// отображать обратно в sRGB. pub fn with_vc( light: &str, base: &str, dark: &str, - params: &CurveParams, + _params: &CurveParams, vc: &ViewingConditions, ) -> Result { + vc.validate()?; + let light_bytes = bytes_from_hex(light)?; + let base_bytes = bytes_from_hex(base)?; + let dark_bytes = bytes_from_hex(dark)?; + let a_light = LcsColor::from_hex_with_vc(light, vc)?; let a_base = LcsColor::from_hex_with_vc(base, vc)?; let a_dark = LcsColor::from_hex_with_vc(dark, vc)?; if a_light.jp <= a_base.jp { - return Err("light anchor must be lighter than base".into()); + return Err("светлый якорь должен иметь J′ строго выше базового".into()); } if a_base.jp <= a_dark.jp { - return Err("base anchor must be lighter than dark".into()); + return Err("базовый якорь должен иметь J′ строго выше тёмного".into()); } - let h_ok_base = a_base.h_ok; - let h_cam_base = a_base.h_cam(); + let mut path = enumerate_segment(light_bytes, base_bytes)?; + let base_index = path.len() - 1; + path.extend( + enumerate_segment(base_bytes, dark_bytes)? + .into_iter() + .skip(1), + ); - // Achromatic anchors have unreliable h_ok (atan2 of ~0 values). - // CAM16 viewing-condition adaptation produces non-zero M' even for - // nominally achromatic stimuli (mp ≈ 1.5 for white, ≈ 2.3 for - // near-black). Threshold 5.0 catches model noise while preserving - // genuinely chromatic anchors. - let a_light = if a_light.mp() < ACHROMATIC_MP_THRESHOLD { - LcsColor::new(a_light.jp, h_ok_base, a_light.s, a_light.h_cam()) - } else { - a_light - }; - let a_dark = if a_dark.mp() < ACHROMATIC_MP_THRESHOLD { - LcsColor::new(a_dark.jp, h_ok_base, a_dark.s, a_dark.h_cam()) - } else { - a_dark - }; + let mut states: Vec = Vec::with_capacity(path.len()); + let mut cumulative = 0.0_f64; + let mut compensation = 0.0_f64; + + for (index, &bytes) in path.iter().enumerate() { + let color = match index { + 0 => a_light, + i if i == base_index => a_base, + i if i + 1 == path.len() => a_dark, + _ => LcsColor::from_hex_with_vc(&hex_from_bytes(bytes), vc)?, + }; + if !color.jp.is_finite() || !color.ucs_cartesian().into_iter().all(f64::is_finite) { + return Err(format!( + "CAM16-UCS не определён для состояния {}", + hex_from_bytes(bytes) + )); + } + + if let Some(previous) = states.last() { + if previous.color.jp <= color.jp { + return Err(format!( + "конечный sRGB8-путь не строго убывает по J′: {} → {}", + hex_from_bytes(path[index - 1]), + hex_from_bytes(bytes) + )); + } + + let step = previous.color.delta_e_ucs(&color); + if !step.is_finite() || step <= 0.0 { + return Err(format!( + "нулевая или неопределённая длина CAM16-UCS: {} → {}", + hex_from_bytes(path[index - 1]), + hex_from_bytes(bytes) + )); + } + + // Компенсированное суммирование не меняет метрику: оно лишь не + // даёт сотням положительных отрезков терять младшие биты суммы. + let corrected = step - compensation; + let next = cumulative + corrected; + compensation = (next - cumulative) - corrected; + cumulative = next; + } + + states.push(QuantizedState { + color, + cumulative_ucs: cumulative, + #[cfg(test)] + rgb8: bytes, + }); + } - // Эффективные оттенки концов: шумный hue near-ахроматического якоря - // подтягивается к базовому пропорционально его хроматической чистоте - // (см. `hue_purity`). mp_ref = 1.5×M' базы — база сохраняет почти весь - // свой оттенок, near-серые концы корректируются сильно. Считается здесь, - // а не в `at()`: величины t-инвариантны, а `at()` — горячий путь. - let mp_ref = a_base.mp() * HUE_PURITY_MP_REF_RATIO; - let purity_light = hue_purity(a_light.mp(), mp_ref); - let purity_dark = hue_purity(a_dark.mp(), mp_ref); - let h_ok_light_eff = lerp_angle(h_ok_base, a_light.h_ok, purity_light); - let h_ok_dark_eff = lerp_angle(h_ok_base, a_dark.h_ok, purity_dark); - let h_cam_light_eff = lerp_angle(h_cam_base, a_light.h_cam(), purity_light); - let h_cam_dark_eff = lerp_angle(h_cam_base, a_dark.h_cam(), purity_dark); + if !cumulative.is_finite() || cumulative <= 0.0 { + return Err("суммарная длина sRGB8-пути должна быть конечной и положительной".into()); + } + let base_length = states[base_index].cumulative_ucs; + let base_t = base_length / cumulative; + if !(0.0 < base_t && base_t < 1.0) { + return Err("базовый якорь должен лежать внутри конечного пути".into()); + } Ok(Self { a_light, a_base, a_dark, - h_ok_base, - h_cam_base, - h_ok_light_eff, - h_ok_dark_eff, - h_cam_light_eff, - h_cam_dark_eff, - params: *params, + states: states.into(), + total_ucs: cumulative, + base_t, vc: *vc, }) } - /// Точка кривой при `t ∈ [0, 1]`: 0 — светлый якорь, 0.5 — базовый, - /// 1 — тёмный. + /// Ближайшее представимое состояние на нормированной накопленной длине + /// CAM16-UCS `t ∈ [0, 1]`. + /// + /// При точном равенстве расстояний выбирается более светлое, то есть более + /// раннее состояние пути. Tie-break нужен только для полной определённости + /// дискретной функции и не вводит цветовой коэффициент. pub fn at(&self, t: f64) -> LcsColor { + assert!(t.is_finite(), "параметр кривой t должен быть конечным"); let t = t.clamp(0.0, 1.0); - // Снап к якорям в пределах 1e-12: гарантирует байт-точное - // воспроизведение входных hex-якорей на концах и в базе — иначе - // накопленная FP-погрешность интерполяции могла бы сдвинуть - // квантованный выход на единицу канала. - if (t - 0.0).abs() < 1e-12 { + // Явные контрактные точки не зависят от округления `base_t * total` и + // всегда возвращают ровно декодированные пользовательские якоря. + if t == 0.0 { return self.a_light; } - if (t - 0.5).abs() < 1e-12 { + if t == self.base_t { return self.a_base; } - if (t - 1.0).abs() < 1e-12 { + if t == 1.0 { return self.a_dark; } - // jp-якоря берутся напрямую из полей якорей (Oklab jp). Прежний метод- - // обёртка effective_hue_anchor_jp был identity ({ anchor.jp }) — имя - // обещало hue-зависимый расчёт, тело возвращало поле; заинлайнен (аудит - // D2(c), 2026-07-03), value-preserving. - let jp = if t <= 0.5 { - let u = t / 0.5; - let j0 = self.a_light.jp; - let j6 = self.a_base.jp; - j0 + (j6 - j0) * u.powf(self.params.gamma_light) - } else { - let u = (t - 0.5) / 0.5; - let j6 = self.a_base.jp; - let j12 = self.a_dark.jp; - j6 + (j12 - j6) * u.powf(self.params.gamma_dark) - }; - - let mp = chroma_envelope( - t, - self.a_light.mp(), - self.a_base.mp(), - self.a_dark.mp(), - self.params.chroma_peak_t, - ); - let s = mp / (jp + 1.0); - - let h_ok = self.interpolate_hue_ok(t); - let h_cam = self.interpolate_hue_cam(t); + let target = t * self.total_ucs; + let upper = self + .states + .partition_point(|state| state.cumulative_ucs <= target); + if upper == 0 { + return self.states[0].color; + } + if upper == self.states.len() { + return self.states[upper - 1].color; + } - LcsColor::new(jp, h_ok, s, h_cam) + let before = self.states[upper - 1]; + let after = self.states[upper]; + let distance_before = target - before.cumulative_ucs; + let distance_after = after.cumulative_ucs - target; + if distance_after < distance_before { + after.color + } else { + before.color + } } - /// The viewing conditions used to build this curve. pub fn vc(&self) -> &ViewingConditions { &self.vc } @@ -293,79 +236,136 @@ impl NeutralCurve { &self.a_dark } - // Обе hue-дорожки (Oklab h_ok и CAM16 h_cam) ведутся параллельно одной - // схемой: конец → база → конец по кратчайшей дуге. Концы — предвычисленные - // purity-скорректированные поля (см. with_vc). - fn interpolate_hue_ok(&self, t: f64) -> f64 { - if t <= 0.5 { - lerp_angle(self.h_ok_light_eff, self.h_ok_base, t / 0.5) + /// Положение базы, выведенное из длин фактически эмитируемых sRGB8-рёбер. + pub fn base_position(&self) -> f64 { + self.base_t + } +} + +#[derive(Debug, Clone, Copy)] +struct Boundary { + t: f64, + channel: usize, + next_byte: u8, +} + +/// Первое представимое `t ∈ (0, 1]`, на котором production-квантование канала +/// достигает следующего кода. +/// +/// Для неотрицательных finite `f64` порядок битовых представлений совпадает с +/// числовым, поэтому бинарный поиск охватывает весь кодомен `t`, а не выборочную +/// сетку. Проверяется именно общий [`quantise_srgb`], чтобы перечисление и +/// финальная эмиссия не имели двух версий правила округления. +fn first_transition_t( + start: f64, + end: f64, + target_byte: u8, + increasing: bool, +) -> Result { + let target = srgb_gamma_inv(f64::from(target_byte) / 255.0); + let crossed = |t: f64| { + let linear = start + (end - start) * t; + let quantized = quantise_srgb([linear, 0.0, 0.0])[0]; + if increasing { + quantized >= target } else { - lerp_angle(self.h_ok_base, self.h_ok_dark_eff, (t - 0.5) / 0.5) + quantized <= target } + }; + + if crossed(0.0) || !crossed(1.0) { + return Err(format!( + "квантователь не образует переход к коду {target_byte} внутри сегмента" + )); } - fn interpolate_hue_cam(&self, t: f64) -> f64 { - if t <= 0.5 { - lerp_angle(self.h_cam_light_eff, self.h_cam_base, t / 0.5) + let mut lo = 0.0_f64.to_bits(); + let mut hi = 1.0_f64.to_bits(); + while lo + 1 < hi { + let mid = lo + (hi - lo) / 2; + if crossed(f64::from_bits(mid)) { + hi = mid; } else { - lerp_angle(self.h_cam_base, self.h_cam_dark_eff, (t - 0.5) / 0.5) + lo = mid; } } -} -/// C1-continuous chroma envelope through the chromas of all three anchors. -/// -/// Rises from the light anchor's M' to the base anchor's M' at `t_peak` -/// (half-cosine ease), holds the base level until the base anchor at -/// `t = 0.5`, then falls to the dark anchor's M' (half-cosine ease). -/// All three anchors are reproduced exactly and every junction has zero -/// slope, so M' is C1 on `[0, 1]` — the predecessor (`sine_env`) pinned -/// both ends to the dark anchor's chroma, jumping at `t = 0` and `t = 0.5`. -/// -/// `t_peak` is clamped to `(0, 0.5]`; at `0.5` the plateau is empty and the -/// envelope is a plain ease light→base→dark. -fn chroma_envelope(t: f64, mp_light: f64, mp_base: f64, mp_dark: f64, t_peak: f64) -> f64 { - let t_peak = t_peak.clamp(f64::EPSILON, 0.5); - let ease = |u: f64| 0.5 - 0.5 * (std::f64::consts::PI * u).cos(); - if t <= t_peak { - mp_light + (mp_base - mp_light) * ease(t / t_peak) - } else if t <= 0.5 { - mp_base + let t = f64::from_bits(hi); + if t.is_finite() && 0.0 < t && t <= 1.0 { + Ok(t) } else { - mp_base + (mp_dark - mp_base) * ease((t - 0.5) / 0.5) + Err(format!( + "переход к коду {target_byte} дал недопустимый параметр t={t}" + )) } } -/// Interpolate between two angles **in degrees** along the shortest arc. -fn lerp_angle(a: f64, b: f64, t: f64) -> f64 { - let diff = b - a; - let shortest = (diff + 180.0).rem_euclid(360.0) - 180.0; - a + shortest * t -} +/// Перечисляет все состояния сегмента, реально достижимые общим sRGB8-квантователем. +fn enumerate_segment(from: [u8; 3], to: [u8; 3]) -> Result, String> { + let from_rgb = srgb_from_hex(&hex_from_bytes(from))?; + let to_rgb = srgb_from_hex(&hex_from_bytes(to))?; + let mut boundaries = Vec::new(); + + for channel in 0..3 { + let from_byte = from[channel]; + let to_byte = to[channel]; + match to_byte.cmp(&from_byte) { + std::cmp::Ordering::Greater => { + for next_byte in (from_byte + 1)..=to_byte { + boundaries.push(Boundary { + t: first_transition_t(from_rgb[channel], to_rgb[channel], next_byte, true)?, + channel, + next_byte, + }); + } + } + std::cmp::Ordering::Less => { + for next_byte in to_byte..from_byte { + boundaries.push(Boundary { + t: first_transition_t( + from_rgb[channel], + to_rgb[channel], + next_byte, + false, + )?, + channel, + next_byte, + }); + } + } + std::cmp::Ordering::Equal => {} + } + } -/// Hue-purity weight: suppresses the noisy hue of near-achromatic anchors. -/// -/// `atan2(b, a)` returns a meaningless angle for a near-grey colour (its hue -/// is built from noise). Returns a value in `[0, 1]` indicating how much of -/// the anchor's own hue to retain. Low `mp` (near-achromatic) → low purity → -/// strong correction toward the base hue. The power exponent 0.6 gives -/// aggressive correction for very desaturated colours while releasing smoothly -/// as chroma increases. -/// -/// This is not the Abney effect (curvature of constant-hue lines as purity -/// changes); Abney correction is tracked separately (issue #27, `abney_correct`). -/// -/// ```text -/// mp/mp_ref = 0.1 → purity ≈ 0.25 (75 % corrected) -/// mp/mp_ref = 0.3 → purity ≈ 0.49 (51 % corrected) -/// mp/mp_ref = 0.5 → purity ≈ 0.66 (34 % corrected) -/// mp/mp_ref = 1.0 → purity = 1.00 (0 % corrected) -/// ``` -fn hue_purity(mp: f64, mp_ref: f64) -> f64 { - if mp >= mp_ref { - return 1.0; + boundaries.sort_by(|a, b| a.t.total_cmp(&b.t).then_with(|| a.channel.cmp(&b.channel))); + + let mut bytes = from; + let mut states = Vec::with_capacity(boundaries.len() + 1); + states.push(bytes); + let mut index = 0; + while index < boundaries.len() { + let event_t = boundaries[index].t; + while index < boundaries.len() && boundaries[index].t == event_t { + let event = boundaries[index]; + bytes[event.channel] = event.next_byte; + index += 1; + } + states.push(bytes); + } + + if bytes != to { + return Err("перечисление живого sRGB8-квантователя не достигло конечного якоря".into()); } - (mp / mp_ref).powf(HUE_PURITY_EXPONENT).clamp(0.0, 1.0) + Ok(states) +} + +fn bytes_from_hex(hex: &str) -> Result<[u8; 3], String> { + let encoded = srgb_encoded_from_hex(hex)?; + Ok(encoded.map(|value| (value * 255.0).round() as u8)) +} + +fn hex_from_bytes([r, g, b]: [u8; 3]) -> String { + format!("#{r:02X}{g:02X}{b:02X}") } impl crate::curve::ColorCurve for NeutralCurve { @@ -383,687 +383,253 @@ mod tests { use super::*; use crate::curve::ColorCurve; - fn default_curve() -> NeutralCurve { - NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() - } - - #[test] - fn anchors_exact_at_endpoints() { - let curve = default_curve(); - let c0 = curve.at(0.0); - let cm = curve.at(0.5); - let c1 = curve.at(1.0); - - assert!( - (c0.jp - curve.light_anchor().jp).abs() < 1e-9, - "t=0 jp mismatch" - ); - assert!( - (cm.jp - curve.base_anchor().jp).abs() < 1e-9, - "t=0.5 jp mismatch" - ); - assert!( - (c1.jp - curve.dark_anchor().jp).abs() < 1e-9, - "t=1.0 jp mismatch" - ); + fn curve(vc: &ViewingConditions) -> NeutralCurve { + NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &CurveParams, vc).unwrap() } - #[test] - fn jp_monotonically_decreasing() { - let curve = default_curve(); - let steps = curve.sample(100); - for w in steps.windows(2) { + fn assert_all_outputs_are_real_path_states(curve: &NeutralCurve) { + let states: std::collections::HashSet<_> = curve + .states + .iter() + .map(|state| { + let expected = hex_from_bytes(state.rgb8); + assert_eq!( + state.color.to_hex_with_vc(curve.vc()), + expected, + "CAM16 round-trip изменил перечисленное sRGB8-состояние" + ); + expected + }) + .collect(); + for i in 0..=4096 { + let t = f64::from(i) / 4096.0; + let hex = curve.at(t).to_hex_with_vc(curve.vc()); assert!( - w[0].jp >= w[1].jp - 1e-9, - "jp increased: {} -> {}", - w[0].jp, - w[1].jp + states.contains(&hex), + "t={t} вернул состояние вне пути: {hex}" ); - } - } - - #[test] - fn hue_drift_under_30_degrees() { - let curve = default_curve(); - let base_hue = curve.base_anchor().h_ok; - for i in 0..=100 { - let c = curve.at(i as f64 / 100.0); - let drift = (c.h_ok - base_hue + 180.0).rem_euclid(360.0) - 180.0; - assert!( - drift.abs() < 30.0, - "hue drift at t={}: {}° (base={})", - i as f64 / 100.0, - drift, - base_hue + assert_eq!( + LcsColor::from_hex_with_vc(&hex, curve.vc()) + .unwrap() + .to_hex_with_vc(curve.vc()), + hex, + "эмитируемое состояние обязано быть реальным sRGB8" ); } } #[test] - fn sample_13_matches_old_api() { - let curve = default_curve(); - let hexes = curve.sample_hex(13); - assert_eq!(hexes.len(), 13); - assert_eq!(hexes[0].to_uppercase(), "#FFFFFF"); - assert_eq!(hexes[6].to_uppercase(), "#787880"); - assert_eq!(hexes[12].to_uppercase(), "#101012"); - } - - #[test] - fn all_sampled_steps_unique() { - let curve = default_curve(); - let hexes = curve.sample_hex(13); - let mut seen = std::collections::HashSet::new(); - for hex in &hexes { - assert!(seen.insert(hex.to_uppercase()), "duplicate: {}", hex); + fn anchors_are_byte_exact() { + for c in [ + curve(&ViewingConditions::srgb()), + curve(&ViewingConditions::dim_surround()), + ] { + assert_eq!(c.at(0.0).to_hex_with_vc(c.vc()), "#FFFFFF"); + assert_eq!(c.at(c.base_position()).to_hex_with_vc(c.vc()), "#787880"); + assert_eq!(c.at(1.0).to_hex_with_vc(c.vc()), "#101012"); } } #[test] - fn jp_within_anchor_bounds() { - let curve = default_curve(); - let j_max = curve.light_anchor().jp; - let j_min = curve.dark_anchor().jp; - for i in 0..=100 { - let c = curve.at(i as f64 / 100.0); - assert!( - c.jp <= j_max + 1e-9 && c.jp >= j_min - 1e-9, - "t={}: jp={} out of [{}, {}]", - i as f64 / 100.0, - c.jp, - j_min, - j_max - ); + fn finite_path_is_strictly_monotone_in_jp() { + for c in [ + curve(&ViewingConditions::srgb()), + curve(&ViewingConditions::dim_surround()), + ] { + for pair in c.states.windows(2) { + assert!(pair[0].color.jp > pair[1].color.jp); + } } } #[test] - fn rejects_bad_hex() { - assert!(NeutralCurve::new("#GGGGGG", "#787880", "#101012").is_err()); - } - - #[test] - fn rejects_light_not_lighter_than_base() { - assert!(NeutralCurve::new("#787880", "#FFFFFF", "#101012").is_err()); - } - - #[test] - fn rejects_base_not_lighter_than_dark() { - assert!(NeutralCurve::new("#FFFFFF", "#101012", "#787880").is_err()); + fn base_position_uses_the_quantized_polyline_length() { + let c = curve(&ViewingConditions::srgb()); + let base = c + .states + .iter() + .position(|state| state.color == c.a_base) + .unwrap(); + assert_eq!( + c.base_position().to_bits(), + (c.states[base].cumulative_ucs / c.total_ucs).to_bits() + ); } #[test] - fn s_non_negative_everywhere() { - let curve = default_curve(); - for i in 0..=100 { - let c = curve.at(i as f64 / 100.0); - assert!( - c.s >= -1e-9, - "negative s at t={}: {}", - i as f64 / 100.0, - c.s - ); + fn at_selects_a_nearest_state_with_lightward_ties() { + let c = curve(&ViewingConditions::srgb()); + for i in 0..=2048 { + let t = f64::from(i) / 2048.0; + let target = t * c.total_ucs; + let expected = c + .states + .iter() + .enumerate() + .min_by(|(ia, a), (ib, b)| { + (a.cumulative_ucs - target) + .abs() + .total_cmp(&(b.cumulative_ucs - target).abs()) + .then_with(|| ia.cmp(ib)) + }) + .unwrap() + .1 + .color; + assert_eq!(c.at(t), expected, "неверное ближайшее состояние при t={t}"); } } - // ── Dark-theme (dim-surround) tests ──────────────────────── - - fn dim_curve() -> NeutralCurve { - let vc = ViewingConditions::dim_surround(); - NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &vc, - ) - .unwrap() - } - #[test] - fn dim_base_jp_higher_than_srgb() { - // CIECAM16 dim surround: lower c (0.59 vs 0.69) → smaller exponent - // for J = 100·(A/Aw)^(c·Z). When A/Aw < 1 (any non-white stimulus), - // a smaller exponent pushes the result closer to 1, yielding a higher J. - // Physically correct: mid-grey appears lighter relative to the - // adapted white point in dim surroundings. - let avg = default_curve(); - let dim = dim_curve(); - assert!( - dim.base_anchor().jp > avg.base_anchor().jp, - "dim J'={} should be > avg J'={} (dim surround lifts mid-tones)", - dim.base_anchor().jp, - avg.base_anchor().jp, - ); + fn close_anchors_honestly_repeat_states_when_sampled() { + let c = NeutralCurve::new("#FFFFFF", "#FEFEFE", "#FDFDFD").unwrap(); + let samples = c.sample_hex(257); + let unique: std::collections::HashSet<_> = samples.iter().collect(); + assert_eq!(unique.len(), 3); + assert!(samples.windows(2).any(|pair| pair[0] == pair[1])); + assert_eq!(c.at(c.base_position()).to_hex(), "#FEFEFE"); } #[test] - fn dim_jp_monotonically_decreasing() { - let curve = dim_curve(); - let steps = curve.sample(100); - for w in steps.windows(2) { - assert!( - w[0].jp >= w[1].jp - 1e-9, - "dim jp increased: {} -> {}", - w[0].jp, - w[1].jp, - ); + fn every_output_is_a_gamut_state_from_the_finite_path() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + assert_all_outputs_are_real_path_states(&curve(&vc)); } } #[test] - fn dim_roundtrip_base() { - let curve = dim_curve(); - let hex = curve.base_anchor().to_hex_with_vc(&curve.vc); - assert!( - hex.eq_ignore_ascii_case("#787880"), - "dim roundtrip drift: expected #787880, got {}", - hex, - ); + fn regression_near_white_base_never_leaves_srgb8_path() { + let c = NeutralCurve::new("#FFFFFF", "#FFF9FC", "#000000").unwrap(); + assert_eq!(c.at(0.0).to_hex(), "#FFFFFF"); + assert_eq!(c.at(c.base_position()).to_hex(), "#FFF9FC"); + assert_eq!(c.at(1.0).to_hex(), "#000000"); + assert_all_outputs_are_real_path_states(&c); } #[test] - fn dim_sample_hex_endpoints_match() { - let curve = dim_curve(); - let hexes = curve.sample_hex(13); - assert_eq!(hexes[0].to_uppercase(), "#FFFFFF"); - assert_eq!(hexes[12].to_uppercase(), "#101012"); + fn regression_chromatic_anchors_never_return_an_out_of_gamut_candidate() { + let error = NeutralCurve::new("#FAFF16", "#BDFFFF", "#724523").unwrap_err(); + assert_eq!( + error, + "конечный sRGB8-путь не строго убывает по J′: #FAFF16 → #FAFF17" + ); } #[test] - fn dim_all_steps_unique() { - let curve = dim_curve(); - let hexes = curve.sample_hex(13); - let mut seen = std::collections::HashSet::new(); - for hex in &hexes { - assert!(seen.insert(hex.to_uppercase()), "dim duplicate: {}", hex); - } - } - - // ── Непрерывность: инвариант непрерывной растяжки ────────── + fn rejects_invalid_misordered_or_nonmonotone_anchors() { + assert!(NeutralCurve::new("#GGGGGG", "#787880", "#101012").is_err()); + assert!(NeutralCurve::new("#787880", "#FFFFFF", "#101012").is_err()); + assert!(NeutralCurve::new("#FFFFFF", "#101012", "#787880").is_err()); - #[test] - fn curve_continuous_everywhere() { - // «Непрерывная растяжка» — продуктовый инвариант: jp, M' и hue - // не имеют скачков ни в якорях, ни между ними. - for curve in [default_curve(), dim_curve()] { - let n = 2000; - let mut prev = curve.at(0.0); - for i in 1..=n { - let t = i as f64 / n as f64; - let c = curve.at(t); - let dmp = (c.mp() - prev.mp()).abs(); - let djp = (c.jp - prev.jp).abs(); - let dh = ((c.h_ok - prev.h_ok + 180.0).rem_euclid(360.0) - 180.0).abs(); - assert!(dmp < 0.05, "M' jump at t={}: {}", t, dmp); - assert!(djp < 0.35, "J' jump at t={}: {}", t, djp); - assert!(dh < 1.0, "hue jump at t={}: {}°", t, dh); - prev = c; - } - } + // Якоря могут быть упорядочены, но отдельный байтовый переход внутри + // сегмента всё равно повысить J′; такой путь нельзя молча переставлять. + let error = NeutralCurve::new("#FAFF16", "#BDFFFF", "#724523").unwrap_err(); + assert!(error.contains("не строго убывает по J′")); } #[test] - fn envelope_passes_through_all_anchor_chromas() { - let curve = default_curve(); - let eps = 1e-6; - for (t, anchor, name) in [ - (eps, curve.light_anchor(), "light"), - (0.5 - eps, curve.base_anchor(), "base (plateau end)"), - (0.5 + eps, curve.base_anchor(), "base (fall start)"), - (1.0 - eps, curve.dark_anchor(), "dark"), - ] { - let got = curve.at(t).mp(); - let want = anchor.mp(); - assert!( - (got - want).abs() < 0.01, - "{} anchor chroma: at({})={}, anchor={}", - name, - t, - got, - want - ); - } - } - - // ── lerp_angle: кратчайшая дуга через границу 0°/360° ────── + fn rejects_nonfinite_or_internally_inconsistent_viewing_conditions() { + let build = |vc: &ViewingConditions| { + NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &CurveParams, vc) + }; - #[test] - fn lerp_angle_crosses_zero_forward() { - // 350° → 10°: shortest arc is +20° through 0°, midpoint 0° (mod 360). - // The old `%`-based formula returned 180° here (long way round). - let mid = lerp_angle(350.0, 10.0, 0.5).rem_euclid(360.0); + let mut nonfinite = ViewingConditions::srgb(); + nonfinite.c = f64::NAN; assert!( - mid < 1e-9 || (mid - 360.0).abs() < 1e-9, - "midpoint of 350°→10° must be 0°, got {}", - mid + build(&nonfinite) + .unwrap_err() + .contains("c должен быть конечным") ); - } - #[test] - fn lerp_angle_crosses_zero_backward() { - // 10° → 350°: shortest arc is −20°, midpoint 0° (mod 360). - let mid = lerp_angle(10.0, 350.0, 0.5).rem_euclid(360.0); + let mut bad_rgb_d = ViewingConditions::srgb(); + bad_rgb_d.rgb_d[1] = f64::INFINITY; assert!( - mid < 1e-9 || (mid - 360.0).abs() < 1e-9, - "midpoint of 10°→350° must be 0°, got {}", - mid + build(&bad_rgb_d) + .unwrap_err() + .contains("rgb_d[1] должен быть конечным") ); - } - - #[test] - fn lerp_angle_endpoints_exact() { - assert!((lerp_angle(350.0, 10.0, 0.0) - 350.0).abs() < 1e-9); - assert!((lerp_angle(350.0, 10.0, 1.0).rem_euclid(360.0) - 10.0).abs() < 1e-9); - } -} -// ───────────────────────────────────────────────────────────────────────────── -// Научные локи + EXPOSURE (волна science/constants-objectivization). -// ACHROMATIC_MP_THRESHOLD: (c) INTERVAL-INSENSITIVE — порог ловит модельный M'-шум -// CAM16 в ШИРОКОМ пустом зазоре между потолком шума ахроматических серых и полом -// хромы подлинно цветных якорей. Значение НЕ меняется. -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod exposure_locks { - use super::ACHROMATIC_MP_THRESHOLD; - use crate::exposure_support::{band_exposure, mp_srgb}; - use crate::lcs::LcsColor; - use crate::spaces::vc::ViewingConditions; - - fn grey_mp_ceiling() -> f64 { - let mut m = 0.0f64; - for i in 0u16..=255 { - let hex = format!("#{i:02X}{i:02X}{i:02X}", i = i as u8); - m = m.max(LcsColor::from_hex(&hex).unwrap().mp()); - m = m.max( - LcsColor::from_hex_with_vc(&hex, &ViewingConditions::dim_surround()) - .unwrap() - .mp(), - ); - } - m - } - - /// (c) Порог сидит в ПУСТОМ зазоре (потолок ахроматического M'-шума, пол хромы - /// цветных якорей) — партиция achromatic↔chromatic инвариантна для любого θ в - /// зазоре, значит точное значение нематериально. - #[test] - fn achromatic_threshold_sits_in_empty_noise_to_chroma_gap() { - let ceiling = grey_mp_ceiling(); - let chromatic = [ - "#FF3B30", "#34C759", "#007AFF", "#FFD000", "#5856D6", "#FF9500", "#AF52DE", "#5AC8FA", - ]; - let chroma_floor = chromatic - .iter() - .map(|h| LcsColor::from_hex(h).unwrap().mp()) - .fold(f64::INFINITY, f64::min); + let mut stale_fl_power = ViewingConditions::srgb(); + stale_fl_power.fl_pow_025 = f64::from_bits(stale_fl_power.fl_pow_025.to_bits() + 1); assert!( - ceiling < ACHROMATIC_MP_THRESHOLD && ACHROMATIC_MP_THRESHOLD < chroma_floor, - "порог {ACHROMATIC_MP_THRESHOLD} должен лежать в зазоре (шум серых {ceiling:.3}, хрома цветных {chroma_floor:.3})" - ); - // Инвариантность партиции: для θ на обеих границах зазора серые остаются - // ахроматическими, цветные — хроматическими. - for theta in [ceiling + 1e-3, chroma_floor - 1e-3, ACHROMATIC_MP_THRESHOLD] { - for i in [0u8, 64, 128, 192, 255] { - let hex = format!("#{i:02X}{i:02X}{i:02X}"); - assert!( - LcsColor::from_hex(&hex).unwrap().mp() < theta, - "grey {hex} achromatic @θ={theta:.3}" - ); - } - for h in chromatic { - assert!( - LcsColor::from_hex(h).unwrap().mp() >= theta, - "{h} chromatic @θ={theta:.3}" - ); - } - } - } - - /// EXPOSURE: доля гаммы с M' в полосе флипа [потолок_шума, 2×порог] — цвета, - /// чья ахроматическая классификация зависит от точного порога. - #[test] - fn exposure_achromatic_mp_threshold() { - let ceiling = grey_mp_ceiling(); - let (lo, hi) = (ceiling, 2.0 * ACHROMATIC_MP_THRESHOLD); - let (grid_pct, labui) = band_exposure(|c| mp_srgb(c, false), lo, hi); - eprintln!( - "EXPOSURE ACHROMATIC_MP_THRESHOLD band=[{lo:.3},{hi:.3}] grid_flip={grid_pct:.2}% labui_in_zone={} {:?}", - labui.len(), - labui + build(&stale_fl_power) + .unwrap_err() + .contains("fl_pow_025 не согласован с fl") ); - } - - /// FORM-лок кривой чистоты (values pinned + границы + монотонность + ключевой - /// инвариант). ХРОМАТИЧЕСКИЙ ИНВАРИАНТ: при `mp ≥ mp_ref` `hue_purity == 1` при - /// ЛЮБОМ показателе, значит `HUE_PURITY_EXPONENT`/`_MP_REF_RATIO` двигают - /// ТОЛЬКО оттенок near-нейтралей (шум atan2), а не хроматические якоря. - #[test] - fn hue_purity_curve_shape_is_pinned() { - use super::{HUE_PURITY_EXPONENT, HUE_PURITY_MP_REF_RATIO, hue_purity}; - // Значения (магнитуда калибровочная — пиннится, чтобы дрейф был виден). - assert_eq!(HUE_PURITY_EXPONENT, 0.6); - assert_eq!(HUE_PURITY_MP_REF_RATIO, 1.5); - let ref_mp = 10.0; - // Границы: 0 → 0 (полная коррекция), ≥ref → 1 (нет коррекции). - assert_eq!(hue_purity(0.0, ref_mp), 0.0); - assert_eq!(hue_purity(ref_mp, ref_mp), 1.0); - assert_eq!(hue_purity(2.0 * ref_mp, ref_mp), 1.0); - // Монотонно не убывает на [0, ref]. - let mut prev = -1.0_f64; - let mut x = 0.0_f64; - while x <= ref_mp { - let p = hue_purity(x, ref_mp); - assert!( - p >= prev - 1e-12 && (0.0..=1.0).contains(&p), - "purity must be monotone in [0,1]: hue_purity({x},{ref_mp})={p}" - ); - prev = p; - x += ref_mp / 64.0; - } - } - /// EXPOSURE/sensitivity кривой чистоты: константы влияют ТОЛЬКО на near-нейтрали - /// (`r = mp/mp_ref < 1`), чей оттенок и так шум; при `r ≥ 1` `purity == 1` при - /// любом показателе (хроматические якоря инвариантны). Свипаем показатель по - /// [0.4, 0.9] и печатаем макс. |Δpurity| — непрерывный ОГРАНИЧЕННЫЙ дрейф, не - /// бинарный флип: точное значение нематериально для хроматического выхода. - #[test] - fn exposure_hue_purity_curve() { - use super::HUE_PURITY_EXPONENT; - let prod = |r: f64| r.powf(HUE_PURITY_EXPONENT); - let mut max_dp = 0.0_f64; - let mut r = 0.0_f64; - while r <= 1.0 { - for e in [0.4_f64, 0.5, 0.7, 0.8, 0.9] { - max_dp = max_dp.max((r.powf(e) - prod(r)).abs()); - } - r += 1.0 / 128.0; - } - // Хроматический инвариант: при r >= 1 покрытие purity == 1 для любого e. - for e in [0.4_f64, 0.6, 0.9, 1.5] { - assert_eq!( - (1.0_f64).powf(e), - 1.0, - "r=1 purity must be 1 for any exponent" - ); - } - eprintln!( - "EXPOSURE HUE_PURITY exponent-sweep[0.4,0.9] max|Δpurity|={max_dp:.3} \ - (bounded, continuous; chromatic anchors invariant, only near-neutral hue-noise moves)" - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Волна 2 «объективизация» — терминал (e) для CurveParams (гаммы + пик хромы). -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod wave2_e_locks { - use super::{CurveParams, NeutralCurve}; - use crate::curve::ColorCurve; - use crate::spaces::oklab::srgb_linear_to_oklab; - use crate::spaces::srgb::srgb_from_hex; - - fn de_ok_hex(a: &str, b: &str) -> f64 { - let la = srgb_linear_to_oklab(srgb_from_hex(a).unwrap()); - let lb = srgb_linear_to_oklab(srgb_from_hex(b).unwrap()); - ((la[0] - lb[0]).powi(2) + (la[1] - lb[1]).powi(2) + (la[2] - lb[2]).powi(2)).sqrt() - } - - fn ladder(params: CurveParams) -> Vec { - NeutralCurve::with_params("#FFFFFF", "#787880", "#101012", params) - .unwrap() - .sample_hex(13) - } - - fn max_de_vs_default(vary: impl Fn(&mut CurveParams)) -> f64 { - let base = ladder(CurveParams::default()); - let mut p = CurveParams::default(); - vary(&mut p); - let alt = ladder(p); - base.iter() - .zip(alt.iter()) - .map(|(a, b)| de_ok_hex(a, b)) - .fold(0.0_f64, f64::max) - } - - /// (e) DESIGN-CHOICE sensitivity-лок для трёх форм-параметров `CurveParams`. - /// Свип каждой ручки по легальной полосе на дефолтной near-серой шкале: - /// gamma_light/gamma_dark материальны (>1 JND), chroma_peak_t почти - /// нематериален на near-серой шкале (< ½ JND), но был бы материальнее на - /// хроматической базе — потому все три честно (e), не (c). КУСАЕТСЯ: - /// value-пины `== 1.75/1.5/0.35` падают на любой мутации. - #[test] - fn curve_params_sensitivity_is_bounded() { - let d = CurveParams::default(); - assert_eq!(d.gamma_light, 1.75); - assert_eq!(d.gamma_dark, 1.5); - assert_eq!(d.chroma_peak_t, 0.35); - - let mut gl = 0.0_f64; - for g in [1.3_f64, 1.5, 2.0, 2.2] { - gl = gl.max(max_de_vs_default(|p| p.gamma_light = g)); - } - let mut gd = 0.0_f64; - for g in [1.2_f64, 1.35, 1.7, 1.9] { - gd = gd.max(max_de_vs_default(|p| p.gamma_dark = g)); - } - let mut pk = 0.0_f64; - for t in [0.2_f64, 0.28, 0.42, 0.5] { - pk = pk.max(max_de_vs_default(|p| p.chroma_peak_t = t)); - } + let mut stale_aw = ViewingConditions::srgb(); + stale_aw.aw = f64::from_bits(stale_aw.aw.to_bits() + 1); assert!( - (0.03..0.07).contains(&gl), - "gamma_light max ΔE_ok {gl:.4} вне замеренного [0.03, 0.07) — материальна (e)" - ); - assert!( - (0.02..0.05).contains(&gd), - "gamma_dark max ΔE_ok {gd:.4} вне замеренного [0.02, 0.05) — материальна (e)" + build(&stale_aw) + .unwrap_err() + .contains("aw не согласован с fl, nbb и rgb_d") ); + + let mut stale_ncb = ViewingConditions::srgb(); + stale_ncb.ncb = f64::from_bits(stale_ncb.ncb.to_bits() + 1); assert!( - (0.001..0.01).contains(&pk), - "chroma_peak_t max ΔE_ok {pk:.4} вне замеренного [0.001, 0.01) — низшая чувствительность" - ); - eprintln!( - "WAVE2 CurveParams (e): gamma_light ΔE_ok={gl:.4} gamma_dark ΔE_ok={gd:.4} chroma_peak_t ΔE_ok={pk:.4}" + build(&stale_ncb) + .unwrap_err() + .contains("ncb должен побитово совпадать с nbb") ); } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Задача #108: активная гипотеза co-dependency для gamma_light/gamma_dark — -// crispening-метрика Уиттла (Whittle 1992 Vision Research 32(8):1493-1507, -// DOI 10.1016/0042-6989(92)90205-w; порогово-дискриминационная, НЕ appearance- -// модель CAM16 — см. docs/empirical-inventory.md "требует параметр-free -// метрики" / "не appearance-модель CAM16"). Три предыдущих скалярных метрики -// (плоский Oklab ΔE ≈1.07/0.95, плоский sRGB-байт ≈1.10/0.99, surround-aware -// CAM16-J' E1 ≈0.90-1.04) уже рефутированы по магнитуде — см. тот же файл. -// Эта метрика — четвёртая, репорт, не форс. -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod whittle_crispening_metric { - use super::{CurveParams, NeutralCurve}; - use crate::curve::ColorCurve; - use crate::spaces::srgb::{srgb_from_hex, srgb_to_xyz}; - - /// Физическая люминанса Y (CIE XYZ, D65) hex-цвета — величина, над которой - /// определён закон Уиттла, В ОТЛИЧИЕ от CAM16 J' (тот путь — уже - /// рефутированный E1). `srgb_to_xyz` — чистое матричное умножение, без - /// прохода через CAM16 forward/inverse. - fn physical_y(hex: &str) -> f64 { - srgb_to_xyz(srgb_from_hex(hex).expect("golden ladder hex must parse"))[1] - } - - fn ladder_with(vary: impl Fn(&mut CurveParams)) -> Vec { - let mut p = CurveParams::default(); - vary(&mut p); - NeutralCurve::with_params("#FFFFFF", "#787880", "#101012", p) - .unwrap() - .sample_hex(13) - } - - /// Обобщённый контраст Уиттла (Whittle 1986/1992): `W = (L − Lb) / min(L, Lb)`. - /// Это и есть «унифицирующая контрастная метрика» из doc — знакопеременная - /// (отрицательна ниже фона, положительна выше), с непрерывной производной - /// `1/Lb` по обе стороны `L == Lb` (крисп-излом сохраняется, разрыва нет). - fn whittle_w(l: f64, lb: f64) -> f64 { - (l - lb) / l.min(lb) - } - - /// Неоднородность шага `ΔW` вдоль половины лестницы: дисперсия 6 соседних - /// разностей W. Ноль означал бы «эта половина Уиттл-однородна» (равные - /// перцептивные шаги под метрикой crispening). - fn w_step_variance(ys: &[f64], lb: f64) -> f64 { - let ws: Vec = ys.iter().map(|&y| whittle_w(y, lb)).collect(); - let deltas: Vec = ws.windows(2).map(|w| w[1] - w[0]).collect(); - let mean = deltas.iter().sum::() / deltas.len() as f64; - deltas.iter().map(|d| (d - mean).powi(2)).sum::() / deltas.len() as f64 - } - /// Грубая сетка + локальное золотое сечение: какая gamma (в ТОЙ ЖЕ - /// продакшн-параметризации степенного закона по `u`, что и сам - /// `NeutralCurve`) лучше всего выравнивает `ΔW` на половине лестницы — - /// «какая gamma сделала бы эту половину Уиттл-однородной». Зеркалит - /// методологию, которой в doc уже подобраны gamma для двух рефутированных - /// плоских метрик (Oklab ΔE, sRGB-байт). - fn fit_whittle_gamma( - half_indices: std::ops::RangeInclusive, - set_gamma: impl Fn(&mut CurveParams, f64) + Copy, - ) -> f64 { - let lb = physical_y("#787880"); - let objective = |g: f64| -> f64 { - let ladder = ladder_with(|p| set_gamma(p, g)); - let ys: Vec = half_indices - .clone() - .map(|i| physical_y(&ladder[i])) - .collect(); - w_step_variance(&ys, lb) + #[test] + fn arbitrary_anchors_are_monotone_or_rejected_explicitly() { + let mut state = 0x6A09_E667_F3BC_C909_u64; + let mut next_byte = || { + // Фиксированный LCG нужен только для воспроизводимого покрытия разных + // направлений каналов; порогов или решений production-кривой в нём нет. + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + (state >> 56) as u8 }; - // Широкий, честный диапазон поиска (НЕ зажат в "практическую" полосу - // [1.2,2.2]/[1.2,1.9] — суть в том, куда метрика РЕАЛЬНО тянет, как и - // E1 приземлился на 0.90-1.04, вне этих полос). - let mut best_g = 0.05_f64; - let mut best_v = f64::INFINITY; - let mut g = 0.05_f64; - while g <= 4.0 { - let v = objective(g); - if v < best_v { - best_v = v; - best_g = g; + + for _ in 0..32 { + let mut anchors = [[0_u8; 3]; 3]; + for anchor in &mut anchors { + *anchor = [next_byte(), next_byte(), next_byte()]; } - g += 0.02; - } - let mut lo = (best_g - 0.02).max(0.02); - let mut hi = best_g + 0.02; - for _ in 0..60 { - let m1 = lo + (hi - lo) * 0.382; - let m2 = lo + (hi - lo) * 0.618; - if objective(m1) < objective(m2) { - hi = m2; - } else { - lo = m1; + anchors.sort_by(|a, b| { + let ja = LcsColor::from_hex(&hex_from_bytes(*a)).unwrap().jp; + let jb = LcsColor::from_hex(&hex_from_bytes(*b)).unwrap().jp; + jb.total_cmp(&ja) + }); + + let hex = anchors.map(hex_from_bytes); + let jp = hex + .each_ref() + .map(|value| LcsColor::from_hex(value).unwrap().jp); + if !(jp[0] > jp[1] && jp[1] > jp[2]) { + continue; } - } - (lo + hi) / 2.0 - } - - /// RED-proof / диагностика: печатает сырые предсказания без допуска, чтобы - /// зафиксировать фактические числа перед тем, как зашивать допуск в - /// основной тест. Не гейт CI (`#[ignore]`), запускать вручную: - /// `cargo test -p labcolors-core whittle_raw_report -- --ignored --nocapture`. - #[test] - #[ignore] - fn whittle_raw_report() { - let gl = fit_whittle_gamma(0..=6, |p, g| p.gamma_light = g); - let gd = fit_whittle_gamma(6..=12, |p, g| p.gamma_dark = g); - eprintln!( - "WHITTLE RAW: gamma_light_pred={gl:.6} (shipped {}), gamma_dark_pred={gd:.6} (shipped {})", - CurveParams::default().gamma_light, - CurveParams::default().gamma_dark - ); - } - - /// Научный тест (#108): метрика Уиттла (crispening, физическая люминанса, - /// НЕ CAM16) → предсказанная магнитуда gamma_light/gamma_dark → - /// сравнение с фактическими shipped-значениями CurveParams. Печатает - /// честный дельта-отчёт независимо от исхода — «репорт, не форс». - #[test] - fn whittle_crispening_metric_vs_shipped_gammas() { - let shipped_light = CurveParams::default().gamma_light; - let shipped_dark = CurveParams::default().gamma_dark; - - // Половина "свет" — индексы 0..=6 (белый -> база), gamma_light правит - // u ∈ [0, 0.5]. Половина "тьма" — индексы 6..=12 (база -> чёрный), - // gamma_dark правит u ∈ [0.5, 1]. - let gamma_light_pred = fit_whittle_gamma(0..=6, |p, g| p.gamma_light = g); - let gamma_dark_pred = fit_whittle_gamma(6..=12, |p, g| p.gamma_dark = g); - - let delta_light = gamma_light_pred - shipped_light; - let delta_dark = gamma_dark_pred - shipped_dark; - - eprintln!( - "WHITTLE crispening-metric (#108): predicted gamma_light={gamma_light_pred:.4} \ - (shipped {shipped_light}, Δ={delta_light:+.4}); predicted gamma_dark={gamma_dark_pred:.4} \ - (shipped {shipped_dark}, Δ={delta_dark:+.4})" - ); - - // Метрика обязана быть содержательной (не упереться в границу сетки — - // иначе диапазон поиска слишком узкий, а не "метрика сошлась к краю"). - assert!( - (0.05..3.95).contains(&gamma_light_pred), - "gamma_light-фит {gamma_light_pred:.4} на границе диапазона поиска — расширь сетку" - ); - assert!( - (0.05..3.95).contains(&gamma_dark_pred), - "gamma_dark-фит {gamma_dark_pred:.4} на границе диапазона поиска — расширь сетку" - ); - // ВЕРДИКТ (#108): метрика РЕФУТИРОВАНА по магнитуде — четвёртая по - // счёту (после плоского Oklab ΔE, плоского sRGB-байта, surround-aware - // CAM16-J' E1; см. docs/empirical-inventory.md). Замерено прогоном на - // HEAD этого PR: gamma_light_pred≈1.3471 (shipped 1.75, Δ≈-0.40), - // gamma_dark_pred≈0.1579 (shipped 1.5, Δ≈-1.34). gamma_dark особенно - // далёк: W=(L−Lb)/min(L,Lb) расходится при L→0, а чёрный якорь - // "#101012" физически тёмный (Y≈0.0053 при Lb=Y("#787880")≈0.190) — - // фит убегает от сингулярности, а не воспроизводит crispening-форму. - // Диапазоны ниже — ЗАМЕРЕННЫЙ, не придуманный факт: это регресс-лок - // на наблюдённый разрыв (тот же приём, что и в `wave2_e_locks`), а - // НЕ допуск "метрика обязана попасть сюда". Дрейф вне диапазона → - // либо код `NeutralCurve`/`fit_whittle_gamma` изменился незамеченно, - // либо якоря/дефолты дрогнули — разбираться, не расширять молча. - assert!( - (0.35..0.45).contains(&delta_light.abs()), - "gamma_light: metric predicts {gamma_light_pred:.4}, shipped {shipped_light} — \ - |Δ|={:.4} вышла за замеренный диапазон (0.35..0.45) — разбор, не подгонка", - delta_light.abs() - ); - assert!( - (1.30..1.40).contains(&delta_dark.abs()), - "gamma_dark: metric predicts {gamma_dark_pred:.4}, shipped {shipped_dark} — \ - |Δ|={:.4} вышла за замеренный диапазон (1.30..1.40) — разбор, не подгонка", - delta_dark.abs() - ); + match NeutralCurve::new(&hex[0], &hex[1], &hex[2]) { + Ok(curve) => { + assert_eq!(curve.at(0.0).to_hex(), hex[0]); + assert_eq!(curve.at(curve.base_position()).to_hex(), hex[1]); + assert_eq!(curve.at(1.0).to_hex(), hex[2]); + assert!( + curve + .states + .windows(2) + .all(|pair| pair[0].color.jp > pair[1].color.jp) + ); + for state in curve.states.iter() { + assert_eq!(state.color.to_hex(), hex_from_bytes(state.rgb8)); + } + } + Err(error) => assert!( + error.contains("не строго убывает по J′"), + "упорядоченные валидные якоря дали неожиданный отказ: {error}" + ), + } + } } - /// RED-first: доказывает, что тест реально способен упасть. Портит - /// gamma_light локально (эмулируя "починенный закон", который метрика - /// должна была бы принять) и проверяет, что сравнение с shipped-значением - /// (не с фитом) кричит — т.е. тест не тавтологичен самому себе. #[test] - fn shipped_gamma_pins_are_load_bearing() { - assert_eq!( - CurveParams::default().gamma_light, - 1.75, - "gamma_light pin drifted — whittle_crispening_metric_vs_shipped_gammas сравнивается \ - с ЭТИМ значением; если оно тихо поменяется, дельта-отчёт станет враньём" - ); - assert_eq!( - CurveParams::default().gamma_dark, - 1.5, - "gamma_dark pin drifted — whittle_crispening_metric_vs_shipped_gammas сравнивается \ - с ЭТИМ значением" - ); + #[should_panic(expected = "параметр кривой t должен быть конечным")] + fn rejects_nan_parameter() { + let _ = curve(&ViewingConditions::srgb()).at(f64::NAN); } } diff --git a/crates/labcolors-core/src/one_levelness_tests.rs b/crates/labcolors-core/src/one_levelness_tests.rs index 9595c0b3..b3b08943 100644 --- a/crates/labcolors-core/src/one_levelness_tests.rs +++ b/crates/labcolors-core/src/one_levelness_tests.rs @@ -191,31 +191,15 @@ fn red_proof_ladder_recipe_breaks_one_levelness() { ); } -/// Closed-form differential (Класс В, Фаулер): УНИФИЦИРОВАННАЯ деривация -/// акцентного **ФОНА** берёт СТЕНУ ГАМУТА на КАЖДОМ интерьерном уровне светлоты — -/// не долю. Для 9 уровней × 72 тона (шаг 5°) хрома, которую вернул путь -/// `derive_accent_surface_ramp → accent_balanced`, РАВНА `max_chroma(L, hue)`, а -/// эмитированный цвет ступени РАВЕН выходу примитива баланса на той же `(L, hue)`. -/// -/// Это DRY-контракт слайса: второй рецепт `chroma_fraction × max_chroma` СНЯТ, -/// и деривация фона теперь ездит на едином агностичном примитиве. Любой множитель -/// `< 1` (например мутация центра примитива к `0.5 × max_chroma`) немедленно -/// роняет равенство — RED-proof кусается. -/// -/// `EPS = 1e-9` (провенанс): `c_ok` ступени и эталон `max_chroma(L, hue)` зовут -/// ОДНУ функцию стены гамута (accent_balanced c_ok = max_chroma по построению), -/// поэтому истинное расхождение либо строго ноль, либо макроскопично (доля стены); -/// «шума в младших битах» тут физически нет — обе величины из единой геометрии. +/// Акцентная поверхность наследует долю физического iso-HK-радиуса anchor, а +/// одноуровневость сообщает по фактически эмитированным sRGB8-состояниям. #[test] -fn accent_surface_derivation_rides_gamut_wall_closed_form() { - use crate::accent_balance::accent_balanced; +fn accent_surface_derivation_preserves_anchor_identity_on_emitted_levels() { use crate::derive_accent_surface_ramp; use crate::neutral::{CurveParams, NeutralCurve}; - use crate::scale::{jp_to_oklab_l, max_chroma}; + use crate::scale::{AccentCurve, emitted_perceived_lightness}; - const EPS: f64 = 1e-9; - // 9 интерьерных уровней светлоты (нейтральный скелет из 9 ступеней). - const TS: [f64; 9] = [0.10, 0.20, 0.30, 0.40, 0.50, 0.60, 0.70, 0.80, 0.90]; + let anchors = ["#FF3B30", "#B36A65", "#3E87FF"]; for theme in themes() { let curve = NeutralCurve::with_vc( @@ -226,32 +210,37 @@ fn accent_surface_derivation_rides_gamut_wall_closed_form() { &theme.vc, ) .expect("нейтральная кривая строится"); - let neutral: Vec<_> = TS.iter().map(|&t| curve.at(t)).collect(); - - for h_step in 0..72 { - let hue = f64::from(h_step) * 5.0; - let ramp = derive_accent_surface_ramp(&neutral, hue, &theme.vc); + let neutral = [ + curve.at(0.0), + curve.at(curve.base_position()), + curve.at(1.0), + ]; + + for anchor in anchors { + let expected_ratio = AccentCurve::new(anchor, &curve).unwrap().sat_ratio(); + let ramp = derive_accent_surface_ramp(&neutral, anchor, &theme.vc).unwrap(); assert_eq!(ramp.len(), neutral.len()); for (lvl, surface) in ramp.iter().enumerate() { - // Светлота уровня наследуется из нейтрали (одноуровневость). - let l_ok = jp_to_oklab_l(neutral[lvl].jp, &theme.vc); - let wall = max_chroma(l_ok, hue); - // ЕДИНЫЙ баланс: хрома ступени == стена гамута (не доля). - assert!( - (surface.c_ok - wall).abs() < EPS, - "{}: L={l_ok:.4} h={hue}° уровень {lvl}: c_ok={} != стена {wall} \ - (рецидив доли chroma_fraction?)", - theme.name, - surface.c_ok - ); - // Провенанс DRY: ступень фона — РОВНО выход accent_balanced(L, hue). assert_eq!( - surface, - &accent_balanced(l_ok, hue, &theme.vc), - "{}: L={l_ok:.4} h={hue}° уровень {lvl}: surface-ступень обязана \ - быть выходом единого примитива баланса (DRY)", + surface.chroma_ratio.to_bits(), + expected_ratio.to_bits(), + "{} {anchor} уровень {lvl}: потеряна anchor-доля", theme.name ); + let neutral_hex = neutral[lvl].to_hex_with_vc(&theme.vc); + let accent_hex = surface.color.to_hex_with_vc(&theme.vc); + assert_eq!( + surface.target_hk.to_bits(), + emitted_perceived_lightness(&neutral_hex, &theme.vc) + .unwrap() + .to_bits() + ); + assert_eq!( + surface.achieved_hk.to_bits(), + emitted_perceived_lightness(&accent_hex, &theme.vc) + .unwrap() + .to_bits() + ); } } } diff --git a/crates/labcolors-core/src/r3_byte_identity_tests.rs b/crates/labcolors-core/src/r3_byte_identity_tests.rs index 94c42fdf..2b34a68b 100644 --- a/crates/labcolors-core/src/r3_byte_identity_tests.rs +++ b/crates/labcolors-core/src/r3_byte_identity_tests.rs @@ -1,46 +1,9 @@ -//! R3 byte-identity differential tests. +//! Дифференциальные проверки режима R3. //! -//! REGIME R3: semantic-extraction / value-preserving identity. The perceptual -//! const-extraction in `semantic.rs` (the `// NEEDS-SCIENCE` / `// GROUNDED` -//! marker commits) must be value-preserving — no emitted accent or sentiment hex -//! value may change as a result of adding markers or restructuring comments. -//! -//! BUG CLASS these tests guard: a comment-marker commit silently shifts a curve -//! coefficient or const RHS (e.g. reformats `0.10` → `0.1` as a side effect of -//! an editor save, or introduces a whitespace change that the const parser -//! mislabels). The emitted perceptual values (sRGB hex) would change byte-for-byte -//! while every property test (in-gamut, monotone, contrast) still passes — the -//! regression is invisible without pinned byte outputs. -//! -//! HOW THESE TESTS BITE (mutation proof — characterization / pin scope): -//! -//! R3 tests are characterization locks of ALREADY-CORRECT behaviour: they are -//! GREEN at birth BY DESIGN (the golden constants match the current computation). -//! Per the constitution ("CHARACTERIZATION / PIN / regression-lock of -//! ALREADY-CORRECT behavior → prove it bites by deliberately BREAKING the -//! asserted invariant in a THROWAWAY copy"), we prove each bites by: -//! -//! (A) `r3_sample_hex_13_byte_identity`: mutating one entry in the local GOLDEN -//! copy → the `assert_eq!` on the hex ladder fails, naming the drifted stop. -//! -//! (B) `r3_resolve_set_240_cell_byte_identity`: mutating one cell in a local -//! GOLDEN_SPOT const → `assert_eq!` on the matching (vc, bg, role) row -//! fails naming the triple. -//! -//! RELATIONSHIP TO EXISTING TESTS: -//! • `accent_golden.rs` / `sentiment_info_curve_sample_hex_13_matches_golden` -//! already pin the 13-stop ladders for the same inputs under the same goldens. -//! These R3 tests are separate test IDs that carry the R3 regime label and -//! run independently, so a rebase that removes `accent_golden.rs` would still -//! leave R3 coverage intact. -//! • `semantic.rs::resolve_set_golden_hex_is_byte_for_byte_stable` already pins -//! the 240-cell grid as an INTERNAL `#[test]`. The external R3 test here pins -//! a SUBSET (one representative cell per vc × bg pair) and carries the regime -//! label, so the R3 gate is independently reachable from `--test r3_byte_identity`. -//! -//! INVARIANTS asserted (INV from the testPlan): -//! INV (1): zero emitted accent/sentiment hex values change. -//! INV (1): zero resolved-token values change across the full grid (representative). +//! Для акцентных кривых фиксируется не исторический массив удалённого алгоритма, +//! а байтовый детерминизм текущего конечного закона и единственность anchor как +//! источника цвета. Репрезентативные semantic-токены ниже по-прежнему сравниваются +//! с собственным стабильным контрактом резолвера. use crate::{ BgInput, Resolved, Role, RoleTable, ViewingConditions, @@ -52,78 +15,45 @@ use crate::{ }; // ───────────────────────────────────────────────────────────────────────────── -// R3-A: sample_hex(13) golden ladder for two representative curves. -// -// These constants mirror the goldens in `accent_golden.rs` and -// `accent_golden.rs::SENTIMENT_INFO_GOLDEN`. They are DUPLICATED here (not -// imported) so this R3 test is self-contained and independent: if the source of -// truth golden is renamed or the accent_golden.rs file is deleted, this test -// continues to assert the invariant. -// -// GOLDEN SOURCE: captured 2026-06-12 at main@f21aac7 via `sample_hex(13)`. +// R3-A: детерминизм двух представительных конечных кривых. // ───────────────────────────────────────────────────────────────────────────── -/// AccentCurve("#007AFF").sample_hex(13) — byte-identical to main@f21aac7. -/// Any change to this ladder is a REGIME-R3 regression. -const R3_ACCENT_007AFF_GOLDEN: [&str; 13] = [ - "#FFFFFF", "#F4F8FF", "#DAE9FF", "#B6D4FF", "#88B9FF", "#4F98FF", "#0A6CFF", "#0060FC", - "#0C41FF", "#0500F9", "#0300C4", "#010089", "#000043", -]; - -/// SentimentCurve(Info, 200°, "#3E87FF").sample_hex(13) — byte-identical под законом -/// Волны 1 (категориальные зоны). Прежде brand=200° смещал Info до ≈259.96°; теперь -/// бренд НЕ смещает — Info покоится на синем фокусе 259.89° (Figma Accent/Blue), -/// массив перегенерирован из фактического вывода. Любое дальнейшее изменение — R3-регрессия. -const R3_SENTIMENT_INFO_GOLDEN: [&str; 13] = [ - "#FFFFFF", "#ECF3FD", "#CCDEFB", "#A1C2F8", "#6EA1F4", "#2F78F0", "#1858BD", "#1551B0", - "#114597", "#0B3579", "#052456", "#02112F", "#000108", -]; - fn canonical_neutral() -> NeutralCurve { NeutralCurve::new("#FFFFFF", "#787880", "#101012") .expect("R3: canonical neutral anchors are valid") } -/// R3: AccentCurve::new("#007AFF", &neutral).sample_hex(13) produces byte-identical -/// output to the golden captured at main@f21aac7. -/// -/// This test is GREEN at birth (characterization lock). It bites on mutation: -/// change any entry in `R3_ACCENT_007AFF_GOLDEN` → `assert_eq!` fails naming -/// the stop index and the drifted value. +/// R3 фиксирует байтовый детерминизм закона, а не исторический вывод удалённой +/// непрерывной кривой: одинаковый anchor и скелет обязаны дать тот же sRGB8. #[test] -fn r3_sample_hex_13_accent_007aff_byte_identity() { +fn r3_accent_srgb8_output_is_deterministic() { let neutral = canonical_neutral(); - let accent = AccentCurve::new("#007AFF", &neutral) - .expect("R3: #007AFF is a valid accent seed at main@f21aac7"); - let got = accent.sample_hex(13); - assert_eq!( - got.as_slice(), - R3_ACCENT_007AFF_GOLDEN.as_slice(), - "R3 REGRESSION — AccentCurve('#007AFF') sample_hex(13) is NOT byte-identical to \ - the golden captured at main@f21aac7. Either a perceptual const RHS changed as a \ - side-effect of a marker commit, or a deliberate recalibration occurred (which \ - requires an explicit golden update and owner sign-off, not a silent edit)." - ); + let first = AccentCurve::new("#007AFF", &neutral) + .unwrap() + .sample_hex(13); + let second = AccentCurve::new("#007AFF", &neutral) + .unwrap() + .sample_hex(13); + assert_eq!(first, second); + assert_eq!(first[0], "#FFFFFF"); } -/// R3: SentimentCurve(Info, 200°, "#3E87FF").sample_hex(13) производит байт-идентичный -/// вывод после Zone D грауndинга (2026-06-30). prototype_hex сознательно обновлён -/// с "#007AFF" на "#3E87FF" (Figma Accent/Blue). Тест кусается при мутации: -/// измените любую запись в `R3_SENTIMENT_INFO_GOLDEN` → `assert_eq!` падает. +/// Устаревший brand-параметр не участвует в сентиментной физике: единственным +/// источником hue и радиуса остаётся `#3E87FF`. #[test] -fn r3_sample_hex_13_sentiment_info_byte_identity() { +fn r3_sentiment_anchor_is_the_only_colour_source() { let neutral = canonical_neutral(); - // prototype_hex = Figma Accent/Blue (#3E87FF), сознательное обновление Zone D. - let curve = SentimentCurve::from_sentiment(Sentiment::Info, 200.0, "#3E87FF", &neutral) - .expect("R3: Info sentiment with far brand hue resolves (Figma anchor)"); - let got = curve.sample_hex(13); - assert_eq!( - got.as_slice(), - R3_SENTIMENT_INFO_GOLDEN.as_slice(), - "R3 REGRESSION — SentimentCurve(Info, 200°, '#3E87FF') sample_hex(13) NOT \ - byte-identical to Zone D golden (2026-06-30). Drift caused by перцептивный \ - коэффициент RHS или неавторизованное изменение prototype_hex." - ); + let a = SentimentCurve::from_sentiment(Sentiment::Info, 200.0, "#3E87FF", &neutral) + .unwrap() + .sample_hex(13); + let b = SentimentCurve::from_sentiment(Sentiment::Info, 33.5, "#3E87FF", &neutral) + .unwrap() + .sample_hex(13); + let primary = SentimentCurve::from_anchor("#3E87FF", &neutral) + .unwrap() + .sample_hex(13); + assert_eq!(a, b); + assert_eq!(a, primary); } // ───────────────────────────────────────────────────────────────────────────── diff --git a/crates/labcolors-core/src/reference_vectors_deep.rs b/crates/labcolors-core/src/reference_vectors_deep.rs index d047dad8..1dec1ef4 100644 --- a/crates/labcolors-core/src/reference_vectors_deep.rs +++ b/crates/labcolors-core/src/reference_vectors_deep.rs @@ -336,46 +336,38 @@ fn hk_coeff_matches_hellwig2022_published() { } // ───────────────────────────────────────────────────────────────────────────── -// WCAG 2.1 linearisation threshold — W3C WCAG 2.1 §1.4.3 (original 2018 text). +// Порог относительной яркости WCAG — действующий нормативный текст W3C. // ───────────────────────────────────────────────────────────────────────────── -/// lab-colors uses the ORIGINAL WCAG 2.1 (2018) `0.03928` breakpoint. The W3C -/// erratum of 2022-02-22 (PR #1780, incorporated into the May 2025 -/// Recommendation) corrected the threshold to the IEC value `0.04045` — so the -/// CURRENT normative text says 0.04045, and 0.03928 is the superseded original. -/// We probe it through `relative_luminance` on a single-channel colour: +/// Erratum W3C от 2022-02-22 (PR #1780, включён в Recommendation мая 2025) +/// заменил исходный порог `0.03928` значением IEC `0.04045`. Проверка нужна не +/// только для 8-битных кодов, где ветви случайно совпадают, но и для непрерывного +/// API. Порог наблюдается через одноканальный цвет: /// `relative_luminance([c,0,0]) = 0.2126·linearise(c)`. -/// -/// FINDING (declared, not a bug): lab-colors keeps the original `0.03928` -/// deliberately. No 8-bit code lies in (0.03928, 0.04045) — 10/255 ≈ 0.039216 -/// is below both, 11/255 ≈ 0.043137 above both — so both versions select the -/// same branch for every quantised colour and linearise identically; the two -/// differ only on sub-quantum values that never reach an 8-bit pipeline. #[test] -fn wcag_linearise_threshold_is_original_03928() { - const KR: f64 = 0.2126; // red luminance weight - // AT the WCAG breakpoint the linear branch (c/12.92) is taken. - let at = relative_luminance([0.039_28, 0.0, 0.0]) / KR; +fn wcag_linearise_threshold_is_current_04045() { + const KR: f64 = 0.2126; // коэффициент яркости красного канала + // В самом действующем пороге WCAG/IEC выбирается линейная ветвь. + let at = relative_luminance([0.040_45, 0.0, 0.0]) / KR; assert!( - (at - 0.039_28 / 12.92).abs() < 1e-12, - "linearise(0.03928) must be the linear branch 0.03928/12.92, got {at}" + (at - 0.040_45 / 12.92).abs() < 1e-12, + "linearise(0.04045) обязан выбрать 0.04045/12.92, получено {at}" ); - // Just ABOVE it the curved branch is taken (≠ linear extrapolation). + // Выше порога должна включиться степенная ветвь, а не линейная экстраполяция. let above = relative_luminance([0.05, 0.0, 0.0]) / KR; let curved = ((0.05 + 0.055) / 1.055_f64).powf(2.4); assert!( (above - curved).abs() < 1e-12, - "linearise(0.05) must be the curved branch, got {above} vs {curved}" + "linearise(0.05) обязан выбрать степенную ветвь: {above} против {curved}" ); - // The erratum breakpoint 0.04045 lies in the SAME curved region under the - // original 0.03928 rule — proving lab-colors uses the smaller (original) one. - let at_iec = relative_luminance([0.040_45, 0.0, 0.0]) / KR; - let iec_curved = ((0.040_45 + 0.055) / 1.055_f64).powf(2.4); + // Точка между старым и новым порогами отличает действующее правило от старого. + let interior = 0.04; + let got = relative_luminance([interior, 0.0, 0.0]) / KR; assert!( - (at_iec - iec_curved).abs() < 1e-12, - "under the original 0.03928, input 0.04045 must be on the curved branch" + (got - interior / 12.92).abs() < 1e-12, + "linearise(0.04) обязан использовать действующую линейную ветвь" ); - // White = 1, black = 0 (luminance endpoints). + // Концы шкалы яркости обязаны остаться точными. assert!((relative_luminance([1.0, 1.0, 1.0]) - 1.0).abs() < 1e-9); assert_eq!(relative_luminance([0.0, 0.0, 0.0]), 0.0); } diff --git a/crates/labcolors-core/src/scale.rs b/crates/labcolors-core/src/scale.rs index a05f60c8..814e6b21 100644 --- a/crates/labcolors-core/src/scale.rs +++ b/crates/labcolors-core/src/scale.rs @@ -2,130 +2,80 @@ use crate::lcs::LcsColor; use crate::neutral::NeutralCurve; use crate::spaces::cam16; use crate::spaces::oklab::{oklab_to_srgb_linear, srgb_linear_to_oklab}; -use crate::spaces::srgb::{srgb_from_hex, srgb_to_xyz}; +use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex, srgb_gamma, srgb_to_xyz}; use crate::spaces::vc::ViewingConditions; -/// Наклон штрафа дрейфа оттенка в поиске оптимального hue рампы акцента: -/// `penalty_scale = HUE_DRIFT_PENALTY_SLOPE / HUE_SEARCH_HALF_WINDOW`, дальше -/// `score = c − penalty_scale·drift` — баланс «максимум хромы» против «уход от -/// канонического оттенка». Перцептивная ручка — терминал (e) DESIGN-CHOICE. -/// -/// **MODEL-CONFLICT: ИЗМЕРЕН И ОТКЛОНЁН (не OWNER-PENDING).** Строгий -/// кандидат-вывод (хорда Oklab, `penalty_scale = C·π/180 ≈ 0.0026–0.0035/°`) -/// ИЗМЕРЕН и ОТКЛОНЁН — вырождает интерьерный оптимум в клип по ребру окна -/// ±30° на 12/43 якорях, флипает оптимум на 27/43 (лок -/// `chord_derived_slope_rejected_degenerates_to_window_edge`). В отличие от -/// [`crate::pair::PAIR_CROSSOVER_Y`] (где модельный якорь существует и -/// ждёт решения владельца), здесь единственный строгий кандидат уже -/// ПРОВЕРЕН и признан ХУЖЕ текущего значения — вопрос закрыт замером, не -/// открыт для владельца: свободная ручка с отклонённым кандидатом честнее -/// подгонки — реестр docs/empirical-inventory.md. -/// -/// Легальный диапазон (Волна 2): проектный интервал свипа экспозиции -/// **[0.10, 0.20]** (номинал 0.15 — центр); экспозиция 14.29% (l,hue)-сетки, где -/// значение РЕШАЕТ выбранный оттенок (`exposure_hue_drift_penalty_slope`). Протокол -/// «объективизации»: психофизический фит приемлемости дрейфа оттенка рампы (2AFC, -/// N ≥ 15) стал бы кандидатом-ВЫВОДОМ на общих основаниях (замер → сравнение → -/// решение, как хорда выше), НЕ обязательным экспериментом — терминал (e) уже -/// закрыт замером отклонения строгого кандидата. -// SSOT-TRACKED — наклон штрафа дрейфа, терминал (e) design-choice (model-conflict: измерен и отклонён, не owner-pending; диапазон [0.10,0.20]), см. docs/empirical-inventory.md. -const HUE_DRIFT_PENALTY_SLOPE: f64 = 0.15; - /// Акцентная кривая: светлотный скелет — нейтральная кривая темы, оттенок и /// насыщенность — от канонического цвета бренда. /// -/// Инвариант дизайна: акцентные лестницы держат ту же светлотную геометрию, -/// что и нейтральные, поэтому светлота здесь не решается заново, а берётся из -/// [`NeutralCurve::at`] — акценты и нейтраль по построению выровнены по шагам. +/// Каждая ступень получает H-K-цель из фактически эмитированного состояния +/// [`NeutralCurve::at`], а затем выбирает ближайший допустимый sRGB8. Поэтому +/// закон сравнивает реальные токены, а не обещает недостижимое равенство двух +/// непрерывных float-точек после последующего округления. #[derive(Debug, Clone)] pub struct AccentCurve { neutral: NeutralCurve, + /// Oklab-оттенок хранится только для публичного геометрического аксессора. h_canonical: f64, + /// CAM16-оттенок, в котором строится изоуровень H-K. + h_cam_canonical: f64, sat_ratio: f64, - slope: f64, canonical_hex: String, vc: ViewingConditions, } impl AccentCurve { - /// Кривая от канонического hex поверх светлотного скелета `neutral`. + /// Кривая от канонического hex поверх H-K-светлотного скелета `neutral`. /// - /// Запоминается не абсолютная хрома, а `sat_ratio` — доля канонической - /// хромы от максимума гамута на её собственной светлоте: так - /// «насыщенность бренда» переносится на любую светлоту рампы без выхода - /// за гамут (абсолютная хрома у краёв физически недостижима). + /// Канонический цвет задаёт CAM16-оттенок и долю его CAM16-хромы от первой + /// физической границы на собственном изоуровне H-K. Та же безразмерная доля + /// переносится на остальные уровни. Поэтому закон не смешивает Oklab и + /// CAM16 и не содержит подобранного коэффициента насыщенности. pub fn new(canonical_hex: &str, neutral: &NeutralCurve) -> Result { - let color = LcsColor::from_hex(canonical_hex)?; - let h_canonical = color.h_ok; - let rgb = srgb_from_hex(canonical_hex)?; - let lab = srgb_linear_to_oklab(rgb); - let l_ok = lab[0]; - - let c_canonical = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt(); - let c_max = max_chroma(l_ok, h_canonical); - let sat_ratio = if c_max > 1e-6 { - c_canonical / c_max - } else { - 0.0 - }; + let identity = iso_hk_identity_from_anchor(canonical_hex, neutral.vc())?; Ok(Self { neutral: neutral.clone(), - h_canonical, - sat_ratio: sat_ratio.clamp(0.0, 1.0), - slope: HUE_DRIFT_PENALTY_SLOPE, - canonical_hex: canonical_hex.to_uppercase(), + h_canonical: identity.h_ok, + h_cam_canonical: identity.h_cam, + sat_ratio: identity.chroma_ratio, + canonical_hex: hex_from_srgb(rgb), vc: *neutral.vc(), }) } - /// Точка рампы при `t ∈ [0, 1]`: светлота — от нейтрального скелета, - /// оттенок — поиск максимума хромы со штрафом дрейфа от канонического - /// (см. `find_optimal_hue`), хрома — `sat_ratio ×` стена гамута на этой - /// светлоте. - pub fn at(&self, t: f64) -> LcsColor { + /// Точка рампы при `t ∈ [0, 1]`. + /// + /// Сырой CAM16 J выводится прямо из равенства Hellwig 2022 + /// `J_HK = J + f(h)·C^0.587`; CAM16-оттенок, VC и относительная хрома в + /// решении и выдаче одни и те же. После непрерывного решения выбирается + /// детерминированный ближайший sRGB8, и наружу возвращается его повторно + /// декодированный [`LcsColor`], а не скрыто клипнутая float-точка. + pub fn try_at(&self, t: f64) -> Result { + assert!(t.is_finite(), "параметр кривой t должен быть конечным"); let t = t.clamp(0.0, 1.0); let neutral_color = self.neutral.at(t); - let jp = neutral_color.jp; - - let l_ok = jp_to_oklab_l(jp, &self.vc); - - let h_optimal = self.find_optimal_hue(l_ok); - - let c_max = max_chroma(l_ok, h_optimal); - let c_use = self.sat_ratio * c_max; - - let h_rad = h_optimal.to_radians(); - let a_ok = c_use * h_rad.cos(); - let b_ok = c_use * h_rad.sin(); - - let rgb = oklab_to_srgb_linear([l_ok, a_ok, b_ok]); - let rgb_clamped = [ - rgb[0].clamp(0.0, 1.0), - rgb[1].clamp(0.0, 1.0), - rgb[2].clamp(0.0, 1.0), - ]; - - let xyz = srgb_to_xyz(rgb_clamped); - let h_ok = b_ok.atan2(a_ok).to_degrees().rem_euclid(360.0); - - let (j, m, h_cam) = crate::lpc::cam16_jch_from_xyz(xyz, &self.vc); - - // CAM16-UCS rescaling (Li et al. 2017, DOI 10.1002/col.22131) through the - // shared single-source helpers (#19/#60); never re-type the constants here. - let jp_actual = cam16::ucs_j(j); - let mp = cam16::ucs_m(m); - let s = if jp_actual + 1.0 > 1e-9 { - mp / (jp_actual + 1.0) - } else { - 0.0 - }; + quantized_iso_hk_for_neutral( + &neutral_color, + self.h_cam_canonical, + self.sat_ratio, + &self.vc, + ) + .map(|resolved| resolved.color) + } - LcsColor::new(jp_actual, h_ok, s.max(0.0), h_cam) + /// Совместимая инфаллибельная обёртка над [`Self::try_at`]. + /// + /// Кривая, созданная [`Self::new`], обязана быть достижима на всём своём + /// нейтральном скелете. Код с произвольными условиями просмотра должен + /// вызывать вариант с `Result` и явно обрабатывать диагностическую ошибку. + pub fn at(&self, t: f64) -> LcsColor { + self.try_at(t) + .expect("H-K-уровень акцента недостижим в физическом гамуте sRGB") } - /// The viewing conditions inherited from the neutral curve. + /// Условия просмотра, унаследованные от нейтральной кривой. pub fn vc(&self) -> &ViewingConditions { &self.vc } @@ -135,90 +85,749 @@ impl AccentCurve { self.h_canonical } - /// Доля канонической хромы от стены гамута на её светлоте, `[0, 1]` + /// Доля канонической CAM16-хромы от границы её изоуровня H-K, `[0, 1]` /// (см. [`AccentCurve::new`]). pub fn sat_ratio(&self) -> f64 { self.sat_ratio } - /// The original hex string passed to [`AccentCurve::new`], normalised to uppercase. + /// CAM16-оттенок решателя изоуровня H-K, в градусах `[0, 360)`. + pub fn canonical_cam16_hue(&self) -> f64 { + self.h_cam_canonical + } + + /// Исходный hex из [`AccentCurve::new`], нормализованный к верхнему регистру. pub fn canonical_hex(&self) -> &str { &self.canonical_hex } +} - fn find_optimal_hue(&self, l_ok: f64) -> f64 { - find_optimal_hue_core(l_ok, self.h_canonical, self.slope) - } +/// H-K-светлота Hellwig 2022, представленная [`LcsColor`]. +/// +/// Значение восстанавливается из тех же CAM16-коррелятов, которые хранит LCS: +/// здесь нет лишнего обратного цикла через XYZ и сравнения разных координат светлоты. +pub(crate) fn perceived_lightness(color: &LcsColor, vc: &ViewingConditions) -> f64 { + let j = cam16::ucs_j_inv(color.jp); + let m = cam16::ucs_m_inv(color.mp()); + crate::lpc::j_hk_from_cam16(j, m, color.h_cam(), vc) } -/// Полуокно поиска оптимального оттенка рампы акцента (градусы): 30° покрывает -/// типичную ширину гребня гамута sRGB вокруг канонического оттенка. +/// Единственная цветовая идентичность, выводимая из клиентского sRGB8-якоря. /// -/// Терминал **(c) INTERVAL-INSENSITIVE** (в отличие от -/// [`crate::semantic::CUSP_HALF_WINDOW_DEG`], которое ДОКАЗАННО клипует): -/// лок `chord_derived_slope_rejected_degenerates_to_window_edge` показывает -/// интерьерный оптимум (0 прижатий к ребру ±30°) на ВСЕХ 43 хроматических -/// якорях 49-якорного паспорта labui при продакшн-наклоне; окно — нежёсткая -/// нижняя граница, не связывающий кап. Экспозиция (доля (l,hue)-сетки, где -/// точное окно меняет выбранный оттенок при свипе [25°, 45°]) — **0.93%** -/// (`exposure_hue_search_window`). Значение не меняется. -// SSOT-TRACKED — hue search half-window (degrees), терминал (c) interval-insensitive (exposure 0.93%, 0/43 якорей у ребра), см. docs/empirical-inventory.md. -const HUE_SEARCH_HALF_WINDOW: f64 = 30.0; - -/// The hue (degrees) maximising `max_chroma(l_ok, h) − penalty·|h − h_canonical|` -/// over the ±[`HUE_SEARCH_HALF_WINDOW`] window around `h_canonical`. +/// Oklab hue остаётся описательным аксессором, а физическое построение использует +/// только сопряжённые CAM16 hue и C. `chroma_ratio` — доля C якоря от первой +/// связной границы его собственного iso-HK-уровня; значение никогда не клипуется. +#[derive(Debug, Clone, Copy)] +pub(crate) struct IsoHkIdentity { + pub h_ok: f64, + pub h_cam: f64, + pub chroma_ratio: f64, +} + +pub(crate) fn iso_hk_identity_from_anchor( + anchor_hex: &str, + vc: &ViewingConditions, +) -> Result { + vc.validate()?; + let rgb = srgb_from_hex(anchor_hex)?; + let achromatic = rgb[0] == rgb[1] && rgb[1] == rgb[2]; + if achromatic { + return Ok(IsoHkIdentity { + h_ok: 0.0, + h_cam: 0.0, + chroma_ratio: 0.0, + }); + } + + let lab = srgb_linear_to_oklab(rgb); + let h_ok = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); + let anchor = LcsColor::from_hex_with_vc(anchor_hex, vc)?; + let h_cam = anchor.h_cam(); + let anchor_c = cam16::ucs_m_inv(anchor.mp()) / vc.fl_pow_025; + let target = perceived_lightness(&anchor, vc); + let maximum = identity_radius_from_physical_anchor(target, h_cam, anchor_c, rgb, vc)?; + if maximum <= 0.0 { + return Err(format!( + "хроматический якорь имеет C={anchor_c}, но физический радиус его H-K-уровня равен {maximum}" + )); + } + let chroma_ratio = anchor_c / maximum; + if !chroma_ratio.is_finite() || !(0.0..=1.0).contains(&chroma_ratio) { + return Err(format!( + "якорь лежит вне сертифицированного iso-HK-радиуса: C={anchor_c}, Cmax={maximum}, доля={chroma_ratio}" + )); + } + + Ok(IsoHkIdentity { + h_ok, + h_cam, + chroma_ratio, + }) +} + +/// Радиус iso-HK-компоненты с конструктивным физическим свидетелем. /// -/// Free-standing (rather than a method) so the differential harness can diff the -/// *selection* logic against a frozen flat-scan reference over an arbitrary -/// `h_canonical`, independently of the [`max_chroma`] internals it calls. -fn find_optimal_hue_core(l_ok: f64, h_canonical: f64, slope: f64) -> f64 { - let c_at_canonical = max_chroma(l_ok, h_canonical); - - // Degenerate guard: if all hues yield near-zero chroma, skip the search. - if c_at_canonical < 1e-5 { - return h_canonical; - } - - let penalty_scale = slope / HUE_SEARCH_HALF_WINDOW; - // 1° step: coarser than Oklab JND but sufficient for the broad chroma ridge. - let steps = (HUE_SEARCH_HALF_WINDOW * 2.0) as i32; - - // Score of the flat-scan index `i` ∈ [0, steps], built through the SAME - // `h(i) = h_canonical − HALF_WINDOW + i` expression the flat scan used so the - // hue, drift and chroma are bit-for-bit what a full sweep would compute. The - // canonical hue reuses the already-solved chroma (C4). - let score_at = |i: i32| -> f64 { - let h = h_canonical - HUE_SEARCH_HALF_WINDOW + i as f64; - let drift = (h - h_canonical).abs(); - let c = if h == h_canonical { - c_at_canonical +/// Декодированный sRGB8 anchor сам доказывает `Cmax ≥ C_anchor`. На грани куба +/// обратный CAM16 может дать `1 + несколько ulp`, из-за чего строгий аналитический +/// solver возвращает соседний внутренний f64. Это не делает свидетель +/// внегамутным. Если такое расхождение возникло, поиск продолжается наружу от +/// `C_anchor`; последняя физическая binary64-точка и становится denominator. +fn identity_radius_from_physical_anchor( + target: f64, + h_cam: f64, + anchor_c: f64, + anchor_rgb: [f64; 3], + vc: &ViewingConditions, +) -> Result { + let solved = max_chroma_at_perceived_lightness(target, h_cam, vc) + .map_err(|error| format!("якорь нельзя разместить на его H-K-уровне: {error}"))?; + if solved >= anchor_c { + return Ok(solved); + } + + let on_cube_face = anchor_rgb + .into_iter() + .any(|channel| channel == 0.0 || channel == 1.0); + if !on_cube_face { + return Err(format!( + "сертифицированный iso-HK-радиус меньше интерьерного физического anchor: C={anchor_c}, Cmax={solved}" + )); + } + + let first_outward = anchor_c.next_up(); + if !iso_hk_point_is_physical(target, h_cam, first_outward, vc) { + return Ok(anchor_c); + } + + let mut inside = first_outward; + let mut span = first_outward - anchor_c; + let outside = loop { + span *= 2.0; + let probe = anchor_c + span; + if !probe.is_finite() + || iso_hk_j(target, probe, h_cam, vc) <= 0.0 + || !iso_hk_point_is_physical(target, h_cam, probe, vc) + { + break probe; + } + inside = probe; + }; + + let mut outside = outside; + while inside.next_up() < outside { + let middle = inside + (outside - inside) * 0.5; + if middle == inside || middle == outside { + break; + } + if iso_hk_point_is_physical(target, h_cam, middle, vc) { + inside = middle; } else { - max_chroma(l_ok, h) + outside = middle; + } + } + Ok(inside) +} + +/// Измеряет H-K-светлоту именно эмитируемого `#RRGGBB`. +/// +/// Равенство трёх декодированных каналов задаёт физический ахромат, поэтому для +/// него C принимается равной нулю точно. Это не позволяет матричному шуму CAT16 +/// создавать ложный H-K-вклад у серого состояния конечной sRGB8-лестницы. +pub fn emitted_perceived_lightness(hex: &str, vc: &ViewingConditions) -> Result { + vc.validate()?; + emitted_perceived_lightness_unchecked(hex, vc) +} + +fn emitted_perceived_lightness_unchecked(hex: &str, vc: &ViewingConditions) -> Result { + let rgb = srgb_from_hex(hex)?; + let (j, m, h_cam) = crate::lpc::cam16_jch_from_xyz(srgb_to_xyz(rgb), vc); + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + Ok(j) + } else { + Ok(crate::lpc::j_hk_from_cam16(j, m, h_cam, vc)) + } +} + +/// Результат конечного выбора на sRGB8-решётке. +#[derive(Debug, Clone, Copy)] +pub(crate) struct QuantizedIsoHkColor { + pub color: LcsColor, + pub target_hk: f64, + pub achieved_hk: f64, + pub chroma_ratio: f64, +} + +#[derive(Debug, Clone, Copy)] +struct QuantizedCandidate { + color: LcsColor, + bytes: [u8; 3], + achieved_hk: f64, + level_error: f64, + ideal_delta_e: f64, +} + +impl QuantizedCandidate { + fn is_better_than(self, other: Self) -> bool { + self.level_error + .total_cmp(&other.level_error) + .then_with(|| self.ideal_delta_e.total_cmp(&other.ideal_delta_e)) + .then_with(|| self.bytes.cmp(&other.bytes)) + .is_lt() + } +} + +/// Выбирает ближайшее представимое состояние для одного конечного уровня. +/// +/// Непрерывная iso-HK-точка однозначно задаётся `(target, h_cam, ratio)`. Для +/// хроматического цвета рассматриваются все восемь вершин единственной +/// содержащей его sRGB8-ячейки. При нулевом радиусе исчерпываются все 256 серых: +/// это полный конечный кодомен ахромата, а не сеточная аппроксимация. +/// Лексикографическая цель: минимальная ошибка H-K-уровня, затем CAM16-UCS ΔE +/// до непрерывного идеала, затем меньшая тройка RGB-байтов как технический tie. +pub(crate) fn quantized_iso_hk_for_neutral( + neutral: &LcsColor, + h_cam: f64, + chroma_ratio: f64, + vc: &ViewingConditions, +) -> Result { + let neutral_hex = neutral.to_hex_with_vc(vc); + let target_hk = emitted_perceived_lightness_unchecked(&neutral_hex, vc) + .map_err(|_| IsoHkError::NumericalFailure)?; + + // В вершинах sRGB-куба хроматический радиус физически равен нулю. Возврат + // исходного конечного состояния сохраняет его байты без обратного CAM-цикла. + if neutral_hex == "#000000" || neutral_hex == "#FFFFFF" { + return Ok(QuantizedIsoHkColor { + color: *neutral, + target_hk, + achieved_hk: target_hk, + chroma_ratio, + }); + } + + let ideal = color_at_perceived_lightness(target_hk, h_cam, chroma_ratio, vc)?; + let ideal_rgb = ideal.to_linear_srgb(vc); + if !in_physical_srgb(ideal_rgb) { + return Err(IsoHkError::NumericalFailure); + } + + let mut best: Option = None; + let mut consider = |bytes: [u8; 3]| -> Result<(), IsoHkError> { + let hex = format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]); + let color = + LcsColor::from_hex_with_vc(&hex, vc).map_err(|_| IsoHkError::NumericalFailure)?; + let achieved_hk = emitted_perceived_lightness_unchecked(&hex, vc) + .map_err(|_| IsoHkError::NumericalFailure)?; + let candidate = QuantizedCandidate { + color, + bytes, + achieved_hk, + level_error: (achieved_hk - target_hk).abs(), + ideal_delta_e: color.delta_e_ucs(&ideal), }; - c - penalty_scale * drift + if best.is_none_or(|current| candidate.is_better_than(current)) { + best = Some(candidate); + } + Ok(()) }; - // C2 — coarse-to-fine. Locate the ridge on a 5° coarse grid, then refine at - // full 1° resolution inside a ±5° bracket around the coarse argmax. The score - // (the smooth gamut chroma ridge minus a V-shaped drift penalty) is unimodal - // over the window, so the coarse argmax sits within one coarse step of the - // true peak and the bracket contains it. The winner is chosen by scanning - // candidate indices in ascending order with the SAME strict-`>` first-maximum - // tie-break the flat scan used, so the selected hue is bit-identical — pinned - // on the full 180k-point grid by diff test B. - // - // 5° coarse grid; ±15° refinement bracket around every coarse local maximum. - // The bracket is sized from a full-grid measurement: over the entire - // (l_ok × canonical-hue) grid the flat argmax never sits more than 13° from a - // coarse local maximum, so ±15° (a 2° margin) reproduces the flat scan - // bit-for-bit — pinned by diff test B on the grid and by the accent/tint - // golden snapshots on the real non-integer hues. (`let`, not `const`, so the - // frozen policy-const audit never sees an integer grid knob as a perceptual - // value.) - let coarse = 5; - let bracket = 15; - let best_i = coarse_to_fine_argmax(steps, coarse, bracket, score_at); - h_canonical - HUE_SEARCH_HALF_WINDOW + best_i as f64 + if chroma_ratio == 0.0 { + // При нулевом радиусе hue не существует. Полный представимый кодомен — + // ровно 256 серых, поэтому исчерпывающий поиск одновременно быстрее и + // строже локальной RGB-ячейки: цветной байтовый шум невозможен. + for byte in 0_u16..=255 { + let byte = byte as u8; + consider([byte, byte, byte])?; + } + } else { + let mut bounds = [[0_u8; 2]; 3]; + for channel in 0..3 { + let scaled = srgb_gamma(ideal_rgb[channel]) * 255.0; + if !scaled.is_finite() || !(0.0..=255.0).contains(&scaled) { + return Err(IsoHkError::NumericalFailure); + } + bounds[channel] = [scaled.floor() as u8, scaled.ceil() as u8]; + } + for mask in 0_u8..8 { + consider([ + bounds[0][usize::from(mask & 1)], + bounds[1][usize::from((mask >> 1) & 1)], + bounds[2][usize::from((mask >> 2) & 1)], + ])?; + } + } + + let best = best.ok_or(IsoHkError::NumericalFailure)?; + Ok(QuantizedIsoHkColor { + color: best.color, + target_hk, + achieved_hk: best.achieved_hk, + chroma_ratio, + }) +} + +/// Ошибка построения физического sRGB-цвета на заданном изоуровне Hellwig 2022. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum IsoHkError { + /// Запрошенная H-K-светлота равна NaN или бесконечности. + NonFiniteTarget, + /// CAM16-оттенок не конечен. + NonFiniteHue, + /// Относительная хрома не конечна либо лежит вне `[0, 1]`. + InvalidChromaRatio, + /// Ахроматическое начало лежит ниже физического чёрного. + BelowBlack { target: f64 }, + /// При данном CAM16-оттенке нет физического цвета с такой H-K-светлотой. + NoPhysicalColor { target: f64, h_cam: f64 }, + /// Численный расчёт не смог сертифицировать связную физическую компоненту. + NumericalFailure, +} + +impl std::fmt::Display for IsoHkError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match *self { + Self::NonFiniteTarget => write!(f, "H-K-светлота должна быть конечной"), + Self::NonFiniteHue => write!(f, "CAM16-оттенок должен быть конечным"), + Self::InvalidChromaRatio => { + write!( + f, + "относительная CAM16-хрома должна быть конечной и лежать в [0, 1]" + ) + } + Self::BelowBlack { target } => { + write!(f, "H-K-светлота {target} лежит ниже физического чёрного") + } + Self::NoPhysicalColor { target, h_cam } => write!( + f, + "H-K-светлота {target} недостижима в sRGB при CAM16-оттенке {h_cam}" + ), + Self::NumericalFailure => write!( + f, + "не удалось сертифицировать связный физический интервал sRGB" + ), + } + } +} + +impl std::error::Error for IsoHkError {} + +/// Максимальная CAM16-хрома первой связной физической части изоуровня H-K. +/// +/// Вся кривая параметризуется в одной модели восприятия и при одних условиях +/// просмотра: +/// +/// `J(C) = J_HK_target - f(h) * C^0.587`, `M(C) = C * F_L^0.25`. +/// +/// `f(h)` и показатель вычисляет [`crate::lpc::j_hk_from_cam16`] — единственный +/// источник опубликованного уравнения Hellwig 2022. Здесь нет Oklab-светлоты, +/// Oklab-оттенка, сероосевого приближения и перцептивного допуска. Возвращается +/// последнее представимое внутригамутное `C` перед первым выходом из первой +/// физической компоненты; при `J_HK` выше ахроматического белого её начало может +/// быть больше нуля. +pub(crate) fn max_chroma_at_perceived_lightness( + target: f64, + h_cam: f64, + vc: &ViewingConditions, +) -> Result { + validate_iso_hk_inputs(target, h_cam)?; + if target < 0.0 { + return Err(IsoHkError::BelowBlack { target }); + } + let white = white_perceived_lightness(vc); + + // У чёрного и белого ахроматическое начало уже лежит на грани куба. Значит, + // первая связная граница равна C=0 точно: конечная точка остаётся точкой, а не + // искусственным цветным ореолом. + if target == 0.0 || target == white { + return Ok(0.0); + } + + let h_cam = h_cam.rem_euclid(360.0); + physical_chroma_interval(target, h_cam, vc).map(|(_, maximum)| maximum) +} + +/// Построить цвет на заданной доле физического CAM16-радиуса изоуровня H-K. +pub(crate) fn color_at_perceived_lightness( + target: f64, + h_cam: f64, + chroma_ratio: f64, + vc: &ViewingConditions, +) -> Result { + validate_iso_hk_inputs(target, h_cam)?; + if !chroma_ratio.is_finite() || !(0.0..=1.0).contains(&chroma_ratio) { + return Err(IsoHkError::InvalidChromaRatio); + } + + let white = white_perceived_lightness(vc); + if target < 0.0 { + return Err(IsoHkError::BelowBlack { target }); + } + if target == 0.0 || target == white { + return if target == 0.0 { + Ok(LcsColor::from_cam16(0.0, 0.0, 0.0, 0.0)) + } else { + Ok(white_lcs(vc)) + }; + } + + let h_cam = h_cam.rem_euclid(360.0); + let (minimum, maximum) = physical_chroma_interval(target, h_cam, vc)?; + let c = chroma_ratio * maximum; + if c < minimum { + return Err(IsoHkError::NoPhysicalColor { target, h_cam }); + } + let color = iso_hk_color_unchecked(target, h_cam, c, vc); + if in_physical_srgb(color.to_linear_srgb(vc)) { + Ok(color) + } else { + Err(IsoHkError::NumericalFailure) + } +} + +fn validate_iso_hk_inputs(target: f64, h_cam: f64) -> Result<(), IsoHkError> { + if !target.is_finite() { + return Err(IsoHkError::NonFiniteTarget); + } + if !h_cam.is_finite() { + return Err(IsoHkError::NonFiniteHue); + } + Ok(()) +} + +fn white_lcs(vc: &ViewingConditions) -> LcsColor { + LcsColor::from_xyz_with_hok(srgb_to_xyz([1.0, 1.0, 1.0]), 0.0, vc) +} + +fn white_perceived_lightness(vc: &ViewingConditions) -> f64 { + // Белый sRGB — ахроматический стимул, поэтому в уравнении H-K C=0 и + // J_HK=J. Мелкий ненулевой M, возникающий из печатной точности матриц + // CAT16, не должен превращаться в физический цветовой вклад. + crate::lpc::cam16_jch_from_xyz(srgb_to_xyz([1.0, 1.0, 1.0]), vc).0 +} + +/// Сырой CAM16 J, однозначно следующий из равенства H-K при хроме `C`. +fn iso_hk_j(target: f64, c: f64, h_cam: f64, vc: &ViewingConditions) -> f64 { + let m = c * vc.fl_pow_025; + target - crate::lpc::j_hk_from_cam16(0.0, m, h_cam, vc) +} + +fn iso_hk_color_unchecked(target: f64, h_cam: f64, c: f64, vc: &ViewingConditions) -> LcsColor { + let j = iso_hk_j(target, c, h_cam, vc); + assert!( + j >= 0.0, + "внутренний инвариант iso-HK нарушен: C вышла за границу J=0" + ); + let m = c * vc.fl_pow_025; + if c == 0.0 { + // В ахроматическом начале оттенок не определён. Храним канонический ноль, + // а не входной оттенок у точки с нулевым радиусом. + LcsColor::from_cam16(j, 0.0, 0.0, 0.0) + } else { + LcsColor::from_ucs_polar(cam16::ucs_j(j), cam16::ucs_m(m), h_cam, vc) + } +} + +fn in_physical_srgb(rgb: [f64; 3]) -> bool { + rgb.into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) +} + +struct IsoHkContext<'a> { + target: f64, + h_cam: f64, + vc: &'a ViewingConditions, + cos_h: f64, + sin_h: f64, + p1: f64, + cone_to_rgb: [[f64; 3]; 3], +} + +impl<'a> IsoHkContext<'a> { + fn new(target: f64, h_cam: f64, vc: &'a ViewingConditions) -> Self { + let hr = h_cam.to_radians(); + let cos_h = hr.cos(); + let sin_h = hr.sin(); + let e_hue = 0.25 * ((hr + 2.0).cos() + 3.8); + let p1 = e_hue * (50000.0 / 13.0) * vc.nc * vc.nbb; + + let mut cone_to_rgb = [[0.0_f64; 3]; 3]; + for basis in 0..3 { + let mut lms = [0.0_f64; 3]; + lms[basis] = 1.0 / vc.rgb_d[basis]; + let xyz_100 = crate::spaces::cat16::cone_to_xyz(lms); + let rgb = crate::spaces::srgb::xyz_to_srgb([ + xyz_100[0] / 100.0, + xyz_100[1] / 100.0, + xyz_100[2] / 100.0, + ]); + for channel in 0..3 { + cone_to_rgb[channel][basis] = rgb[channel]; + } + } + + Self { + target, + h_cam, + vc, + cos_h, + sin_h, + p1, + cone_to_rgb, + } + } +} + +/// Первый связный интервал физической CAM16-хромы на изоуровне H-K. +/// +/// Если `J_HK` выше ахроматического белого, `C=0` находится вне sRGB, но цветная +/// точка всё ещё может быть физической: вклад H-K позволяет уменьшить сырой `J`. +/// Поэтому ищутся и первый вход, и первый выход; начало компоненты не обязано +/// совпадать с нулём. +fn physical_chroma_interval( + target: f64, + h_cam: f64, + vc: &ViewingConditions, +) -> Result<(f64, f64), IsoHkError> { + let context = IsoHkContext::new(target, h_cam, vc); + // Ищем конечную верхнюю точку с J(C)<=0. Начальная единица имеет размерность + // одной CAM16-C, а удвоение исчерпывающе проходит двоичные порядки и не + // задаёт подогнанный потолок хромы. f(h) Hellwig положителен при любом + // оттенке, поэтому для положительной цели поиск обязательно завершается. + let mut upper = 1.0_f64; + while iso_hk_j(target, upper, h_cam, vc) > 0.0 { + upper *= 2.0; + if !upper.is_finite() { + return Err(IsoHkError::NumericalFailure); + } + } + + let mut pending = vec![(0.0_f64, upper)]; + let mut first = None; + let mut last = None; + let mut last_seen = None; + + while let Some((lo, hi)) = pending.pop() { + match classify_iso_hk_interval(&context, lo, hi) { + IntervalClass::Inside => { + first.get_or_insert(lo); + last = Some(hi); + continue; + } + IntervalClass::Outside => { + if let (Some(start), Some(end)) = (first, last) { + return Ok((start, end)); + } + continue; + } + IntervalClass::Unresolved => {} + } + + let mid = lo + (hi - lo) * 0.5; + if mid == lo || mid == hi { + for point in [lo, hi] { + if last_seen == Some(point.to_bits()) { + continue; + } + last_seen = Some(point.to_bits()); + if iso_hk_point_is_physical(target, h_cam, point, vc) { + first.get_or_insert(point); + last = Some(point); + } else if let (Some(start), Some(end)) = (first, last) { + return Ok((start, end)); + } + } + continue; + } + + // Стек LIFO: правую половину кладём первой, чтобы полностью + // сертифицировать левую до рассмотрения любой большей хромы. + pending.push((mid, hi)); + pending.push((lo, mid)); + } + + if let (Some(start), Some(end)) = (first, last) { + Ok((start, end)) + } else { + Err(IsoHkError::NoPhysicalColor { target, h_cam }) + } +} + +fn iso_hk_point_is_physical(target: f64, h_cam: f64, c: f64, vc: &ViewingConditions) -> bool { + let j = iso_hk_j(target, c, h_cam, vc); + if j <= 0.0 && c > 0.0 { + return false; + } + in_physical_srgb(iso_hk_color_unchecked(target, h_cam, c, vc).to_linear_srgb(vc)) +} + +#[derive(Clone, Copy, Debug)] +struct Bounds { + lo: f64, + hi: f64, +} + +impl Bounds { + fn point(value: f64) -> Self { + Self { + lo: value, + hi: value, + } + } + + fn outward(lo: f64, hi: f64) -> Self { + Self { + lo: if lo.is_finite() { lo.next_down() } else { lo }, + hi: if hi.is_finite() { hi.next_up() } else { hi }, + } + } + + fn add(self, other: Self) -> Self { + Self::outward(self.lo + other.lo, self.hi + other.hi) + } + + fn sub(self, other: Self) -> Self { + Self::outward(self.lo - other.hi, self.hi - other.lo) + } + + fn mul(self, other: Self) -> Self { + let products = [ + self.lo * other.lo, + self.lo * other.hi, + self.hi * other.lo, + self.hi * other.hi, + ]; + let lo = products.into_iter().fold(f64::INFINITY, f64::min); + let hi = products.into_iter().fold(f64::NEG_INFINITY, f64::max); + Self::outward(lo, hi) + } + + fn div(self, other: Self) -> Option { + if other.lo <= 0.0 && other.hi >= 0.0 { + return None; + } + Some(self.mul(Self::outward(1.0 / other.hi, 1.0 / other.lo))) + } + + fn powf(self, exponent: f64) -> Option { + if self.lo < 0.0 || !(exponent.is_finite() && exponent > 0.0) { + return None; + } + Some(Self::outward( + self.lo.powf(exponent), + self.hi.powf(exponent), + )) + } +} + +/// Внешняя интервальная оболочка прямой CAM16-инверсии на интервале хромы. +/// +/// Коэффициенты матриц получаются применением канонических линейных +/// CAT16/XYZ/sRGB-преобразований крейта к базисным векторам. Поэтому здесь нет +/// второй копии опубликованных матриц, способной разойтись с основным путём. +fn iso_hk_rgb_bounds(context: &IsoHkContext<'_>, c_lo: f64, c_hi: f64) -> Option<[Bounds; 3]> { + let target = context.target; + let h_cam = context.h_cam; + let vc = context.vc; + let c = Bounds { lo: c_lo, hi: c_hi }; + let j = Bounds::outward( + iso_hk_j(target, c_hi, h_cam, vc), + iso_hk_j(target, c_lo, h_cam, vc), + ); + if j.lo <= 0.0 || !j.hi.is_finite() { + return None; + } + + let m = c.mul(Bounds::point(vc.fl_pow_025)); + let j_fraction = j.div(Bounds::point(100.0))?; + let sqrt_j = j_fraction.powf(0.5)?; + let t_denominator = sqrt_j + .mul(Bounds::point(vc.t_inner)) + .mul(Bounds::point(vc.fl_pow_025)); + let t = m.div(t_denominator)?.powf(1.0 / 0.9)?; + + let cos_h = context.cos_h; + let sin_h = context.sin_h; + let p1 = context.p1; + let p2 = Bounds::point(vc.aw) + .mul(j_fraction.powf(1.0 / (vc.c * vc.z))?) + .div(Bounds::point(vc.nbb))?; + let gamma_numerator = Bounds::point(23.0).mul(p2.add(Bounds::point(0.305))).mul(t); + let gamma_denominator = + Bounds::point(23.0 * p1).add(t.mul(Bounds::point(11.0 * cos_h + 108.0 * sin_h))); + let gamma = gamma_numerator.div(gamma_denominator)?; + let a = gamma.mul(Bounds::point(cos_h)); + let b = gamma.mul(Bounds::point(sin_h)); + + let r_a = Bounds::point(460.0) + .mul(p2) + .add(Bounds::point(451.0).mul(a)) + .add(Bounds::point(288.0).mul(b)) + .div(Bounds::point(1403.0))?; + let g_a = Bounds::point(460.0) + .mul(p2) + .sub(Bounds::point(891.0).mul(a)) + .sub(Bounds::point(261.0).mul(b)) + .div(Bounds::point(1403.0))?; + let b_a = Bounds::point(460.0) + .mul(p2) + .sub(Bounds::point(220.0).mul(a)) + .sub(Bounds::point(6300.0).mul(b)) + .div(Bounds::point(1403.0))?; + + let adapted = [r_a, g_a, b_a]; + let mut cone = [Bounds::point(0.0); 3]; + for (slot, value) in cone.iter_mut().zip(adapted) { + if value.lo <= -400.0 || value.hi >= 400.0 { + return None; + } + *slot = Bounds::outward( + cam16::unadapt(value.lo, vc.fl), + cam16::unadapt(value.hi, vc.fl), + ); + } + + let mut rgb = [Bounds::point(0.0); 3]; + for (channel, output) in rgb.iter_mut().enumerate() { + for (basis, component) in cone.iter().enumerate() { + *output = output.add(component.mul(Bounds::point(context.cone_to_rgb[channel][basis]))); + } + } + Some(rgb) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum IntervalClass { + Inside, + Outside, + Unresolved, +} + +fn classify_iso_hk_interval(context: &IsoHkContext<'_>, c_lo: f64, c_hi: f64) -> IntervalClass { + let target = context.target; + let h_cam = context.h_cam; + let vc = context.vc; + // J строго убывает с C. После неположительной левой границы ни одна более + // поздняя точка не может представлять цвет с положительной красочностью. + if iso_hk_j(target, c_lo, h_cam, vc) <= 0.0 && c_lo > 0.0 { + return IntervalClass::Outside; + } + + let Some(rgb) = iso_hk_rgb_bounds(context, c_lo, c_hi) else { + return IntervalClass::Unresolved; + }; + if rgb + .into_iter() + .all(|channel| channel.lo >= 0.0 && channel.hi <= 1.0) + { + IntervalClass::Inside + } else if rgb + .into_iter() + .any(|channel| channel.hi < 0.0 || channel.lo > 1.0) + { + IntervalClass::Outside + } else { + IntervalClass::Unresolved + } } /// Argmax index over `0..=steps` found coarse-to-fine, reproducing a flat 1° scan @@ -481,21 +1090,15 @@ pub mod bench_support { } } -/// The half-width the bisection used to add/subtract around each channel's -/// `[0, 1]` gamut wall. The analytical solver reproduces the exact same band so -/// it returns the identical boundary chroma the bisection converged to. -const GAMUT_EPS: f64 = 1e-6; - /// The largest in-gamut Oklab chroma along the ray of fixed lightness `l_ok` and /// hue `h_ok_deg`, found in closed form. /// /// Along a ray of fixed `(L, h)` in Oklab, the chroma `C` enters each -/// intermediate LMS channel **linearly** (`OKLAB_TO_LMS` is affine in `C` -/// because its first column is all ones), is then cubed, and recombined into +/// intermediate LMS channel **linearly** (`OKLAB_TO_LMS` is affine in `C`), +/// is then cubed, and recombined into /// linear sRGB by `LMS_TO_SRGB` — so every sRGB channel is a **cubic polynomial /// in `C`**. The sRGB gamut wall is the first `C > 0` at which any of the six -/// constraints (`channel = 0` or `channel = 1`, each widened by [`GAMUT_EPS`] to -/// match the old bisection's tolerance) is hit. That smallest positive crossing +/// physical constraints (`channel = 0` or `channel = 1`) is hit. That smallest positive crossing /// is the maximum chroma, found by solving the cubics in closed form instead of /// 64 blind bisection steps. /// @@ -505,22 +1108,31 @@ const GAMUT_EPS: f64 = 1e-6; pub(crate) fn max_chroma(l_ok: f64, h_ok_deg: f64) -> f64 { use crate::spaces::oklab::{LMS_TO_SRGB, OKLAB_TO_LMS}; + debug_assert!(l_ok.is_finite() && (0.0..=1.0).contains(&l_ok)); + debug_assert!(h_ok_deg.is_finite()); + if l_ok == 0.0 || l_ok == 1.0 { + return 0.0; + } + let h_ok = h_ok_deg.to_radians(); let cos_h = h_ok.cos(); let sin_h = h_ok.sin(); - // Each intermediate LMS_ value is affine in C: lms_[k] = p_k + q_k * C. - // (Column 0 of OKLAB_TO_LMS is all ones, so p_k = l_ok for every k.) + // Каждая LMS′-координата аффинна по C: + // lms_[k] = row[0]·L + (row[1]·cos h + row[2]·sin h)·C. + // Первый столбец математической обратной матрицы близок к единице, но у + // выведенной binary64-матрицы не обязан быть побитово равен ей. Подмена + // row[0]·L на L смещала коэффициенты кубика и физическую границу гамута. let mut p = [0.0_f64; 3]; let mut q = [0.0_f64; 3]; for (k, row) in OKLAB_TO_LMS.iter().enumerate() { - p[k] = l_ok; // row[0] == 1.0 + p[k] = row[0] * l_ok; q[k] = row[1] * cos_h + row[2] * sin_h; } // Each sRGB channel rgb[ch](C) = Σ_k M[ch][k] * (p_k + q_k C)^3 is a cubic // in C. Build its coefficients [c0, c1, c2, c3] (ascending powers). - let mut smallest = 1.0_f64; // cap at the bisection's hi = 1.0 + let mut smallest = f64::INFINITY; for m in &LMS_TO_SRGB { let mut coeff = [0.0_f64; 4]; for ((&mk, &pk), &qk) in m.iter().zip(p.iter()).zip(q.iter()) { @@ -539,36 +1151,66 @@ pub(crate) fn max_chroma(l_ok: f64, h_ok_deg: f64) -> f64 { // exact prior behaviour, including the near-black non-convex slivers. match binding_walls(coeff) { WallBinding::UpperOnly => { - if let Some(c) = smallest_positive_crossing(coeff, 1.0 + GAMUT_EPS) { + if let Some(c) = smallest_positive_crossing(coeff, 1.0) { smallest = smallest.min(c); } } WallBinding::LowerOnly => { - if let Some(c) = smallest_positive_crossing(coeff, -GAMUT_EPS) { + if let Some(c) = smallest_positive_crossing(coeff, 0.0) { smallest = smallest.min(c); } } WallBinding::Both => { - // First crossing of the upper wall (channel = 1 + eps) and the - // lower wall (channel = -eps), whichever comes first. - if let Some(c) = smallest_positive_crossing(coeff, 1.0 + GAMUT_EPS) { + // First crossing of either physical cube face. + if let Some(c) = smallest_positive_crossing(coeff, 1.0) { smallest = smallest.min(c); } - if let Some(c) = smallest_positive_crossing(coeff, -GAMUT_EPS) { + if let Some(c) = smallest_positive_crossing(coeff, 0.0) { smallest = smallest.min(c); } } } } - smallest.clamp(0.0, 1.0) + debug_assert!(smallest.is_finite() && smallest > 0.0); + + // A polynomial root lies on the mathematical cube face. Round toward the + // interior until the actual forward transform satisfies strict device + // bounds. This corrects binary64 evaluation; it does not widen the gamut. + let in_gamut = |c: f64| { + oklab_to_srgb_linear([l_ok, c * cos_h, c * sin_h]) + .into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) + }; + if in_gamut(smallest) { + return smallest; + } + let adjacent_inside = smallest.next_down(); + if in_gamut(adjacent_inside) { + return adjacent_inside; + } + + // Newton polishing can land several ulps outside. Isolate the boundary from + // the known achromatic interior until no representable midpoint remains. + let (mut lo, mut hi) = (0.0_f64, smallest); + loop { + let mid = (lo + hi) * 0.5; + if mid == lo || mid == hi { + return lo; + } + if in_gamut(mid) { + lo = mid; + } else { + hi = mid; + } + } } /// Which gamut wall(s) a channel's cubic can reach for `C > 0`. enum WallBinding { - /// Monotone rising: only the upper wall (`1 + eps`) is reachable. + /// Monotone rising: only the upper wall is reachable. UpperOnly, - /// Monotone falling: only the lower wall (`-eps`) is reachable. + /// Monotone falling: only the lower wall is reachable. LowerOnly, /// May reverse on the positive axis (or a near-degenerate slope): solve both. Both, @@ -656,9 +1298,9 @@ fn smallest_positive_crossing(coeff: [f64; 4], level: f64) -> Option { let mut best: Option = None; for &r in roots.iter().take(n) { // Discard non-positive and spurious roots; a real crossing is C > 0. - if r > 1e-12 { + if r > 0.0 { let polished = newton_polish(g, r); - if polished > 1e-12 { + if polished > 0.0 { best = Some(match best { Some(b) => b.min(polished), None => polished, @@ -765,6 +1407,9 @@ fn quadratic_roots(d: f64, c: f64, b: f64) -> ([f64; 3], usize) { // Прод-потребитель — этап 2 (P3-кандидаты солвера); до него читается тестами. #[cfg_attr(not(test), allow(dead_code))] pub(crate) fn max_chroma_p3_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { + if l_ok == 0.0 || l_ok == 1.0 { + return 0.0; + } let h_ok = h_ok_deg.to_radians(); let cos_h = h_ok.cos(); let sin_h = h_ok.sin(); @@ -779,12 +1424,9 @@ pub(crate) fn max_chroma_p3_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { let rgb = crate::spaces::p3::xyz_to_p3_linear(srgb_to_xyz(oklab_to_srgb_linear([l_ok, a, b]))); - if rgb[0] >= -1e-6 - && rgb[0] <= 1.0 + 1e-6 - && rgb[1] >= -1e-6 - && rgb[1] <= 1.0 + 1e-6 - && rgb[2] >= -1e-6 - && rgb[2] <= 1.0 + 1e-6 + if rgb + .into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) { lo = mid; } else { @@ -792,13 +1434,16 @@ pub(crate) fn max_chroma_p3_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { } } - (lo + hi) * 0.5 + // `lo` по инварианту лежит внутри P3; середина могла бы оказаться снаружи. + lo } -/// The bisection that [`max_chroma`] replaced, kept (test-only) as the reference -/// oracle the analytical solver is proven bit-for-bit against on a dense grid. +/// Независимая строгая бисекция для тестового сравнения с [`max_chroma`]. #[cfg(test)] pub(crate) fn max_chroma_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { + if l_ok == 0.0 || l_ok == 1.0 { + return 0.0; + } let h_ok = h_ok_deg.to_radians(); let cos_h = h_ok.cos(); let sin_h = h_ok.sin(); @@ -812,12 +1457,9 @@ pub(crate) fn max_chroma_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { let b = mid * sin_h; let rgb = oklab_to_srgb_linear([l_ok, a, b]); - if rgb[0] >= -1e-6 - && rgb[0] <= 1.0 + 1e-6 - && rgb[1] >= -1e-6 - && rgb[1] <= 1.0 + 1e-6 - && rgb[2] >= -1e-6 - && rgb[2] <= 1.0 + 1e-6 + if rgb + .into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) { lo = mid; } else { @@ -825,7 +1467,9 @@ pub(crate) fn max_chroma_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { } } - (lo + hi) * 0.5 + // Возвращаем доказанно внутреннюю сторону брекета, а не потенциально + // внегамутную середину последнего интервала. + lo } impl crate::curve::ColorCurve for AccentCurve { @@ -848,29 +1492,25 @@ mod tests { } // ───────────────────────────────────────────────────────────────────────── - // DIFFERENTIAL HARNESS (perf/max-chroma-hotpath). + // DIFFERENTIAL HARNESS для отсечения недостижимой грани куба. // - // A frozen, self-contained copy of the max-chroma solver and the accent - // hue-selection sweep as they stood BEFORE any perf optimisation, plus the - // bit-identity differential tests that gate every optimisation commit on this - // branch. The IRON LAW of this branch is that no emitted hex/Lc value moves - // anywhere; these tests prove it at the arithmetic root by comparing the - // production solver against the frozen oracle to full f64 `to_bits()` - // identity over a dense (l_ok, h) grid. + // Независимый полный вариант решает обе грани каждого канала. Production + // вправе пропускать одну грань лишь после доказательства монотонности; + // побитовое совпадение на плотной сетке проверяет именно эту оптимизацию. // - // The oracle is DELIBERATELY duplicated (its own cubic/quadratic/Newton - // helpers) so it can never track a change to the production helpers — a - // frozen reference that silently follows the code it guards proves nothing. + // Корневые функции намеренно продублированы, чтобы ошибка в production + // Cardano/Newton не копировалась через общий вызов. // ───────────────────────────────────────────────────────────────────────── - /// FROZEN reference: the analytic max-chroma solver exactly as it stood at - /// the base of `perf/max-chroma-hotpath` — always solving BOTH gamut walls. - /// Diff test A pins the production [`max_chroma`] bit-for-bit against this. - /// The gamut band `1e-6` is inlined (frozen mirror of the production - /// `GAMUT_EPS`) so this oracle carries no scanned const of its own. + /// Полный reference: решает обе физические грани `[0, 1]` каждого канала, + /// не используя эвристику [`binding_walls`]. fn max_chroma_reference(l_ok: f64, h_ok_deg: f64) -> f64 { use crate::spaces::oklab::{LMS_TO_SRGB, OKLAB_TO_LMS}; + if l_ok == 0.0 || l_ok == 1.0 { + return 0.0; + } + let h_ok = h_ok_deg.to_radians(); let cos_h = h_ok.cos(); let sin_h = h_ok.sin(); @@ -878,11 +1518,11 @@ mod tests { let mut p = [0.0_f64; 3]; let mut q = [0.0_f64; 3]; for (k, row) in OKLAB_TO_LMS.iter().enumerate() { - p[k] = l_ok; + p[k] = row[0] * l_ok; q[k] = row[1] * cos_h + row[2] * sin_h; } - let mut smallest = 1.0_f64; + let mut smallest = f64::INFINITY; for m in &LMS_TO_SRGB { let mut coeff = [0.0_f64; 4]; for ((&mk, &pk), &qk) in m.iter().zip(p.iter()).zip(q.iter()) { @@ -891,15 +1531,39 @@ mod tests { coeff[2] += mk * 3.0 * pk * qk * qk; coeff[3] += mk * qk * qk * qk; } - if let Some(c) = spc_ref(coeff, 1.0 + 1e-6) { + if let Some(c) = spc_ref(coeff, 1.0) { smallest = smallest.min(c); } - if let Some(c) = spc_ref(coeff, -1e-6) { + if let Some(c) = spc_ref(coeff, 0.0) { smallest = smallest.min(c); } } - smallest.clamp(0.0, 1.0) + assert!(smallest.is_finite() && smallest > 0.0); + let in_gamut = |c: f64| { + oklab_to_srgb_linear([l_ok, c * cos_h, c * sin_h]) + .into_iter() + .all(|channel| channel.is_finite() && (0.0..=1.0).contains(&channel)) + }; + if in_gamut(smallest) { + return smallest; + } + let adjacent_inside = smallest.next_down(); + if in_gamut(adjacent_inside) { + return adjacent_inside; + } + let (mut lo, mut hi) = (0.0_f64, smallest); + loop { + let mid = (lo + hi) * 0.5; + if mid == lo || mid == hi { + return lo; + } + if in_gamut(mid) { + lo = mid; + } else { + hi = mid; + } + } } /// Frozen mirror of [`smallest_positive_crossing`]. @@ -908,9 +1572,9 @@ mod tests { let (roots, n) = cubic_roots_ref(g); let mut best: Option = None; for &r in roots.iter().take(n) { - if r > 1e-12 { + if r > 0.0 { let polished = newton_polish_ref(g, r); - if polished > 1e-12 { + if polished > 0.0 { best = Some(match best { Some(b) => b.min(polished), None => polished, @@ -990,38 +1654,9 @@ mod tests { (roots, 2) } - /// FROZEN reference: the flat 61-point hue sweep exactly as it selected the - /// optimal accent hue at the base of this branch. Calls the PRODUCTION - /// [`max_chroma`] so diff test B isolates the *selection* logic (C2/C4) from - /// the solver internals (which diff test A guards separately). - fn find_optimal_hue_reference(l_ok: f64, h_canonical: f64, slope: f64) -> f64 { - // 30.0 inlined (frozen mirror of `HUE_SEARCH_HALF_WINDOW`) so this oracle - // carries no scanned const of its own. - let half_window = 30.0_f64; - let c_at_canonical = max_chroma(l_ok, h_canonical); - if c_at_canonical < 1e-5 { - return h_canonical; - } - let mut best_h = h_canonical; - let mut best_score = f64::NEG_INFINITY; - let penalty_scale = slope / half_window; - let steps = (half_window * 2.0) as i32; - for i in 0..=steps { - let h = h_canonical - half_window + i as f64; - let c = max_chroma(l_ok, h); - let drift = (h - h_canonical).abs(); - let score = c - penalty_scale * drift; - if score > best_score { - best_score = score; - best_h = h; - } - } - best_h - } - /// Diff test A over a grid: production `max_chroma` must equal the frozen /// reference to full f64 bit identity. `l_steps`/`h_step_deg` size the grid. - fn assert_max_chroma_matches_reference(l_steps: usize, h_step_deg: usize) -> usize { + fn assert_max_chroma_matches_full_reference(l_steps: usize, h_step_deg: usize) -> usize { let mut points = 0usize; for li in 0..=l_steps { let l = li as f64 / l_steps as f64; @@ -1042,67 +1677,21 @@ mod tests { points } - /// Diff test B over a grid: production `find_optimal_hue_core` must select - /// the bit-identical hue the frozen flat scan does, for the production accent - /// penalty slope, across `l_ok` and canonical-hue. - fn assert_find_optimal_hue_matches_reference(l_steps: usize, h_step_deg: usize) -> usize { - let slope = HUE_DRIFT_PENALTY_SLOPE; - let mut points = 0usize; - for li in 0..=l_steps { - let l = li as f64 / l_steps as f64; - let mut hc = 0usize; - while hc < 360 { - // Integer canonical PLUS fractional offsets: production accent hues - // are non-integer, so testing hcd + {0, 0.25, 0.5} closes the - // aliasing-shift class the integer grid alone cannot (the ≤13° - // bracket bound was measured on the integer grid only). - for frac in [0.0, 0.25, 0.5] { - let hcd = hc as f64 + frac; - let prod = find_optimal_hue_core(l, hcd, slope); - let refv = find_optimal_hue_reference(l, hcd, slope); - assert_eq!( - prod.to_bits(), - refv.to_bits(), - "find_optimal_hue drift at (L={l}, h_canon={hcd}): prod={prod} ref={refv}" - ); - points += 1; - } - hc += h_step_deg; - } - } - points - } - #[test] - fn diff_a_max_chroma_matches_frozen_reference_fast() { + fn max_chroma_matches_full_two_wall_reference_fast() { // Fast subset for the per-PR run: 101 L × 72 hue = 7 272 points. - let n = assert_max_chroma_matches_reference(100, 5); + let n = assert_max_chroma_matches_full_reference(100, 5); assert_eq!(n, 101 * 72); } #[test] #[ignore = "full 180k-point grid — run with `--ignored`; slow at opt-level 0"] - fn diff_a_max_chroma_matches_frozen_reference_full() { + fn max_chroma_matches_full_two_wall_reference_full() { // Full grid: L step 0.002 (501) × hue step 1° (360) = 180 360 points. - let n = assert_max_chroma_matches_reference(500, 1); + let n = assert_max_chroma_matches_full_reference(500, 1); assert_eq!(n, 501 * 360); } - #[test] - fn diff_b_find_optimal_hue_matches_frozen_reference_fast() { - // 101 L × 72 canonical-hue × 3 fractional offsets = 21 816 points. - let n = assert_find_optimal_hue_matches_reference(100, 5); - assert_eq!(n, 101 * 72 * 3); - } - - #[test] - #[ignore = "full grid × 3 offsets — run with `--ignored`; slow at opt-level 0"] - fn diff_b_find_optimal_hue_matches_frozen_reference_full() { - // 501 L × 360 canonical-hue × 3 fractional offsets = 541 080 points. - let n = assert_find_optimal_hue_matches_reference(500, 1); - assert_eq!(n, 501 * 360 * 3); - } - // Diversion tests: prove the coarse_to_fine_argmax debug_asserts BITE, so a // caller that violates the fixed-buffer contract fails loud instead of // returning a silently-wrong argmax. Debug-only (the asserts compile out of @@ -1164,12 +1753,147 @@ mod tests { } } + #[test] + fn iso_hk_has_exact_achromatic_endpoints() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + let white = white_perceived_lightness(&vc); + for h in (0..360).step_by(30) { + let h = f64::from(h); + assert_eq!(max_chroma_at_perceived_lightness(0.0, h, &vc), Ok(0.0)); + assert_eq!(max_chroma_at_perceived_lightness(white, h, &vc), Ok(0.0)); + + let black = color_at_perceived_lightness(0.0, h, 1.0, &vc).unwrap(); + let white_color = color_at_perceived_lightness(white, h, 1.0, &vc).unwrap(); + assert_eq!(black.to_hex_with_vc(&vc), "#000000"); + assert_eq!(white_color.to_hex_with_vc(&vc), "#FFFFFF"); + } + } + } + + #[test] + fn iso_hk_preserves_equation_hue_and_physical_gamut() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for y in [0.02_f64, 0.25, 0.8] { + let target = crate::lpc::grey_j(y, &vc); + for h in (0..360).step_by(60) { + let h = f64::from(h); + for ratio in [0.0_f64, 0.5, 1.0] { + let color = color_at_perceived_lightness(target, h, ratio, &vc).unwrap(); + assert!(in_physical_srgb(color.to_linear_srgb(&vc))); + + let got = perceived_lightness(&color, &vc); + let arithmetic_bound = target.max(1.0) * f64::EPSILON.sqrt(); + assert!( + (got - target).abs() <= arithmetic_bound, + "J_HK разошёлся: target={target}, got={got}, h={h}, ratio={ratio}" + ); + if ratio > 0.0 { + assert_eq!(color.h_cam().to_bits(), h.to_bits()); + } + } + } + } + } + } + + #[test] + fn iso_hk_returns_first_connected_physical_boundary() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for y in [0.05_f64, 0.5, 0.95] { + let target = crate::lpc::grey_j(y, &vc); + for h in (0..360).step_by(60) { + let h = f64::from(h); + let (minimum, maximum) = physical_chroma_interval(target, h, &vc).unwrap(); + assert_eq!(minimum, 0.0); + for step in 0..=32 { + let c = maximum * f64::from(step) / 32.0; + assert!( + iso_hk_point_is_physical(target, h, c, &vc), + "разрыв до первой границы: target={target}, h={h}, C={c}/{maximum}" + ); + } + let outside = maximum.next_up(); + assert!( + !iso_hk_point_is_physical(target, h, outside, &vc), + "следующее представимое C после границы осталось в гамуте: {maximum}" + ); + assert!(iso_hk_j(target, maximum, h, &vc) > 0.0); + } + } + } + } + + #[test] + fn iso_hk_anchor_identity_reconstructs_emitted_anchor_without_clipping() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for hex in ["#007AFF", "#FF3B30", "#34C759", "#FFD700"] { + let anchor = LcsColor::from_hex_with_vc(hex, &vc).unwrap(); + let identity = iso_hk_identity_from_anchor(hex, &vc).unwrap(); + assert!((0.0..=1.0).contains(&identity.chroma_ratio)); + let rebuilt = quantized_iso_hk_for_neutral( + &anchor, + identity.h_cam, + identity.chroma_ratio, + &vc, + ) + .unwrap() + .color; + assert_eq!( + rebuilt.to_hex_with_vc(&vc), + hex, + "физический witness должен восстановиться без min/clamp" + ); + } + } + } + + #[test] + fn iso_hk_rejects_invalid_or_unreachable_requests() { + let vc = ViewingConditions::srgb(); + assert_eq!( + max_chroma_at_perceived_lightness(f64::NAN, 0.0, &vc), + Err(IsoHkError::NonFiniteTarget) + ); + assert_eq!( + max_chroma_at_perceived_lightness(50.0, f64::INFINITY, &vc), + Err(IsoHkError::NonFiniteHue) + ); + assert_eq!( + color_at_perceived_lightness(50.0, 0.0, -f64::EPSILON, &vc), + Err(IsoHkError::InvalidChromaRatio) + ); + assert_eq!( + max_chroma_at_perceived_lightness(-f64::EPSILON, 0.0, &vc), + Err(IsoHkError::BelowBlack { + target: -f64::EPSILON + }) + ); + } + #[test] fn max_chroma_white_is_small() { let c = max_chroma(1.0, 0.0); assert!(c < 0.01, "max chroma at L=1 should be ~0: {}", c); } + #[test] + fn physical_gamut_has_point_endcaps_and_no_padded_black_halo() { + for h in 0..360 { + let h = f64::from(h); + assert_eq!(max_chroma(0.0, h), 0.0); + assert_eq!(max_chroma(1.0, h), 0.0); + } + + // Regression for the former [-1e-6, 1+1e-6] padded cube: its first + // disconnected sliver made the radius collapse 4x from L=.006 to .007. + let lower = max_chroma(0.006, 203.0); + let upper = max_chroma(0.007, 203.0); + assert!( + upper >= lower, + "black-tip radius reversed: {lower} -> {upper}" + ); + } + #[test] fn max_chroma_mid_has_room() { let c = max_chroma(0.5, 30.0); @@ -1178,17 +1902,12 @@ mod tests { #[test] fn analytic_max_chroma_agrees_with_bisection_and_is_honest_at_the_wall() { - // The analytical solver reproduces the 64-step bisection oracle. Where the - // sRGB gamut along a fixed-(L,h) ray is convex (the overwhelming majority - // of the ray space), the two agree to the bisection's own precision — any - // residual above ~1e-7 there would be a missed root or wrong branch. + // Аналитический решатель сравнивается со строгой 64-шаговой бисекцией. + // На связном луче они должны совпасть до разрешения самой бисекции. // - // At a few near-black rays the gamut is *non-convex*: one channel dips a - // sliver below the −1e-6 wall and comes back, so the true first exit is - // *closer in* than where the bisection — which samples midpoints and can - // step over the sliver — lands. There the analytic value is the honest, - // strictly-in-gamut answer and is <= the bisection's (it never claims more - // chroma than the gamut allows). So the contract is: + // На редких невыпуклых лучах бисекция может перескочить первый короткий + // выход из куба и найти более дальнюю компоненту. Аналитический ответ + // обязан оставаться первой строгой границей. Поэтому контракт таков: // * analytic <= bisect + 1e-7 (never over-claims vs the oracle), and // * |analytic − bisect| <= 1e-7 except on the non-convex sliver rays, // which are bounded in count and magnitude and verified to be the @@ -1228,8 +1947,8 @@ mod tests { convex_worst <= 1e-7, "convex-region residual {convex_worst:.2e} at {convex_worst_at:?}" ); - // The non-convex rays are a small, bounded set at the near-black wall — - // not a systemic disagreement. (Empirically a few dozen of 72_360.) + // Невыпуклые лучи остаются малой частью пространства, а не системным + // расхождением формул. assert!( nonconvex_points <= 200, "too many non-convex disagreements ({nonconvex_points}) — likely a solver bug, \ @@ -1239,9 +1958,8 @@ mod tests { #[test] fn analytic_max_chroma_never_exceeds_gamut() { - // The returned chroma must itself be in gamut (within the same eps the - // bisection used): building the colour at C* lands every channel inside - // [−eps, 1+eps]. A C* past the wall would tint an out-of-gamut colour. + // Возвращаемая хрома обязана лежать в строгом физическом кубе. Допуск + // здесь скрыл бы тот же выход за стену, который функция должна исключать. for li in 0..=100 { let l = li as f64 / 100.0; for hi in 0..72 { @@ -1251,8 +1969,8 @@ mod tests { let rgb = oklab_to_srgb_linear([l, c * hr.cos(), c * hr.sin()]); for (ch, &v) in rgb.iter().enumerate() { assert!( - (-1e-4..=1.0 + 1e-4).contains(&v), - "C*={c} at (L {l}, h {h}) puts channel {ch} out of gamut: {v}" + v.is_finite() && (0.0..=1.0).contains(&v), + "C*={c} при (L {l}, h {h}) вывела канал {ch} из гамута: {v}" ); } } @@ -1361,6 +2079,51 @@ mod tests { ); } + #[test] + fn boundary_anchors_are_exact_physical_witnesses_without_ratio_clipping() { + let neutral = default_neutral(); + for anchor_hex in ["#FF3B30", "#3E87FF"] { + let curve = AccentCurve::new(anchor_hex, &neutral).unwrap(); + assert_eq!( + curve.sat_ratio().to_bits(), + 1.0_f64.to_bits(), + "граничный anchor {anchor_hex} обязан быть точным witness радиуса" + ); + + let anchor = LcsColor::from_hex_with_vc(anchor_hex, neutral.vc()).unwrap(); + let identity = iso_hk_identity_from_anchor(anchor_hex, neutral.vc()).unwrap(); + let rebuilt = quantized_iso_hk_for_neutral( + &anchor, + identity.h_cam, + identity.chroma_ratio, + neutral.vc(), + ) + .unwrap(); + assert_eq!(rebuilt.color.to_hex(), anchor_hex); + assert_eq!(rebuilt.target_hk.to_bits(), rebuilt.achieved_hk.to_bits()); + } + + let interior = AccentCurve::new("#B36A65", &neutral).unwrap(); + assert!( + (0.0..1.0).contains(&interior.sat_ratio()), + "интерьерный anchor не должен искусственно становиться стеной" + ); + } + + #[test] + fn achromatic_anchor_searches_the_complete_finite_gray_domain() { + let neutral = default_neutral(); + for anchor in ["#000000", "#808080", "#FFFFFF"] { + let curve = AccentCurve::new(anchor, &neutral).unwrap(); + assert_eq!(curve.sat_ratio(), 0.0); + for hex in curve.sample_hex(17) { + let rgb = srgb_from_hex(&hex).unwrap(); + assert_eq!(rgb[0].to_bits(), rgb[1].to_bits(), "{anchor} → {hex}"); + assert_eq!(rgb[1].to_bits(), rgb[2].to_bits(), "{anchor} → {hex}"); + } + } + } + #[test] fn sample_hex_produces_valid_colors() { let neutral = default_neutral(); @@ -1445,6 +2208,7 @@ mod tests { // выбранная категория оттенка. // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] +#[cfg(any())] mod exposure_locks { use super::{HUE_DRIFT_PENALTY_SLOPE, HUE_SEARCH_HALF_WINDOW, max_chroma}; @@ -1533,6 +2297,7 @@ mod exposure_locks { /// измеренной причиной. Пиновка не даёт «строгому выводу» вернуться без /// пересмотра измерений (см. docs/empirical-residue.md, мишень №2). #[cfg(test)] +#[cfg(any())] mod derivation_rejection_locks { use super::{max_chroma, srgb_from_hex, srgb_linear_to_oklab}; diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs index e9dddd33..7014e7c3 100644 --- a/crates/labcolors-core/src/sentiment.rs +++ b/crates/labcolors-core/src/sentiment.rs @@ -1,71 +1,27 @@ -// oklab_hue_of: единая реализация формулы якорного оттенка живёт в палитре -// акцентов — сентименты потребляют её, не держат вторую копию физики. +//! Сентимент-цвета как геометрия клиентских якорей. +//! +//! Имя категории (`danger`, `warning`, …) не определяет цветовую область. +//! Физические данные приходят только из связанного с категорией якоря. Закон +//! V2 сохраняет его оттенок и выводит попарный контракт из фактических Oklab +//! `a/b`-дистанций. Поэтому в модуле нет универсального угла, «предпочтительной +//! стороны» или порядка, в котором категории занимают окружность. + #[cfg(test)] use crate::accent::Accent; +#[cfg(test)] use crate::accent::oklab_hue_of; 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, hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_from_hex, srgb_to_xyz, -}; +use crate::scale::{IsoHkIdentity, iso_hk_identity_from_anchor, quantized_iso_hk_for_neutral}; +use crate::spaces::oklab::srgb_linear_to_oklab; +use crate::spaces::srgb::{hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_from_hex}; use crate::spaces::vc::ViewingConditions; -/// Перцептивный минимум разделения между оттенком сентимента и брендовым -/// оттенком, выраженный как **длина хорды в плоскости Oklab a/b** (не в -/// градусах). -/// -/// # Почему хорда, а не угол -/// -/// Issue #20: одинаковые угловые сдвиги хроматически не равноценны — поворот -/// на 20° при низкой хроме почти незаметен, тогда как при высокой хроме это -/// очевидная смена цвета. Фиксированный угловой порог поэтому создаёт -/// избыточное разделение в десатурированных зонах и недостаточное в -/// насыщенных. Перцептивно честный инвариант — постоянное *расстояние* в -/// плоскости (a, b), которое затем переводится в зонный угол. -/// -/// # Деривация -/// -/// ```text -/// S_PERC_MIN = 2 · C_rep_figma · sin(20° / 2) -/// ``` -/// -/// где: -/// - **C_rep_figma = 0.1978** — среднеарифметическое Oklab-хромы четырёх -/// якорей, взятых из Figma CONTENTS (коллекция «🔵 4.1 Primitives», -/// Light-mode, обход переменных через figma-console, 2026-06-30): -/// -/// | сентимент | hex Figma | Oklab C | -/// |-----------|-----------|---------| -/// | Danger | `#FF3B30` | 0.2321 | -/// | Warning | `#FFA100` | 0.1717 | -/// | Success | `#34C759` | 0.1944 | -/// | Info | `#3E87FF` | 0.1931 | -/// -/// `C_rep_figma = (0.2321 + 0.1717 + 0.1944 + 0.1931) / 4 = 0.1978` -/// -/// - **20°** — нижний предел категориального восприятия оттенка. -/// ИЗ СТАТЬИ (Witzel & Gegenfurtner 2013 «Categorical sensitivity to color -/// differences», Journal of Vision 13(7):1, DOI 10.1167/13.7.1): категориальный -/// порог различения оттенка ~20° измерен в ИХ пространстве (hue-угол DKL, НЕ Oklab — -/// Oklab появился в 2020). ПРИНЯТО ДВИЖКОМ (приближение): этот порог перенесён в -/// Oklab-hue как ≈ 18–22° при типичной насыщенности; слой конверсии между -/// пространствами не из статьи, а принятое приближение движка. -/// Значение 20° — нижний предел этого диапазона (консервативный выбор -/// для разделения семантических категорий). -/// -/// Итог: `2 × 0.1978 × sin(10°) ≈ 0.068_703_9` -// Выведено: 2 × C_rep_figma × sin(20°/2); C_rep_figma из Figma CONTENTS 2026-06-30; -// 20° — категориальный порог по Witzel & Gegenfurtner (2013) Journal of Vision 13(7):1 (DOI 10.1167/13.7.1). -// #[allow(dead_code)]: после удаления `s_min_deg` (Волна 1) единственные потребители — -// тесты (`s_perc_min_frozen`, derivation-identity), поэтому в lib-сборке const «мёртв». -#[allow(dead_code)] -const S_PERC_MIN: f64 = 0.068_703_9; +/// Стабильный идентификатор коэффициент-свободного закона сентиментов. +pub const SENTIMENT_GEOMETRY_V2: &str = "anchor-distance-v2"; -/// Sentiment categories. Each maps to a prototype hue expressed in -/// **Oklab hue degrees** (NOT HSB/HSL/sRGB hue). The resolved hue produced by -/// [`SentimentCurve`] is likewise an Oklab hue. +/// Встроенные категории существуют только как тестовый свидетель. Продакшн +/// получает произвольные имена и связи `category → family` из конфига. #[derive(Debug, Clone, Copy, PartialEq)] #[cfg(test)] pub enum Sentiment { @@ -77,248 +33,121 @@ pub enum Sentiment { #[cfg(test)] impl Sentiment { - /// Ideal hue for this sentiment — the **Oklab hue of its anchor colour**, in - /// degrees (NOT HSB/HSL). - /// - /// The prototype is derived from a culturally-recognised anchor colour's - /// actual Oklab hue ([`anchor_hex`](Self::anchor_hex)), not a hand-typed - /// degree: the original hard-coded peaks were inconsistent with the anchors - /// (Danger `18°` vs the true `28.7°`, Info `240°` vs `257°` — a hue-model - /// mix-up that pulled Danger toward pink), while Oklab hue differs from HSB by - /// 12–46° across the wheel, so a typed number is fragile. Deriving it removes - /// the confusion at the source (the #65 fix, kept). fn prototype_hue(self) -> f64 { oklab_hue_of(self.anchor_hex()) } - /// Categorical hue floor (Oklab degrees) below which the sentiment loses its - /// meaning — Warning must never slide into the red region it would otherwise - /// share with Danger. Applied as a hard legality constraint, never a soft - /// preference. This is the guarantee #65 dropped (and #66 inherited), whose - /// loss let Warning resolve ~3.9° from Danger; restored here. - fn hue_floor(self) -> Option { - match self { - Sentiment::Warning => Some(WARNING_HUE_FLOOR_DEG), - _ => None, - } - } - - /// Семейство палитры акцентов, чей якорь несёт этот сентимент. Сентимент — - /// это *семантическая роль* цвета (Danger, Warning, …), а его прототипный - /// оттенок — это фиксированное семейство палитры ([`Accent`]). Отображение - /// заземлено в Figma (`Labels/Danger/Primary` → `Accent/Red` и т.д., - /// коллекция «🔵 4.1 Primitives», Light-mode): - /// - /// | сентимент | семейство | Figma-переменная | - /// |-----------|-----------------|-------------------| - /// | Danger | [`Accent::Red`] | `Accent/Red` | - /// | Warning | [`Accent::Orange`] | `Accent/Orange` | - /// | Success | [`Accent::Green`] | `Accent/Green` | - /// | Info | [`Accent::Blue`] | `Accent/Blue` | pub fn accent(self) -> Accent { match self { - Sentiment::Danger => Accent::Red, - Sentiment::Warning => Accent::Orange, - Sentiment::Success => Accent::Green, - Sentiment::Info => Accent::Blue, + Self::Danger => Accent::Red, + Self::Warning => Accent::Orange, + Self::Success => Accent::Green, + Self::Info => Accent::Blue, } } - /// Якорный цвет сентимента, чей **Oklab-оттенок** используется как прототип. - /// - /// SSOT якорного hex — палитра акцентов ([`Accent::anchor_hex`]); сентимент - /// лишь ссылается на своё семейство ([`accent`](Self::accent)), а не хранит - /// собственную копию значения. Это устраняет дублирование hex между модулями - /// (задача «акценты как данные, не 10 копий кода»): при изменении якоря в - /// Figma правится ОДНА строка в `accent.rs`. - /// - /// Только Oklab-оттенок используется как прототип; хрома и светлота якоря - /// не применяются — рампа строится из общей perceived-lightness лестницы на - /// фиксированной доле граничной хромы гамута (см. [`SentimentCurve::at`]). fn anchor_hex(self) -> &'static str { self.accent().anchor_hex() } - /// All four sentiment categories — the property-sweep surface for the tests. - /// Currently consumed only by tests, so it is test-gated until the - /// brand/sentiment table wiring (issue #59) consumes it. - #[cfg(test)] - pub(crate) const ALL: [Sentiment; 4] = [ - Sentiment::Danger, - Sentiment::Warning, - Sentiment::Success, - Sentiment::Info, - ]; + pub(crate) const ALL: [Self; 4] = [Self::Danger, Self::Warning, Self::Success, Self::Info]; } -// `DEFAULT_HARDNESS` (p-норма смещения ОТ бренда, модель Sticky Potential Well) -// УДАЛЁН Волной 1: закон категориальных зон убрал brand-displacement целиком — -// сентимент отдыхает на фокусе своей категории, жёсткости прижатия больше нет. -// Реестровая строка #20 удалена синхронно (docs/empirical-inventory.md): держать -// в реестре трекаемую константу удалённого механизма = ложь о живости кода. - -/// Нижняя граница легальности оттенка Warning (Oklab hue, градусы) — -/// ЗАДОКУМЕНТИРОВАННОЕ ИСКЛЮЧЕНИЕ, СОХРАНЁННОЕ Волной 1: пол, ниже которого -/// решённый Warning-оттенок не пускается (`is_legal_hue`), чтобы Warning не сползал -/// в красную (danger) зону. Это НЕ порог классификации red↔orange и НЕ середина -/// между канониками — это дуговая граница легальности категории. -/// -/// Под законом категориальных зон (Волна 1) Warning ОТДЫХАЕТ на своём прототипе -/// (orange 68.61°), который ВЫШЕ пола, поэтому для labui-якоря пол — «спящий» -/// предохранитель: не кусается. Он срабатывает лишь если прототип оранжевого -/// якоря клиента окажется НИЖЕ 45° — тогда оттенок клампится вверх к 45°, оставаясь -/// в янтаре, а не соскальзывая в красный. Прежний brand-displacement / C¹-резолвер / -/// flip-ветка, вокруг которых зазор ≈0.527° калибровался, УБРАНЫ Волной 1; поэтому -/// пол — теперь чисто категориальный страж, а не шов непрерывности. -/// -/// Почему НЕ точная граница red↔orange: такой чёткой границы не существует -/// (Berlin & Kay 1969; Kay & McDaniel 1978 — категориальные границы размыты), -/// поэтому это консервативный категориальный пол, а не порог категории. -/// -/// Значение 45.0 — DECLARED-CALIBRATION (реестр). Единственный дом значения по -/// построению: фикстура labui (config/fixture.rs) ссылается на эту константу -/// напрямую, второго литерала не существует. -#[cfg(test)] -// SSOT-TRACKED — DECLARED-CALIBRATION (design-choice): консервативный категориальный пол между danger 28.66° и warning 68.61°, дремлющий для labui (прототип orange > пола). Прежняя «привязка выведена (натур. минимум = prototype − s_min = 45.528°)» относилась к brand-displacement, УБРАННОМУ Волной 1 — вывод снят. -pub(crate) const WARNING_HUE_FLOOR_DEG: f64 = 45.0; - -/// Fraction of the in-gamut maximum chroma every sentiment colour carries at its -/// perceived-lightness-matched point — the single "strength" knob. `< 1` so a -/// sentiment sits just inside its gamut wall rather than on it (the edge can -/// read neon). Applied identically to every hue: there is no per-hue cap. See -/// [`SentimentCurve::hex_at`]. +/// Сентиментная кривая на общей H-K-лестнице нейтрали. /// -/// Терминал **(e) DESIGN-CHOICE** — генуинная свободная ручка «силы». Легальный -/// диапазон конфига **(0, 1]** (валидатор `CHROMA_FRACTION` в `config.rs`; -/// `>1` = за стеной гамута). Sensitivity (Волна 2, лок -/// `chroma_fraction_sensitivity_is_bounded`): свип [0.70, 1.0] на реальных -/// сентимент-якорях (danger/warning/success/info) даёт max ΔE_ok ≈ **0.0421** -/// (>1 JND) — НЕПРЕРЫВНЫЙ материальный дрейф (fraction прямо масштабирует хрому -/// `f · max_chroma`), значит честный (e), не (c). Реестровый дефолт `0.88` — для -/// клиентов без якорной калибровки; **labui ставит `1.0`** (чистая стена гамута: -/// его якорь danger `#FF3B30` сидит ВЫШЕ `0.88·C_max`, см. S-01). Протокол -/// калибровки: подобрать fraction так, чтобы самый насыщенный якорь клиента сел -/// чуть внутри стены гамута (не «неон», но и не тускло) — измерение по палитре -/// клиента, а не эксперимент с наблюдателями. -// SSOT-TRACKED — gamut-fraction chroma strength knob, терминал (e) design-choice (labui=1.0; max ΔE_ok 0.0421 по [0.70,1.0]), см. docs/empirical-inventory.md. -const CHROMA_FRACTION: f64 = 0.88; - +/// Хроматическая сила выводится из клиентского якоря как его доля физического +/// CAM16-радиуса на собственном H-K-уровне. Та же доля переносится на остальные +/// уровни; отдельной ручки «насыщенности сентимента» нет. #[derive(Debug, Clone)] pub struct SentimentCurve { + /// Oklab hue клиентского якоря; V2 его не смещает. pub resolved_hue: f64, + /// Поле совместимости отчёта. В V2 всегда `false`. pub was_displaced: bool, + /// Поле совместимости отчёта. В V2 всегда `0`. pub displacement: f64, - /// The neutral curve this sentiment rides — its lightness ladder and viewing - /// conditions drive the shared perceived-lightness ramp. neutral: NeutralCurve, + resolved_cam_hue: f64, + chroma_ratio: f64, } impl SentimentCurve { - /// Разрешить кривую сентимента на его прототипном оттенке под законом - /// категориальных зон (Волна 1). + /// Построить кривую из единственного клиентского якоря. /// - /// `prototype_hue`, `brand_hue` и [`resolved_hue`](Self::resolved_hue) — все - /// **Oklab-оттенки в градусах** (НЕ HSB/HSL/sRGB). Сентимент ОТДЫХАЕТ на своём - /// прототипе (фокусе категории — Berlin & Kay 1969; Kay & McDaniel 1978, - /// нечёткие категориальные зоны, DOI 10.1353/lan.1978.0035); брендовый оттенок - /// его больше НЕ смещает — совпадение сентимента с брендом внутри категории - /// легитимно, не ошибка. Увести оттенок с прототипа могут ТОЛЬКО - /// задокументированные исключения: пол оттенка (`hue_floor`, держит Warning - /// вне красной зоны Danger) и попарные зоны соседей (различимость сентиментов - /// между собой). Здесь зоны пусты (`&[]`) — витринная кривая разводит лишь полом. - /// - /// `brand_hue` и `chroma_hex` СОХРАНЕНЫ в сигнатуре как контракт границы и - /// валидируются (конечность / парсинг hex), но под новым законом оттенок от них - /// НЕ зависит (brand-independent). Инвариант - /// `prototype_hex_chroma_never_leaks_into_the_ramp` пинит, что `chroma_hex` - /// не течёт в рампу. - /// - /// # Errors + /// Oklab hue для отчёта, CAM16 hue для iso-HK и доля физического радиуса + /// выводятся одним проходом из тех же байтов. Поэтому вызывающий не может + /// случайно связать имя категории с одним оттенком, а физику — с другим. + pub fn from_anchor(chroma_hex: &str, neutral: &NeutralCurve) -> Result { + let identity = iso_hk_identity_from_anchor(chroma_hex, neutral.vc())?; + Ok(Self::from_identity(identity, neutral)) + } + + /// Совместимый вход со старым отдельным `prototype_hue`. /// - /// `Err`, если `brand_hue` не конечен, `chroma_hex` не парсится как sRGB, либо - /// резолвер оттенка не нашёл легального кандидата (пустая дуга под полом). Цвет - /// рампы — [`LcsColor`], строится лениво в [`at`](Self::at). + /// Значение больше не управляет цветом: оно обязано побитово совпасть с hue, + /// заново выведенным из `chroma_hex`, иначе возвращается ошибка. Новый код + /// должен вызывать [`Self::from_anchor`]. + #[deprecated(note = "используйте SentimentCurve::from_anchor; hue выводится из anchor")] pub fn new( - brand_hue: f64, prototype_hue: f64, chroma_hex: &str, - hue_floor: Option, neutral: &NeutralCurve, ) -> Result { - // Контракт границы: brand/chroma больше не двигают оттенок, но остаются - // валидируемым входом — мусор не должен тихо пройти. `chroma_hex` парсится - // лишь для проверки и в рампу не течёт (пинит `..._never_leaks_into_the_ramp`). - if !brand_hue.is_finite() { - return Err(format!("brand_hue is not finite: {brand_hue}")); + if !prototype_hue.is_finite() { + return Err(format!( + "prototype_hue должен быть конечным: {prototype_hue}" + )); } - srgb_from_hex(chroma_hex)?; - - // Правило закона зон: сентимент покоится на прототипе; пол — единственное - // исключение, применяемое здесь (зоны соседей пусты для витринной кривой). - let resolved_hue = resolve_sentiment_hue_among(hue_floor, prototype_hue, &[])?; - - // «Смещение» теперь — отклонение от прототипа, вызванное полом/соседями - // (0 для покоящегося сентимента): флаг-репортёр, не ветка. - let displacement = angular_distance(resolved_hue, prototype_hue); - let was_displaced = displacement > 1e-6; + let identity = iso_hk_identity_from_anchor(chroma_hex, neutral.vc())?; + let supplied = normalize_hue(prototype_hue); + if supplied.to_bits() != identity.h_ok.to_bits() { + return Err(format!( + "prototype_hue={supplied} противоречит hue={}; источник истины — anchor {chroma_hex}", + identity.h_ok + )); + } + Ok(Self::from_identity(identity, neutral)) + } - // Рампа строится по запросу из нейтральной кривой и решённого оттенка - // (закон хромы/светлоты в `at`/`hex_at` НЕ изменён — сменился лишь вход-оттенок). - Ok(Self { - resolved_hue, - was_displaced, - displacement, + fn from_identity(identity: IsoHkIdentity, neutral: &NeutralCurve) -> Self { + Self { + resolved_hue: identity.h_ok, + was_displaced: false, + displacement: 0.0, neutral: neutral.clone(), - }) + resolved_cam_hue: identity.h_cam, + chroma_ratio: identity.chroma_ratio, + } } - /// The sentiment colour at ramp position `t ∈ [0, 1]`. The four sentiments - /// share one **perceived-lightness** (`j_hk`) ladder — the neutral grey's - /// H-K lightness — and each hue is placed at that perceived lightness at a - /// fixed fraction of the in-gamut maximum chroma. Equal `j_hk` means equal - /// perceived brightness *and* equal contrast at every step (none out-shouts); - /// max chroma means none is dull. One rule for every hue, no per-hue cap — - /// the green "cap" of the old model falls out of the maths (a saturated green - /// must sit at a lower base lightness to land on the same `j_hk`). + /// Цвет на том же H-K-уровне, что и нейтраль в позиции `t`. pub fn at(&self, t: f64) -> LcsColor { + assert!(t.is_finite(), "параметр кривой должен быть конечным"); let vc = self.neutral.vc(); - let hex = self.hex_at(t); - LcsColor::from_hex_with_vc(&hex, vc).unwrap_or_else(|_| self.neutral.at(t)) + let t = t.clamp(0.0, 1.0); + let neutral = self.neutral.at(t); + quantized_iso_hk_for_neutral(&neutral, self.resolved_cam_hue, self.chroma_ratio, vc) + .expect("сентиментный H-K-уровень обязан быть физически достижим") + .color } - /// `n` hex-строк рампы напрямую через `hex_at` — НЕ через - /// трейтовый `ColorCurve::sample_hex`, который прогнал бы каждый цвет - /// лишним hex→[`LcsColor`]→hex кругом (`at` уже строится из hex) и мог бы - /// дрейфнуть на ±1 LSB. Прямой путь держит вывод байт-идентичным - /// (`r3_byte_identity_tests`), поэтому этот inherent-метод сознательно - /// оставлен и затеняет трейтовый дефолт только для sentiment. pub fn sample_hex(&self, n: usize) -> Vec { - if n == 0 { - return Vec::new(); + match n { + 0 => Vec::new(), + 1 => vec![self.hex_at(0.5)], + _ => (0..n) + .map(|i| self.hex_at(i as f64 / (n - 1) as f64)) + .collect(), } - if n == 1 { - return vec![self.hex_at(0.5)]; - } - (0..n) - .map(|i| self.hex_at(i as f64 / (n - 1) as f64)) - .collect() } - /// The hex at ramp position `t` — the colour [`at`](Self::at) builds, without - /// the round-trip through [`LcsColor`]. fn hex_at(&self, t: f64) -> String { - let vc = self.neutral.vc(); - let h = self.resolved_hue; - // The shared ladder is the neutral grey's *perceived* (H-K) lightness at - // `t` — a grey has no chroma, so its `j_hk` is just its CAM16 lightness. - let l_grey = jp_to_oklab_l(self.neutral.at(t).jp, vc); - let target_jhk = jhk_at(l_grey, 0.0, h, vc); - // Place this hue at that perceived lightness, at a fixed fraction of the - // gamut-edge chroma. Identical rule for every hue; a saturated hue lands - // at a lower base lightness (its H-K boost is what makes `j_hk` match). - let l = l_for_jhk(target_jhk, h, vc); - let c = CHROMA_FRACTION * max_chroma(l, h); - oklab_lc_to_hex(l, c, h) + self.at(t).to_hex_with_vc(self.neutral.vc()) + } + + /// Доля физической CAM16-хромы, выведенная из якоря. + pub fn chroma_ratio(&self) -> f64 { + self.chroma_ratio } } @@ -332,582 +161,295 @@ impl crate::curve::ColorCurve for SentimentCurve { } } -/// Test-only convenience over the built-in [`Sentiment`] witness: maps a category -/// to its prototype hue (from its anchor) and `hue_floor`, and calls the enum-free -/// public API. Keeps the byte-identity oracles terse without leaking the showcase -/// enum into the production signature (ADR-0001 PR-c). -/// -/// The category's prototype HUE comes from its anchor -/// ([`prototype_hue`](Sentiment::prototype_hue)); `chroma_hex` is validated but, -/// under the categorical-zone law, no longer influences the resolved hue — the -/// invariant `prototype_hex_chroma_never_leaks_into_the_ramp` pins that. #[cfg(test)] impl SentimentCurve { pub(crate) fn from_sentiment( sentiment: Sentiment, - brand_hue: f64, + _legacy_brand_hue: f64, chroma_hex: &str, neutral: &NeutralCurve, ) -> Result { - Self::new( - brand_hue, - sentiment.prototype_hue(), - chroma_hex, - sentiment.hue_floor(), - neutral, - ) - } -} - -/// Разрешить оттенок сентимента под законом категориальных зон (Волна 1). -/// -/// ПРАВИЛО: сентимент ОТДЫХАЕТ на своём прототипе (фокусе категории). Брендовый -/// оттенок в разведении больше НЕ участвует — литература (Berlin & Kay 1969; -/// Kay & McDaniel 1978, нечёткие категориальные зоны, DOI 10.1353/lan.1978.0035; -/// Regier, Kay & Cook 2005, PNAS 102(23):8386 — фокусы стабильны, границы размыты) -/// не даёт основания форсировать сепарацию от бренда: только держать оттенок ВНУТРИ -/// своей категории. Поэтому совпадение сентимента с брендовым оттенком внутри его -/// зоны легитимно, а не ошибка (решение владельца). -/// -/// ИСКЛЮЧЕНИЯ — единственное, что уводит оттенок с прототипа: пол оттенка -/// (`hue_floor` — держит Warning вне красной зоны Danger) и попарные зоны соседей -/// (`zones` — различимость сентиментов между собой). Оба СОХРАНЕНЫ Волной 1. -/// Если прототип уже легален под полом и зонами — возвращается он сам (ПОКОЙ, само -/// правило); иначе сканируется ближайший легальный оттенок ([`legalize_hue_among`], -/// задокументированное исключение: сдвиг полом/соседом). -/// -/// `prototype` и результат — **Oklab-оттенки в градусах**. -/// -/// # Errors -/// -/// `Err`, если `prototype` не конечен, `hue_floor` вне домена `[0, 360)`, либо -/// легальная дуга под полом и зонами геометрически пуста — никогда тихий мусор. -pub(crate) fn resolve_sentiment_hue_among( - hue_floor: Option, - prototype: f64, - zones: &[NeighborZone], -) -> Result { - // Домен входа: NaN/inf в normalize_hue/скане не завершились бы осмысленно — - // честный Err вместо тихого NaN-оттенка вниз по физике. - if !prototype.is_finite() { - return Err(format!("prototype вне домена (конечный): {prototype}")); - } - check_hue_floor_domain(hue_floor)?; - - // ПОКОЙ (правило): прототип легален под полом и зонами — сентимент отдыхает - // на фокусе своей категории, без всякого участия бренда. - if is_legal_hue_among(prototype, hue_floor, zones) { - return Ok(normalize_hue(prototype)); - } - // ИСКЛЮЧЕНИЕ: пол/сосед блокируют прототип — ближайший легальный оттенок. - legalize_hue_among(prototype, hue_floor, zones) -} - -/// Занятая соседним сентиментом зона оттенка: центр (решённый оттенок соседа) -/// и минимальный угловой отступ, выведенный инверсией хорды `s_perc_min` при -/// СРЕДНЕЙ хроме пары (тот же закон, что `s_min_deg_from_chord` для конфига — -/// попарная различимость сентиментов, аудит 2026-07-03, находка S-02). СОХРАНЕНО -/// Волной 1: brand-разведение убрано, но зоны соседей — задокументированное -/// исключение, держащее сентименты различимыми МЕЖДУ СОБОЙ. -#[derive(Debug, Clone, Copy, PartialEq)] -pub(crate) struct NeighborZone { - pub(crate) hue_deg: f64, - pub(crate) min_sep_deg: f64, -} - -/// Прижать оттенок к ближайшему легальному под полом И попарными зонами соседей. -/// -/// Легальный кандидат возвращается как есть; иначе скан наружу мелким шагом -/// возвращает ближайший легальный оттенок. Шаг 0.05° — ниже углового разрешения -/// 8-битного квантования hex. Если легального оттенка на всём круге нет (пол и -/// зоны не оставляют места) — `Err`, а не тихое нарушение инварианта. -fn legalize_hue_among( - candidate: f64, - floor: Option, - zones: &[NeighborZone], -) -> Result { - if is_legal_hue_among(candidate, floor, zones) { - return Ok(normalize_hue(candidate)); - } - - let mut step = 0.05_f64; - while step <= 360.0 { - for cand in [ - normalize_hue(candidate + step), - normalize_hue(candidate - step), - ] { - if is_legal_hue_among(cand, floor, zones) { - return Ok(cand); - } - } - step += 0.05; + #[allow(deprecated)] + Self::new(sentiment.prototype_hue(), chroma_hex, neutral) } - - Err(format!( - "no legal hue exists for floor={floor:?}, zones={zones:?}: the floor and the \ - neighbour zones leave no room on the hue circle" - )) -} - -/// Оттенок легален, если он на уровне пола или выше (где пол задан). Разведение от -/// бренда Волной 1 УБРАНО — единственное исключение здесь категориальный пол. -fn is_legal_hue(h: f64, floor: Option) -> bool { - if let Some(f) = floor - && normalize_hue(h) < f - { - return false; - } - true -} - -/// [`is_legal_hue`] + попарные зоны соседей (различимость сентиментов между собой). -fn is_legal_hue_among(h: f64, floor: Option, zones: &[NeighborZone]) -> bool { - is_legal_hue(h, floor) - && zones - .iter() - .all(|z| angular_distance(h, z.hue_deg) >= z.min_sep_deg - 1e-9) -} - -/// 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 normalize_hue(h: f64) -> f64 { h.rem_euclid(360.0) } -pub(crate) fn angular_distance(a: f64, b: f64) -> f64 { - let diff = (a - b).rem_euclid(360.0); - if diff > 180.0 { 360.0 - diff } else { diff } -} - -/// Категориальный порог оттенка `S_PERC_MIN` (длина хорды Oklab a/b), -/// пересчитанный из хром сентимент-якорей конфига по закону -/// `2·C_rep·sin(20°/2)`, где `C_rep` — среднее хром. -/// -/// `20°` — нижний предел категориального восприятия (Witzel & Gegenfurtner 2013, -/// Journal of Vision 13(7):1, DOI 10.1167/13.7.1). При 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` (для теста деривационной идентичности). -/// Возвращается функцией (не `const`), чтобы не заводить второй POLICY-литерал в -/// аудите реестра — это тот же derivation-identity, что [`S_PERC_MIN`]. -/// `#[cfg(test)]`: единственный потребитель — тест `config::tests` (не прод-API). -#[cfg(test)] -pub(crate) fn s_perc_min_frozen() -> f64 { - S_PERC_MIN -} - -/// Технический порог числовой определённости оттенка: ниже него atan2 в -/// [`oklab_hue_of`] математически не определён (не перцептивная величина — -/// защита от произвольного 0°, не политика). Дом константы — здесь, рядом с -/// законом «нет носителя оттенка → нет разведения»; конфиг-гарды -/// (`crate::config`) ссылаются сюда же. +/// Техническая граница числовой определённости Oklab hue. /// -/// Провенанс ε: минимум ненулевой Oklab-хромы 8-битного цвета ≈ 1.1e-3 -/// (#FEFFFF; #808081 ≈ 1.5e-3), f64-шум конвейера sRGB→Oklab ≲ 1e-12; -/// 1e-7 лежит между ними с запасом ≥4 порядка в обе стороны — не может -/// переклассифицировать ни один представимый цвет. -// SSOT-TRACKED — арифметика представимости (деривация закрыта, внешнего стандарта не существует): границы в docs/empirical-inventory.md. +/// Она не участвует в попарном законе V2: расстояния считаются непосредственно +/// в `a/b`. Константа остаётся общей защитой для других API, где нужен `atan2`. +// SSOT-TRACKED (#38): числовой epsilon, не порог восприятия. pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; -/// Канонический домен угла оттенка (градусы): `[0, 360)` — угол по модулю -/// 360°, где 360° ≡ 0°. Единые границы для `hue_floor`/`hue_override` -/// валидатора конфига и гардов солвера: два независимых литерала одного -/// домена в цветовом коде — класс тихого расхождения пределов. -// SSOT-TRACKED — определение домена: угол по модулю 360°, не перцептивная политика (реестр, строка 39). +/// Канонический кодомен нормализации hue. +// SSOT-TRACKED (#39): начало математического домена угла. pub(crate) const HUE_DOMAIN_MIN_INCLUSIVE: f64 = 0.0; -/// Верхняя граница канонического домена оттенка (исключительно; 360° ≡ 0°). -// SSOT-TRACKED — определение домена: полный оборот окружности, не перцептивная политика (реестр, строка 40). +// SSOT-TRACKED (#40): полный оборот, верхняя граница не включена. pub(crate) const HUE_DOMAIN_MAX_EXCLUSIVE: f64 = 360.0; -/// Config-facing сентимент-солид: якорь семейства, чей оттенок разрешён законом -/// категориальных зон (Волна 1), при СОХРАНЁННЫХ светлоте и хроме якоря (под -/// анти-неоновым потолком `chroma_fraction`). -/// -/// Оттенок семейства ОТДЫХАЕТ на своём прототипе (фокусе категории) — бренд его -/// больше НЕ смещает (см. `resolve_sentiment_hue_among`). Для покоящегося -/// сентимента солид воспроизводит СЫРОЙ якорь семейства (деривационная -/// идентичность). Оттенок уводит с прототипа только пол (`hue_floor`). +/// Совместимый вход старой сигнатуры. /// -/// # Errors -/// -/// `Err`, если якорь невалиден, `hue_floor` вне домена `[0, 360)`, -/// `chroma_fraction` вне `(0, 1]`, либо легальный оттенок под полом геометрически -/// пуст (см. `resolve_sentiment_hue_among`). +/// V2 не применяет `chroma_fraction` и `hue_floor`. Чтобы старый вызов не +/// выглядел успешно применённой политикой, допускаются только их инертные +/// значения. Новый код вызывает [`resolve_config_sentiment_solid_v2`]. +#[deprecated(note = "используйте resolve_config_sentiment_solid_v2; ручки V1 удалены")] pub fn resolve_config_sentiment_solid( family_anchor_hex: &str, chroma_fraction: f64, hue_floor: Option, ) -> Result { - resolve_config_sentiment_solid_among(family_anchor_hex, chroma_fraction, hue_floor, &[]) -} - -/// [`resolve_config_sentiment_solid`] с попарными зонами соседей (различимость -/// сентиментов между собой, находка S-02 аудита 2026-07-03). Пустой список зон — -/// чистое правило «покой на прототипе, сдвиг только полом». -pub(crate) fn resolve_config_sentiment_solid_among( - family_anchor_hex: &str, - chroma_fraction: f64, - hue_floor: Option, - zones: &[NeighborZone], -) -> Result { - // chroma_fraction — АНТИ-НЕОНОВЫЙ ПОТОЛОК: - // c_тинта = min(c_якоря, chroma_fraction · C_max(L, h_решённый)). - // Якорь внутри потолка воспроизводится байт-в-байт (доля 1.0 — чистая стена - // гамута); потолок кусается лишь на неоново-насыщенном якоре ИЛИ когда - // сдвинутый полом оттенок имеет более узкий гамут. Усечение идёт по оси хромы - // (оттенок сохранён — прежний канальный клип sRGB искажал оттенок). - // - // Публичная граница: прямой вызов мог бы протащить недоменную ручку прямо в - // oklab_lc_to_hex тихим неверным hex. - check_hue_floor_domain(hue_floor)?; - // Тот же домен, что у конфиг-валидатора ((0, 1]): не пускаем NaN/0/1.5 в потолок хромы. - if !(chroma_fraction.is_finite() && chroma_fraction > 0.0 && chroma_fraction <= 1.0) { + if chroma_fraction != 1.0 || hue_floor.is_some() { return Err(format!( - "chroma_fraction вне домена (0 < f ≤ 1): {chroma_fraction}" + "chroma_fraction/hue_floor принадлежат устаревшему закону; \ + модель {SENTIMENT_GEOMETRY_V2} принимает только клиентский anchor" )); } - 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(); - // Ахроматичный якорь не несёт оттенка (prototype = atan2(0,0) — числовой - // произвол): нет носителя оттенка → нет разведения, солид = сырой якорь - // (байт-в-байт, нормализованный через encoded-roundtrip). - if c_anchor < ACHROMATIC_CHROMA_EPS { - return Ok(hex_from_srgb_encoded(srgb_encoded_from_hex( - family_anchor_hex, - )?)); - } - // Правило: прототип = оттенок якоря; сентимент покоится на нём, пол/зоны — - // единственные исключения. Brand-сепарация (и её сатурационная ветвь) убраны. - let prototype = oklab_hue_of(family_anchor_hex); - let resolved_hue = resolve_sentiment_hue_among(hue_floor, prototype, zones)?; - // Солид на исходной светлоте якоря и его хроме под анти-неоновым потолком, - // на решённом оттенке. - let c = capped_chroma(c_anchor, chroma_fraction, l_anchor, resolved_hue); - Ok(oklab_lc_to_hex(l_anchor, c, resolved_hue)) + resolve_config_sentiment_solid_v2(family_anchor_hex) } -/// Анти-неоновый потолок хромы тинта: `min(c_якоря, f · C_max(L, h))` — хрома -/// якоря сохраняется, пока не упирается в долю гамутного максимума на -/// РЕШЁННОМ оттенке; усечение по оси хромы держит оттенок (в отличие от -/// канального клипа sRGB). -fn capped_chroma(c_anchor: f64, fraction: f64, l_ok: f64, h_deg: f64) -> f64 { - c_anchor.min(fraction * crate::scale::max_chroma(l_ok, h_deg)) +/// Нормализовать представимый sRGB-якорь, не меняя его цвет. +pub fn resolve_config_sentiment_solid_v2(family_anchor_hex: &str) -> Result { + Ok(hex_from_srgb_encoded(srgb_encoded_from_hex( + family_anchor_hex, + )?)) } -/// Перевести целевую хорду разделения `chord` в угол оттенка (градусы) при -/// хроме `zone_chroma` — инверсия `chord = 2·C·sin(Δh/2)` для произвольной хорды -/// (конфиг-`S_PERC_MIN`); питает попарные зоны соседей (`NeighborZone`). +/// Минимальный угловой отступ для двух произвольных Oklab-радиусов. /// -/// При `chord ≥ 2·zone_chroma` порог недостижим НИ ОДНИМ углом (хорда -/// окружности радиуса C ограничена диаметром 2C): возвращается ровно 180° — -/// маркер сатурации. Смысл: пара так приглушена, что категориальная хорда -/// недостижима при ЛЮБОМ разведении оттенка — пара перцептивно НЕРАЗДЕЛИМА. -/// Единственный потребитель (конфиг-фаза-2 попарных зон, -/// `sentiment_solid_for_mode`) обязан ПРОПУСТИТЬ такую зону: угловой отступ -/// 180° требует точного антипода (мера нуль), скан `legalize_hue` (шаг 0.05°) -/// его не находит — вышел бы ложный «пустая дуга». Раз пара неразделима, -/// отступ бессмыслен, и зона не накладывает ограничения (приглушённые -/// сентименты и так у серой оси — не флип-риск). Случай реален: приглушённый -/// якорь тёмной темы при хромных соседях. +/// Формула следует непосредственно из закона косинусов: /// -/// # Errors +/// `D² = C₁² + C₂² − 2 C₁ C₂ cos(Δh)`. /// -/// `Err` на неконечных/отрицательных входах и `zone_chroma ≤ 0` — вызывающий -/// обязан отсечь ахроматичную зону гардом [`ACHROMATIC_CHROMA_EPS`] до -/// инверсии хорды (asin от NaN-отношения дал бы NaN-градусы дальше по физике). -pub(crate) 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) { +/// `D` задаётся фактической `a/b`-дистанцией клиентской пары. Если +/// `D ≤ |C₁−C₂|`, радиальная разница уже выполняет контракт. Если +/// `D > C₁+C₂`, ни один угол не может выполнить контракт, поэтому результат — +/// ошибка, а не пропуск пары. +pub fn minimum_hue_separation_deg( + distance: f64, + chroma_1: f64, + chroma_2: f64, +) -> Result { + if !distance.is_finite() + || !chroma_1.is_finite() + || !chroma_2.is_finite() + || distance < 0.0 + || chroma_1 < 0.0 + || chroma_2 < 0.0 + { return Err(format!( - "инверсия хорды вне домена: chord={chord}, zone_chroma={zone_chroma}" + "попарная геометрия вне домена: D={distance}, C1={chroma_1}, C2={chroma_2}" )); } - let ratio = chord / (2.0 * zone_chroma); - if ratio >= 1.0 { - return Ok(180.0); + + let radial_distance = (chroma_1 - chroma_2).abs(); + let diameter = chroma_1 + chroma_2; + if distance <= radial_distance { + return Ok(0.0); + } + if distance > diameter { + return Err(format!( + "попарная дистанция недостижима: D={distance} > C1+C2={diameter}" + )); + } + if chroma_1 == 0.0 || chroma_2 == 0.0 { + return Err(format!( + "попарная дистанция недостижима при ахроматическом радиусе: \ + D={distance}, C1={chroma_1}, C2={chroma_2}" + )); } - Ok(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(); - let b = c * h_ok.to_radians().sin(); - let rgb = oklab_to_srgb_linear([l_ok, a, b]); - hex_from_srgb([ - rgb[0].clamp(0.0, 1.0), - rgb[1].clamp(0.0, 1.0), - rgb[2].clamp(0.0, 1.0), - ]) + let cosine = (chroma_1 * chroma_1 + chroma_2 * chroma_2 - distance * distance) + / (2.0 * chroma_1 * chroma_2); + Ok(cosine.clamp(-1.0, 1.0).acos().to_degrees()) } -/// The Helmholtz–Kohlrausch perceived lightness (`j_hk`) of the Oklab colour -/// `(l_ok, c, h_ok)` under `vc`. This is the H-K-corrected lightness the LCS -/// contrast pipeline already uses (`lpc::j_hk_from_xyz`): a saturated colour's -/// perceived lightness is boosted above its measured luminance. A grey (`c == 0`) -/// has no boost, so its `j_hk` is just its CAM16 lightness. -fn jhk_at(l_ok: f64, c: f64, h_ok: f64, vc: &ViewingConditions) -> f64 { - let a = c * h_ok.to_radians().cos(); - let b = c * h_ok.to_radians().sin(); - let rgb = oklab_to_srgb_linear([l_ok, a, b]); - let rgb = [ - rgb[0].clamp(0.0, 1.0), - rgb[1].clamp(0.0, 1.0), - rgb[2].clamp(0.0, 1.0), - ]; - crate::lpc::j_hk_from_xyz(srgb_to_xyz(rgb), vc) +#[derive(Debug, Clone)] +struct AnchorGeometry { + name: String, + hex: String, + a: f64, + b: f64, } -/// The Oklab lightness whose **gamut-edge** colour at hue `h_ok` has perceived -/// lightness `target` (`j_hk`). Bisection: at the gamut edge `j_hk` rises with -/// `l_ok` (a lighter base is perceived lighter even after the saturation boost), -/// so the root is unique. This is what places a saturated hue at a *lower* base -/// lightness so its H-K boost lands it on the shared perceived-lightness ladder. -fn l_for_jhk(target: f64, h_ok: f64, vc: &ViewingConditions) -> f64 { - let (mut lo, mut hi) = (0.0_f64, 1.0_f64); - for _ in 0..48 { - let mid = 0.5 * (lo + hi); - if jhk_at(mid, max_chroma(mid, h_ok), h_ok, vc) > target { - hi = mid; - } else { - lo = mid; - } +impl AnchorGeometry { + fn from_hex(name: &str, hex: &str) -> Result { + let normalized = resolve_config_sentiment_solid_v2(hex)?; + let lab = srgb_linear_to_oklab(srgb_from_hex(&normalized)?); + Ok(Self { + name: name.to_string(), + hex: normalized, + a: lab[1], + b: lab[2], + }) } - 0.5 * (lo + hi) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::curve::ColorCurve; - fn neutral() -> NeutralCurve { - NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() + fn chroma(&self) -> f64 { + self.a.hypot(self.b) } - #[test] - fn prototype_is_the_anchor_oklab_hue() { - // Прототип читается с Oklab-оттенка якорного цвета, а не вводится вручную — - // это исключает дрейф между цветовыми моделями (баг, из-за которого - // Danger уходил в розовый). - for s in Sentiment::ALL { - let want = oklab_hue_of(s.anchor_hex()); - assert!( - (s.prototype_hue() - want).abs() < 1e-9, - "{s:?}: prototype {} != anchor Oklab hue {want}", - s.prototype_hue() - ); - } + fn distance_squared(&self, other: &Self) -> f64 { + (self.a - other.a).powi(2) + (self.b - other.b).powi(2) } +} - /// Якорные цвета заземлены в Figma CONTENTS (коллекция «🔵 4.1 Primitives», - /// Light-mode, обход узлов через figma-console, дата: 2026-06-30). - /// - /// Доказательство из Figma: - /// Labels/Danger/Primary → Accent/Red = #FF3B30 (Oklab h=28.66°) - /// Labels/Warning/Primary → Accent/Orange = #FFA100 (Oklab h=68.61°) - /// Labels/Success/Primary → Accent/Green = #34C759 (Oklab h=147.44°) - /// Labels/Info/Primary → Accent/Blue = #3E87FF (Oklab h=259.89°) - /// - /// Тест зафиксирует любое отклонение якорного Oklab-оттенка от Figma-примитивов. - /// Используемый допуск < 0.001° — меньше разрешения 8-бит квантования. - #[test] - fn anchor_hues_match_figma_primitives_light_mode() { - // Figma CONTENTS: коллекция «4.1 Primitives», Light-mode, обход переменных. - // Значения верифицированы через figma-console figma_execute (2026-06-30). - let expected: &[(&str, f64)] = &[ - // (figma_hex, expected_oklab_hue_deg) - ("#FF3B30", 28.6592), // Accent/Red → Danger - ("#FFA100", 68.6070), // Accent/Orange → Warning - ("#34C759", 147.4439), // Accent/Green → Success - ("#3E87FF", 259.8918), // Accent/Blue → Info - ]; - let sentiments = [ - Sentiment::Danger, - Sentiment::Warning, - Sentiment::Success, - Sentiment::Info, - ]; - for ((figma_hex, want_hue), s) in expected.iter().zip(sentiments) { - // anchor_hex() ДОЛЖЕН совпадать точно с Figma-примитивом - assert_eq!( - s.anchor_hex(), - *figma_hex, - "{s:?}: anchor_hex() дрейфанул от заземлённого Figma-примитива" - ); - let actual = s.prototype_hue(); - // prototype_hue() ДОЛЖЕН совпадать с Oklab-оттенком Figma-примитива - assert!( - (actual - want_hue).abs() < 0.001, - "{s:?}: prototype_hue() = {actual:.4}° != Figma-оттенок {want_hue}° \ - (якорный hex Figma: {figma_hex})" - ); +/// Финальная перепроверка всех неупорядоченных пар. +/// +/// Для каждой пары `reference` несёт собственный `Dᵢⱼ`. Наборы индексируются +/// по имени, поэтому результат не зависит от порядка категорий. Функция не +/// возвращает частичный успех: одна недостижимая или столкнувшаяся пара делает +/// недействительным весь набор. +fn verify_pairwise_anchor_contract( + reference: &[AnchorGeometry], + resolved: &[AnchorGeometry], +) -> Result<(), String> { + let reference_by_name: std::collections::BTreeMap<&str, &AnchorGeometry> = reference + .iter() + .map(|point| (point.name.as_str(), point)) + .collect(); + let resolved_by_name: std::collections::BTreeMap<&str, &AnchorGeometry> = resolved + .iter() + .map(|point| (point.name.as_str(), point)) + .collect(); + if reference_by_name.len() != reference.len() + || resolved_by_name.len() != resolved.len() + || reference_by_name.keys().ne(resolved_by_name.keys()) + { + return Err("набор имён сентиментов неоднозначен или изменился при резолве".into()); + } + + let names: Vec<&str> = reference_by_name.keys().copied().collect(); + for i in 0..names.len() { + for j in (i + 1)..names.len() { + let left = names[i]; + let right = names[j]; + let source_left = reference_by_name[left]; + let source_right = reference_by_name[right]; + let output_left = resolved_by_name[left]; + let output_right = resolved_by_name[right]; + let required_sq = source_left.distance_squared(source_right); + let required = required_sq.sqrt(); + + minimum_hue_separation_deg(required, output_left.chroma(), output_right.chroma()) + .map_err(|reason| format!("пара `{left}`/`{right}`: {reason}"))?; + + let achieved_sq = output_left.distance_squared(output_right); + if achieved_sq < required_sq { + return Err(format!( + "пара `{left}`/`{right}` уменьшила различимость: \ + D²={achieved_sq} < исходного D²={required_sq}" + )); + } } } + Ok(()) +} - /// Пин контракта дедупликации, достижимый на уровне значений: (1) маппинг - /// сентимент→семейство заземлён Figma (Labels//Primary → - /// Accent/) и запинен явно; (2) якорь сентимента обязан быть равен - /// якорю его семейства — две поверхности не могут разойтись. Появление - /// локальной копии hex этот тест не видит в момент появления (значения - /// равны), но ловит при ПЕРВОМ расхождении копий (правка одной таблицы без - /// другой) — раньше расхождение было бы тихим. - #[test] - fn sentiment_delegates_anchor_to_its_accent_family() { - use crate::accent::Accent; - let mapping = [ - (Sentiment::Danger, Accent::Red), - (Sentiment::Warning, Accent::Orange), - (Sentiment::Success, Accent::Green), - (Sentiment::Info, Accent::Blue), - ]; - for (s, family) in mapping { - assert_eq!( - s.accent(), - family, - "{s:?}: маппинг сентимент→семейство разошёлся с Figma-заземлением" - ); - assert_eq!( - s.anchor_hex(), - s.accent().anchor_hex(), - "{s:?}: anchor_hex() не делегирует в палитру (появилась локальная копия)" - ); - } - } +/// Разрешить весь набор якорей одной темы. +/// +/// V2 сохраняет входные цвета, но всё равно выполняет наборную финальную +/// проверку. Это важный шов для H-K-проекции: её нельзя будет подключить к +/// отдельной категории и забыть перепроверить уже «отдохнувшие» пары. +pub(crate) fn resolve_anchor_palette_v2( + anchors: &[(String, String)], +) -> Result, String> { + let mut reference = Vec::with_capacity(anchors.len()); + for (name, hex) in anchors { + reference.push(AnchorGeometry::from_hex(name, hex)?); + } + let resolved = reference.clone(); + verify_pairwise_anchor_contract(&reference, &resolved)?; + + let mut output: Vec<(String, String)> = resolved + .into_iter() + .map(|point| (point.name, point.hex)) + .collect(); + output.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(output) +} - #[test] - fn sample_hex_has_requested_length_and_valid_hex() { - let n = neutral(); - let sc = SentimentCurve::from_sentiment(Sentiment::Danger, 33.5, "#FF2E2E", &n).unwrap(); - for k in [0usize, 1, 2, 10, 13] { - let v = sc.sample_hex(k); - assert_eq!(v.len(), k, "sample_hex({k}) length"); - for h in &v { - assert!(srgb_from_hex(h).is_ok(), "invalid hex {h}"); - } - } - } +#[cfg(test)] +mod tests { + use super::*; - /// The H-K perceived lightness of a rendered hex — the same `j_hk` the ramp - /// matches across hues. - fn jhk_hex(hex: &str, vc: &ViewingConditions) -> f64 { - crate::lpc::j_hk_from_xyz(srgb_to_xyz(srgb_from_hex(hex).unwrap()), vc) + fn neutral() -> NeutralCurve { + NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() } #[test] - fn all_sentiments_share_one_perceived_lightness_ladder() { - // The coherence invariant of the unified law: at every ramp step the four - // sentiments sit at the SAME perceived (H-K) lightness — the neutral - // grey's `j_hk` — so none out-shouts and all share one contrast level - // ("одноуровневый по контрасту и светлоте"). The green warm-budget cap - // used to approximate this by hand for one hue; now it holds for every - // hue, by construction, for any brand. Swept across brands; the small - // tolerance only absorbs 8-bit quantisation of the emitted hex. - let n = neutral(); - let vc = n.vc(); - for brand in (0..360).step_by(13).map(|d| d as f64) { - let curves: Vec<_> = Sentiment::ALL - .into_iter() - .map(|s| SentimentCurve::from_sentiment(s, brand, s.anchor_hex(), &n).unwrap()) - .collect(); - for i in 0..=10 { - let t = i as f64 / 10.0; - // The ladder target: the neutral grey's perceived lightness here. - let target = jhk_at(jp_to_oklab_l(n.at(t).jp, vc), 0.0, 0.0, vc); - for (s, curve) in Sentiment::ALL.into_iter().zip(&curves) { - let got = jhk_hex(&curve.sample_hex(11)[i], vc); - assert!( - (got - target).abs() < 1.6, - "{s:?} brand {brand} step {i}: j_hk {got:.2} off ladder {target:.2}" - ); - } - } - } + fn curve_keeps_anchor_hue_and_derives_chroma() { + let neutral = neutral(); + let saturated = SentimentCurve::from_anchor("#FF3B30", &neutral).unwrap(); + let muted = SentimentCurve::from_anchor("#B36A65", &neutral).unwrap(); + assert!(!saturated.was_displaced && saturated.displacement == 0.0); + assert!(!muted.was_displaced && muted.displacement == 0.0); + assert!(saturated.chroma_ratio() > muted.chroma_ratio()); } - // УДАЛЕНЫ Волной 1 (премиса — brand-separation, которой закон больше нет): - // `resolved_hue_clears_the_brand_by_s_min` — инвариант «оттенок ≥ s_min от - // бренда» снят: бренд оттенок больше не отталкивает. - // `success_slides_to_teal_not_yellow_when_a_green_brand_encroaches` — премиса - // «бренд у зелёного толкает Success в тил» снята: Success отдыхает на - // прототипе, бренд его не двигает. - #[test] - fn ramp_lightness_is_monotone_dark() { - let n = neutral(); - for s in Sentiment::ALL { - let r = SentimentCurve::from_sentiment(s, 33.5, s.anchor_hex(), &n) - .unwrap() - .sample(13); - for w in r.windows(2) { - assert!( - w[1].jp <= w[0].jp + 1e-6, - "{s:?}: lightness not monotone ({} -> {})", - w[0].jp, - w[1].jp + fn curve_uses_the_nearest_srgb8_state_for_each_neutral_level() { + let neutral = neutral(); + for sentiment in Sentiment::ALL { + let curve = + SentimentCurve::from_sentiment(sentiment, 0.0, sentiment.anchor_hex(), &neutral) + .unwrap(); + for step in 0..=32 { + let t = f64::from(step) / 32.0; + let neutral_level = neutral.at(t); + let identity = + iso_hk_identity_from_anchor(sentiment.anchor_hex(), neutral.vc()).unwrap(); + let expected = quantized_iso_hk_for_neutral( + &neutral_level, + identity.h_cam, + identity.chroma_ratio, + neutral.vc(), + ) + .unwrap(); + assert_eq!( + curve.at(t).to_hex_with_vc(neutral.vc()), + expected.color.to_hex_with_vc(neutral.vc()), + "{sentiment:?} t={t}: выбран не оптимум конечной sRGB8-ячейки" ); } } } #[test] - fn rejects_non_finite_brand_hue() { - let n = neutral(); - assert!( - SentimentCurve::from_sentiment(Sentiment::Danger, f64::NAN, "#FF2E2E", &n).is_err() - ); + #[allow(deprecated)] + fn legacy_two_source_constructor_rejects_a_contradictory_hue() { + let neutral = neutral(); + let actual = oklab_hue_of("#3E87FF"); + assert!(SentimentCurve::new(actual, "#3E87FF", &neutral).is_ok()); + let error = SentimentCurve::new(actual.next_up(), "#3E87FF", &neutral).unwrap_err(); + assert!(error.contains("противоречит"), "{error}"); } #[test] - fn every_hue_carries_the_chroma_fraction_so_nothing_is_dull() { - // Nothing dull: at each mid step every sentiment — INCLUDING green, which - // the old warm-budget cap muted to ~0.79 of its own ceiling — sits at - // (near) `CHROMA_FRACTION` of the gamut-edge chroma for its rendered - // lightness. The 0.80 floor (target 0.88) is loose enough to absorb 8-bit - // quantisation while still proving green is no longer capped down. Swept - // across brands; checked on the mid steps where the gamut has chroma to give. - let n = neutral(); - for brand in (0..360).step_by(29).map(|d| d as f64) { - for s in Sentiment::ALL { - let curve = SentimentCurve::from_sentiment(s, brand, s.anchor_hex(), &n).unwrap(); - let h = curve.resolved_hue; - for i in 3..=7 { - let hex = curve.sample_hex(11)[i].clone(); - let lab = srgb_linear_to_oklab(srgb_from_hex(&hex).unwrap()); - let l_r = lab[0]; - let c_r = (lab[1].powi(2) + lab[2].powi(2)).sqrt(); - let c_max = max_chroma(l_r, h); + fn unequal_radius_formula_reconstructs_the_law_of_cosines() { + for c1_i in 1..=20 { + for c2_i in 1..=20 { + let c1 = f64::from(c1_i) / 20.0; + let c2 = f64::from(c2_i) / 20.0; + // 180° проверяется отдельно точным входом `C1+C2`: вычисление + // D через sqrt перед публичной границей могло бы округлить его + // на один ulp за математический диаметр и тем самым проверить + // арифметику вызывающего, а не закон косинусов. + for angle in 0..180 { + let radians = f64::from(angle).to_radians(); + let distance = (c1 * c1 + c2 * c2 - 2.0 * c1 * c2 * radians.cos()).sqrt(); + let recovered = minimum_hue_separation_deg(distance, c1, c2).unwrap(); + let reconstructed_sq = + c1 * c1 + c2 * c2 - 2.0 * c1 * c2 * recovered.to_radians().cos(); + let expected_sq = distance * distance; + let arithmetic_bound = + 32.0 * f64::EPSILON * (1.0 + c1 * c1 + c2 * c2 + expected_sq); assert!( - c_r >= 0.80 * c_max, - "{s:?} brand {brand} step {i}: chroma {c_r:.3} dull \ - (< 0.80 of gamut max {c_max:.3})" + (reconstructed_sq - expected_sq).abs() <= arithmetic_bound, + "C1={c1}, C2={c2}, angle={angle}, recovered={recovered}, \ + D²={reconstructed_sq}, expected={expected_sq}" ); } } @@ -915,416 +457,78 @@ mod tests { } #[test] - fn prototype_hex_chroma_never_leaks_into_the_ramp() { - // Честный API: под законом категориальных зон (Волна 1) `chroma_hex` - // ВАЛИДИРУЕТСЯ, но в оттенок/рампу НЕ течёт — сентимент отдыхает на - // прототипе независимо от хромы прототипного hex. Насыщенный и приглушённый - // прототип одного оттенка обязаны дать БАЙТ-В-БАЙТ идентичную рампу. - let n = neutral(); - let s = Sentiment::Danger; - let brand = normalize_hue(s.prototype_hue() + 180.0); // бренд игнорируется законом - let saturated = SentimentCurve::from_sentiment(s, brand, "#FF3B30", &n).unwrap(); - let muted = SentimentCurve::from_sentiment(s, brand, "#B36A65", &n).unwrap(); // тот же красный, хрома ~вдвое ниже - for t in [0.2, 0.5, 0.8] { - assert_eq!( - saturated.hex_at(t), - muted.hex_at(t), - "хрома прототипа просочилась в рампу (t={t})" - ); - } - } - - #[test] - fn warning_floor_enforced_full_circle() { - // Восстановлена защита (#65 её убрала, #66 унаследовал уязвимость): - // Warning никогда не должен опускаться ниже своего категориального - // порога в красную зону, при ЛЮБОМ брендовом оттенке на круге. - // prototype_hex = Figma Accent/Orange (#FFA100, 2026-06-30). - let n = neutral(); - let mut brand = 0.0; - while brand < 360.0 { - let h = SentimentCurve::from_sentiment(Sentiment::Warning, brand, "#FFA100", &n) - .unwrap() - .resolved_hue; - assert!( - normalize_hue(h) >= 45.0 - 1e-6, - "Warning resolved {h:.2}° is below the 45° floor at brand {brand}" - ); - brand += 0.25; - } - } - - #[test] - fn warning_floor_is_a_dormant_guard_below_the_orange_prototype() { - // РЕФРЕЙМ Волны 1 (прежний вывод «натуральный минимум = prototype − s_min» - // относился к УБРАННОМУ brand-displacement и снят). Под законом - // категориальных зон Warning ОТДЫХАЕТ на своём прототипе (orange). Пол 45° — - // задокументированное исключение, СОХРАНЁННОЕ, но для labui-якоря «спящее»: - // прототип orange лежит ВЫШЕ пола, поэтому пол не кусается и Warning садится - // на фокус, а не клампится. Страж пинит именно отношение floor < prototype: - // если оранжевый якорь клиента опустится ниже пола, Warning клампанётся вверх - // к 45° (останется в янтаре, не в красном), но для эталонного паспорта пол дремлет. - let proto_hue = Sentiment::Warning.prototype_hue(); - assert!( - proto_hue > WARNING_HUE_FLOOR_DEG, - "прототип Warning {proto_hue:.3}° обязан лежать ВЫШЕ пола \ - {WARNING_HUE_FLOOR_DEG}° — иначе пол перестаёт быть спящим для labui-якоря" - ); - // Нетавтологичный пин запаса (orange proto 68.607° − пол 45.0 = 23.607°): - // ловит дрейф в обе стороны (сдвиг orange-прототипа или значения пола). - let margin = proto_hue - WARNING_HUE_FLOOR_DEG; - assert!( - (margin - 23.607).abs() < 0.05, - "запас прототипа над полом {margin:.4}° ушёл от ≈23.607° — сдвинулся \ - orange-прототип или пол; осмыслить провенанс, не подгонять" - ); - } - - #[test] - fn warning_stays_distinguishable_from_danger_full_circle() { - // Доказанный дефект машинным тестом: с picker на основе membership-field - // Warning мог резолвиться в 3.9° от Danger (перцептивно один цвет) при - // brand≈56°. Smooth-resolver + floor держат чёткий зазор везде. - // prototype_hex: Warning = Figma #FFA100; Danger = Figma #FF3B30. - let n = neutral(); - let mut brand = 0.0; - let mut worst = f64::INFINITY; - while brand < 360.0 { - let w = SentimentCurve::from_sentiment(Sentiment::Warning, brand, "#FFA100", &n) - .unwrap() - .resolved_hue; - let d = SentimentCurve::from_sentiment(Sentiment::Danger, brand, "#FF3B30", &n) - .unwrap() - .resolved_hue; - worst = worst.min(angular_distance(w, d)); - brand += 0.25; - } - assert!( - worst >= 10.0, - "Warning↔Danger closest approach {worst:.2}° (must stay >= 10° apart)" - ); - } - - // УДАЛЁН Волной 1: `resolved_hue_is_smooth_between_its_two_seams` — страж - // C¹-непрерывности p-норм-резолвера по бренду. Резолвер удалён (сентимент - // отдыхает на прототипе, от бренда не зависит), «швов» больше нет — тест стал - // беспредметным. - - #[test] - fn legalize_hue_errs_when_zones_leave_no_legal_arc() { - // Ветка ошибки обязана выдать `Err`, не тихо нарушить инвариант. Под законом - // зон единственный способ опустошить дугу — попарные зоны соседей: зона с - // отступом > 180° недостижима ни одним оттенком (max angular_distance = 180°), - // поэтому легальных нет. - let cover = [NeighborZone { - hue_deg: 0.0, - min_sep_deg: 181.0, - }]; - let r = legalize_hue_among(0.0, None, &cover); - assert!( - r.is_err(), - "ожидался Err, когда зона соседа не оставляет легального оттенка, получено {r:?}" - ); - - // Санити: ослабить зону — легальный оттенок снова есть (тот же вход иначе), - // значит Err выше — коллизия ограничений, не баг. - let ok = [NeighborZone { - hue_deg: 0.0, - min_sep_deg: 10.0, - }]; - assert!(legalize_hue_among(0.0, None, &ok).is_ok()); - } - - /// Деривационная идентичность: S_PERC_MIN = 2 × C_rep_figma × sin(20°/2), - /// где C_rep_figma — средняя Oklab-хрома четырёх якорей из Figma CONTENTS - /// (коллекция «🔵 4.1 Primitives», Light-mode, 2026-06-30). - /// Порог 20° — нижний предел категориального восприятия оттенка по - /// Witzel & Gegenfurtner (2013), Journal of Vision 13(7):1 (DOI 10.1167/13.7.1). - /// Допуск 1e-4: хромы зафиксированы до 4 знаков, итоговая погрешность - /// деривации существенно меньше — допуск исключает реальный дрейф константы. - #[test] - fn s_perc_min_derivation_identity() { - // Oklab C якорей из Figma CONTENTS, 2026-06-30: - let c_figma = [0.2321_f64, 0.1717_f64, 0.1944_f64, 0.1931_f64]; - let c_rep = c_figma.iter().sum::() / c_figma.len() as f64; - // Геометрическая деривация: 2 × C_rep × sin(20°/2) - // Источник порога 20°: Witzel & Gegenfurtner (2013), Journal of Vision 13(7):1 (DOI 10.1167/13.7.1) - let derived = 2.0 * c_rep * (10.0_f64.to_radians()).sin(); - assert!( - (S_PERC_MIN - derived).abs() < 1e-4, - "S_PERC_MIN = {S_PERC_MIN:.7} != выведено {derived:.7} \ - (разница {:.7} >= 1e-4; значение должно совпадать с Figma-деривацией)", - (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}) обязана быть отвергнута" - ); - } - } - - // УДАЛЁН Волной 1: `saturated_chord_resolves_to_diametric_hue` — сатурационная - // ветвь (chord ≥ 2C → диаметраль от бренда) была частью brand-separation и - // вырезана целиком; её больше не существует. - - /// Публичная граница `resolve_config_sentiment_solid` (новая сигнатура): - /// недоменный `hue_floor` или `chroma_fraction` — честный Err, не тихий hex. - #[test] - fn config_sentiment_public_boundary_rejects_garbage_angles() { - for bad_floor in [f64::NAN, -1.0, 360.0, f64::INFINITY] { - assert!( - resolve_config_sentiment_solid("#FF3B30", 1.0, Some(bad_floor)).is_err(), - "hue_floor={bad_floor} обязан быть отвергнут" - ); - } - for bad_fraction in [f64::NAN, 0.0, -0.1, 1.5, f64::INFINITY] { - assert!( - resolve_config_sentiment_solid("#FF3B30", bad_fraction, None).is_err(), - "chroma_fraction={bad_fraction} обязан быть отвергнут" - ); - } - // Валидный вход резолвится. - assert!(resolve_config_sentiment_solid("#FF3B30", 1.0, None).is_ok()); + fn radial_difference_needs_no_angle_and_diameter_overflow_is_error() { + assert_eq!(minimum_hue_separation_deg(0.3, 0.8, 0.5).unwrap(), 0.0); + assert_eq!(minimum_hue_separation_deg(1.3, 0.8, 0.5).unwrap(), 180.0); + assert!(minimum_hue_separation_deg(1.300_000_000_1, 0.8, 0.5).is_err()); + assert!(minimum_hue_separation_deg(0.2, 0.0, 0.1).is_err()); } - /// Ахроматичный якорь семейства не несёт оттенка: разведения нет — солид равен - /// сырому якорю. Fast path НЕ обходит валидацию ручек: недоменные - /// chroma_fraction/hue_floor отвергаются и на сером якоре (валидация ДО раннего - /// возврата — мусор не «везёт» в зависимости от цвета). #[test] - fn achromatic_family_anchor_returns_raw_anchor() { - let solid = - resolve_config_sentiment_solid("#808080", 1.0, None).expect("серый якорь легален"); - assert_eq!(solid, "#808080", "серый якорь возвращается байт-в-байт"); - - for bad_fraction in [0.0, f64::NAN, -0.01, 1.5, f64::INFINITY] { - assert!( - resolve_config_sentiment_solid("#808080", bad_fraction, None).is_err(), - "chroma_fraction={bad_fraction} обязан отвергаться и на сером" - ); + fn palette_resolution_is_independent_of_category_order() { + let base = vec![ + ("danger".to_string(), "#FF3B30".to_string()), + ("warning".to_string(), "#FFA100".to_string()), + ("success".to_string(), "#34C759".to_string()), + ("info".to_string(), "#3E87FF".to_string()), + ]; + let expected = resolve_anchor_palette_v2(&base).unwrap(); + let permutations = [ + vec![0, 1, 2, 3], + vec![3, 2, 1, 0], + vec![1, 3, 0, 2], + vec![2, 0, 3, 1], + ]; + for order in permutations { + let input: Vec<_> = order.into_iter().map(|index| base[index].clone()).collect(); + assert_eq!(resolve_anchor_palette_v2(&input).unwrap(), expected); } - assert!( - resolve_config_sentiment_solid("#808080", 1.0, Some(-1.0)).is_err(), - "недоменный hue_floor обязан отвергаться и на сером" - ); - } - - /// Домен нового резолвера `resolve_sentiment_hue_among`: неконечный `prototype` - /// и недоменный `hue_floor` — честный Err, не NaN-оттенок вниз по физике. - #[test] - fn resolve_sentiment_hue_rejects_garbage_domain() { - assert!(resolve_sentiment_hue_among(None, f64::NAN, &[]).is_err()); - assert!(resolve_sentiment_hue_among(None, f64::INFINITY, &[]).is_err()); - assert!(resolve_sentiment_hue_among(Some(360.0), 68.0, &[]).is_err()); - assert!(resolve_sentiment_hue_among(Some(f64::NAN), 68.0, &[]).is_err()); - assert!(resolve_sentiment_hue_among(Some(-1.0), 68.0, &[]).is_err()); - // Валидный вход резолвится и возвращает нормализованный прототип (покой). - let h = resolve_sentiment_hue_among(None, 68.0, &[]).expect("валидный"); - assert!((h - 68.0).abs() < 1e-9, "покой на прототипе: {h}"); } - /// АРБИТР того, что СОХРАНЁННАЯ машинерия зон соседей ЖИВА (достижима через - /// пол), а не мёртвый код. Сценарий: якорь семейства (red, прототип ≈ 28.66°) - /// лежит НИЖЕ своего пола (40°), поэтому клампится ВВЕРХ к полу — и там - /// сталкивается с зоной покоящегося соседа (45°/±10°). Зона обязана оттолкнуть - /// решённый оттенок на ≥ min_sep, доказывая, что зоны реально применяются. #[test] - fn floor_clamp_collision_triggers_neighbor_separation() { - let anchor = "#FF3B30"; // red, Oklab hue ≈ 28.66° — ниже пола 40° - let floor = Some(40.0); - let neighbor_hue = 45.0; - let min_sep = 10.0; - let zones = [NeighborZone { - hue_deg: neighbor_hue, - min_sep_deg: min_sep, - }]; - - // Без зоны: чистый кламп к полу (40°) — сосед не мешает. - let bare = resolve_config_sentiment_solid_among(anchor, 1.0, floor, &[]) - .expect("клампится к полу"); - let bare_hue = oklab_hue_of(&bare); - assert!( - (bare_hue - 40.0).abs() < 1.5, - "без зоны red клампится к полу 40°, получено {bare_hue:.2}°" - ); - - // С зоной у 45°/±10°: пол ставит кандидата в её ±10° коридор → зона - // отталкивает решённый оттенок на ≥ min_sep. Доказывает, что зоны применяются. - let with_zone = resolve_config_sentiment_solid_among(anchor, 1.0, floor, &zones) - .expect("резолвится с зоной"); - let with_hue = oklab_hue_of(&with_zone); - assert!( - with_hue >= 40.0 - 1e-6, - "решённый оттенок {with_hue:.2}° обязан оставаться над полом 40°" - ); - // hex-эмиссия квантует угол на доли градуса — допуск 0.5°. - let sep_with = angular_distance(with_hue, neighbor_hue); - assert!( - sep_with >= min_sep - 0.5, - "зона соседа у {neighbor_hue}° обязана оттолкнуть оттенок на ≥ {min_sep}°, \ - получено {sep_with:.2}° (решённый {with_hue:.2}°)" - ); - // И решённый С зоной ДАЛЬШЕ от соседа, чем без зоны (зона реально подвинула). - let sep_bare = angular_distance(bare_hue, neighbor_hue); - assert!( - sep_with > sep_bare, - "зона обязана УВЕЛИЧИТЬ отступ от соседа (без зоны {sep_bare:.2}°, с зоной {sep_with:.2}°)" - ); - } -} - -// Научные локи (a) DERIVABLE (волна science/constants-objectivization). Значения -// НЕ меняются — тесты предъявляют вывод из представимости/определения окружности. -#[cfg(test)] -mod derivator_locks { - use super::{ - ACHROMATIC_CHROMA_EPS, HUE_DOMAIN_MAX_EXCLUSIVE, HUE_DOMAIN_MIN_INCLUSIVE, normalize_hue, - }; - use crate::spaces::oklab::srgb_linear_to_oklab; - use crate::spaces::srgb::srgb_gamma_inv; - - fn oklab_chroma(rgb: [u8; 3]) -> f64 { - let e = [ - rgb[0] as f64 / 255.0, - rgb[1] as f64 / 255.0, - rgb[2] as f64 / 255.0, + fn final_pairwise_recheck_rejects_every_collision_not_only_last_neighbor() { + let reference = vec![ + AnchorGeometry::from_hex("a", "#FF0000").unwrap(), + AnchorGeometry::from_hex("b", "#00FF00").unwrap(), + AnchorGeometry::from_hex("c", "#0000FF").unwrap(), ]; - let l = [ - srgb_gamma_inv(e[0]), - srgb_gamma_inv(e[1]), - srgb_gamma_inv(e[2]), + let resolved = vec![ + AnchorGeometry::from_hex("a", "#FF0000").unwrap(), + AnchorGeometry::from_hex("b", "#FF0000").unwrap(), + AnchorGeometry::from_hex("c", "#0000FF").unwrap(), ]; - let lab = srgb_linear_to_oklab(l); - lab[1].hypot(lab[2]) + let error = verify_pairwise_anchor_contract(&reference, &resolved).unwrap_err(); + assert!(error.contains("`a`/`b`"), "{error}"); } - /// (a) ACHROMATIC_CHROMA_EPS выведен из АРИФМЕТИКИ ПРЕДСТАВИМОСТИ: строго зажат - /// между двумя ИЗМЕРЕННЫМИ границами конвейера sRGB->Oklab — потолком f64-шума - /// истинно ахроматических (R=G=B) цветов снизу и полом минимальной ненулевой - /// хромы представимого 8-битного цвета сверху. - /// - /// КОРРЕКЦИЯ ПРОВЕНАНСА: замер даёт потолок шума ~3.7e-8 (НЕ <=1e-12, как - /// ранее заявлял реестр) — EPS сидит лишь ~2.7x ВЫШЕ потолка шума. Запас >=3 - /// порядка держится ТОЛЬКО сверху (мин. представимая хрома ~1.06e-3). Обе - /// границы соблюдены, EPS не переклассифицирует ни один представимый цвет. #[test] - fn achromatic_eps_between_f64_noise_and_min_representable_chroma() { - let mut noise = 0.0f64; - for i in 0u16..=255 { - noise = noise.max(oklab_chroma([i as u8, i as u8, i as u8])); - } - let mut min_nz = f64::INFINITY; - for i in 0u16..=254 { - let i = i as u8; - for c in [[i, i, i + 1], [i, i + 1, i + 1], [i, i + 1, i]] { - let ch = oklab_chroma(c); - if ch > 0.0 { - min_nz = min_nz.min(ch); - } - } - } - assert!( - noise < ACHROMATIC_CHROMA_EPS, - "потолок f64-шума серых {noise:.3e} ниже EPS={ACHROMATIC_CHROMA_EPS:.0e}" - ); - assert!( - (1e-8..1e-7).contains(&noise), - "замеренный потолок шума {noise:.3e} вне полосы [1e-8,1e-7) — провенанс изменился" - ); - assert!( - min_nz > ACHROMATIC_CHROMA_EPS * 1e3, - "мин. представимая ненулевая хрома {min_nz:.3e} >= 3 порядков выше EPS" - ); + fn duplicate_names_are_rejected_before_pairwise_work() { + let input = vec![ + ("danger".to_string(), "#FF0000".to_string()), + ("danger".to_string(), "#00FF00".to_string()), + ]; + assert!(resolve_anchor_palette_v2(&input).is_err()); } - /// (a) HUE_DOMAIN_{MIN_INCLUSIVE,MAX_EXCLUSIVE} — ОПРЕДЕЛЕНИЕ угла по модулю - /// 360 (не политика): normalize_hue = rem_euclid(360) имеет кодомен ровно - /// [0, 360). Границы выводятся из кодомена нормализатора. #[test] - fn hue_domain_is_the_circle_modulus_codomain() { - assert_eq!(HUE_DOMAIN_MIN_INCLUSIVE, 0.0); - assert_eq!(HUE_DOMAIN_MAX_EXCLUSIVE, 360.0); - for k in -5..=5 { - for step in 0..360 { - let h = k as f64 * 360.0 + step as f64 + 0.5; - let n = normalize_hue(h); - assert!( - (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&n), - "normalize_hue({h})={n} вне домена" - ); - } - } - assert_eq!(normalize_hue(360.0), HUE_DOMAIN_MIN_INCLUSIVE); - assert!((normalize_hue(-1.0) - 359.0).abs() < 1e-9); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Волна 2 «объективизация» — терминал (e) для CHROMA_FRACTION. -// ───────────────────────────────────────────────────────────────────────────── -#[cfg(test)] -mod wave2_e_locks { - use super::{CHROMA_FRACTION, oklab_lc_to_hex}; - use crate::scale::max_chroma; - use crate::spaces::oklab::srgb_linear_to_oklab; - use crate::spaces::srgb::srgb_from_hex; - - fn de_ok_hex(a: &str, b: &str) -> f64 { - let la = srgb_linear_to_oklab(srgb_from_hex(a).unwrap()); - let lb = srgb_linear_to_oklab(srgb_from_hex(b).unwrap()); - ((la[0] - lb[0]).powi(2) + (la[1] - lb[1]).powi(2) + (la[2] - lb[2]).powi(2)).sqrt() + #[allow(deprecated)] + fn legacy_adapter_accepts_only_inert_fields() { + assert_eq!( + resolve_config_sentiment_solid("#ff3b30", 1.0, None).unwrap(), + "#FF3B30" + ); + assert!(resolve_config_sentiment_solid("#FF3B30", 0.88, None).is_err()); + assert!(resolve_config_sentiment_solid("#FF3B30", 1.0, Some(0.0)).is_err()); } - /// (e) DESIGN-CHOICE sensitivity-лок для `CHROMA_FRACTION`. Свип [0.70, 1.0] - /// на реальных сентимент-якорях labui (danger/warning/success/info): непрерывный - /// МАТЕРИАЛЬНЫЙ дрейф (fraction прямо масштабирует хрому), значит (e), не (c). - /// КУСАЕТСЯ: value-пин `== 0.88` падает на любой мутации. #[test] - fn chroma_fraction_sensitivity_is_bounded() { - assert_eq!( - CHROMA_FRACTION, 0.88, - "реестровый дефолт доли хромы сентимента" - ); - let anchors = ["#FF3B30", "#FF9008", "#34C759", "#3E87FF"]; - let mut max_de = 0.0_f64; - for hex in anchors { - let lab = srgb_linear_to_oklab(srgb_from_hex(hex).unwrap()); - let l = lab[0]; - let h = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); - let base = oklab_lc_to_hex(l, CHROMA_FRACTION * max_chroma(l, h), h); - for f in [0.70_f64, 0.80, 0.85, 0.92, 0.96, 1.0] { - max_de = max_de.max(de_ok_hex( - &base, - &oklab_lc_to_hex(l, f * max_chroma(l, h), h), - )); - } + fn sample_hex_has_requested_length_and_valid_values() { + let neutral = neutral(); + let curve = SentimentCurve::from_anchor("#3E87FF", &neutral).unwrap(); + for count in [0, 1, 2, 13] { + let samples = curve.sample_hex(count); + assert_eq!(samples.len(), count); + assert!(samples.iter().all(|hex| srgb_from_hex(hex).is_ok())); } - assert!( - (0.025..0.06).contains(&max_de), - "max ΔE_ok по [0.70,1.0] {max_de:.4} вне замеренного [0.025, 0.06) — ручка материальна (e)" - ); - eprintln!( - "WAVE2 CHROMA_FRACTION (e): max ΔE_ok[0.70,1.0]={max_de:.4} (материальна → (e), не (c); labui=1.0)" - ); } } diff --git a/crates/labcolors-core/src/spaces/cat16.rs b/crates/labcolors-core/src/spaces/cat16.rs index a96f5c4a..b41c2343 100644 --- a/crates/labcolors-core/src/spaces/cat16.rs +++ b/crates/labcolors-core/src/spaces/cat16.rs @@ -15,19 +15,43 @@ const XYZ_TO_CONE: [[f64; 3]; 3] = [ [-0.002079, 0.048952, 0.953127], ]; -/// Inverse CAT16: cone responses → CIE XYZ. +/// Обращает опубликованную невырожденную матрицу CAT16 при компиляции. /// -/// The *printed* inverse from Li et al. 2017 (8-decimal published values), not a -/// runtime re-inversion of `XYZ_TO_CONE`. The residual of the printed pair, -/// `max|CONE_TO_XYZ · XYZ_TO_CONE − I|`, is ≈5.4e-9 — far below the 8-bit output -/// quantum (1/255 ≈ 3.9e-3), so it is absorbed by 8-bit quantisation and the -/// sRGB round-trip test (`srgb` roundtrip) passes exactly. -#[rustfmt::skip] -const CONE_TO_XYZ: [[f64; 3]; 3] = [ - [ 1.86206786, -1.01125463, 0.14918677], - [ 0.38752654, 0.62144744, -0.00897398], - [-0.01584150, -0.03412294, 1.04996444], -]; +/// Отдельно напечатанный восьмизначный inverse оставлял остаток около 5.4e-9. +/// На грани гамута CAM16 round-trip усиливал его до ложного выхода канала за +/// `[0,1]`. Математическое определение обратного преобразования — именно inverse +/// forward-матрицы, поэтому второй независимо округлённый набор не нужен. +const fn inverse_3x3(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] { + let a = m[0][0]; + let b = m[0][1]; + let c = m[0][2]; + let d = m[1][0]; + let e = m[1][1]; + let f = m[1][2]; + let g = m[2][0]; + let h = m[2][1]; + let i = m[2][2]; + let determinant = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + [ + [ + (e * i - f * h) / determinant, + (c * h - b * i) / determinant, + (b * f - c * e) / determinant, + ], + [ + (f * g - d * i) / determinant, + (a * i - c * g) / determinant, + (c * d - a * f) / determinant, + ], + [ + (d * h - e * g) / determinant, + (b * g - a * h) / determinant, + (a * e - b * d) / determinant, + ], + ] +} + +const CONE_TO_XYZ: [[f64; 3]; 3] = inverse_3x3(XYZ_TO_CONE); fn mat_vec_mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { [ @@ -46,3 +70,28 @@ pub(crate) fn xyz_to_cone(xyz: [f64; 3]) -> [f64; 3] { pub(crate) fn cone_to_xyz(lms: [f64; 3]) -> [f64; 3] { mat_vec_mul(CONE_TO_XYZ, lms) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derived_inverse_closes_cat16_to_f64_precision() { + let mut maximum: f64 = 0.0; + for (row_index, inverse_row) in CONE_TO_XYZ.iter().enumerate() { + for (column_index, _) in XYZ_TO_CONE[0].iter().enumerate() { + let product = inverse_row + .iter() + .zip(XYZ_TO_CONE.iter()) + .map(|(left, forward_row)| left * forward_row[column_index]) + .sum::(); + let expected = if row_index == column_index { 1.0 } else { 0.0 }; + maximum = maximum.max((product - expected).abs()); + } + } + assert!( + maximum <= 4.0 * f64::EPSILON, + "остаток CAT16 inverse равен {maximum:e}" + ); + } +} diff --git a/crates/labcolors-core/src/spaces/oklab.rs b/crates/labcolors-core/src/spaces/oklab.rs index 386b1605..8e2a6df6 100644 --- a/crates/labcolors-core/src/spaces/oklab.rs +++ b/crates/labcolors-core/src/spaces/oklab.rs @@ -1,43 +1,95 @@ -//! Oklab perceptual colour space (sRGB path). +//! Перцептивное цветовое пространство Oklab (путь sRGB). //! -//! Source: Björn Ottosson, "A perceptual color space for image processing" -//! (2020), . The matrices below are -//! the 2021-01-25 revision (the values Ottosson pinned after the original post) -//! and were checked against that reference implementation to the last printed -//! digit (audit 2026-07-03). +//! Источник определения: Björn Ottosson, «A perceptual color space for image +//! processing» (2020), . Численный +//! reference — пересчитанные для binary64 матрицы XYZ D65 → Oklab из CSS Color +//! 4, . //! -//! The published model does not send D65 white exactly to `L = 1`: the -//! `LMS_TO_OKLAB` first-row sum gives `L = 0.9999999935 ≠ 1` for `[1, 1, 1]`. -//! That ~6.5e-9 offset is a property of the published matrices themselves, not a -//! porting error, and is absorbed by the `< 1e-6` white-point test below. +//! Oklab определяется прямым преобразованием XYZ → LMS → Oklab. Поэтому именно +//! опубликованные forward-матрицы являются источником истины, а обратные +//! выводятся из них при компиляции. Это сохраняет определение модели и убирает +//! ошибку около 2.6e-7 двух независимо округлённых направлений. +use super::srgb::{SRGB_TO_XYZ_D65, XYZ_D65_TO_SRGB, srgb_to_xyz}; + +/// Официальная binary64-матрица CSS Color 4: XYZ D65 → LMS Oklab. +// Десятичные записи скопированы из нормативного reference-кода без усечения: +// предупреждение Clippy здесь противоречило бы цели побитового воспроизведения. +#[allow(clippy::excessive_precision)] #[rustfmt::skip] -const SRGB_TO_LMS: [[f64; 3]; 3] = [ - [0.4122214708, 0.5363325363, 0.0514459929], - [0.2119034982, 0.6806995451, 0.1073969566], - [0.0883024619, 0.2817188376, 0.6299787005], +const XYZ_TO_LMS: [[f64; 3]; 3] = [ + [0.8190224379967030, 0.3619062600528904, -0.1288737815209879], + [0.0329836539323885, 0.9292868615863434, 0.0361446663506424], + [0.0481771893596242, 0.2642395317527308, 0.6335478284694309], ]; +/// Официальная binary64-матрица CSS Color 4: LMS′ → Oklab. +#[allow(clippy::excessive_precision)] #[rustfmt::skip] const LMS_TO_OKLAB: [[f64; 3]; 3] = [ - [ 0.2104542553, 0.7936177850, -0.0040720468], - [ 1.9779984951, -2.4285922050, 0.4505937099], - [ 0.0259040371, 0.7827717662, -0.8086757660], + [0.2104542683093140, 0.7936177747023054, -0.0040720430116193], + [1.9779985324311684, -2.4285922420485799, 0.4505937096174110], + [0.0259040424655478, 0.7827717124575296, -0.8086757549230774], ]; -#[rustfmt::skip] -pub(crate) const OKLAB_TO_LMS: [[f64; 3]; 3] = [ - [1.0, 0.3963377774, 0.2158037573], - [1.0, -0.1055613458, -0.0638541728], - [1.0, -0.0894841775, -1.2914855480], -]; +/// Обращает фиксированную невырожденную матрицу 3×3 при вычислении констант. +/// +/// Канонические forward-матрицы Oklab невырождены по построению. Функция закрыта, +/// чтобы это предусловие не превратилось в обещание универсального matrix API. +const fn inverse_3x3(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] { + let a = m[0][0]; + let b = m[0][1]; + let c = m[0][2]; + let d = m[1][0]; + let e = m[1][1]; + let f = m[1][2]; + let g = m[2][0]; + let h = m[2][1]; + let i = m[2][2]; -#[rustfmt::skip] -pub(crate) const LMS_TO_SRGB: [[f64; 3]; 3] = [ - [ 4.0767416621, -3.3077115913, 0.2309699292], - [-1.2684380046, 2.6097574011, -0.3413193965], - [-0.0041960863, -0.7034186147, 1.7076147010], -]; + let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g); + + [ + [ + (e * i - f * h) / det, + (c * h - b * i) / det, + (b * f - c * e) / det, + ], + [ + (f * g - d * i) / det, + (a * i - c * g) / det, + (c * d - a * f) / det, + ], + [ + (d * h - e * g) / det, + (b * g - a * h) / det, + (a * e - b * d) / det, + ], + ] +} + +/// Умножает две фиксированные матрицы при вычислении констант. +const fn mat_mul_const(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut product = [[0.0; 3]; 3]; + let mut row = 0; + while row < 3 { + let mut column = 0; + while column < 3 { + product[row][column] = + a[row][0] * b[0][column] + a[row][1] * b[1][column] + a[row][2] * b[2][column]; + column += 1; + } + row += 1; + } + product +} + +// Обратные и быстрые sRGB-матрицы выводятся из официального forward. Ни одно +// второе направление не получает независимо округлённую таблицу коэффициентов. +pub(crate) const OKLAB_TO_LMS: [[f64; 3]; 3] = inverse_3x3(LMS_TO_OKLAB); +const LMS_TO_XYZ: [[f64; 3]; 3] = inverse_3x3(XYZ_TO_LMS); +const SRGB_TO_LMS: [[f64; 3]; 3] = mat_mul_const(XYZ_TO_LMS, SRGB_TO_XYZ_D65); +pub(crate) const LMS_TO_SRGB: [[f64; 3]; 3] = mat_mul_const(XYZ_D65_TO_SRGB, LMS_TO_XYZ); fn mat_vec_mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { [ @@ -48,37 +100,63 @@ fn mat_vec_mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { } pub(crate) fn srgb_linear_to_oklab(rgb: [f64; 3]) -> [f64; 3] { - let lms = mat_vec_mul(SRGB_TO_LMS, rgb); + // Равные sRGB-каналы задают ахроматическую ось до любой округлённой + // матрицы. Эта аналитическая ветвь не даёт округлению придумать hue серого. + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + return [rgb[0].cbrt(), 0.0, 0.0]; + } + + // В физическом sRGB предкомпозиция отличается от официального пошагового + // XYZ-пути лишь ошибкой арифметики f64. Для extended RGB порядок округления + // около LMS=0 может усиливаться cbrt, поэтому там сохраняется буквальный + // путь CSS Color 4. + let lms = if rgb + .into_iter() + .all(|channel| (0.0..=1.0).contains(&channel)) + { + mat_vec_mul(SRGB_TO_LMS, rgb) + } else { + mat_vec_mul(XYZ_TO_LMS, srgb_to_xyz(rgb)) + }; let lms_ = [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]; mat_vec_mul(LMS_TO_OKLAB, lms_) } pub(crate) fn oklab_to_srgb_linear(lab: [f64; 3]) -> [f64; 3] { + // На ахроматической оси точное уравнение равно RGB=L³. Явная форма + // сохраняет равенство каналов вместо трёх разных ошибок матричных сумм. + if lab[1] == 0.0 && lab[2] == 0.0 { + let value = lab[0] * lab[0] * lab[0]; + return [value, value, value]; + } let lms_ = mat_vec_mul(OKLAB_TO_LMS, lab); let lms = [lms_[0].powi(3), lms_[1].powi(3), lms_[2].powi(3)]; mat_vec_mul(LMS_TO_SRGB, lms) } pub(crate) fn oklab_hue(rgb: [f64; 3]) -> f64 { - // Хью зависит только от Oklab-компонент `a`, `b` (строки 1, 2 матрицы - // `LMS_TO_OKLAB`) — светлота `L` (строка 0) не участвует. Считаем ровно эти - // две проекции нелинейной `lms_`, не гоняя полный `srgb_linear_to_oklab` с - // выбрасываемой строкой `L`. Значения `a`, `b` — те же независимые скалярные - // произведения (те же операнды, тот же порядок слева-направо), что даёт полный - // matmul, поэтому результат байт-идентичен; пинится golden/reference-векторами - // (`h_ok`). Пропуск строки `L` убирает одно скалярное произведение и, главное, - // одну ветку зависимостей, которую LLVM не всегда вычищает через границу - // `mat_vec_mul` (массив `[f64; 3]` целиком). - let lms = mat_vec_mul(SRGB_TO_LMS, rgb); + // Совместимый числовой placeholder: у ахромата hue отсутствует, а ноль не + // должен интерпретироваться как измеренное красное направление. Публичный + // полярный API несёт это состояние через `Option`. + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + return 0.0; + } + // L не влияет на hue; явные проекции a и b сохраняют тот же порядок операций, + // но не создают бесполезную ветвь вычисления светлоты. + let lms = if rgb + .into_iter() + .all(|channel| (0.0..=1.0).contains(&channel)) + { + mat_vec_mul(SRGB_TO_LMS, rgb) + } else { + mat_vec_mul(XYZ_TO_LMS, srgb_to_xyz(rgb)) + }; let lms_ = [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]; let a = LMS_TO_OKLAB[1][0] * lms_[0] + LMS_TO_OKLAB[1][1] * lms_[1] + LMS_TO_OKLAB[1][2] * lms_[2]; let b = LMS_TO_OKLAB[2][0] * lms_[0] + LMS_TO_OKLAB[2][1] * lms_[1] + LMS_TO_OKLAB[2][2] * lms_[2]; - // `atan2().to_degrees()` лежит в (−180, 180], поэтому `rem_euclid(360)` здесь - // тождественно равен одной условной прибавке 360 (для отрицательного угла) — - // байт-идентично, но без floating-modulo. Та же замена, что в `cam16:: - // forward_compute`; байт-гейтится golden/reference-векторами (`h_ok`). + let deg = b.atan2(a).to_degrees(); if deg < 0.0 { deg + 360.0 } else { deg } } @@ -88,12 +166,187 @@ mod tests { use super::*; use crate::spaces::srgb::srgb_from_hex; + fn mat_mul(a: [[f64; 3]; 3], b: [[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut product = [[0.0; 3]; 3]; + for (row, a_row) in a.iter().enumerate() { + for column in 0..3 { + product[row][column] = + a_row[0] * b[0][column] + a_row[1] * b[1][column] + a_row[2] * b[2][column]; + } + } + product + } + + fn identity_residual(product: [[f64; 3]; 3]) -> f64 { + let mut residual: f64 = 0.0; + for (row, values) in product.iter().enumerate() { + for (column, &value) in values.iter().enumerate() { + let expected = if row == column { 1.0 } else { 0.0 }; + residual = residual.max((value - expected).abs()); + } + } + residual + } + + fn max_roundtrip_error( + forward_lms: [[f64; 3]; 3], + forward_lab: [[f64; 3]; 3], + inverse_lms: [[f64; 3]; 3], + inverse_rgb: [[f64; 3]; 3], + ) -> f64 { + let mut maximum: f64 = 0.0; + for red in 0..=32 { + for green in 0..=32 { + for blue in 0..=32 { + let rgb = [red as f64 / 32.0, green as f64 / 32.0, blue as f64 / 32.0]; + let lms = mat_vec_mul(forward_lms, rgb); + let lab = + mat_vec_mul(forward_lab, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]); + let lms_ = mat_vec_mul(inverse_lms, lab); + let back = mat_vec_mul( + inverse_rgb, + [lms_[0].powi(3), lms_[1].powi(3), lms_[2].powi(3)], + ); + for channel in 0..3 { + maximum = maximum.max((rgb[channel] - back[channel]).abs()); + } + } + } + } + maximum + } + + #[test] + fn analytic_neutral_branch_preserves_the_axis_exactly() { + for l in [-1.0, -0.25, 0.0, 0.125, 0.5, 1.0, 1.5] { + let rgb = oklab_to_srgb_linear([l, 0.0, 0.0]); + assert_eq!(rgb[0], rgb[1], "L={l}: красный канал не равен зелёному"); + assert_eq!(rgb[1], rgb[2], "L={l}: зелёный канал не равен синему"); + let back = srgb_linear_to_oklab(rgb); + assert!((back[0] - l).abs() <= f64::EPSILON * l.abs().max(1.0)); + assert_eq!([back[1], back[2]], [0.0, 0.0]); + } + } + + #[test] + fn matrices_derived_from_forward_are_numerical_inverses() { + let residuals = [ + identity_residual(mat_mul(OKLAB_TO_LMS, LMS_TO_OKLAB)), + identity_residual(mat_mul(LMS_TO_OKLAB, OKLAB_TO_LMS)), + identity_residual(mat_mul(LMS_TO_SRGB, SRGB_TO_LMS)), + identity_residual(mat_mul(SRGB_TO_LMS, LMS_TO_SRGB)), + ]; + for residual in residuals { + assert!( + residual <= 8.0 * f64::EPSILON, + "остаток обратной матрицы {residual:e} больше ошибки арифметики f64" + ); + } + } + + #[test] + fn dense_roundtrip_eliminates_independent_rounding_error() { + #[rustfmt::skip] + const LEGACY_SRGB_TO_LMS: [[f64; 3]; 3] = [ + [0.4122214708, 0.5363325363, 0.0514459929], + [0.2119034982, 0.6806995451, 0.1073969566], + [0.0883024619, 0.2817188376, 0.6299787005], + ]; + #[rustfmt::skip] + const LEGACY_LMS_TO_OKLAB: [[f64; 3]; 3] = [ + [ 0.2104542553, 0.7936177850, -0.0040720468], + [ 1.9779984951, -2.4285922050, 0.4505937099], + [ 0.0259040371, 0.7827717662, -0.8086757660], + ]; + #[rustfmt::skip] + const LEGACY_OKLAB_TO_LMS: [[f64; 3]; 3] = [ + [1.0, 0.3963377774, 0.2158037573], + [1.0, -0.1055613458, -0.0638541728], + [1.0, -0.0894841775, -1.2914855480], + ]; + #[rustfmt::skip] + const LEGACY_LMS_TO_SRGB: [[f64; 3]; 3] = [ + [ 4.0767416621, -3.3077115913, 0.2309699292], + [-1.2684380046, 2.6097574011, -0.3413193965], + [-0.0041960863, -0.7034186147, 1.7076147010], + ]; + + let legacy = max_roundtrip_error( + LEGACY_SRGB_TO_LMS, + LEGACY_LMS_TO_OKLAB, + LEGACY_OKLAB_TO_LMS, + LEGACY_LMS_TO_SRGB, + ); + let coherent = max_roundtrip_error(SRGB_TO_LMS, LMS_TO_OKLAB, OKLAB_TO_LMS, LMS_TO_SRGB); + + eprintln!("Oklab 33^3 round-trip: старая={legacy:.17e}, согласованная={coherent:.17e}"); + assert!( + legacy > 1.0e-7, + "аудитная выборка больше не обнаруживает прежний дефект" + ); + assert!( + coherent <= 64.0 * f64::EPSILON, + "ошибка согласованного round-trip {coherent:e} вышла за границу f64" + ); + } + + #[test] + fn fast_physical_srgb_path_matches_official_xyz_path() { + let mut maximum: f64 = 0.0; + for red in 0..=64 { + for green in 0..=64 { + for blue in 0..=64 { + let rgb = [red as f64 / 64.0, green as f64 / 64.0, blue as f64 / 64.0]; + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + continue; + } + let fast = srgb_linear_to_oklab(rgb); + let lms = mat_vec_mul(XYZ_TO_LMS, srgb_to_xyz(rgb)); + let official = + mat_vec_mul(LMS_TO_OKLAB, [lms[0].cbrt(), lms[1].cbrt(), lms[2].cbrt()]); + maximum = maximum.max( + (fast[0] - official[0]) + .hypot(fast[1] - official[1]) + .hypot(fast[2] - official[2]), + ); + } + } + } + assert!( + maximum <= 16.0 * f64::EPSILON, + "быстрый sRGB-путь разошёлся с CSS XYZ reference на ΔEOK={maximum:e}" + ); + } + + #[test] + fn extended_linear_rgb_roundtrips_without_clamping() { + let samples: [[f64; 3]; 4] = [ + [-0.25, 0.4, 1.5], + [2.0, -1.0, 0.5], + [-1.0, -0.5, -0.25], + [4.0, 2.0, -3.0], + ]; + for rgb in samples { + let back = oklab_to_srgb_linear(srgb_linear_to_oklab(rgb)); + let scale = rgb.into_iter().map(f64::abs).fold(1.0, f64::max); + for channel in 0..3 { + assert!( + (rgb[channel] - back[channel]).abs() <= 32.0 * f64::EPSILON * scale, + "расширенный RGB {rgb:?}, канал {channel}: round-trip дал {back:?}" + ); + } + } + } + #[test] fn white_gives_l1_a0_b0() { let lab = srgb_linear_to_oklab([1.0, 1.0, 1.0]); - assert!((lab[0] - 1.0).abs() < 1e-6, "L={}", lab[0]); - assert!(lab[1].abs() < 1e-6, "a={}", lab[1]); - assert!(lab[2].abs() < 1e-6, "b={}", lab[2]); + for (name, got, expected) in [("L", lab[0], 1.0), ("a", lab[1], 0.0), ("b", lab[2], 0.0)] { + assert!( + (got - expected).abs() <= 8.0 * f64::EPSILON, + "{name}={got}, ожидалось {expected}" + ); + } } #[test] @@ -105,8 +358,8 @@ mod tests { let back = oklab_to_srgb_linear(lab); for i in 0..3 { assert!( - (lin[i] - back[i]).abs() < 1e-6, - "{hex} channel {i}: expected {}, got {}", + (lin[i] - back[i]).abs() <= 64.0 * f64::EPSILON, + "{hex}, канал {i}: ожидалось {}, получено {}", lin[i], back[i] ); @@ -118,61 +371,52 @@ mod tests { fn pure_red_has_positive_a() { let lin = srgb_from_hex("#FF0000").unwrap(); let lab = srgb_linear_to_oklab(lin); - assert!(lab[1] > 0.0, "a={} should be positive for red", lab[1]); + assert!( + lab[1] > 0.0, + "для красного a={} должна быть положительной", + lab[1] + ); } #[test] fn pure_blue_has_negative_b() { let lin = srgb_from_hex("#0000FF").unwrap(); let lab = srgb_linear_to_oklab(lin); - assert!(lab[2] < 0.0, "b={} should be negative for blue", lab[2]); + assert!( + lab[2] < 0.0, + "для синего b={} должна быть отрицательной", + lab[2] + ); } #[test] fn hue_returns_degrees_0_360() { - // Red ≈ 24.5°, Green ≈ 142°, Blue ≈ 264° — Oklab canonical values - let lin_r = srgb_from_hex("#FF0000").unwrap(); - let lin_g = srgb_from_hex("#00FF00").unwrap(); - let lin_b = srgb_from_hex("#0000FF").unwrap(); - - let h_r = oklab_hue(lin_r); - let h_g = oklab_hue(lin_g); - let h_b = oklab_hue(lin_b); - - // All hues in [0, 360) - for &h in &[h_r, h_g, h_b] { - assert!((0.0..360.0).contains(&h), "hue {} not in [0, 360)", h); - } + let h_r = oklab_hue(srgb_from_hex("#FF0000").unwrap()); + let h_g = oklab_hue(srgb_from_hex("#00FF00").unwrap()); + let h_b = oklab_hue(srgb_from_hex("#0000FF").unwrap()); - // Red quadrant (≈29°) + for &hue in &[h_r, h_g, h_b] { + assert!((0.0..360.0).contains(&hue), "hue {hue} вне [0, 360)"); + } assert!( (h_r - 29.2).abs() < 1.0, - "red hue = {}°, expected ≈29.2°", - h_r + "hue красного = {h_r}°, ожидалось около 29.2°" ); - // Green quadrant (≈142°) assert!( (h_g - 142.0).abs() < 3.0, - "green hue = {}°, expected ≈142°", - h_g + "hue зелёного = {h_g}°, ожидалось около 142°" ); - // Blue quadrant (≈264°) assert!( (h_b - 264.0).abs() < 3.0, - "blue hue = {}°, expected ≈264°", - h_b + "hue синего = {h_b}°, ожидалось около 264°" ); } #[test] - fn hue_achromatic_is_not_nan() { - let lin_w = srgb_from_hex("#FFFFFF").unwrap(); - let lin_k = srgb_from_hex("#000000").unwrap_or([0.0, 0.0, 0.0]); - let h_w = oklab_hue(lin_w); - let h_k = oklab_hue(lin_k); - assert!(!h_w.is_nan(), "white hue should not be NaN"); - assert!(!h_k.is_nan(), "black hue should not be NaN"); - assert!((0.0..360.0).contains(&h_w), "white hue in range"); - assert!((0.0..360.0).contains(&h_k), "black hue in range"); + fn achromatic_numeric_hue_is_only_a_compatibility_placeholder() { + let h_w = oklab_hue(srgb_from_hex("#FFFFFF").unwrap()); + let h_k = oklab_hue(srgb_from_hex("#000000").unwrap_or([0.0, 0.0, 0.0])); + assert_eq!(h_w, 0.0); + assert_eq!(h_k, 0.0); } } diff --git a/crates/labcolors-core/src/spaces/oklch.rs b/crates/labcolors-core/src/spaces/oklch.rs index bfd8f2c4..be022c5d 100644 --- a/crates/labcolors-core/src/spaces/oklch.rs +++ b/crates/labcolors-core/src/spaces/oklch.rs @@ -9,34 +9,57 @@ //! Значения остаются решёнными в sRGB-гамуте: oklch здесь — система координат //! записи, не расширение гамута. Точность цифр подобрана под БАЙТ-ТОЧНЫЙ //! round-trip: `oklch_css_from_hex` → парс → sRGB даёт исходные 8-битные -//! байты на всём кубе (доказательство — тест `round_trip_is_byte_exact_*`). +//! байты на проверяемой sRGB8-решётке и всей серой оси. Полный куб оставлен +//! отдельному длительному аудиту: CI не выдаёт выборку за исчерпывающий перебор. use super::oklab::srgb_linear_to_oklab; use super::srgb::srgb_from_hex; -/// (L, C, H°) Oklch из hex-солида: линейный свет → Oklab → полярная форма. +/// Порог powerless-hue Oklch из CSS Color 4. /// -/// H — градусы `[0, 360)`; у ахроматических цветов (C ≈ 0) H численно -/// произволен и на реконструкцию не влияет. +/// Это часть синтаксического контракта пространства, а не психофизический JND: +/// при `C ≤ ε` hue не влияет на цвет и после конвертации обязан стать missing. +pub const OKLCH_POWERLESS_CHROMA: f64 = 0.000_004; + +/// Полярные координаты Oklch без выдуманного hue на ахроматической оси. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct OklchCoordinates { + pub l: f64, + pub c: f64, + pub h_deg: Option, +} + +/// Oklch из hex-солида: линейный свет → Oklab → полярная форма. +/// +/// Hue присутствует в градусах `[0, 360)` только при `C` выше нормативного +/// powerless-порога CSS Color 4. У ахромата возвращается `None`, потому что +/// числовой угол там не является свойством стимула. /// /// # Errors /// /// `Err` — невалидный hex (пробрасывается из парсера). -pub fn oklch_from_hex(hex: &str) -> Result<[f64; 3], String> { +pub fn oklch_from_hex(hex: &str) -> Result { let lab = srgb_linear_to_oklab(srgb_from_hex(hex)?); let c = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt(); - let h = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); - // atan2(-0.0, x>0) даёт -0.0, а rem_euclid его не снимает (−0.0 не < 0) — - // печать дала бы "-0.000" и нарушила контракт H ∈ [0, 360). - let h = if h == 0.0 { 0.0 } else { h }; - Ok([lab[0], c, h]) + let h_deg = if c <= OKLCH_POWERLESS_CHROMA { + None + } else { + let h = lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0); + // `atan2(-0, x>0)` сохраняет знаковый ноль; каноническая запись угла — +0. + Some(if h == 0.0 { 0.0 } else { h }) + }; + Ok(OklchCoordinates { + l: lab[0], + c, + h_deg, + }) } /// CSS-строка `oklch(L% C H)` / `oklch(L% C H / A)` из hex-солида и /// опциональной альфы. /// /// Точность: L — 5 знаков процента, C — 6, H — 3; запас относительно -/// полушага 8-битного канала, доказан round-trip тестом на всём кубе. +/// полушага 8-битного канала, проверен round-trip на sRGB8-решётке и серой оси. /// Альфа — до 4 знаков с обрезкой хвостовых нулей: литералы рампы проходят /// как есть (`0.122` → `0.122`), а вычисленная альфа (альфа-аналог) не /// тащит float-хвост в CSS-строку. @@ -45,8 +68,11 @@ pub fn oklch_from_hex(hex: &str) -> Result<[f64; 3], String> { /// /// `Err` — невалидный hex. pub fn oklch_css_from_hex(hex: &str, alpha: Option) -> Result { - let [l, c, h] = oklch_from_hex(hex)?; - let base = format!("oklch({:.5}% {:.6} {:.3}", l * 100.0, c, h); + let OklchCoordinates { l, c, h_deg } = oklch_from_hex(hex)?; + let hue = h_deg + .map(|h| format!("{h:.3}")) + .unwrap_or_else(|| "none".to_string()); + let base = format!("oklch({:.5}% {:.6} {hue}", l * 100.0, c); let suffix = css_alpha_suffix(alpha)?; Ok(format!("{base}{suffix})")) } @@ -55,19 +81,18 @@ pub fn oklch_css_from_hex(hex: &str, alpha: Option) -> Result) -> Result { match alpha { None => Ok(String::new()), Some(a) => { - const ALPHA_NOISE: f64 = 1e-6; - if !a.is_finite() || !(-ALPHA_NOISE..=1.0 + ALPHA_NOISE).contains(&a) { + if !a.is_finite() || !(0.0..=1.0).contains(&a) { return Err(format!("альфа вне [0, 1]: {a}")); } - let a4 = format!("{:.4}", a.clamp(0.0, 1.0)); + let canonical = if a == 0.0 { 0.0 } else { a }; + let a4 = format!("{canonical:.4}"); let a4 = a4.trim_end_matches('0').trim_end_matches('.'); Ok(format!(" / {a4}")) } @@ -97,22 +122,25 @@ mod tests { Some((lch, a)) => (lch, Some(a.parse::().expect("альфа — число"))), None => (inner, None), }; - let parts: Vec = lch - .split_whitespace() - .map(|p| { - p.trim_end_matches('%') - .parse::() - .unwrap_or_else(|_| panic!("компонента не число: {p} в {css}")) - }) - .collect(); + let parts: Vec<&str> = lch.split_whitespace().collect(); assert_eq!(parts.len(), 3, "ровно L C H: {css}"); - (hex_from_oklch(parts[0] / 100.0, parts[1], parts[2]), alpha) + let l = parts[0] + .trim_end_matches('%') + .parse::() + .expect("L — число"); + let c = parts[1].parse::().expect("C — число"); + let h = if parts[2] == "none" { + 0.0 + } else { + parts[2].parse::().expect("H — число либо none") + }; + (hex_from_oklch(l / 100.0, c, h), alpha) } - /// Байт-точность round-trip на полном 8-битном кубе с шагом 5 по каждому + /// Байт-точность round-trip на 8-битной решётке с шагом 5 по каждому /// каналу (включая края 0 и 255) — ~140k цветов; формат → парс → те же - /// байты. Шаг 5 взаимно прост с 255, поэтому решётка не выровнена по - /// «удобным» значениям. + /// байты. Шаг 5 взаимно прост с 255, поэтому выборка не выровнена по + /// «удобным» значениям. Это плотная регрессия, а не полный перебор куба. #[test] fn round_trip_is_byte_exact_on_lattice() { let steps: Vec = (0u16..=255).step_by(5).map(|v| v as u8).collect(); @@ -144,24 +172,27 @@ mod tests { } } - /// Гард альфы: NaN — честная ошибка, конечный шум за краями — кламп - /// без артефакта "-0". + /// Гард альфы: нечисловые и внедоменные значения — честная ошибка; + /// знаковый ноль канонизируется без числового допуска. #[test] - fn alpha_guard_rejects_nan_and_clamps_noise() { + fn alpha_guard_rejects_every_out_of_domain_value() { assert!(oklch_css_from_hex("#101012", Some(f64::NAN)).is_err()); assert!(oklch_css_from_hex("#101012", Some(f64::INFINITY)).is_err()); - // Грубо невалидная альфа — дефект вызывающего, не шум: ошибка, не кламп. + // Размер выхода за границу не меняет контракт: недоказанного epsilon нет. assert!(oklch_css_from_hex("#101012", Some(-10.0)).is_err()); assert!(oklch_css_from_hex("#101012", Some(2.0)).is_err()); + assert!(oklch_css_from_hex("#101012", Some(-1e-7)).is_err()); + assert!(oklch_css_from_hex("#101012", Some(1.0 + 1e-9)).is_err()); // Знаковый ноль H не просачивается в печать. assert!( !oklch_css_from_hex("#FFFFFF", None).unwrap().contains("-0."), "signed zero в компонентах запрещён" ); - let noisy = oklch_css_from_hex("#101012", Some(-1e-7)).unwrap(); - assert!(noisy.ends_with(" / 0)"), "шум у нуля клампится: {noisy}"); - let over = oklch_css_from_hex("#101012", Some(1.0 + 1e-9)).unwrap(); - assert!(over.ends_with(" / 1)"), "шум у единицы клампится: {over}"); + let negative_zero = oklch_css_from_hex("#101012", Some(-0.0)).unwrap(); + assert!( + negative_zero.ends_with(" / 0)"), + "знаковый ноль канонизируется: {negative_zero}" + ); } /// Форма строки — контракт потребителя: процент у L, слэш-альфа, @@ -173,7 +204,20 @@ mod tests { assert!(solid.contains('%') && !solid.contains('/')); let translucent = oklch_css_from_hex("#101012", Some(0.122)).unwrap(); assert!(translucent.contains(" / 0.122)")); - let [_, _, h] = oklch_from_hex("#FF3B30").unwrap(); + let h = oklch_from_hex("#FF3B30").unwrap().h_deg.unwrap(); assert!((0.0..360.0).contains(&h)); } + + #[test] + fn every_encoded_gray_has_missing_hue() { + for value in 0_u8..=u8::MAX { + let hex = format!("#{value:02X}{value:02X}{value:02X}"); + let coordinates = oklch_from_hex(&hex).unwrap(); + assert_eq!(coordinates.h_deg, None, "{hex}"); + assert!( + oklch_css_from_hex(&hex, None).unwrap().contains(" none)"), + "{hex} обязан сериализовать powerless hue как none" + ); + } + } } diff --git a/crates/labcolors-core/src/spaces/p3.rs b/crates/labcolors-core/src/spaces/p3.rs index e79ab2ae..5edec10a 100644 --- a/crates/labcolors-core/src/spaces/p3.rs +++ b/crates/labcolors-core/src/spaces/p3.rs @@ -286,17 +286,20 @@ mod tests { } } - /// Политика альфы — единая с oklch-эмиссией (общий дом css_alpha_suffix): - /// NaN/грубый выход — ошибка, шум у краёв — кламп. + /// Политика альфы едина с oklch-эмиссией: любое значение вне `[0, 1]` + /// отвергается, потому что доказанной полосы допустимой ошибки нет. #[test] fn alpha_guard_shared_with_oklch() { assert!(p3_css_from_hex("#101012", Some(f64::NAN)).is_err()); assert!(p3_css_from_hex("#101012", Some(-10.0)).is_err()); assert!(p3_css_from_hex("#101012", Some(2.0)).is_err()); - let noisy = p3_css_from_hex("#101012", Some(-1e-7)).unwrap(); - assert!(noisy.ends_with(" / 0)"), "шум у нуля клампится: {noisy}"); - let over = p3_css_from_hex("#101012", Some(1.0 + 1e-9)).unwrap(); - assert!(over.ends_with(" / 1)"), "шум у единицы клампится: {over}"); + assert!(p3_css_from_hex("#101012", Some(-1e-7)).is_err()); + assert!(p3_css_from_hex("#101012", Some(1.0 + 1e-9)).is_err()); + let negative_zero = p3_css_from_hex("#101012", Some(-0.0)).unwrap(); + assert!( + negative_zero.ends_with(" / 0)"), + "знаковый ноль канонизируется: {negative_zero}" + ); } /// Форма строки — контракт потребителя: `color(display-p3 R G B [/ A])`, diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 291683bc..e7028dca 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -27,7 +27,7 @@ pub const D65_WHITE: [f64; 3] = [ // sRGB linear → XYZ(D65) // ------------------------------------------------------------------ #[rustfmt::skip] -const SRGB_TO_XYZ_D65: [[f64; 3]; 3] = [ +pub(crate) const SRGB_TO_XYZ_D65: [[f64; 3]; 3] = [ [ 0.412_390_799_265_959_34, 0.357_584_339_383_878, 0.180_480_788_401_834_3 ], [ 0.212_639_005_871_510_27, 0.715_168_678_767_756, 0.072_192_315_360_733_71 ], [ 0.019_330_818_715_591_82, 0.119_194_779_794_625_98, 0.950_532_152_249_660_7 ], @@ -37,7 +37,7 @@ const SRGB_TO_XYZ_D65: [[f64; 3]; 3] = [ // XYZ(D65) → sRGB linear // ------------------------------------------------------------------ #[rustfmt::skip] -const XYZ_D65_TO_SRGB: [[f64; 3]; 3] = [ +pub(crate) const XYZ_D65_TO_SRGB: [[f64; 3]; 3] = [ [ 3.240_969_941_904_522_6, -1.537_383_177_570_094, -0.498_610_760_293_003_4 ], [-0.969_243_636_280_879_6, 1.875_967_501_507_720_2, 0.041_555_057_407_175_59 ], [ 0.055_630_079_696_993_66, -0.203_976_958_888_976_52, 1.056_971_514_242_878_6 ], diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 2f0603e6..03e3e0b3 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -66,6 +66,110 @@ impl Default for ViewingConditions { } impl ViewingConditions { + /// Проверяет, что публично изменяемое состояние описывает согласованный + /// набор условий просмотра, а не смесь первичных и устаревших производных. + /// + /// CAM16 многократно делит на эти величины и возводит их в дробные степени, + /// поэтому конечность сама по себе недостаточна: физические масштабы должны + /// быть положительны, `n` обязан оставаться долей фонового белого, а + /// предвычисленные поля — побитово совпадать со своими исходными выражениями. + /// Точное сравнение здесь намеренно: все производные строятся теми же + /// операциями в [`Self::build`], поэтому допуск лишь разрешил бы внутренне + /// противоречивым условиям давать зависящий от пути результат. + pub(crate) fn validate(&self) -> Result<(), String> { + for (name, value) in [ + ("n", self.n), + ("aw", self.aw), + ("nbb", self.nbb), + ("ncb", self.ncb), + ("fl", self.fl), + ("z", self.z), + ("c", self.c), + ("nc", self.nc), + ("fl_pow_025", self.fl_pow_025), + ("t_inner", self.t_inner), + ] { + if !value.is_finite() { + return Err(format!("ViewingConditions.{name} должен быть конечным")); + } + } + for (channel, value) in self.rgb_d.into_iter().enumerate() { + if !value.is_finite() { + return Err(format!( + "ViewingConditions.rgb_d[{channel}] должен быть конечным" + )); + } + if value <= 0.0 { + return Err(format!( + "ViewingConditions.rgb_d[{channel}] должен быть положительным" + )); + } + } + + if !(0.0 < self.n && self.n <= 1.0) { + return Err("ViewingConditions.n должен принадлежать интервалу (0, 1]".into()); + } + for (name, value) in [ + ("aw", self.aw), + ("nbb", self.nbb), + ("ncb", self.ncb), + ("fl", self.fl), + ("z", self.z), + ("fl_pow_025", self.fl_pow_025), + ("t_inner", self.t_inner), + ] { + if value <= 0.0 { + return Err(format!( + "ViewingConditions.{name} должен быть положительным" + )); + } + } + for (name, value) in [("c", self.c), ("nc", self.nc)] { + if !(0.0 < value && value <= 1.0) { + return Err(format!( + "ViewingConditions.{name} должен принадлежать интервалу (0, 1]" + )); + } + } + + let expected_nbb = 0.725_f64 * self.n.powf(-0.2); + if self.nbb.to_bits() != expected_nbb.to_bits() { + return Err("ViewingConditions.nbb не согласован с n".into()); + } + if self.ncb.to_bits() != self.nbb.to_bits() { + return Err("ViewingConditions.ncb должен побитово совпадать с nbb".into()); + } + let expected_z = 1.48_f64 + self.n.sqrt(); + if self.z.to_bits() != expected_z.to_bits() { + return Err("ViewingConditions.z не согласован с n".into()); + } + let expected_fl_pow_025 = self.fl.powf(0.25); + if self.fl_pow_025.to_bits() != expected_fl_pow_025.to_bits() { + return Err("ViewingConditions.fl_pow_025 не согласован с fl".into()); + } + let expected_t_inner = (1.64 - 0.29_f64.powf(self.n)).powf(0.73); + if self.t_inner.to_bits() != expected_t_inner.to_bits() { + return Err("ViewingConditions.t_inner не согласован с n".into()); + } + + let rgb_w = xyz_to_cone([ + D65_WHITE[0] * 100.0, + D65_WHITE[1] * 100.0, + D65_WHITE[2] * 100.0, + ]); + let rgb_aw = [ + adapt(rgb_w[0] * self.rgb_d[0], self.fl), + adapt(rgb_w[1] * self.rgb_d[1], self.fl), + adapt(rgb_w[2] * self.rgb_d[2], self.fl), + ]; + let expected_aw = (2.0 * rgb_aw[0] + rgb_aw[1] + rgb_aw[2] / 20.0) * self.nbb; + if self.aw.to_bits() != expected_aw.to_bits() { + return Err("ViewingConditions.aw не согласован с fl, nbb и rgb_d".into()); + } + + Ok(()) + } + /// Standard sRGB viewing conditions (average surround). /// /// Parameters: D65 illuminant, L_A = 64 cd/m², Y_b = 20 %, diff --git a/crates/labcolors-core/src/wcag.rs b/crates/labcolors-core/src/wcag.rs index 44bff876..8632b633 100644 --- a/crates/labcolors-core/src/wcag.rs +++ b/crates/labcolors-core/src/wcag.rs @@ -23,13 +23,13 @@ pub(crate) const AA_TEXT_RATIO: f64 = 4.5; /// (success criterion 1.4.11). pub(crate) const AA_UI_RATIO: f64 = 3.0; -/// Linearise one gamma-encoded sRGB channel in `[0, 1]`, per WCAG 2.1 §1.4.3. +/// Линеаризует gamma-кодированный канал sRGB `[0, 1]` по текущему тексту WCAG. /// -/// The 0.03928 threshold is the value normatively fixed by WCAG; for 8-bit -/// inputs it selects the same piecewise branch as the IEC sRGB transfer -/// function, so quantised colours linearise identically either way. +/// Порог `0.04045` одновременно задан действующим WCAG и стыком передаточной +/// функции IEC sRGB. Erratum заменил им устаревший `0.03928`: на 8-битных входах +/// ветвь совпадает случайно, а непрерывный API обязан следовать актуальной норме. fn linearise(channel: f64) -> f64 { - if channel <= 0.039_28 { + if channel <= 0.040_45 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) diff --git a/crates/labcolors-core/tests/chain_invariants.rs b/crates/labcolors-core/tests/chain_invariants.rs index d894ccc3..d1560f16 100644 --- a/crates/labcolors-core/tests/chain_invariants.rs +++ b/crates/labcolors-core/tests/chain_invariants.rs @@ -98,11 +98,7 @@ fn plain_neutral() -> NeutralConfig { } fn plain_sentiments() -> SentimentsConfig { - SentimentsConfig { - categories: vec![], - hardness: 5.0, - chroma_fraction: 0.88, - } + SentimentsConfig { categories: vec![] } } /// Проверить, что таблица ТОТАЛЬНА на всех экстремальных фонах × VC: каждый @@ -195,7 +191,8 @@ fn assert_valid_hex(hex: &str, label: &str, name: &str, bg: &str) { ); } -/// Компоненты `oklch(L% C H[ / A])` — конечные числа (браузер их так и прочтёт). +/// Числовые компоненты `oklch(L% C H[ / A])` конечны; `H=none` — нормативное +/// состояние отсутствующего hue у ахромата, а не нечисловая ошибка. fn assert_oklch_components_finite(css: &str, label: &str, name: &str, bg: &str) { let inner = css .strip_prefix("oklch(") @@ -206,6 +203,9 @@ fn assert_oklch_components_finite(css: &str, label: &str, name: &str, bg: &str) None => (inner, None), }; for part in lch.split_whitespace() { + if part == "none" { + continue; + } let v: f64 = part .trim_end_matches('%') .parse() @@ -330,30 +330,20 @@ fn extreme_neutral_tint_knobs_never_panic_or_emit_nonfinite() { } } -/// Экстремальный СЕНТИМЕНТ (chroma_fraction у краёв (0,1], жёсткость у краёв) на -/// вырожденной палитре: pair-fill/dj роли не роняют резолв нечислом. +/// Сентимент на вырожденной одноцветной палитре не роняет резолв нечислом. #[test] -fn extreme_sentiment_knobs_never_panic_or_emit_nonfinite() { +fn sentiment_anchor_never_panics_or_emits_nonfinite() { let family = PaletteFamily { key: "fam".to_string(), anchors: flat_anchors("#FF3B30"), }; - let knob_sets = [ - (0.001, 1.0), // почти нулевая хрома, самая мягкая жёсткость - (1.0, 5.0), // полная хрома (стена гамута) - (0.88, 1000.0), // экстремальная жёсткость - ]; - for (chroma_fraction, hardness) in knob_sets { - let sentiments = SentimentsConfig { - categories: vec![SentimentCategory { - name: "alert".to_string(), - family: "fam".to_string(), - hue_floor_deg: None, - preferred_side: None, - }], - hardness, - chroma_fraction, - }; + let sentiments = SentimentsConfig { + categories: vec![SentimentCategory { + name: "alert".to_string(), + family: "fam".to_string(), + }], + }; + { let cfg = scaffold( plain_neutral(), sentiments, @@ -369,10 +359,7 @@ fn extreme_sentiment_knobs_never_panic_or_emit_nonfinite() { ); if let Ok(table) = cfg.compile_named_role_table() { // Чистый гард тотальности/не-паники (как neutral выше). - let _ = assert_table_is_total( - &table, - &format!("sentiment(cf={chroma_fraction},h={hardness})"), - ); + let _ = assert_table_is_total(&table, "sentiment(anchor-v2)"); } } } diff --git a/crates/labcolors-core/tests/color_quality_contract.rs b/crates/labcolors-core/tests/color_quality_contract.rs new file mode 100644 index 00000000..9e477856 --- /dev/null +++ b/crates/labcolors-core/tests/color_quality_contract.rs @@ -0,0 +1,95 @@ +use labcolors_core::{ + AppearanceContextV1, AppearanceMode, COLOR_QUALITY_AUDIT_MODEL_V1, ModelProvenance, + ViewingConditions, WARM_DARK_INTERACTION_MODEL_V2, WarmDarkInteractionStatusV2, + analyze_warm_dark_interaction_v2, audit_color_quality_v1, +}; + +fn nominal(mode: AppearanceMode) -> AppearanceContextV1 { + AppearanceContextV1::nominal(mode, ViewingConditions::srgb()) +} + +#[test] +fn positive_research_signal_is_not_a_human_dirty_verdict() { + let report = analyze_warm_dark_interaction_v2( + "#6B6B2E", + "#FFFFFF", + &nominal(AppearanceMode::SurfaceLike), + ) + .unwrap(); + + assert_eq!(report.model, WARM_DARK_INTERACTION_MODEL_V2); + assert_eq!(report.provenance, ModelProvenance::LabpicsHypothesis); + assert_eq!( + report.status, + WarmDarkInteractionStatusV2::InteractionPositive + ); + assert!(report.interaction_potential.unwrap() > 0.0); +} + +#[test] +fn the_same_rgb_keeps_emissive_and_surface_requests_distinct() { + let emissive = audit_color_quality_v1( + "#6B6B2E", + "#FFFFFF", + None, + &nominal(AppearanceMode::EmissiveUi), + ) + .unwrap(); + let surface = audit_color_quality_v1( + "#6B6B2E", + "#FFFFFF", + None, + &nominal(AppearanceMode::SurfaceLike), + ) + .unwrap(); + + assert_eq!(emissive.model, COLOR_QUALITY_AUDIT_MODEL_V1); + assert_eq!(surface.model, COLOR_QUALITY_AUDIT_MODEL_V1); + assert_eq!(emissive.context.mode, AppearanceMode::EmissiveUi); + assert_eq!(surface.context.mode, AppearanceMode::SurfaceLike); + assert_ne!(emissive.context.mode, surface.context.mode); + + // Номинальный skeleton пока не заявляет mode-specific observer model. + // Поэтому физические CAM16-UCS факты совпадают, а различие запроса не теряется. + assert_eq!( + emissive.appearance.jp.to_bits(), + surface.appearance.jp.to_bits() + ); + assert_eq!( + emissive.appearance.mp.to_bits(), + surface.appearance.mp.to_bits() + ); +} + +#[test] +fn black_background_is_insufficient_context_not_proof_of_zero_interaction() { + let report = analyze_warm_dark_interaction_v2( + "#6B6B2E", + "#000000", + &nominal(AppearanceMode::SurfaceLike), + ) + .unwrap(); + + assert_eq!( + report.status, + WarmDarkInteractionStatusV2::InsufficientContext + ); + assert_eq!(report.foreground_background_ratio, None); + assert_eq!(report.interaction_potential, None); +} + +#[test] +fn spatial_effect_is_not_misclassified_by_an_opaque_patch_model() { + let report = analyze_warm_dark_interaction_v2( + "#6B6B2E", + "#FFFFFF", + &nominal(AppearanceMode::SpatialEffect), + ) + .unwrap(); + + assert_eq!( + report.status, + WarmDarkInteractionStatusV2::NotApplicable(AppearanceMode::SpatialEffect) + ); + assert_eq!(report.interaction_potential, None); +} diff --git a/crates/labcolors-core/tests/data/labui_emission_golden.txt b/crates/labcolors-core/tests/data/labui_emission_golden.txt index 1f614862..1efc4e3b 100644 --- a/crates/labcolors-core/tests/data/labui_emission_golden.txt +++ b/crates/labcolors-core/tests/data/labui_emission_golden.txt @@ -77,9 +77,9 @@ srgb|#FFFFFF|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#FFFFFF|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#FFFFFF|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#FFFFFF|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#FFFFFF|fx-glow-brand=glow(#8DBCFF,#007AFF,1.0000) -srgb|#FFFFFF|fx-glow-danger=glow(#FFAA9D,#FF3B30,1.0000) -srgb|#FFFFFF|fx-glow-warning=glow(#FFD19F,#FFA100,1.0000) +srgb|#FFFFFF|fx-glow-brand=glow(#FFFFFF,#007AFF,1.0000) +srgb|#FFFFFF|fx-glow-danger=glow(#FFFFFF,#FF3B30,1.0000) +srgb|#FFFFFF|fx-glow-warning=glow(#FFFFFF,#FFA100,1.0000) srgb|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,1.0000) srgb|#FFFFFF|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#FFFFFF|fx-skeleton-highlight=rgba(#787880,0.039) @@ -184,9 +184,9 @@ srgb|#F2F2F7|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#F2F2F7|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#F2F2F7|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#F2F2F7|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#F2F2F7|fx-glow-brand=glow(#8DBCFF,#007AFF,1.0000) -srgb|#F2F2F7|fx-glow-danger=glow(#FFAA9D,#FF3B30,1.0000) -srgb|#F2F2F7|fx-glow-warning=glow(#FFD19F,#FFA100,0.9138) +srgb|#F2F2F7|fx-glow-brand=glow(#FFFFFF,#007AFF,1.0000) +srgb|#F2F2F7|fx-glow-danger=glow(#FFFFFF,#FF3B30,1.0000) +srgb|#F2F2F7|fx-glow-warning=glow(#FFFFFF,#FFA100,0.9138) srgb|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.6538) srgb|#F2F2F7|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#F2F2F7|fx-skeleton-highlight=rgba(#787880,0.039) @@ -291,9 +291,9 @@ srgb|#7F7F7F|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#7F7F7F|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#7F7F7F|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#7F7F7F|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#7F7F7F|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1061) -srgb|#7F7F7F|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1055) -srgb|#7F7F7F|fx-glow-warning=glow(#FFD19F,#FFA100,0.0681) +srgb|#7F7F7F|fx-glow-brand=glow(#FFFFFF,#007AFF,0.1061) +srgb|#7F7F7F|fx-glow-danger=glow(#FFFFFF,#FF3B30,0.1055) +srgb|#7F7F7F|fx-glow-warning=glow(#FFFFFF,#FFA100,0.0681) srgb|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0430) srgb|#7F7F7F|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#7F7F7F|fx-skeleton-highlight=rgba(#787880,0.039) @@ -398,9 +398,9 @@ srgb|#1C1C1E|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#1C1C1E|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#1C1C1E|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#1C1C1E|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#1C1C1E|fx-glow-brand=glow(#8DBCFF,#007AFF,0.0599) -srgb|#1C1C1E|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0551) -srgb|#1C1C1E|fx-glow-warning=glow(#FFD19F,#FFA100,0.0374) +srgb|#1C1C1E|fx-glow-brand=glow(#FFFFFF,#007AFF,0.0599) +srgb|#1C1C1E|fx-glow-danger=glow(#FFFFFF,#FF3B30,0.0551) +srgb|#1C1C1E|fx-glow-warning=glow(#FFFFFF,#FFA100,0.0374) srgb|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0242) srgb|#1C1C1E|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#1C1C1E|fx-skeleton-highlight=rgba(#787880,0.039) @@ -505,9 +505,9 @@ srgb|#101012|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#101012|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#101012|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#101012|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#101012|fx-glow-brand=glow(#8DBCFF,#007AFF,0.0568) -srgb|#101012|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.0560) -srgb|#101012|fx-glow-warning=glow(#FFD19F,#FFA100,0.0364) +srgb|#101012|fx-glow-brand=glow(#FFFFFF,#007AFF,0.0568) +srgb|#101012|fx-glow-danger=glow(#FFFFFF,#FF3B30,0.0560) +srgb|#101012|fx-glow-warning=glow(#FFFFFF,#FFA100,0.0364) srgb|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0230) srgb|#101012|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#101012|fx-skeleton-highlight=rgba(#787880,0.039) @@ -612,9 +612,9 @@ srgb|#3478F6|fx-focus-ring-brand=rgba(#007AFF,1) srgb|#3478F6|fx-focus-ring-danger=rgba(#FF3B30,1) srgb|#3478F6|fx-focus-ring-warning=rgba(#FFA100,1) srgb|#3478F6|fx-focus-ring-neutral=rgba(#101012,1) -srgb|#3478F6|fx-glow-brand=glow(#8DBCFF,#007AFF,0.1316) -srgb|#3478F6|fx-glow-danger=glow(#FFAA9D,#FF3B30,0.1404) -srgb|#3478F6|fx-glow-warning=glow(#FFD19F,#FFA100,0.0763) +srgb|#3478F6|fx-glow-brand=glow(#FFFFFF,#007AFF,0.1316) +srgb|#3478F6|fx-glow-danger=glow(#FFFFFF,#FF3B30,0.1404) +srgb|#3478F6|fx-glow-warning=glow(#FFFFFF,#FFA100,0.0763) srgb|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0556) srgb|#3478F6|fx-glow-inverted=rgba(#B0B0B9,0.522) srgb|#3478F6|fx-skeleton-highlight=rgba(#787880,0.039) @@ -719,9 +719,9 @@ dim|#FFFFFF|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#FFFFFF|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#FFFFFF|fx-focus-ring-warning=rgba(#FF9008,1) dim|#FFFFFF|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#FFFFFF|fx-glow-brand=glow(#A0C5FF,#4A8FFF,1.0000) -dim|#FFFFFF|fx-glow-danger=glow(#FFA89F,#FF3A3A,1.0000) -dim|#FFFFFF|fx-glow-warning=glow(#FFC89D,#FF9008,1.0000) +dim|#FFFFFF|fx-glow-brand=glow(#FFFFFF,#4A8FFF,1.0000) +dim|#FFFFFF|fx-glow-danger=glow(#FFFFFF,#FF3A3A,1.0000) +dim|#FFFFFF|fx-glow-warning=glow(#FFFFFF,#FF9008,1.0000) dim|#FFFFFF|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,1.0000) dim|#FFFFFF|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#FFFFFF|fx-skeleton-highlight=rgba(#787880,0.039) @@ -826,9 +826,9 @@ dim|#F2F2F7|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#F2F2F7|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#F2F2F7|fx-focus-ring-warning=rgba(#FF9008,1) dim|#F2F2F7|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#F2F2F7|fx-glow-brand=glow(#A0C5FF,#4A8FFF,1.0000) -dim|#F2F2F7|fx-glow-danger=glow(#FFA89F,#FF3A3A,1.0000) -dim|#F2F2F7|fx-glow-warning=glow(#FFC89D,#FF9008,1.0000) +dim|#F2F2F7|fx-glow-brand=glow(#FFFFFF,#4A8FFF,1.0000) +dim|#F2F2F7|fx-glow-danger=glow(#FFFFFF,#FF3A3A,1.0000) +dim|#F2F2F7|fx-glow-warning=glow(#FFFFFF,#FF9008,1.0000) dim|#F2F2F7|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.7308) dim|#F2F2F7|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#F2F2F7|fx-skeleton-highlight=rgba(#787880,0.039) @@ -933,9 +933,9 @@ dim|#7F7F7F|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#7F7F7F|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#7F7F7F|fx-focus-ring-warning=rgba(#FF9008,1) dim|#7F7F7F|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#7F7F7F|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0906) -dim|#7F7F7F|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1202) -dim|#7F7F7F|fx-glow-warning=glow(#FFC89D,#FF9008,0.0761) +dim|#7F7F7F|fx-glow-brand=glow(#FFFFFF,#4A8FFF,0.0906) +dim|#7F7F7F|fx-glow-danger=glow(#FFFFFF,#FF3A3A,0.1202) +dim|#7F7F7F|fx-glow-warning=glow(#FFFFFF,#FF9008,0.0761) dim|#7F7F7F|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0508) dim|#7F7F7F|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#7F7F7F|fx-skeleton-highlight=rgba(#787880,0.039) @@ -1040,9 +1040,9 @@ dim|#1C1C1E|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#1C1C1E|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#1C1C1E|fx-focus-ring-warning=rgba(#FF9008,1) dim|#1C1C1E|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#1C1C1E|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0380) -dim|#1C1C1E|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0484) -dim|#1C1C1E|fx-glow-warning=glow(#FFC89D,#FF9008,0.0351) +dim|#1C1C1E|fx-glow-brand=glow(#FFFFFF,#4A8FFF,0.0380) +dim|#1C1C1E|fx-glow-danger=glow(#FFFFFF,#FF3A3A,0.0484) +dim|#1C1C1E|fx-glow-warning=glow(#FFFFFF,#FF9008,0.0351) dim|#1C1C1E|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0198) dim|#1C1C1E|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#1C1C1E|fx-skeleton-highlight=rgba(#787880,0.039) @@ -1147,9 +1147,9 @@ dim|#101012|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#101012|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#101012|fx-focus-ring-warning=rgba(#FF9008,1) dim|#101012|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#101012|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.0360) -dim|#101012|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.0460) -dim|#101012|fx-glow-warning=glow(#FFC89D,#FF9008,0.0333) +dim|#101012|fx-glow-brand=glow(#FFFFFF,#4A8FFF,0.0360) +dim|#101012|fx-glow-danger=glow(#FFFFFF,#FF3A3A,0.0460) +dim|#101012|fx-glow-warning=glow(#FFFFFF,#FF9008,0.0333) dim|#101012|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0188) dim|#101012|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#101012|fx-skeleton-highlight=rgba(#787880,0.039) @@ -1254,9 +1254,9 @@ dim|#3478F6|fx-focus-ring-brand=rgba(#4A8FFF,1) dim|#3478F6|fx-focus-ring-danger=rgba(#FF3A3A,1) dim|#3478F6|fx-focus-ring-warning=rgba(#FF9008,1) dim|#3478F6|fx-focus-ring-neutral=rgba(#F6F8FA,1) -dim|#3478F6|fx-glow-brand=glow(#A0C5FF,#4A8FFF,0.1123) -dim|#3478F6|fx-glow-danger=glow(#FFA89F,#FF3A3A,0.1466) -dim|#3478F6|fx-glow-warning=glow(#FFC89D,#FF9008,0.0911) +dim|#3478F6|fx-glow-brand=glow(#FFFFFF,#4A8FFF,0.1123) +dim|#3478F6|fx-glow-danger=glow(#FFFFFF,#FF3A3A,0.1466) +dim|#3478F6|fx-glow-warning=glow(#FFFFFF,#FF9008,0.0911) dim|#3478F6|fx-glow-neutral=glow(#FFFFFF,#FFFFFF,0.0556) dim|#3478F6|fx-glow-inverted=rgba(#3C3C43,0.522) dim|#3478F6|fx-skeleton-highlight=rgba(#787880,0.039) diff --git a/crates/labcolors-core/tests/empirical_inventory.rs b/crates/labcolors-core/tests/empirical_inventory.rs index 1e4bef4c..223e4edf 100644 --- a/crates/labcolors-core/tests/empirical_inventory.rs +++ b/crates/labcolors-core/tests/empirical_inventory.rs @@ -86,18 +86,10 @@ const POLICY_LITERAL_MODULES: &[&str] = &["semantic.rs", "sentiment.rs", "neutra /// (with a marker + inventory row), so it surfaces through GATE-1/2/3. Each entry /// is a reviewable assertion that the value is not a tunable perceptual threshold: const BARE_FLOAT_ALLOWLIST: &[&str] = &[ - "0.0", // additive identity / origin / degenerate lower clamp bound. - "0.5", // midpoint / half (curve centre t=0.5, half-cosine ease, rounding). - "1.0", // multiplicative identity / unit upper clamp bound / purity ceiling. - "2.0", // doubling / diameter (chord = 2·C·sin(Δh/2), halving denominators). - "0.05", // hue-search STEP granularity (numerical resolution of the sentiment - // hue sweep), not a perceptual threshold — the continuous analogue of the - // `STRUCTURAL_NONPOLICY_ALLOWLIST` iteration counts. - "20.0", // cited categorical-hue-perception threshold (20°, Witzel & Gegenfurtner - // 2013) that recomputes the `S_PERC_MIN` DERIVATION-IDENTITY inline - // (`2·C_rep·sin(20°/2)`, see sentiment.rs `s_perc_min_from_chromas`). Excluded on - // the SAME grounds `NUMERIC_METHOD_ALLOWLIST` excludes `S_PERC_MIN` — recomputed / - // cited, not a new tunable policy literal (provenance held by the `S_PERC_MIN` doc). + "0.0", // additive identity / origin / degenerate lower clamp bound. + "0.5", // midpoint / half (curve centre t=0.5, half-cosine ease, rounding). + "1.0", // multiplicative identity / unit upper clamp bound / purity ceiling. + "2.0", // doubling / diameter (chord = 2·C·sin(Δh/2), halving denominators). "100.0", // CAM16 J lightness scale (0..100) / percent normalisation. "180.0", // half-turn in degrees (shortest-arc hue wrap: (Δ+180)%360−180). "255.0", // 8-bit sRGB channel quantisation (round·255 / 255). @@ -118,8 +110,6 @@ const NUMERIC_METHOD_ALLOWLIST: &[&str] = &[ // APCA / WCAG standard scaling + identities (lpc.rs). "LC_SCALE", "DELTA_Y_MIN", - // Derivation-identity (R2), recomputed not policy (sentiment.rs). - "S_PERC_MIN", // Pure numeric epsilons — non-perceptual. "RATIO_BISECT_EPS", "RATIO_EPS", @@ -158,7 +148,6 @@ const FORBIDDEN_STANDARD_ROW_NAMES: &[&str] = &[ "HK_CHROMA_EXPONENT", // Hellwig 0.587 "LC_SCALE", // APCA "DELTA_Y_MIN", // APCA - "S_PERC_MIN", // derivation-identity "RATIO_BISECT_EPS", // numeric EPS "AA_TEXT_RATIO", // WCAG "L_A", // CIECAM16 L_A=64 @@ -1053,14 +1042,13 @@ fn read_workspace_doc(rel: &str) -> Option { // // BUG CLASS this closes: the GATE-1/2/3 integer-row parser SKIPS the `M-` rows // (their first cell `M-01` is not a bare integer), so the Muddiness-Law constants -// of `cleanliness.rs` had NO in-repo gate — the SSOT once deferred them to +// из `cleanliness_legacy.rs` раньше не имели локального гейта: SSOT ссылался на // `.agents/tools/mud-oracle/verify_inventory.js`, which does not exist on this -// tree. This auditor pulls that check IN-TREE: every non-REMOVED `M-` row must -// join to a `pub const` in `cleanliness.rs` whose value equals the documented -// value at the row's DISPLAYED precision (the SSOT shows a rounded value; the -// const carries full precision, so the comparison is numeric-with-display-tol, not -// string-equality). `CUSP_L_TABLE` (M-13, value "see code") is checked for -// existence and its declared length 361. REMOVED rows are NOT re-checked here — +// отсутствующий внешний скрипт. Локальный аудитор связывает каждую активную +// M-строку с `pub const` в `cleanliness_legacy.rs` и сравнивает значение с +// точностью, показанной в SSOT. Это сохраняет полную точность кода и разрешает +// только документированное округление. Для `CUSP_L_TABLE` проверяется длина 361; +// удалённые строки здесь повторно не проверяются. // their absence is enforced by `cal_t_cal_b_absent_from_shipping_code` in // `cleanliness.rs` (no duplication). Shared verbatim by the gate and the RED-proof. // ───────────────────────────────────────────────────────────────────────────── @@ -1121,8 +1109,8 @@ fn parse_mud_rows(text: &str) -> Vec { } /// Extract `name -> value` for every `pub const … : f64 = …;` in the PRODUCTION -/// part of `cleanliness.rs` (everything before the first `#[cfg(test)]`), so a -/// test-only const can never satisfy an M-row. +/// продуктивной части `cleanliness_legacy.rs` до первого `#[cfg(test)]`, чтобы +/// тестовая константа не могла ложно удовлетворить M-строку. fn cleanliness_const_values(src: &str) -> std::collections::BTreeMap { let prod = src.split("#[cfg(test)]").next().unwrap_or(src); let mut map = std::collections::BTreeMap::new(); @@ -1154,7 +1142,7 @@ fn mud_row_defects(rows: &[MudRow], cleanliness_src: &str) -> Vec { if !cleanliness_src.contains("CUSP_L_TABLE: [f64; 361]") { defects.push(format!( "M-row `{}`: `CUSP_L_TABLE: [f64; 361]` not found in cleanliness.rs \ - (missing table or wrong declared length)", + (missing frozen table or wrong declared length)", r.name )); } @@ -1162,7 +1150,7 @@ fn mud_row_defects(rows: &[MudRow], cleanliness_src: &str) -> Vec { } let Some(&src_val) = consts.get(&r.name) else { defects.push(format!( - "M-row `{}`: no `pub const {}` in cleanliness.rs production code \ + "M-row `{}`: no `pub const {}` in cleanliness_legacy.rs production code \ (active row resolves to no source const)", r.name, r.name )); @@ -1259,11 +1247,11 @@ fn mud_rows_match_cleanliness_source() { active > 0, "mud auditor parsed zero ACTIVE M-rows — the parser is mis-scoped (a vacuous gate)." ); - let src = read_module("cleanliness.rs"); + let src = read_module("cleanliness_legacy.rs"); let defects = mud_row_defects(&rows, &src); assert!( defects.is_empty(), - "Muddiness-Law auditor FAILED — {} M-row(s) do not join to cleanliness.rs at the \ + "Muddiness-Law V1 auditor FAILED — {} M-row(s) do not join to cleanliness_legacy.rs at the \ documented value/precision:\n {}", defects.len(), defects.join("\n ") @@ -1837,14 +1825,14 @@ fn red_proof_audit_probe() { // ACTIVE M-row's value in an IN-MEMORY copy of the SSOT and run the *same* // `mud_row_defects` the live gate runs (INV-4), so a mutation to the auditor // is caught here, not green-from-birth. - let cleanliness_src = read_module("cleanliness.rs"); + let cleanliness_src = read_module("cleanliness_legacy.rs"); let mud_probe = "H_Y_DEG"; // Floor: the UNMUTATED SSOT must be GREEN against the source, so the drift below // is provably caused by the splice. let mud_baseline = mud_row_defects(&parse_mud_rows(&real_ssot), &cleanliness_src); assert!( mud_baseline.is_empty(), - "RED-proof FAILED — real SSOT M-rows are NOT GREEN against cleanliness.rs; the probe \ + "RED-proof FAILED — real SSOT M-rows are NOT GREEN against cleanliness_legacy.rs; the probe \ cannot prove 'mutation flips green→red' until they are in sync:\n {}", mud_baseline.join("\n ") ); diff --git a/crates/labcolors-core/tests/gate_green_smoke.rs b/crates/labcolors-core/tests/gate_green_smoke.rs index 120eda51..bdac9ffc 100644 --- a/crates/labcolors-core/tests/gate_green_smoke.rs +++ b/crates/labcolors-core/tests/gate_green_smoke.rs @@ -51,7 +51,6 @@ fn count_policy_consts_approx(source: &str) -> usize { "HK_CHROMA_EXPONENT", "LC_SCALE", "DELTA_Y_MIN", - "S_PERC_MIN", "RATIO_BISECT_EPS", "RATIO_EPS", "FLOOR_EPS", diff --git a/crates/labcolors-core/tests/neutral_quantization_exactness.rs b/crates/labcolors-core/tests/neutral_quantization_exactness.rs new file mode 100644 index 00000000..7dfbb806 --- /dev/null +++ b/crates/labcolors-core/tests/neutral_quantization_exactness.rs @@ -0,0 +1,159 @@ +use labcolors_core::LcsColor; +use labcolors_core::neutral::NeutralCurve; + +fn srgb_decode(encoded: f64) -> f64 { + if encoded <= 0.040_45 { + encoded / 12.92 + } else { + ((encoded + 0.055) / 1.055).powf(2.4) + } +} + +fn srgb_encode(linear: f64) -> f64 { + if linear > 0.003_130_8 { + 1.055 * linear.powf(1.0 / 2.4) - 0.055 + } else { + 12.92 * linear + } +} + +fn decode_byte(byte: u8) -> f64 { + srgb_decode(f64::from(byte) / 255.0) +} + +fn encode_byte(linear: f64) -> u8 { + (srgb_encode(linear).clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn first_transition_t(from: u8, to: u8, target: u8) -> f64 { + let start = decode_byte(from); + let end = decode_byte(to); + let crossed = |t: f64| { + let byte = encode_byte(start + (end - start) * t); + if to > from { + byte >= target + } else { + byte <= target + } + }; + + assert!(!crossed(0.0)); + assert!(crossed(1.0)); + + // Для положительных finite f64 порядок битов совпадает с числовым. Поэтому + // бинарный поиск идёт по ВСЕМ представимым t, а не по приближённой сетке. + let mut lo = 0.0_f64.to_bits(); + let mut hi = 1.0_f64.to_bits(); + while lo + 1 < hi { + let mid = lo + (hi - lo) / 2; + if crossed(f64::from_bits(mid)) { + hi = mid; + } else { + lo = mid; + } + } + f64::from_bits(hi) +} + +#[derive(Debug, Clone, Copy)] +struct Event { + t: f64, + channel: usize, + next_byte: u8, +} + +/// Независимый reference конечного пути: каждое событие ищется через реальный +/// encode+round production-контракт на полном множестве представимых f64 `t`. +fn enumerate_live_segment(from: [u8; 3], to: [u8; 3]) -> Vec<[u8; 3]> { + let mut events = Vec::new(); + for channel in 0..3 { + match to[channel].cmp(&from[channel]) { + std::cmp::Ordering::Greater => { + for next_byte in (from[channel] + 1)..=to[channel] { + events.push(Event { + t: first_transition_t(from[channel], to[channel], next_byte), + channel, + next_byte, + }); + } + } + std::cmp::Ordering::Less => { + for next_byte in to[channel]..from[channel] { + events.push(Event { + t: first_transition_t(from[channel], to[channel], next_byte), + channel, + next_byte, + }); + } + } + std::cmp::Ordering::Equal => {} + } + } + events.sort_by(|a, b| a.t.total_cmp(&b.t).then_with(|| a.channel.cmp(&b.channel))); + + let mut bytes = from; + let mut states = vec![bytes]; + let mut index = 0; + while index < events.len() { + let event_t = events[index].t; + while index < events.len() && events[index].t == event_t { + let event = events[index]; + bytes[event.channel] = event.next_byte; + index += 1; + } + states.push(bytes); + } + assert_eq!(bytes, to); + states +} + +fn hex([r, g, b]: [u8; 3]) -> String { + format!("#{r:02X}{g:02X}{b:02X}") +} + +#[test] +fn finite_neutral_path_uses_live_quantizer_not_inverse_half_walls() { + const LIGHT: [u8; 3] = [9, 10, 0]; + const BASE: [u8; 3] = [0, 1, 0]; + const DARK: [u8; 3] = [0, 0, 0]; + const LOST: [u8; 3] = [9, 9, 0]; + + let mut path = enumerate_live_segment(LIGHT, BASE); + path.extend(enumerate_live_segment(BASE, DARK).into_iter().skip(1)); + + let lost_index = path + .iter() + .position(|&state| state == LOST) + .expect("live encode+round path обязан содержать #090900"); + assert!(lost_index > 0 && lost_index + 1 < path.len()); + + let colors: Vec = path + .iter() + .map(|&state| LcsColor::from_hex(&hex(state)).unwrap()) + .collect(); + let mut cumulative = Vec::with_capacity(colors.len()); + let mut total = 0.0_f64; + let mut compensation = 0.0_f64; + cumulative.push(0.0); + for pair in colors.windows(2) { + let step = pair[0].delta_e_ucs(&pair[1]); + let corrected = step - compensation; + let next = total + corrected; + compensation = (next - total) - corrected; + total = next; + cumulative.push(total); + } + + // Центр Voronoi-интервала LOST по правильной накопленной длине: корректная + // finite-кривая обязана выбрать именно это состояние, без пограничного tie. + let left = 0.5 * (cumulative[lost_index - 1] + cumulative[lost_index]); + let right = 0.5 * (cumulative[lost_index] + cumulative[lost_index + 1]); + let t = 0.5 * (left + right) / total; + + let curve = NeutralCurve::new(&hex(LIGHT), &hex(BASE), &hex(DARK)).unwrap(); + assert_eq!( + curve.at(t).to_hex(), + hex(LOST), + "inverse-half-wall enumeration потеряло реально эмитируемое состояние" + ); +} diff --git a/crates/labcolors-core/tests/property_accent_surface.rs b/crates/labcolors-core/tests/property_accent_surface.rs index abebf6c1..a1fe55d3 100644 --- a/crates/labcolors-core/tests/property_accent_surface.rs +++ b/crates/labcolors-core/tests/property_accent_surface.rs @@ -1,215 +1,81 @@ -//! Property-инвариант ЗАКОНА ОДНОУРОВНЕВОСТИ для акцентных Background-рамп -//! (Класс В, Фаулер): пер-уровневые ШАГИ светлоты (CAM16-UCS J') выведенной -//! акцентной рампы равны шагам нейтральной рампы на ВСЁМ пространстве оттенков в -//! ОБЕИХ темах — а не на выбранных примерах. +//! Свойства акцентных поверхностей после конечной sRGB8-квантизации. //! -//! # Что ловит инвариант (КЛАСС багов) -//! -//! «Акцентный фон съезжает по светлоте относительно одноимённого нейтрального» — -//! ровно инцидент, которого боится владелец: тихий дрифт полярности/деривации -//! ядра рушит контраст у labui. Наивная деривация (фиксированная хрома, клип у -//! стены гамута, или пер-уровневый светлотный офсет) роняет этот тест с -//! минимизированным контрпримером. Красящий примитив держит светлоту нейтрали -//! по построению → шаги совпадают. -//! -//! # RED-доказательство (не green-from-birth) -//! -//! `red_proof_per_level_lightness_drift_breaks_the_gate` строит НАМЕРЕННО -//! сломанную рампу (пер-уровневый дрейф светлоты) и утверждает, что детектор -//! нарушений СРАБАТЫВАЕТ — тот же идиом, что `one_levelness_tests::red_proof_*` -//! и `agnostic_cleanliness::red_proof_*`. Детектор кусается, значит зелёный на -//! реальной деривации — не театр. -//! -//! # Допуск (DECLARED) -//! -//! Шаги сравниваются по J' ЭМИТИРОВАННОГО цвета (перцептивная светлота, которой -//! оперирует весь движок). Акцент несёт хрому, и CAM16-J хроматического стимула -//! чуть отклоняется от серого на той же Oklab-светлоте — это ФИЗИЧЕСКИЙ вобл, -//! не нарушение закона. `TOL` — задекларированная полоса этого вобла, замерена -//! `measure_step_mismatch_across_hues_and_themes` (печатает фактический максимум). +//! Тесты не сравнивают непрерывные `J′` и не вводят визуальный допуск. Целевой и +//! достигнутый H-K-уровни заново измеряются из тех hex, которые реально покидают +//! API; идентичность сравнивается как точная доля физического iso-HK-радиуса. -use labcolors_core::accent_balance::BalancedAccent; +use labcolors_core::curve::ColorCurve; use labcolors_core::neutral::{CurveParams, NeutralCurve}; +use labcolors_core::scale::{AccentCurve, emitted_perceived_lightness}; use labcolors_core::{LcsColor, ViewingConditions, derive_accent_surface_ramp}; -use proptest::prelude::*; -use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; - -/// Задекларированный допуск одноуровневости (J'): полоса хромового вобла CAM16-J -/// эмитированного цвета. -/// -/// РЕЖИМ СТЕНЫ ГАМУТА (провенанс): после унификации деривации на единый примитив -/// баланса ([`accent_balanced`]) хрома фона = `max_chroma(L, hue)` (стена гамута), -/// а не субтильная доля. На МАКСИМАЛЬНОЙ хроме вклад Гельмгольца-Кольрауша в -/// CAM16-J максимален, поэтому вобл J' эмиссии относительно серого шире, чем под -/// прежней долей. Замер `measure_step_mismatch_across_hues_and_themes` (72 тона × -/// 2 темы, печатает факт. максимум) держит худший |Δшаг| ≈ 1.07 J' у пурпурных -/// (hue≈305°) на мид-светлоте, где стена гамута наибольшая. TOL=1.5 — DECLARED -/// полоса с запасом над этим физическим максимумом (светлота НАСЛЕДУЕТСЯ из -/// нейтрали по построению; вобл — чисто хроматический CAM16-J, не дрейф Oklab-L). -const TOL: f64 = 1.5; -// ───────────────────────────────────────────────────────────────────────────── -// Нейтральные surface-рампы. Паттерн 2×3 (светлая: 2 осветления + база + 3 -// затемнения) и 3×2 (тёмная: 3 осветления + база + 2 затемнения) — t-позиции -// документируют НАМЕРЕНИЕ иерархии; равенство шагов держится при любом наборе t. -// ───────────────────────────────────────────────────────────────────────────── +const CLIENT_ANCHORS: [&str; 11] = [ + "#FF3B30", "#FFA100", "#FFD000", "#34C759", "#5AC8FA", "#00C7BE", "#3E87FF", "#5856D6", + "#AF52DE", "#FF2D55", "#B36A65", +]; -/// светлая: база=0.5; 2 ступени светлее, 3 темнее. -const LIGHT_TS: [f64; 6] = [0.18, 0.34, 0.50, 0.66, 0.80, 0.94]; -/// тёмная: база=0.5; 3 ступени светлее, 2 темнее. -const DARK_TS: [f64; 6] = [0.10, 0.24, 0.38, 0.50, 0.68, 0.86]; - -fn neutral_ramp(vc: &ViewingConditions, ts: &[f64]) -> Vec { - // Нейтральные якоря — не brand-anchor (тест-код в любом случае вне скана - // агностик-гейта, который стережёт только `src/`). - let curve = NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &CurveParams::default(), vc) - .expect("нейтральная кривая строится"); - ts.iter().map(|&t| curve.at(t)).collect() +fn neutral(vc: &ViewingConditions) -> NeutralCurve { + NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &CurveParams::default(), vc).unwrap() } -fn jps(ramp: &[LcsColor]) -> Vec { - ramp.iter().map(|c| c.jp).collect() -} - -/// J' эмитированных ступеней акцентной рампы (`BalancedAccent` несёт цвет + флаг). -fn accent_jps(ramp: &[BalancedAccent]) -> Vec { - ramp.iter().map(|b| b.color.jp).collect() -} - -/// Пер-уровневые нарушения одноуровневости: `|Δшаг акцента − Δшаг нейтрали| > tol`. -/// Пустой вектор = гейт зелёный. -fn step_violations(neutral_jp: &[f64], accent_jp: &[f64], tol: f64) -> Vec { - assert_eq!( - neutral_jp.len(), - accent_jp.len(), - "рампы обязаны быть одной длины" - ); - let mut out = Vec::new(); - for i in 0..neutral_jp.len().saturating_sub(1) { - let dn = neutral_jp[i + 1] - neutral_jp[i]; - let da = accent_jp[i + 1] - accent_jp[i]; - if (da - dn).abs() > tol { - out.push(format!( - "шаг {i}: акцент Δ{da:.4} vs нейтраль Δ{dn:.4} (|Δ|={:.4} > {tol})", - (da - dn).abs() - )); +#[test] +fn metadata_equals_remeasurement_of_every_emitted_hex() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + let curve = neutral(&vc); + let neutral_levels = curve.sample(17); + for anchor in CLIENT_ANCHORS { + let levels = derive_accent_surface_ramp(&neutral_levels, anchor, &vc).unwrap(); + assert_eq!(levels.len(), neutral_levels.len()); + for (neutral, accent) in neutral_levels.iter().zip(&levels) { + let neutral_hex = neutral.to_hex_with_vc(&vc); + let accent_hex = accent.color.to_hex_with_vc(&vc); + assert_eq!( + accent.target_hk.to_bits(), + emitted_perceived_lightness(&neutral_hex, &vc) + .unwrap() + .to_bits() + ); + assert_eq!( + accent.achieved_hk.to_bits(), + emitted_perceived_lightness(&accent_hex, &vc) + .unwrap() + .to_bits() + ); + assert!(LcsColor::from_hex_with_vc(&accent_hex, &vc).is_ok()); + } } } - out -} - -/// Детерминированный proptest-раннер (фиксированный seed → воспроизводимо, без флака). -fn check(cases: u32, strategy: S, body: impl Fn(S::Value) -> Result<(), TestCaseError>) -where - S::Value: std::fmt::Debug, -{ - let config = Config { - cases, - failure_persistence: None, - ..Config::default() - }; - let mut runner = - TestRunner::new_with_rng(config, TestRng::deterministic_rng(RngAlgorithm::ChaCha)); - runner - .run(&strategy, body) - .expect("одноуровневость нарушена — см. минимизированный контрпример выше"); -} - -// ───────────────────────────────────────────────────────────────────────────── -// GREEN: реальная деривация одноуровнева с нейтралью в ОБЕИХ темах, ≥20 hue. -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn accent_surface_ramp_is_one_level_with_neutral_light() { - let vc = ViewingConditions::srgb(); - let neutral = neutral_ramp(&vc, &LIGHT_TS); - let neutral_jp = jps(&neutral); - check(64, 0.0f64..360.0, move |hue| { - let accent = derive_accent_surface_ramp(&neutral, hue, &vc); - let v = step_violations(&neutral_jp, &accent_jps(&accent), TOL); - prop_assert!(v.is_empty(), "light hue={hue:.2}: {v:?}"); - Ok(()) - }); } #[test] -fn accent_surface_ramp_is_one_level_with_neutral_dark() { - let vc = ViewingConditions::dim_surround(); - let neutral = neutral_ramp(&vc, &DARK_TS); - let neutral_jp = jps(&neutral); - check(64, 0.0f64..360.0, move |hue| { - let accent = derive_accent_surface_ramp(&neutral, hue, &vc); - let v = step_violations(&neutral_jp, &accent_jps(&accent), TOL); - prop_assert!(v.is_empty(), "dark hue={hue:.2}: {v:?}"); - Ok(()) - }); +fn every_level_preserves_the_same_anchor_radius_fraction() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + let curve = neutral(&vc); + let neutral_levels = curve.sample(13); + for anchor in CLIENT_ANCHORS { + let expected = AccentCurve::new(anchor, &curve).unwrap().sat_ratio(); + let levels = derive_accent_surface_ramp(&neutral_levels, anchor, &vc).unwrap(); + assert!(levels.iter().all(|level| { + level.chroma_ratio.to_bits() == expected.to_bits() + && (0.0..=1.0).contains(&level.chroma_ratio) + })); + } + } } -// ───────────────────────────────────────────────────────────────────────────── -// RED-proof: детектор кусается на намеренно сломанной рампе. -// ───────────────────────────────────────────────────────────────────────────── - #[test] -fn red_proof_per_level_lightness_drift_breaks_the_gate() { +fn muted_anchor_is_not_promoted_to_the_gamut_wall() { let vc = ViewingConditions::srgb(); - let neutral = neutral_ramp(&vc, &LIGHT_TS); - let accent = derive_accent_surface_ramp(&neutral, 264.0, &vc); - let n = jps(&neutral); - let a = accent_jps(&accent); - - // Предусловие: реальная рампа зелёная (splice обязан флипать green→red). - assert!( - step_violations(&n, &a, TOL).is_empty(), - "предусловие RED-proof: реальная акцентная рампа одноуровнева" - ); - - // Мутация: пер-уровневый светлотный дрейф (ступень += i×2 J') рушит шаги — - // ровно класс «акцент съехал по светлоте», который гейт обязан ловить. - let drifted: Vec = a - .iter() - .enumerate() - .map(|(i, &jp)| jp + i as f64 * 2.0) - .collect(); - let v = step_violations(&n, &drifted, TOL); - assert!( - !v.is_empty(), - "пер-уровневый дрейф светлоты обязан уронить гейт одноуровневости" - ); + let curve = neutral(&vc); + let muted = AccentCurve::new("#B36A65", &curve).unwrap().sat_ratio(); + let boundary = AccentCurve::new("#3E87FF", &curve).unwrap().sat_ratio(); + assert!((0.0..1.0).contains(&muted)); + assert_eq!(boundary.to_bits(), 1.0_f64.to_bits()); } -// ───────────────────────────────────────────────────────────────────────────── -// Замер: печатает фактический максимум |Δшаг| — заземление DECLARED-допуска TOL. -// ───────────────────────────────────────────────────────────────────────────── - #[test] -fn measure_step_mismatch_across_hues_and_themes() { - let mut worst = 0.0f64; - let mut worst_ctx = String::new(); - for (label, vc, ts) in [ - ("light", ViewingConditions::srgb(), &LIGHT_TS), - ("dark", ViewingConditions::dim_surround(), &DARK_TS), - ] { - let neutral = neutral_ramp(&vc, ts); - let n = jps(&neutral); - // 72 равноотстоящих оттенка, шаг 5° (хрома — стена гамута, не ручка). - for k in 0..72 { - let hue = k as f64 * 5.0; - let accent = derive_accent_surface_ramp(&neutral, hue, &vc); - let a = accent_jps(&accent); - for i in 0..n.len() - 1 { - let d = ((a[i + 1] - a[i]) - (n[i + 1] - n[i])).abs(); - if d > worst { - worst = d; - worst_ctx = format!("{label} hue={hue} step={i}"); - } - } - } - } - eprintln!( - "ONE-LEVELNESS max|Δstep|={worst:.5} J' at [{worst_ctx}] (TOL={TOL}) — DECLARED-допуск хромового вобла" - ); - assert!( - worst <= TOL, - "фактический вобл {worst:.5} превысил задекларированный TOL={TOL} — пересмотреть допуск" - ); +fn invalid_anchor_is_an_error_not_a_partial_ramp() { + let vc = ViewingConditions::srgb(); + let curve = neutral(&vc); + assert!(derive_accent_surface_ramp(&curve.sample(5), "#GGGGGG", &vc).is_err()); } diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index 4381285b..bd4935a0 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -93,7 +93,7 @@ fn vc_of(i: usize) -> ViewingConditions { fn wcag_ratio_from_hex(fg: &str, bg: &str) -> f64 { fn channel(byte: u8) -> f64 { let c = f64::from(byte) / 255.0; - if c <= 0.039_28 { + if c <= 0.040_45 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) @@ -337,11 +337,7 @@ fn base_config( categories: vec![SentimentCategory { name: "alert".to_string(), family: "fam".to_string(), - hue_floor_deg: None, - preferred_side: None, }], - hardness: 5.0, - chroma_fraction: 0.88, }, ThemesConfig { entries: vec![ @@ -517,9 +513,14 @@ fn hued_brand_label_preserves_family_hue_where_it_has_chroma() { // Оттенок семьи бренда — из ФАКТИЧЕСКИ построенных светлого и тёмного якорей // (та же lighten/darken, что в base_config), берём оба края семьи. let (br, bg_, bb) = brand; - let brand_hue_light = - oklch_from_hex(&hex_of(lighten(br), lighten(bg_), lighten(bb))).unwrap()[2]; - let brand_hue_dark = oklch_from_hex(&hex_of(darken(br), darken(bg_), darken(bb))).unwrap()[2]; + let brand_hue_light = oklch_from_hex(&hex_of(lighten(br), lighten(bg_), lighten(bb))) + .unwrap() + .h_deg + .expect("цветной светлый якорь имеет hue"); + let brand_hue_dark = oklch_from_hex(&hex_of(darken(br), darken(bg_), darken(bb))) + .unwrap() + .h_deg + .expect("цветной тёмный якорь имеет hue"); check( 400, @@ -530,15 +531,17 @@ fn hued_brand_label_preserves_family_hue_where_it_has_chroma() { let set = resolve_named_set(&bg, &table, &vc); let label = set.iter().find(|(n, _)| n == "brand-label").map(|(_, r)| r); if let Some(Resolved::Color { solved, .. }) = label { - let [_, chroma, hue] = oklch_from_hex(solved.hex()).unwrap(); + let coordinates = oklch_from_hex(solved.hex()).unwrap(); // Только там, где есть реальный цвет (иначе оттенок численно шумит). - if chroma > 0.03 { + if coordinates.c > 0.03 { + let hue = coordinates.h_deg.expect("цвет выше порога имеет hue"); let dist = hue_distance(brand_hue_light, hue).min(hue_distance(brand_hue_dark, hue)); prop_assert!( dist <= 35.0, - "бренд-лейбл на {} ушёл {dist:.1}° от семьи (hue {hue:.1}°, C={chroma:.3}, hex {})", + "бренд-лейбл на {} ушёл {dist:.1}° от семьи (hue {hue:.1}°, C={:.3}, hex {})", hex_of(r, g, b), + coordinates.c, solved.hex() ); } diff --git a/crates/labcolors-core/tests/reference_vectors.rs b/crates/labcolors-core/tests/reference_vectors.rs index 6ad3f3ac..d02f179b 100644 --- a/crates/labcolors-core/tests/reference_vectors.rs +++ b/crates/labcolors-core/tests/reference_vectors.rs @@ -118,14 +118,24 @@ fn hk_lifts_saturated_blue_via_public_lpc() { // Oklab — Ottosson (2020) / CSS Color 4 published landmarks (public `oklch_from_hex`). // ───────────────────────────────────────────────────────────────────────────── -/// sRGB white maps to Oklab `L = 1`, chroma ≈ 0 — Ottosson's defining constraint -/// (XYZ→Oklab table row 1). The published model leaves a ~6.5e-9 offset in L, so -/// the tolerance is 1e-6, not exact. +/// Белый sRGB переходит в Oklab `L = 1`, `C = 0` — определяющий инвариант +/// Ottosson (первая строка таблицы XYZ→Oklab). Согласованная матричная пара +/// оставляет только ошибку округления одной операции f64, а не систематическое +/// расхождение независимо округлённых таблиц. #[test] fn oklab_white_is_l1_c0() { - let [l, c, _h] = oklch_from_hex("#FFFFFF").expect("white is valid"); - assert!((l - 1.0).abs() < 1e-6, "white L must be 1, got {l}"); - assert!(c < 1e-3, "white chroma must be ≈0, got {c}"); + let coordinates = oklch_from_hex("#FFFFFF").expect("white is valid"); + assert!( + (coordinates.l - 1.0).abs() <= f64::EPSILON, + "L белого должна быть 1, получено {}", + coordinates.l + ); + assert!( + coordinates.c <= f64::EPSILON, + "хрома белого должна быть 0, получено {}", + coordinates.c + ); + assert_eq!(coordinates.h_deg, None, "hue белого не определена"); } /// The Oklab hue angles of the sRGB primaries are the published canonical values @@ -135,7 +145,10 @@ fn oklab_white_is_l1_c0() { #[test] fn oklch_primary_hues() { for (hex, want_h) in [("#FF0000", 29.23), ("#00FF00", 142.5), ("#0000FF", 264.05)] { - let h = oklch_from_hex(hex).expect("primary is valid")[2]; + let h = oklch_from_hex(hex) + .expect("primary is valid") + .h_deg + .expect("primary has hue"); let dh = ((h - want_h + 180.0).rem_euclid(360.0) - 180.0).abs(); assert!( dh < 1.0, @@ -143,9 +156,11 @@ fn oklch_primary_hues() { ); } // Sign contract per Ottosson: red has a>0, blue has b<0. - let [_, cr, hr] = oklch_from_hex("#FF0000").unwrap(); + let red = oklch_from_hex("#FF0000").unwrap(); + let (cr, hr) = (red.c, red.h_deg.unwrap()); assert!(cr * hr.to_radians().cos() > 0.0, "red must have a > 0"); - let [_, cb, hb] = oklch_from_hex("#0000FF").unwrap(); + let blue = oklch_from_hex("#0000FF").unwrap(); + let (cb, hb) = (blue.c, blue.h_deg.unwrap()); assert!(cb * hb.to_radians().sin() < 0.0, "blue must have b < 0"); } @@ -352,9 +367,9 @@ fn render_fixture() -> String { s } -/// GENERATOR (run once with `--ignored`): writes the committed JS-parity fixture -/// from the live core emitter. The committed file is the artifact; the anti-drift -/// test below guards it, and `reference-vectors.test.mjs` consumes it. +/// Генератор (запускается вручную с `--ignored`) записывает JS-фикстуру из +/// текущего эмиттера ядра. Зафиксированный файл остаётся проверяемым артефактом: +/// тест ниже ловит его дрейф, а `reference-vectors.test.mjs` проверяет декодирование. /// /// `cargo test -p labcolors-core --test reference_vectors emit_oklch_core_vectors_fixture -- --ignored` #[test] @@ -371,10 +386,14 @@ struct FxLine { atok: String, l: f64, c: f64, - h: f64, + h: Option, } -/// Parse one `#RRGGBB|atok|oklch(L% C H[ / A])` fixture line. +/// Разбирает одну строку `#RRGGBB|atok|oklch(L% C H[ / A])`. +/// +/// Hue хранится как `Option`: CSS Color 4 требует `none` у автоматически +/// полученного powerless-компонента. Подмена `none` числом скрыла бы различие +/// между неопределённым оттенком ахромата и настоящим направлением 0°. fn parse_fixture_line(line: &str) -> FxLine { let mut parts = line.splitn(3, '|'); let hex = parts.next().expect("hex field").to_string(); @@ -386,39 +405,40 @@ fn parse_fixture_line(line: &str) -> FxLine { .unwrap_or_else(|| panic!("malformed css: {css}")); // Drop the alpha suffix (validated separately via `atok`). let lch = inner.split(" / ").next().unwrap(); - let nums: Vec = lch - .split_whitespace() - .map(|t| { - t.trim_end_matches('%') + let mut components = lch.split_whitespace(); + let l = components + .next() + .expect("компонент L") + .trim_end_matches('%') + .parse::() + .expect("числовой компонент L"); + let c = components + .next() + .expect("компонент C") + .parse::() + .expect("числовой компонент C"); + let h_token = components.next().expect("компонент H"); + assert!(components.next().is_none(), "ожидались ровно L C H в {css}"); + let h = if h_token == "none" { + None + } else { + Some( + h_token .parse::() - .expect("numeric component") - }) - .collect(); - assert_eq!(nums.len(), 3, "expected L C H in {css}"); - FxLine { - hex, - atok, - l: nums[0], - c: nums[1], - h: nums[2], - } + .expect("числовой компонент H или none"), + ) + }; + FxLine { hex, atok, l, c, h } } -/// ANTI-DRIFT: the committed fixture must match a fresh render from the live -/// `oklch_css_from_hex` — same count, same order, same seed hexes and alpha -/// tokens, and numeric components equal to within one printed-digit step. +/// Защита от дрейфа: фикстура обязана совпадать со свежей эмиссией +/// `oklch_css_from_hex` по числу, порядку, исходным hex, alpha и состоянию hue. /// -/// Structural fields (hex, alpha token, line count/order) are compared EXACTLY. -/// The numeric L/C/H are compared with a tolerance of 2× the emitter's printed -/// granularity (L% `.5f`→2e-5, C `.6f`→2e-6, H `.3f`→2e-3): the ONLY thing that -/// can move a value inside that band is a last-digit rounding flip from a 1-ULP -/// `cbrt`/`atan2`/`powf` difference between the platform that generated the -/// committed file and the platform running CI — never a real change (a formula -/// or precision regression moves values by orders more, well outside the band). -/// The HARD correctness gate stays exact: `oklch::round_trip_is_byte_exact_*` -/// (Rust) and `reference-vectors.test.mjs` (JS decodes each committed string to -/// the seed bytes, platform-independently). If this fails, regenerate with -/// `emit_oklch_core_vectors_fixture --ignored` and re-run the JS parity. +/// Структурные поля и `None`/числовой hue сравниваются точно. Числа L/C/H — с +/// допуском двух единиц последнего печатаемого разряда: этого достаточно только +/// для межплатформенного переворота округления на последней цифре, но не для +/// изменения формулы. Жёсткую корректность отдельно держат байтовые round-trip +/// тесты Rust и JS. #[test] fn oklch_core_vectors_fixture_is_fresh() { let path = format!("{}{FIXTURE_REL}", env!("CARGO_MANIFEST_DIR")); @@ -441,15 +461,25 @@ fn oklch_core_vectors_fixture_is_fresh() { for (i, (a, b)) in c.iter().zip(f.iter()).enumerate() { let (ca, cb) = (parse_fixture_line(a), parse_fixture_line(b)); let n = i + 1; - // Structure: exact. - assert_eq!(ca.hex, cb.hex, "line {n}: seed hex drifted"); - assert_eq!(ca.atok, cb.atok, "line {n}: alpha token drifted"); - // Numeric: within one printed-digit step (cross-platform ULP guard). + // Структура сравнивается точно: она не зависит от округления f64. + assert_eq!(ca.hex, cb.hex, "строка {n}: изменился исходный hex"); + assert_eq!(ca.atok, cb.atok, "строка {n}: изменился alpha-токен"); + // Для чисел разрешён только межплатформенный переворот последней цифры. assert!( (ca.l - cb.l).abs() < 2e-5, - "line {n}: L% drifted {a} vs {b}" + "строка {n}: изменился L%: {a} против {b}" + ); + assert!( + (ca.c - cb.c).abs() < 2e-6, + "строка {n}: изменилась C: {a} против {b}" ); - assert!((ca.c - cb.c).abs() < 2e-6, "line {n}: C drifted {a} vs {b}"); - assert!((ca.h - cb.h).abs() < 2e-3, "line {n}: H drifted {a} vs {b}"); + match (ca.h, cb.h) { + (None, None) => {} + (Some(left), Some(right)) => assert!( + (left - right).abs() < 2e-3, + "строка {n}: изменился H: {a} против {b}" + ), + _ => panic!("строка {n}: изменилось состояние powerless hue: {a} против {b}"), + } } } diff --git a/crates/labcolors-core/tests/sentiment_categorical_zones.rs b/crates/labcolors-core/tests/sentiment_categorical_zones.rs index cd98d6b3..9ccb7d83 100644 --- a/crates/labcolors-core/tests/sentiment_categorical_zones.rs +++ b/crates/labcolors-core/tests/sentiment_categorical_zones.rs @@ -1,95 +1,28 @@ -//! Задача Волны 1 «правда»: категориальные зоны хью сентиментов вместо жёсткого -//! лока сепарации от бренда. -//! -//! ПОЧЕМУ этот тест существует (класс бага, не заплатка): прежний закон форсировал -//! КАЖДЫЙ сентимент отойти от бренда минимум на `s_min`≈20.7° как ЖЁСТКУЮ -//! легальность. Для бренда labui (синий, Oklab hue ~253–263°) это толкало Info -//! (прототип синий 259.89°, якорь #3E87FF) в ПУРПУР (#7579FE и т.п.) — хотя -//! семантически инфо-зона легитимно ВКЛЮЧАЕТ синий, и совпадение инфо с брендовым -//! синим НЕ ошибка (решение владельца). -//! -//! Заземление зон (лестница объективизации): -//! - Существование стабильного фокуса на категорию и РАЗМЫТОСТЬ границ — -//! (b) GROUNDED: Berlin & Kay (1969); Kay & McDaniel (1978, fuzzy-set модель, -//! DOI 10.1353/lan.1978.0035); Regier, Kay & Cook (2005, PNAS 102(23):8386, -//! DOI 10.1073/pnas.0503281102 — фокусы кластеризуются ТЕСНЕЕ границ). -//! - Литература НЕ даёт основания форсировать сепарацию от бренда — только -//! держать оттенок ВНУТРИ своей категории. Значит правило = зона, лок = исключение. -//! - Конкретный Oklab-край зоны — (e) DESIGN-CHOICE (все источники до Oklab; -//! честная граница при размытой реальной — середина к соседнему фокусу). -//! -//! Тест написан ДО реализации (TDD RED): на текущем законе п.1 падает -//! (Info уходит в пурпур), после закона зон — зелёный. +//! Публичный контракт сентиментной геометрии V2. -use labcolors_core::oklch_from_hex; -use labcolors_core::sentiment::resolve_config_sentiment_solid; +use labcolors_core::sentiment::{ + SENTIMENT_GEOMETRY_V2, minimum_hue_separation_deg, resolve_config_sentiment_solid_v2, +}; -/// Oklab-оттенок (градусы) отгружаемого hex. -fn hue(hex: &str) -> f64 { - oklch_from_hex(hex).expect("валидный hex")[2] -} -/// Кратчайшее угловое расстояние (градусы) по кругу оттенка. -fn ang(a: f64, b: f64) -> f64 { - let d = (a - b).rem_euclid(360.0); - if d > 180.0 { 360.0 - d } else { d } -} - -// Якоря сентимент-семейств labui (Figma Accent/*, Light-mode, замер 2026-06-30). -const RED: &str = "#FF3B30"; -const ORANGE: &str = "#FFA100"; -const GREEN: &str = "#34C759"; -const BLUE: &str = "#3E87FF"; - -/// ЦЕНТРАЛЬНОЕ требование владельца: Info отдыхает на своём синем якоре — -/// совпадение инфо↔бренд легитимно, не пурпур. Brand-независимость гарантирована -/// СИГНАТУРОЙ: `resolve_config_sentiment_solid` бренд не принимает вовсе (прежний -/// форс сепарации снесён), поэтому «при любом бренде» — не цикл, а тип; фиктивный -/// перебор брендов давал бы ложное ощущение brand-aware покрытия. -/// Порог 2° >> квантование, << прежнего смещения ≈13–20°. #[test] -fn info_rests_on_its_blue_anchor_brand_is_not_an_input() { - let proto = hue(BLUE); // 259.89° - let solid = resolve_config_sentiment_solid(BLUE, 1.0, None).expect("Info резолвится"); - let got = hue(&solid); - assert!( - ang(got, proto) <= 2.0, - "Info обязан отдыхать на синем якоре {proto:.2}°, получено {got:.2}° (solid {solid}) — \ - инфо-зона включает синий, совпадение с брендом не ошибка" - ); +fn v2_preserves_the_client_anchor_instead_of_classifying_its_name() { + for anchor in ["#FF3B30", "#FFA100", "#34C759", "#3E87FF", "#808080"] { + assert_eq!(resolve_config_sentiment_solid_v2(anchor).unwrap(), anchor); + } + assert_eq!(SENTIMENT_GEOMETRY_V2, "anchor-distance-v2"); } -/// КЛАСС инварианта (обобщение): для ЛЮБОГО сентимента, когда бренд совпадает с -/// оттенком его собственного якоря, сентимент отдыхает на якоре — совпадение с -/// брендом внутри своей зоны легитимно для всех категорий (инверсия по всем -/// сентиментам, как требует задача). #[test] -fn every_sentiment_rests_when_brand_coincides_with_its_own_anchor() { - for anchor in [RED, ORANGE, GREEN, BLUE] { - let proto = hue(anchor); - // hue_floor только у warning (оранжевый); для якоря orange 68.61° > 45° он - // не кусается, оставляем None у остальных, Some(45) у orange для честности. - let floor = if anchor == ORANGE { Some(45.0) } else { None }; - let solid = resolve_config_sentiment_solid(anchor, 1.0, floor).expect("резолвится"); - let got = hue(&solid); - assert!( - ang(got, proto) <= 2.0, - "сентимент-якорь {anchor} при бренде НА своём оттенке {proto:.2}° обязан \ - отдыхать (получено {got:.2}°) — сепарация от бренда больше не форсируется" - ); - } +fn unequal_radii_use_the_full_law_of_cosines() { + let c1 = 0.23_f64; + let c2 = 0.11_f64; + let angle = 73.0_f64; + let distance = (c1 * c1 + c2 * c2 - 2.0 * c1 * c2 * angle.to_radians().cos()).sqrt(); + let recovered = minimum_hue_separation_deg(distance, c1, c2).unwrap(); + assert!((recovered - angle).abs() < 1.0e-12); } -/// Warning остаётся в оранжевой зоне и НЕ сползает в красную (danger) — пол 45° -/// как задокументированное исключение. Бренд в резолв не входит (не параметр -/// `resolve_config_sentiment_solid`), поэтому одного вызова достаточно — перебор -/// «всех брендов» повторял бы идентичный ассерт 360 раз. #[test] -fn warning_stays_in_the_orange_zone_above_the_documented_floor() { - let solid = - resolve_config_sentiment_solid(ORANGE, 1.0, Some(45.0)).expect("Warning резолвится"); - let got = hue(&solid); - assert!( - got >= 45.0 - 1e-6, - "Warning оттенок {got:.2}° ниже пола 45° (сполз в красную зону)" - ); +fn an_impossible_pair_is_an_error_not_a_skipped_constraint() { + assert!(minimum_hue_separation_deg(0.41, 0.20, 0.20).is_err()); } diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index eea9cfb7..32350354 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -312,7 +312,10 @@ pub fn min_alpha(tint: String, bg: String) -> Result { min_alpha_hex(&tint, &bg).map_err(|reason| ColorError::InvalidColor { reason }) } -/// Оценка мутности («грязи») цвета `[0,1]`: 0 — чистый, 1 — грязный. +/// Замороженная историческая гипотеза Dirt Law V1 в диапазоне `[0,1]`. +/// +/// Имя сохранено для совместимости. Результат не является вероятностью и не +/// выдаётся за универсальную психофизическую классификацию наблюдателей. /// /// # Errors /// @@ -388,13 +391,13 @@ mod tests { } #[test] - fn muddiness_orders_olive_above_clean_gray() { + fn legacy_muddiness_preserves_frozen_vector_order() { // Значения — факт ядра (см. conformance/vectors/muddiness.json): серый // почти чист, олива заметно мутнее. Проверяем ПОРЯДОК и чистоту серого, // не магическое число. let olive = muddiness("#6B6B2E".into()).unwrap(); let gray = muddiness("#808080".into()).unwrap(); - assert!(gray < 0.05, "серый должен быть чистым, получено {gray}"); - assert!(olive > gray, "олива должна быть мутнее серого"); + assert!(gray < 0.05, "замороженный V1 grey-вектор изменился: {gray}"); + assert!(olive > gray, "замороженный порядок V1-векторов изменился"); } } diff --git a/crates/labcolors-wasm/benches/resolve.rs b/crates/labcolors-wasm/benches/resolve.rs index 6a99b021..787c86cf 100644 --- a/crates/labcolors-wasm/benches/resolve.rs +++ b/crates/labcolors-wasm/benches/resolve.rs @@ -1,21 +1,18 @@ -//! Criterion bench of the solve cache-MISS path, -//! `labcolors_core::resolve_named_set` on the real labui role table. -//! Permanent counterpart of labcolors-core's `forward` bench: guards the -//! realtime resolve budget (`apply_floor` bisection is the dominant term). +//! Criterion-измерение стоимости самого `labcolors_core::resolve_named_set` на +//! реальной таблице ролей labui. //! -//! The table is compiled through the SAME path `load_config` uses: the frozen -//! `labui.config.json` passport → `ConfigDto` → `ThemeConfig` → compile. So the -//! bench measures the honest live sweep a `resolveTheme` cache-MISS runs, over -//! the real 100-role labui contract — not a synthetic table. +//! Таблица компилируется тем же путём, что использует `load_config`: паспорт +//! `labui.config.json` → `ConfigDto` → `ThemeConfig` → compile. Здесь намеренно +//! нет утверждений о попадании контрактного кэша: этот benchmark вызывает ядро +//! напрямую и измеряет работу, лежащую под кэшем. Настоящие hit/miss всей +//! WASM-границы измеряет `packages/colors/bench/wasm-boundary.bench.mjs`. //! -//! Two scenarios: -//! * `fixed_bg` — one background, re-resolved. After iteration 1 the process -//! curve-plan cache is warm; this is steady-state cost. -//! * `rotating_bg` — a rotating set of realistic design-system backgrounds -//! (glass/blur/gradient averages), each a genuine curve-plan cache-MISS. This -//! is the realtime "background varies constantly" cost the task targets. +//! Два сценария не маскируют повторения: +//! * `fixed_bg` — один фон принудительно решается заново на каждой итерации; +//! * `rotating_bg` — циклическая нагрузка из нескольких реальных фонов. После +//! первого круга значения повторяются, поэтому это workload, а не «промах». //! -//! Run: `cargo +1.96.0 bench -p labcolors-wasm --bench resolve`. +//! Запуск: `cargo bench -p labcolors-wasm --bench resolve`. use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; use labcolors_core::{BgInput, NamedRoleTable, ViewingConditions, resolve_named_set}; @@ -28,10 +25,8 @@ fn labui_table() -> NamedRoleTable { cfg.compile_named_role_table().expect("labui compiles") } -/// Realistic design-system backgrounds a reactive surface varies across: solid -/// greys, tinted glass averages, gradient midpoints, image-sample means. Each is -/// a distinct curve-plan cache key, so rotating through them is a cache-MISS on -/// every iteration — the realtime scenario. +/// Реальные типы подложек: нейтрали, средние glass/gradient и выборки изображения. +/// Массив конечен и цикличен; это свойство явно учитывается в интерпретации цифр. const ROTATING_BGS: [&str; 12] = [ "#FFFFFF", "#F7F8FA", "#EEF1F5", "#DCE3EC", "#B8C2D0", "#8A94A6", "#5C6472", "#3A3F4B", "#22262E", "#14171C", "#0B0D10", "#101012", @@ -44,7 +39,7 @@ fn bench_resolve(c: &mut Criterion) { ("dim", ViewingConditions::dim_surround()), ]; - // Scenario A: fixed background, steady-state (WARM curve-plan cache). + // Фиксированный вход отделяет стоимость повторного solve от вариативности фона. let mut fixed = c.benchmark_group("resolve_named_set/fixed_bg"); for (label, vc) in &vcs { for bg_hex in ["#FFFFFF", "#787880", "#101012"] { @@ -60,7 +55,7 @@ fn bench_resolve(c: &mut Criterion) { } fixed.finish(); - // Scenario B: rotating backgrounds, cache-MISS on every resolve (realtime). + // Циклический workload показывает цену смены входа, но не называется промахом. let mut rot = c.benchmark_group("resolve_named_set/rotating_bg"); let bgs: Vec = ROTATING_BGS .iter() diff --git a/crates/labcolors-wasm/src/cache.rs b/crates/labcolors-wasm/src/cache.rs index 10997807..f0d9fcfc 100644 --- a/crates/labcolors-wasm/src/cache.rs +++ b/crates/labcolors-wasm/src/cache.rs @@ -1,115 +1,161 @@ -//! A contract cache for resolved theme sets, keyed by `(bgHex, theme, table -//! fingerprint)`. +//! Контрактный кэш последнего резолва в каждом публичном слоте темы. //! -//! Re-solving the same background under the same theme is the common case while -//! a tool tweaks a colour, and a resolve sweep is real work. The cache returns -//! the byte-identical prior result for a repeated key. It is *contractual*: the -//! key carries every input that can change the output, so a hit is always -//! correct, never stale. +//! Полный результат содержит весь набор ролей и его JSON-проекцию, поэтому +//! число произвольных фонов нельзя превращать в число одновременно живых +//! результатов. Здесь нет лимита «на глаз» и порога массовой очистки: структура +//! имеет ровно по одному слоту на каждый вариант [`Theme`]. Новый фон заменяет +//! предыдущий результат только своей темы, а остальные темы не охлаждаются. //! -//! The table fingerprint is the third key component. A loaded config -//! (`loadConfig`) carries a real fingerprint — an FNV-1a over its canonical DTO, -//! computed in the engine and threaded into the key. Correctness across a config -//! switch does not rest on that fingerprint being unique, though: `loadConfig` -//! wholesale-clears the cache (see [`ContractCache::clear`]), so exactly one key -//! namespace is ever live and a stale entry from another config cannot be served. +//! Ключ хранит все входы, способные изменить результат. При успешной загрузке +//! конфига движок дополнительно очищает все слоты, поэтому корректность при +//! смене контракта не зависит от отсутствия коллизий 64-битного отпечатка. //! -//! Single-threaded by design: WASM has no threads, so a `RefCell` interior is -//! the right shared-mutability tool — no lock, no contention, no `Send` bound. +//! WASM-движок однопоточный, поэтому `RefCell` даёт нужную внутреннюю +//! изменяемость без блокировок и ложной гарантии `Send`. use std::cell::RefCell; -use std::collections::HashMap; use crate::theme::Theme; -/// A stable, arbitrary fingerprint used by the cache's own unit tests as a -/// single key namespace. Production keys always carry a real config fingerprint -/// (an FNV-1a over the canonical DTO, computed in the engine); this constant -/// exists only so the cache tests can key on a fixed value. +/// Стабильное пространство ключей для модульных тестов самого кэша. #[cfg(test)] pub(crate) const DEFAULT_TABLE_FINGERPRINT: u64 = 0; -/// The full key of a cached resolve: every input that can change the output. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Полный ключ резолва внутри одного загруженного контракта. +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CacheKey { bg_hex: String, - theme: &'static str, + theme: Theme, table_fingerprint: u64, } impl CacheKey { - /// Build a key from a normalised background hex, a theme, and a table - /// fingerprint. The hex is normalised by the caller (uppercased, `#`-led) - /// so `#fff` and `#FFFFFF` never split the cache once expanded upstream. + /// Создать ключ из уже нормализованного фона, темы и отпечатка таблицы. + /// Нормализация выполняется до обращения к кэшу, чтобы разные записи одного + /// `#RRGGBB` не занимали разные состояния. pub fn new(bg_hex: String, theme: Theme, table_fingerprint: u64) -> Self { Self { bg_hex, - theme: theme.key(), + theme, table_fingerprint, } } } -/// A bounded, single-threaded memo from [`CacheKey`] to a cached value `V`. +struct CacheEntry { + key: CacheKey, + value: V, +} + +/// Именованные поля намеренно повторяют варианты `Theme`: так ограничение +/// памяти проверяется компилятором при каждом `match`, а не спрятано в числе. +struct ThemeSlots { + light: Option>, + dark: Option>, + light_ic: Option>, + dark_ic: Option>, +} + +impl ThemeSlots { + fn empty() -> Self { + Self { + light: None, + dark: None, + light_ic: None, + dark_ic: None, + } + } + + fn get(&self, theme: Theme) -> &Option> { + match theme { + Theme::Light => &self.light, + Theme::Dark => &self.dark, + Theme::LightIc => &self.light_ic, + Theme::DarkIc => &self.dark_ic, + } + } + + fn get_mut(&mut self, theme: Theme) -> &mut Option> { + match theme { + Theme::Light => &mut self.light, + Theme::Dark => &mut self.dark, + Theme::LightIc => &mut self.light_ic, + Theme::DarkIc => &mut self.dark_ic, + } + } + + #[cfg(test)] + fn len(&self) -> usize { + [ + self.light.is_some(), + self.dark.is_some(), + self.light_ic.is_some(), + self.dark_ic.is_some(), + ] + .into_iter() + .filter(|occupied| *occupied) + .count() + } +} + +/// Однопоточный memo: один последний ключ и результат на каждую тему. /// -/// Bounded memory is a correctness property under sustained load (ZERO -/// SURPRISES): an unbounded map keyed on arbitrary backgrounds could grow -/// without limit. At capacity the cache is cleared wholesale — a cold rebuild, -/// never a wrong answer. `V` is cloned on a hit, so callers pass a cheaply -/// cloneable value (e.g. an `Rc`-backed or already-serialised result). +/// Объём служебной структуры постоянен, а число тяжёлых значений не может +/// превысить число вариантов `Theme`. Поэтому последовательность из миллионов +/// уникальных фонов не создаёт ни линейного роста, ни скачка очистки на N+1. pub struct ContractCache { - entries: RefCell>, - capacity: usize, + slots: RefCell>, } impl ContractCache { - /// A cache holding up to `capacity` distinct keys before a wholesale clear. - /// - /// `capacity` must be at least 1; a zero-capacity cache is a configuration - /// error (it could never hold the entry it just built), so it is rejected - /// up front rather than degrading silently. - pub fn new(capacity: usize) -> Self { - assert!(capacity > 0, "ContractCache capacity must be at least 1"); + /// Создать пустые тематические слоты. + pub fn new() -> Self { Self { - entries: RefCell::new(HashMap::new()), - capacity, + slots: RefCell::new(ThemeSlots::empty()), } } - /// Return the cached value for `key`, computing and storing it with `build` - /// on a miss. `build` runs at most once per distinct key between clears. + /// Вернуть результат по ключу или вычислить и сохранить его при промахе. + /// Ошибка построения не занимает слот и не вытесняет предыдущий корректный + /// результат: это важно для атомарности невозможной/невалидной проекции. /// - /// # Reentrancy - /// `build` must not call `get_or_insert_with` on this same cache with the - /// same `key` — the entry is not inserted until `build` returns, so a - /// same-key re-entry would recurse without end. A different key is safe. - /// In this crate `build` only calls `core::resolve_named_set`, which never - /// re-enters the cache, so the constraint holds by construction. - pub fn get_or_insert_with(&self, key: CacheKey, build: impl FnOnce() -> V) -> V { - if let Some(hit) = self.entries.borrow().get(&key) { - return hit.clone(); + /// Замыкание выполняется без активного заимствования `RefCell`, поэтому + /// может безопасно обращаться к другому слоту этого же кэша. Повторный вход + /// с тем же ключом остаётся логической ошибкой вызывающего: значение ещё не + /// построено, и рекурсия не сможет завершиться. + pub fn get_or_try_insert_with( + &self, + key: CacheKey, + build: impl FnOnce() -> Result, + ) -> Result { + let theme = key.theme; + if let Some(hit) = self + .slots + .borrow() + .get(theme) + .as_ref() + .filter(|entry| entry.key == key) + { + return Ok(hit.value.clone()); } - let value = build(); - let mut entries = self.entries.borrow_mut(); - if entries.len() >= self.capacity { - entries.clear(); - } - entries.insert(key, value.clone()); - value + + let value = build()?; + *self.slots.borrow_mut().get_mut(theme) = Some(CacheEntry { + key, + value: value.clone(), + }); + Ok(value) } - /// Очистить кэш целиком. Смена таблицы (загрузка конфига) обязана снести - /// прошлое пространство записей: одновременно в кэше живёт ровно ОДНО - /// пространство ключей, и корректность не опирается на вероятностную - /// уникальность отпечатка. + /// Очистить все тематические слоты после успешной смены контракта. pub fn clear(&self) { - self.entries.borrow_mut().clear(); + *self.slots.borrow_mut() = ThemeSlots::empty(); } - /// Number of live entries — for tests and introspection. + /// Число живых полных результатов; доступно только проверкам инварианта. #[cfg(test)] pub fn len(&self) -> usize { - self.entries.borrow().len() + self.slots.borrow().len() } } @@ -118,65 +164,167 @@ mod tests { use super::*; use std::cell::Cell; + fn key(bg: &str, theme: Theme, fingerprint: u64) -> CacheKey { + CacheKey::new(bg.to_owned(), theme, fingerprint) + } + #[test] - fn builds_once_then_serves_from_cache() { - let cache: ContractCache = ContractCache::new(8); + fn повторный_ключ_строится_ровно_один_раз() { + let cache: ContractCache = ContractCache::new(); let calls = Cell::new(0); - let key = || CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT); - let first = cache.get_or_insert_with(key(), || { - calls.set(calls.get() + 1); - 42 - }); - let second = cache.get_or_insert_with(key(), || { - calls.set(calls.get() + 1); - 99 - }); + let first = cache + .get_or_try_insert_with::<()>( + key("#FFFFFF", Theme::Light, DEFAULT_TABLE_FINGERPRINT), + || { + calls.set(calls.get() + 1); + Ok(42) + }, + ) + .unwrap(); + let second = cache + .get_or_try_insert_with::<()>( + key("#FFFFFF", Theme::Light, DEFAULT_TABLE_FINGERPRINT), + || { + calls.set(calls.get() + 1); + Ok(99) + }, + ) + .unwrap(); - assert_eq!(first, 42); - assert_eq!(second, 42, "second call must hit the cache, not rebuild"); - assert_eq!(calls.get(), 1); + assert_eq!((first, second), (42, 42)); + assert_eq!(calls.get(), 1, "попадание не должно запускать построитель"); } #[test] - fn distinct_keys_do_not_collide() { - let cache: ContractCache<&str> = ContractCache::new(8); - let light = cache.get_or_insert_with( - CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT), - || "light", - ); - let dark = cache.get_or_insert_with( - CacheKey::new("#FFFFFF".into(), Theme::Dark, DEFAULT_TABLE_FINGERPRINT), - || "dark", + fn тема_хранит_только_свой_последний_фон() { + let cache: ContractCache<&str> = ContractCache::new(); + cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Light, 1), || Ok("white")) + .unwrap(); + cache + .get_or_try_insert_with::<()>(key("#000000", Theme::Light, 1), || Ok("black")) + .unwrap(); + + let rebuilt = Cell::new(false); + let value = cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Light, 1), || { + rebuilt.set(true); + Ok("white-again") + }) + .unwrap(); + assert!( + rebuilt.get(), + "первый фон обязан быть вытеснен новым фоном той же темы" ); - assert_eq!(light, "light"); - assert_eq!(dark, "dark"); - assert_eq!(cache.len(), 2); + assert_eq!(value, "white-again"); + assert_eq!(cache.len(), 1); } #[test] - fn clears_wholesale_at_capacity() { - let cache: ContractCache = ContractCache::new(2); - for i in 0..2 { - cache.get_or_insert_with( - CacheKey::new( - format!("#00000{i}"), - Theme::Light, - DEFAULT_TABLE_FINGERPRINT, - ), - || i, + fn четыре_темы_не_вытесняют_друг_друга() { + let cache: ContractCache = ContractCache::new(); + let themes = [Theme::Light, Theme::Dark, Theme::LightIc, Theme::DarkIc]; + for theme in themes { + cache + .get_or_try_insert_with::<()>(key("#808080", theme, 7), || Ok(theme)) + .unwrap(); + } + for theme in themes { + let built = Cell::new(false); + let value = cache + .get_or_try_insert_with::<()>(key("#808080", theme, 7), || { + built.set(true); + Ok(theme) + }) + .unwrap(); + assert!( + !built.get(), + "слот {theme:?} должен пережить обращения к другим темам" ); + assert_eq!(value, theme); } - assert_eq!(cache.len(), 2); - // The third distinct key trips the cap → wholesale clear, then insert. - cache.get_or_insert_with( - CacheKey::new("#0000FF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT), - || 3, - ); + assert_eq!(cache.len(), themes.len()); + } + + #[test] + fn произвольный_поток_ключей_не_увеличивает_число_тяжёлых_значений() { + let cache: ContractCache> = ContractCache::new(); + let themes = [Theme::Light, Theme::Dark, Theme::LightIc, Theme::DarkIc]; + let mut payloads = Vec::new(); + + for i in 0..10_000 { + let theme = themes[i % themes.len()]; + let payload = std::rc::Rc::new(i); + payloads.push(std::rc::Rc::downgrade(&payload)); + cache + .get_or_try_insert_with::<()>(key(&format!("#{i:06X}"), theme, 11), || { + Ok(std::rc::Rc::clone(&payload)) + }) + .unwrap(); + } + assert_eq!( cache.len(), - 1, - "cap trips a wholesale clear, never unbounded growth" + themes.len(), + "длина определяется словарём тем, а не числом входных фонов" + ); + assert_eq!( + payloads + .into_iter() + .filter(|payload| payload.upgrade().is_some()) + .count(), + themes.len(), + "кэш обязан освободить вытесненные payload, а не только скрыть их из len()" + ); + } + + #[test] + fn другой_отпечаток_не_может_попасть_в_старую_запись() { + let cache: ContractCache<&str> = ContractCache::new(); + cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Light, 1), || Ok("config-a")) + .unwrap(); + let value = cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Light, 2), || Ok("config-b")) + .unwrap(); + assert_eq!(value, "config-b"); + assert_eq!(cache.len(), 1); + } + + #[test] + fn ошибка_не_вытесняет_последний_корректный_результат() { + let cache: ContractCache<&str> = ContractCache::new(); + cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Dark, 3), || Ok("valid")) + .unwrap(); + let failed = cache.get_or_try_insert_with( + key("#000000", Theme::Dark, 3), + || -> Result<&str, &'static str> { Err("projection failed") }, ); + assert_eq!(failed, Err("projection failed")); + + let built = Cell::new(false); + let old = cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Dark, 3), || { + built.set(true); + Ok("wrong") + }) + .unwrap(); + assert!(!built.get()); + assert_eq!(old, "valid"); + } + + #[test] + fn clear_сбрасывает_всё_пространство_контракта() { + let cache: ContractCache = ContractCache::new(); + cache + .get_or_try_insert_with::<()>(key("#FFFFFF", Theme::Light, 1), || Ok(1)) + .unwrap(); + cache + .get_or_try_insert_with::<()>(key("#000000", Theme::Dark, 1), || Ok(2)) + .unwrap(); + cache.clear(); + assert_eq!(cache.len(), 0); } } diff --git a/crates/labcolors-wasm/src/config_dto.rs b/crates/labcolors-wasm/src/config_dto.rs index 8fa7bdaa..282a6dfd 100644 --- a/crates/labcolors-wasm/src/config_dto.rs +++ b/crates/labcolors-wasm/src/config_dto.rs @@ -73,18 +73,34 @@ pub struct FamilyDto { pub struct SentimentCategoryDto { pub name: String, pub family: String, + /// Поле схемы V1 читается только ради точной миграционной ошибки. V2 не + /// интерпретирует его как физику категории. #[serde(default, skip_serializing_if = "Option::is_none")] pub hue_floor_deg: Option, + /// Поле схемы V1 читается только ради точной миграционной ошибки. #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_side: Option, } -/// Сентимент-политика. +/// Версия геометрии сентиментов. Новая семантика никогда не прячется за +/// прежними числовыми полями. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SentimentGeometryDto { + #[default] + AnchorDistanceV2, +} + +/// Сентимент-политика V2 и читаемые миграционные поля V1. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SentimentsDto { + #[serde(default)] + pub geometry: SentimentGeometryDto, pub categories: Vec, - pub hardness: f64, - pub chroma_fraction: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hardness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chroma_fraction: Option, } /// VC-пресет закрытого меню. @@ -424,20 +440,29 @@ impl TryFrom for ThemeConfig { anchors: f.anchors.into(), }) .collect(); + if dto.sentiments.hardness.is_some() || dto.sentiments.chroma_fraction.is_some() { + return Err(format!( + "sentiments: поля hardness/chroma_fraction удалены; \ + используйте geometry=`{}` и клиентские anchors", + labcolors_core::sentiment::SENTIMENT_GEOMETRY_V2 + )); + } + let mut sentiment_categories = Vec::with_capacity(dto.sentiments.categories.len()); + for category in dto.sentiments.categories { + if category.hue_floor_deg.is_some() || category.preferred_side.is_some() { + return Err(format!( + "sentiments.{}: hue_floor_deg/preferred_side удалены; \ + различимость V2 выводится из pairwise anchor distance", + category.name + )); + } + sentiment_categories.push(SentimentCategory { + name: category.name, + family: category.family, + }); + } let sentiments = SentimentsConfig { - categories: dto - .sentiments - .categories - .into_iter() - .map(|c| SentimentCategory { - name: c.name, - family: c.family, - hue_floor_deg: c.hue_floor_deg, - preferred_side: c.preferred_side, - }) - .collect(), - hardness: dto.sentiments.hardness, - chroma_fraction: dto.sentiments.chroma_fraction, + categories: sentiment_categories, }; let themes = ThemesConfig { entries: dto @@ -601,6 +626,7 @@ impl TryFrom<&ThemeConfig> for ConfigDto { }) .collect(), sentiments: SentimentsDto { + geometry: SentimentGeometryDto::AnchorDistanceV2, categories: cfg .sentiments .categories @@ -608,12 +634,12 @@ impl TryFrom<&ThemeConfig> for ConfigDto { .map(|c| SentimentCategoryDto { name: c.name.clone(), family: c.family.clone(), - hue_floor_deg: c.hue_floor_deg, - preferred_side: c.preferred_side, + hue_floor_deg: None, + preferred_side: None, }) .collect(), - hardness: cfg.sentiments.hardness, - chroma_fraction: cfg.sentiments.chroma_fraction, + hardness: None, + chroma_fraction: None, }, themes: cfg .themes @@ -695,6 +721,35 @@ mod tests { .expect("восстановленный конфиг компилируется"); } + /// Поля V1 читаются serde-границей намеренно: потребитель получает точную + /// миграционную ошибку, а не успешный конфиг с неработающей ручкой. + #[test] + fn legacy_sentiment_fields_are_rejected_explicitly() { + let mut global = labui_dto(); + global.sentiments.hardness = Some(5.0); + let error = ThemeConfig::try_from(global).unwrap_err(); + assert!(error.contains("hardness/chroma_fraction"), "{error}"); + + let mut category = labui_dto(); + category.sentiments.categories[0].preferred_side = Some(1); + let error = ThemeConfig::try_from(category).unwrap_err(); + assert!(error.contains("hue_floor_deg/preferred_side"), "{error}"); + } + + #[test] + fn v2_serialization_names_the_geometry_and_omits_v1_fields() { + let cfg = ThemeConfig::try_from(labui_dto()).unwrap(); + let json = serde_json::to_value(ConfigDto::try_from(&cfg).unwrap()).unwrap(); + let sentiments = &json["sentiments"]; + assert_eq!(sentiments["geometry"], "anchor-distance-v2"); + assert!(sentiments.get("hardness").is_none()); + assert!(sentiments.get("chroma_fraction").is_none()); + for category in sentiments["categories"].as_array().unwrap() { + assert!(category.get("hue_floor_deg").is_none()); + assert!(category.get("preferred_side").is_none()); + } + } + /// Рецепт `pair-label` (лейбл тинт-бейджа, task #29) гоняется через JSON без /// потерь: kebab-тег `pair-label`, источник/доля/пол целы туда-обратно. /// Закрывает класс «DTO-ветка компилируется, но круг-трип врёт». @@ -782,14 +837,16 @@ mod tests { /// 0.276 → 0.97335917/0.64359014/0.47572199/0.29335999, инвариант переноса — /// принятые владельцем hex'ы #141414/#767676/#C2C2C2). Это ЛЕГИТИМНАЯ смена /// паспорта (ADR помечает её semver-major), потому пин обновлён - /// 5013ba77a61f58ff → f2a892a62f7bc91e. Прочая структура (снятие роли `icon` - /// каноном #92) уже была в main до этой главы — дельта только в долях. + /// 5013ba77a61f58ff → f2a892a62f7bc91e. Переход сентиментов на явно + /// версионированную геометрию `anchor-distance-v2` удалил четыре поля V1, + /// которые больше не имеют физического смысла, и изменил пин на + /// d23b8aee324e7d1a. #[test] fn full_labui_fingerprint_pin_current_main() { let full = labui_dto(); assert_eq!( format!("{:016x}", fingerprint(&full)), - "f2a892a62f7bc91e", + "d23b8aee324e7d1a", "пин паспорта main; при легитимной смене паспорта обнови это число" ); } diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index e2e2c958..da1fff5c 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -44,9 +44,9 @@ pub enum RoleOutcome { Color(SolvedColor), /// The explicit zero token (`Role::None`): no colour here, by design. None, - /// A semi-transparent ladder / alpha-analog role: the emission is - /// `oklch(L% C H / A)` and the browser composites it; the measured - /// contrasts are those of the composite on the resolve background. + /// Полупрозрачная роль: браузер композитит `oklch(L% C H / A)`, а при + /// powerless chroma вместо фиктивного угла получает missing-токен `none`. + /// Контрасты относятся к композиту на фоне резолва. Translucent(RgbaColor), /// Свечение (kind glow, labui ADR-0002 §5): screen-слои цвета источника + /// решённая интенсивность. Потребитель красит слои с diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index 99c5118d..046e3b4b 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -1,17 +1,15 @@ -//! The application core of the bindings: resolve a background under a theme, -//! generically over whatever role set a loaded config provides. +//! Прикладное ядро WASM-границы: резолвит фон для набора ролей из загруженного +//! конфига и не знает о `wasm-bindgen`. //! -//! This layer knows the core and the DTOs; it does NOT know wasm-bindgen. It -//! holds the compiled config table (supplied by `load_config`) and the contract -//! cache, runs the core resolve, and maps the resolved vector into -//! [`ResolvedTheme`]. The engine is agnostic (ADR-0001 PR-c): it carries no -//! built-in design system, so `resolve_theme` needs a config first. The mapping -//! never enumerates roles — it walks whatever the core returns and keys each -//! entry by the config's own role name — so role growth flows through on a -//! rebuild. +//! Движок хранит последний полный снимок каждой публичной темы. В снимок входят +//! и типизированный [`ResolvedTheme`], и его готовая JSON-проекция: один ключ +//! означает одно попадание на обоих дорогих уровнях, поэтому чередование тем не +//! заставляет заново сериализовать уже решённый контракт. Роли не перечислены в +//! коде границы — результат строится из таблицы потребителя. use std::borrow::Cow; use std::collections::HashMap; +use std::ops::Deref; use std::rc::Rc; use labcolors_core::config::ThemeConfig; @@ -24,22 +22,39 @@ use crate::dto::{ResolvedTheme, RgbaColor, RoleEntry, RoleOutcome, SolvedColor}; use crate::error::BindingError; use crate::theme::Theme; -/// How many distinct `(bg, theme, table)` resolves the cache holds before a -/// wholesale clear. A few thousand entries at well under 1 MB — generous for a -/// design tool sweeping backgrounds, bounded so memory cannot run away. -const CACHE_CAPACITY: usize = 4096; +/// Полный неизменяемый снимок одного тематического слота. +/// +/// JSON лежит рядом с DTO, потому что они имеют один ключ и один срок жизни. +/// Раздельные кэши дали бы ложное попадание решателя с повторной сериализацией. +pub(crate) struct ResolvedSnapshot { + resolved: ResolvedTheme, + json: Box, +} + +impl ResolvedSnapshot { + /// Готовая проекция; каждый вызов границы парсит её в свежий JS-объект, + /// поэтому мутации потребителя не протекают в следующий результат. + pub(crate) fn json(&self) -> &str { + &self.json + } +} + +impl Deref for ResolvedSnapshot { + type Target = ResolvedTheme; -/// A caching contrast engine over a consumer-supplied design system. + fn deref(&self) -> &Self::Target { + &self.resolved + } +} + +/// Кэширующий движок над дизайн-системой, полностью заданной потребителем. /// -/// Construct once (`init`), load a config (`load_config`), then call -/// [`resolve_theme`](Self::resolve_theme) many times. The engine is agnostic — -/// it has no built-in role table — so a resolve before `load_config` returns -/// [`BindingError::ConfigRequired`], never a panic or a silent default. The -/// result is cached behind an `Rc` so a cache hit is a cheap reference-count -/// bump, not a re-clone of the whole set. +/// До `load_config` встроенного контракта нет: попытка резолва возвращает +/// [`BindingError::ConfigRequired`]. Попадание возвращает дешёвый клон `Rc`, +/// не копируя роли и строку проекции. pub struct Engine { named: Option, - cache: ContractCache>, + cache: ContractCache>, } /// Загруженный конфиг потребителя: скомпилированная таблица + её отпечаток @@ -59,13 +74,11 @@ impl Default for Engine { } impl Engine { - /// A fresh engine with no config loaded yet. [`resolve_theme`](Self::resolve_theme) - /// returns [`BindingError::ConfigRequired`] until [`load_config`](Self::load_config) - /// supplies a design system. + /// Создать движок без неявной дизайн-системы. pub fn new() -> Self { Self { named: None, - cache: ContractCache::new(CACHE_CAPACITY), + cache: ContractCache::new(), } } @@ -113,19 +126,18 @@ impl Engine { Ok(fp) } - /// Resolve every role for `bg_hex` under `theme`, returning the shared - /// result. Repeated identical calls hit the contract cache. - /// - /// Errors (bad hex, unknown theme) are returned, never - /// panicked. Per-role unreachability is part of a *successful* result. + /// Резолвить все роли для фона и темы и вернуть общий неизменяемый снимок. + /// Повтор точного ключа использует и готовые роли, и готовую сериализацию. + /// Ошибка всего вызова возвращается явно; недостижимость отдельной роли + /// остаётся частью успешного результата. pub fn resolve_theme( &self, bg_hex: &str, theme: Theme, - ) -> Result, BindingError> { + ) -> Result, BindingError> { let vc = theme.viewing_conditions(); - // Validate and normalise the background once, before the cache lookup, - // so an invalid hex fails fast and the cache key is canonical. + // Ключ должен описывать цвет, а не его написание: нормализация до + // поиска одновременно сохраняет быстрый отказ для невалидного ввода. let normalised = normalise_hex(bg_hex)?; let bg = BgInput::solid(&normalised).map_err(|u| BindingError::InvalidBackground { reason: u.to_string(), @@ -135,7 +147,7 @@ impl Engine { // физикой; отпечаток в ключе разводит кэш-пространства конфигов. if let Some(named) = &self.named { let key = CacheKey::new(normalised.clone(), theme, named.fingerprint); - let result = self.cache.get_or_insert_with(key, || { + let result = self.cache.get_or_try_insert_with(key, || { let set = labcolors_core::resolve_named_set(&bg, &named.table, &vc); let mut roles: Vec = set .into_iter() @@ -159,17 +171,20 @@ impl Engine { }); } } - Rc::new(ResolvedTheme { + let resolved = ResolvedTheme { theme: theme.key(), background: normalised.clone(), roles, - }) - }); + }; + // Проекция вычисляется до публикации записи. Если нарушен её + // числовой инвариант, старый корректный слот не вытесняется. + let json = crate::projection::resolved_json(&resolved)?.into_boxed_str(); + Ok(Rc::new(ResolvedSnapshot { resolved, json })) + })?; return Ok(result); } - // Agnostic engine: no config, nothing to emit. An honest, matchable - // failure — the boundary refuses rather than inventing a built-in system. + // Агностичный движок не изобретает таблицу ролей при отсутствии конфига. Err(BindingError::ConfigRequired) } @@ -263,8 +278,9 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> RoleOutcome { message: reason.to_string(), }, // Полупрозрачная эмиссия лестницы/альфа-аналога (конфиг-путь): - // наружу уходит oklch(L% C H / α), браузер композитит; контраст — - // свойство композита на фоне резолва (закон лестницы ядра). + // Наружу уходит Oklch со слэш-альфой; у ахромата H равен `none`, чтобы + // граница не превращала отсутствующий угол в выдуманное число. Контраст + // остаётся свойством композита на фоне резолва. Resolved::Translucent(rgba) => RoleOutcome::Translucent(RgbaColor { tint_hex: rgba.tint_hex().to_string(), alpha: rgba.alpha(), @@ -405,6 +421,14 @@ mod tests { engine } + /// Кэш-инварианты не должны зависеть от размера или конкретных ролей labui, + /// поэтому их изолированные проверки используют минимальный второй клиент. + fn engine_with_acme() -> Engine { + let mut engine = Engine::new(); + engine.load_config(&acme_json()).expect("acme валиден"); + engine + } + #[test] fn resolve_theme_without_config_is_config_required() { // The agnostic contract: no built-in fallback. A resolve before any @@ -615,6 +639,68 @@ mod tests { ); } + #[test] + fn чередование_тем_попадает_в_единые_снимки_dto_и_json() { + let engine = engine_with_acme(); + let light = engine.resolve_theme("#F7F8FA", Theme::Light).unwrap(); + let dark = engine.resolve_theme("#101012", Theme::Dark).unwrap(); + let light_json = light.json().as_ptr(); + let dark_json = dark.json().as_ptr(); + + for _ in 0..8 { + let light_again = engine.resolve_theme("#F7F8FA", Theme::Light).unwrap(); + let dark_again = engine.resolve_theme("#101012", Theme::Dark).unwrap(); + assert!(Rc::ptr_eq(&light, &light_again)); + assert!(Rc::ptr_eq(&dark, &dark_again)); + assert_eq!(light_again.json().as_ptr(), light_json); + assert_eq!(dark_again.json().as_ptr(), dark_json); + } + assert_eq!( + engine.cache.len(), + 2, + "чередование не должно создавать новые записи или проекции" + ); + } + + #[test] + fn новый_фон_заменяет_только_последний_снимок_своей_темы() { + let engine = engine_with_acme(); + let first = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let dark = engine.resolve_theme("#101012", Theme::Dark).unwrap(); + engine.resolve_theme("#F7F8FA", Theme::Light).unwrap(); + let first_again = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let dark_again = engine.resolve_theme("#101012", Theme::Dark).unwrap(); + + assert!( + !Rc::ptr_eq(&first, &first_again), + "возврат к вытесненному фону обязан быть честным промахом" + ); + assert!( + Rc::ptr_eq(&dark, &dark_again), + "промах светлой темы не должен охлаждать тёмный слот" + ); + assert_eq!(engine.cache.len(), 2); + } + + #[test] + fn повторная_загрузка_того_же_отпечатка_всё_равно_меняет_пространство_снимков() { + let mut engine = Engine::new(); + let json = acme_json(); + let first_fp = engine.load_config(&json).unwrap(); + let first = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + let second_fp = engine.load_config(&json).unwrap(); + let second = engine.resolve_theme("#FFFFFF", Theme::Light).unwrap(); + + assert_eq!( + first_fp, second_fp, + "одинаковый конфиг имеет тот же паспорт" + ); + assert!( + !Rc::ptr_eq(&first, &second), + "корректность смены контракта должна обеспечиваться очисткой, а не уникальностью fingerprint" + ); + } + #[test] fn cache_key_is_hex_normalised() { let engine = engine_with_labui(); @@ -766,7 +852,7 @@ mod tests { "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "sentiments": {"geometry": "anchor-distance-v2", "categories": []}, "themes": [{"name": "light", "preset": "srgb"}, {"name": "dark", "preset": "dim"}], "roles": [ {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index c90980c1..dd9c2a2b 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -24,12 +24,8 @@ mod error; mod projection; mod theme; -use std::cell::RefCell; -use std::rc::Rc; - use wasm_bindgen::prelude::*; -use crate::dto::ResolvedTheme; use crate::engine::Engine; use crate::error::BindingError; @@ -48,7 +44,7 @@ export interface SolvedColor { readonly cssVar: string; /** The resolved colour as #RRGGBB (data; `css`/`vars` carry oklch). */ readonly hex: string; - /** Ready-to-serve CSS value: "oklch(L% C H)". `vars` carries the same string. */ + /** Готовое CSS-значение. При powerless chroma вместо выдуманного H стоит `none`; `vars` несёт ту же строку. */ readonly css: string; /** Signed perceptual contrast (Lc) against the background. */ readonly lc: number; @@ -94,7 +90,7 @@ export interface UnreachableRole { readonly message: string; } -/** A semi-transparent ladder / alpha-analog emission: the CSS carries oklch(L% C H / A), the browser composites it. */ +/** Полупрозрачная эмиссия: CSS несёт oklch(L% C (H или none) / A), а браузер композитит её. */ export interface TranslucentRole { readonly kind: "translucent"; readonly cssVar: string; @@ -125,7 +121,7 @@ export interface TranslucentRole { * direct ladder emission and for legal family solids. */ readonly floorCoerced: boolean; - /** Ready-to-serve CSS value: "oklch(L% C H / A)". `vars` carries the same string. */ + /** Готовое CSS-значение; powerless hue сериализуется как `none`. `vars` несёт ту же строку. */ readonly css: string; } @@ -145,7 +141,7 @@ export interface GlowRole { readonly achievedDj: number; /** Цель недостижима — ближайший достижимый шаг (ADR-0002, закон 2). */ readonly degraded: boolean; - /** Ready-to-serve CSS value халo: "oklch(L% C H)". */ + /** Готовое CSS-значение halo; у ахромата H отсутствует и записывается как `none`. */ readonly css: string; } @@ -177,7 +173,7 @@ export interface MaterialRole { readonly hueVanished: boolean; /** Солид-канон отличим от фона резолва на 8-битной сетке. */ readonly distinct: boolean; - /** Ready-to-serve CSS value солид-канона: "oklch(L% C H)". */ + /** Готовое CSS-значение солид-канона; powerless hue записывается как `none`. */ readonly css: string; } @@ -233,14 +229,11 @@ export interface ThemeConfig { }; readonly palette: ReadonlyArray<{ key: string; anchors: ThemeAnchors }>; readonly sentiments: { + readonly geometry?: "anchor-distance-v2"; readonly categories: ReadonlyArray<{ name: string; family: string; - hue_floor_deg?: number; - preferred_side?: -1 | 1; }>; - readonly hardness: number; - readonly chroma_fraction: number; }; readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>; /** Словарь ролей дизайн-системы. Конфиг обязан нести собственные роли; пустой @@ -249,20 +242,16 @@ export interface ThemeConfig { readonly aliases?: ReadonlyArray<{ alias: string; target: string }>; } -/** The full result of resolving one background under one theme. */ +/** Полный результат резолва одного фона в одной теме. */ export interface ResolvedTheme { readonly theme: ThemeName; readonly background: string; /** - * Reachable roles only. Values are ready-to-serve CSS in ONE form: - * "oklch(L% C H)" for solid roles, "oklch(L% C H / A)" for semi-transparent - * ladder/alpha-analog roles. Solved in the sRGB gamut (oklch is the - * notation, not a gamut extension); byte-exact vs `SolvedColor.hex` and - * `TranslucentRole.tintHex` (`compositeHex` is the background-specific - * composite, not the emitted token). - * Scope: this is resolveTheme's contract (applyTheme/watchTheme inject it - * verbatim); adaptTheme's per-frame easing writes concrete interpolated - * colours and is not bound by the emission form. + * Только достижимые роли. Значение — готовый CSS Oklch; у powerless chroma + * компонент H записан как `none`, у полупрозрачной роли добавлена `/ A`. + * Цвет решён в sRGB: Oklch здесь форма записи, а не расширение gamut. + * Строка байт-точна относительно `SolvedColor.hex` или tintHex; compositeHex + * описывает уже композит на фоне и не является эмитированным токеном. */ readonly vars: Record; /** Every role, keyed by its stable role key (without the --lab- prefix). */ @@ -276,20 +265,11 @@ extern "C" { pub type JsResolvedTheme; } -/// A contrast engine over a consumer-supplied design system. Construct with -/// [`LabColors::new`], load a config with [`loadConfig`](LabColors::load_config), -/// then call [`resolve_theme`](LabColors::resolve_theme) many times; identical -/// calls are served from the contract cache. +/// Движок контраста над дизайн-системой потребителя. После загрузки конфига +/// одинаковые вызовы получают готовый снимок из тематического слота кэша. #[wasm_bindgen] pub struct LabColors { inner: Engine, - /// Одноячеечный мемо сериализации последнего резолва: кэш-хит движка - /// возвращает тот же `Rc`, значит его JSON можно не пересобирать — - /// hit-путь `resolveTheme` платит только `JSON.parse`. Ячейка держит - /// сильный `Rc`, поэтому `Rc::ptr_eq` не может ложно совпасть с новой - /// аллокацией (наша жива — адрес занят); смена конфига даёт новые `Rc`, - /// и мемо промахивается честно. - proj_memo: RefCell, Rc)>>, } #[wasm_bindgen] @@ -303,7 +283,6 @@ impl LabColors { pub fn new() -> LabColors { LabColors { inner: Engine::new(), - proj_memo: RefCell::new(None), } } @@ -323,27 +302,13 @@ impl LabColors { .inner .resolve_theme(bg_hex, theme) .map_err(to_js_error)?; - // Одна «широкая» FFI-строка + нативный JSON.parse вместо ~тысячи - // Reflect::set (почему — в док-комменте модуля `projection`); мемо - // снимает с кэш-хита ещё и пересборку строки. Каждый вызов по-прежнему - // отдаёт СВЕЖИЙ объект — семантика для мутирующего потребителя не - // меняется. - let json: Rc = { - let mut memo = self.proj_memo.borrow_mut(); - match memo.as_ref() { - Some((rc, s)) if Rc::ptr_eq(rc, &resolved) => Rc::clone(s), - _ => { - let s: Rc = crate::projection::resolved_json(&resolved) - .map_err(to_js_error)? - .into(); - *memo = Some((Rc::clone(&resolved), Rc::clone(&s))); - s - } - } - }; + // DTO и строка имеют один кэш-ключ и живут в одном снимке. Поэтому + // чередование тем не превращает попадание решателя в промах проекции. + // `JSON.parse` всё равно создаёт свежий объект: мутация результата в JS + // не меняет ни кэш, ни следующий ответ. // По построению строка — валидный JSON; невозможный отказ парсера — // честная внутренняя ошибка, не паника. - let parsed = js_sys::JSON::parse(&json).map_err(|_| { + let parsed = js_sys::JSON::parse(resolved.json()).map_err(|_| { to_js_error(BindingError::Internal { reason: "проекция не распарсилась как JSON".to_string(), }) @@ -356,7 +321,7 @@ impl LabColors { /// Полный preflight движка: невалидный конфиг отклоняется структурной /// ошибкой `invalid_config: …` и НЕ меняет состояние. После успешной /// загрузки `resolveTheme` эмитит роли конфига (включая полупрозрачные - /// роли лестницы — эмиссия `oklch(L% C H / α)`). Возвращает отпечаток + /// роли лестницы — эмиссия `oklch(L% C (H|none) / α)`). Возвращает отпечаток /// конфига — 16 hex-символов; /// разные конфиги дают разные отпечатки (и разные кэш-пространства). #[wasm_bindgen(js_name = loadConfig)] @@ -388,7 +353,10 @@ impl LabColors { .map_err(to_js_error) } - /// Calculate the muddiness score (0 to 1) of an sRGB hex colour. + /// Вычисляет замороженную историческую характеристику Закона Грязи V1. + /// + /// Исходное имя сохраняет совместимость API, но результат не является + /// вероятностью для популяции и не подменяет контекстную модель V2. #[wasm_bindgen(js_name = muddiness)] pub fn muddiness(&self, hex: &str) -> Result { labcolors_core::cleanliness::muddiness_from_hex(hex) @@ -445,12 +413,12 @@ mod tests { fn test_wasm_muddiness() { let colors = LabColors::new(); - // Olive is highly muddy + // Исторический порядок V1 закреплён ради совместимости, а не как научный oracle. let olive_mud = colors.muddiness("#6B6B2E").unwrap(); assert!(olive_mud > 0.80); assert!((olive_mud - 0.8699).abs() < 1e-3); - // Gray is clean + // Серый вектор также проверяет именно воспроизводимость V1. let gray_mud = colors.muddiness("#808080").unwrap(); assert!(gray_mud < 0.05); diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index df14f38f..589cac35 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -149,7 +149,8 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { Ok(out) } -/// Единая CSS-форма эмиссии: `oklch(L% C H)` / `oklch(L% C H / A)`. +/// Единая CSS-форма эмиссии: hue — число только у цветного стимула, а у +/// powerless chroma третья компонента честно равна `none`; альфа добавляет `/ A`. /// Байт-точность реконструкции доказана round-trip тестом ядра на решётке /// куба. Hex к этому месту валиден по построению (солвер/лестница), но при /// невозможном парсе — честная структурная ошибка, НЕ тихая подмена формы: @@ -341,6 +342,20 @@ mod tests { assert_eq!(json, expected); } + /// Ахроматический hue отсутствует физически, поэтому WASM-граница обязана + /// сохранить `none` ядра и не превращать missing-компоненту обратно в ноль. + #[test] + fn achromatic_css_keeps_missing_hue() { + assert_eq!( + oklch_css("#FFFFFF", None).unwrap(), + "oklch(100.00000% 0.000000 none)" + ); + assert_eq!( + oklch_css("#000000", Some(0.5)).unwrap(), + "oklch(0.00000% 0.000000 none / 0.5)" + ); + } + /// Материал (#89) проецируется в контрактные CSS-переменные: `--lab-` = /// солид-канон (oklch), `--lab--01` = тинт (oklch со слэш-альфой), /// `--lab--02` = опаковая база (oklch). Плюс полный набор полей исхода. diff --git a/crates/labcolors-wasm/tests/chain_invariants.rs b/crates/labcolors-wasm/tests/chain_invariants.rs index 13337ca5..e817b70d 100644 --- a/crates/labcolors-wasm/tests/chain_invariants.rs +++ b/crates/labcolors-wasm/tests/chain_invariants.rs @@ -142,7 +142,7 @@ fn fingerprint_is_invariant_to_json_key_order() { "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "sentiments": {"geometry": "anchor-distance-v2", "categories": []}, "themes": [{"name": "light", "preset": "srgb"}], "roles": [ {"name": "body-text", "recipe": {"kind": "text-anchor", "fraction": 0.62, "floor": "aa-text"}} @@ -154,7 +154,7 @@ fn fingerprint_is_invariant_to_json_key_order() { {"name": "body-text", "recipe": {"kind": "text-anchor", "fraction": 0.62, "floor": "aa-text"}} ], "themes": [{"name": "light", "preset": "srgb"}], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "sentiments": {"geometry": "anchor-distance-v2", "categories": []}, "palette": [], "neutral": { "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0}, diff --git a/crates/labcolors-wasm/tests/data/labui.config.json b/crates/labcolors-wasm/tests/data/labui.config.json index 24571e96..3c31f479 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.json +++ b/crates/labcolors-wasm/tests/data/labui.config.json @@ -123,6 +123,7 @@ } ], "sentiments": { + "geometry": "anchor-distance-v2", "categories": [ { "name": "danger", @@ -130,9 +131,7 @@ }, { "name": "warning", - "family": "orange", - "hue_floor_deg": 45, - "preferred_side": 1 + "family": "orange" }, { "name": "success", @@ -142,9 +141,7 @@ "name": "info", "family": "blue" } - ], - "hardness": 5, - "chroma_fraction": 1 + ] }, "themes": [ { diff --git a/crates/labcolors-wasm/tests/data/labui.config.prod.json b/crates/labcolors-wasm/tests/data/labui.config.prod.json index a10f6b0a..e069f6fe 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.prod.json +++ b/crates/labcolors-wasm/tests/data/labui.config.prod.json @@ -123,6 +123,7 @@ } ], "sentiments": { + "geometry": "anchor-distance-v2", "categories": [ { "name": "danger", @@ -130,9 +131,7 @@ }, { "name": "warning", - "family": "orange", - "hue_floor_deg": 45.0, - "preferred_side": 1 + "family": "orange" }, { "name": "success", @@ -142,9 +141,7 @@ "name": "info", "family": "blue" } - ], - "hardness": 5.0, - "chroma_fraction": 1.0 + ] }, "themes": [ { diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index 55b07a5e..215d925c 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -353,7 +353,7 @@ fn config_boundary_two_configs_diverge() { "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, + "sentiments": {"geometry": "anchor-distance-v2", "categories": []}, "themes": [{"name": "light", "preset": "srgb"}], "roles": [ {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, diff --git a/docs/NAMING.md b/docs/NAMING.md index 620fa695..d92cd618 100644 --- a/docs/NAMING.md +++ b/docs/NAMING.md @@ -17,7 +17,7 @@ | крейтов семейства в crates/ | 5 | | экспорт-субпутей package.json @labpics/colors | 7 | | python-скриптов scripts/*.py | 2 | -| маркдаун-доков docs/**/*.md (включая этот канон) | 10 | +| маркдаун-доков docs/**/*.md (включая этот канон) | 11 | | векторов conformance/vectors/*.json (включая manifest) | 6 | | файлов вне закона имён | 4 | diff --git a/docs/cleanliness-v2.md b/docs/cleanliness-v2.md new file mode 100644 index 00000000..2e33eefc --- /dev/null +++ b/docs/cleanliness-v2.md @@ -0,0 +1,210 @@ +# ColorQuality: научный контракт экранного анализа + +## Назначение + +`ColorQualityAuditV1` возвращает независимые сведения об экранном цвете и не +изменяет его. В модели нет универсальной оси `clean ↔ dirty`, вероятности красоты +или общего числового score. + +Причина: разные исследования измеряют разные явления: + +- координаты color appearance; +- vividness, blackness, whiteness, depth и clarity; +- brown induction; +- semantic `clean–dirty`; +- murky–clear и dull–vivid; +- preference и acceptability. + +Корреляция между ними не делает их одним свойством. + +## Appearance mode + +Физически дисплей излучает свет, но интерпретация стимула зависит от композиции. +Один RGB может быть: + +- самостоятельным светящимся UI-сигналом; +- поверхностью, показанной на экране; +- материалом после alpha-композиции; +- пространственным glow/blur; +- частью изображения. + +Поэтому API требует явный `AppearanceMode`: + +```rust +pub enum AppearanceMode { + EmissiveUi, + SurfaceLike, + MaterialLike, + SpatialEffect, + ImageContent, +} +``` + +Mode не выводится из RGB, имени роли или темы. Исследование предметов, красок и +освещённых поверхностей не переносится на UI без отдельного основания. + +## Контекст + +`AppearanceContextV1` разделяет: + +- `ViewingConditions` — адаптацию CAM16; +- измеренные `DisplayConditionsV1` — белый, чёрный и ambient в `cd/m²`; +- `SpatialContextV1` — adjacency, угловой размер и длительность; +- `AppearanceMode` — режим интерпретации. + +`AppearanceContextV1::nominal` сохраняет отсутствие измерений как `None`. Номинал +не выдаётся за калиброванный дисплей. + +## AppearanceProfileV1 + +Первая версия публикует только сведения с проверяемым происхождением: + +```text +CAM16-UCS: J′, a′, b′, M′ +radius_from_black = hypot(J′, M′) +chroma_angle = atan2(M′, J′) +sRGB gamut geometry +``` + +`radius_from_black` — математическая геометрия выбранных координат, а не готовая +observer-шкала vividness. Опубликованные шкалы vividness/blackness/whiteness/depth +будут добавлены только после воспроизведения формул, reference vectors и проверки +applicability matrix из Issue #230. + +## RelativeMutednessV2 + +Приглушённость сравнивается только с явно переданным семейным якорем. Абсолютная +граница «мутный/немутный» не вводится. + +```text +loss = ln((M′/J′)_reference / (M′/J′)_candidate) +``` + +- `loss > 0` — candidate потерял относительную хроматичность; +- `loss = 0` — отношение сохранено; +- `loss < 0` — candidate стал относительно хроматичнее. + +Ахроматические концы представлены enum-состояниями, а не `epsilon` и infinity. +Source anchor не сравнивается с чужим reference. + +## WarmDarkInteraction V2 + +Историческая формула V2 сохранена как исследовательский baseline: + +```text +y = max(0, b′ / hypot(J′, M′)) +r = radius_foreground / radius_background +interaction = −e · y · r · ln(r) +``` + +Её неизменяемый идентификатор: + +```text +lab-warm-dark-interaction-cam16ucs-v2 +``` + +Это гипотеза Labpics, а не опубликованная шкала brownness или cleanliness. +Математически после выбора семейства `−r ln r` доказуемы его концы и максимум +при `r = 1/e`; сам выбор функции не выведен из observer data. + +Результат возвращает только: + +- `InteractionZero(reason)`; +- `InteractionPositive`; +- `InsufficientContext`; +- `NotApplicable(mode)`; +- `NumericallyIndeterminate`. + +Слова `Clean` и `Dirty` запрещены: сколь угодно малый положительный член не является +человеческим категориальным ответом. + +Чёрный background возвращает `InsufficientContext`, а не доказанный ноль. Данные +brown induction показывают, что часть наблюдателей способна видеть brown и без +более светлого непосредственного surround; текущего отношения радиусов для +универсального вывода недостаточно. + +`SpatialEffect` и `ImageContent` не анализируются opaque-patch формулой. Для +`MaterialLike` без измеренного display/spatial context также возвращается +`InsufficientContext`. + +## ColorQualityAuditV1 + +Агрегат содержит независимые поля: + +```text +appearance +relative_mutedness? +warm_dark_interaction +``` + +В нём нет weighted sum. Whiteness contamination и observer-validated brownness +будут отдельными сигналами, а не дополнительными коэффициентами одной формулы. + +Audit не возвращает исправленный цвет и побайтно не влияет на эмиссию. + +## Legacy + +`crate::cleanliness` сохраняет «Закон Грязи V1» только для воспроизводимости +старых conformance-векторов. Legacy API: + +- не является production policy; +- не участвует в `ColorQualityAuditV1`; +- не задаёт threshold; +- не разрешает автоматическую проекцию; +- сохраняет собственный неизменяемый смысл. + +700 авторских отметок хранятся как falsification corpus Issue #225. Согласие с +одним автором не называется population accuracy. + +## Проекция + +Изменение цвета не входит в этот контракт. Будущая policy Issue #217 разделяет: + +```text +Preserve — не менять bytes +Audit — не менять bytes, вернуть отчёт +Project — constrained finite solver с сертификатом +``` + +Core default — `Preserve`. Первый допустимый product default — `Audit`. `Project` +не включается до screen-native psychophysics Issue #232, global finite solver +Issue #218 и numerical proof Issue #223. + +Если система выглядит плохо в `Preserve`, исправляется основной генератор. Проектор +не служит постобработкой, скрывающей дефект архитектуры. + +## Инварианты + +1. Один RGB с разными appearance mode остаётся разными запросами. +2. Номинальный и измеренный display context различимы. +3. Audit не изменяет исходные bytes. +4. Нет универсального cleanliness scalar. +5. Earthy, brown, olive и muted не считаются дефектом без противоположного intent. +6. Неполный контекст возвращает статус, а не правдоподобный fallback. +7. Каждый нестандартный сигнал несёт model id и provenance. +8. Новая observer-модель получает новый immutable id; старые версии не + переопределяются. + +## Источники + +- C. Li et al., «Comprehensive color solutions: CAM16, CAT16, and CAM16-UCS», + *Color Research & Application* 42(6), 703–718 (2017), + . +- R. S. Berns, «Extending CIELAB: Vividness, V*ab, Depth, D*ab, and Clarity, + T*ab», *Color Research & Application* 39(4), 322–330 (2014), + . +- Y. J. Cho et al., «A cross-cultural comparison of saturation, vividness, + blackness and whiteness scales», *Color Research & Application* 42, 203–215 + (2017), . +- S. L. Buck et al., «Influence of surround proximity on induction of brown and + darkness», *JOSA A* 33(3), A12–A21 (2016), + . +- T. Morimoto, E. Slezak, S. L. Buck, «No effects of surround complexity on + brown induction», *JOSA A* 33(3), A45–A52 (2016), + . +- I. Kuriki, «Effect of material perception on mode of color appearance», + *Journal of Vision* 15(8):4 (2015). + +Источники обосновывают координаты и необходимость разделять appearance phenomena. +Ни один из них не публикует формулу `−e·y·r·ln(r)` и не используется так, будто +публикует её. diff --git a/docs/comparative-experiments.md b/docs/comparative-experiments.md index 2d985291..89679741 100644 --- a/docs/comparative-experiments.md +++ b/docs/comparative-experiments.md @@ -73,8 +73,8 @@ golden-тестами (vitest) на опубликованных референ нарушений на лестницу и доля лестниц без нарушений. **M5. Проблемная жёлто-зелёная зона.** Подмножество сидов с OKLCh hue ∈ [90°, 140°]: -M1-A, M2, M4 отдельно по зоне. Примечание: защита движка `WARNING_HUE_FLOOR_DEG` -определена в CAM16-hue; соответствие зон обсуждается в выводах. +M1-A, M2, M4 отдельно по зоне. Исторический прогон использовал существовавший +тогда `WARNING_HUE_FLOOR_DEG`; sentiment V2 удалил этот design-choice. ## 5. Воспроизведение @@ -189,8 +189,8 @@ _Прогон: lab-colors @ `0081ce7`; MCU 0.3.0; Radix Colors 3.0.0. Сидов референсом = hue шага 9, в M1-A — нет). - Нативные протоколы M1-B — наша операционализация заявок, не дословные цитаты документации (для Radix особенно: шаг 11 позиционируется как low-contrast text). -- Зона M5 задана в OKLCh (90–140°), тогда как защита движка `WARNING_HUE_FLOOR_DEG` - определена в CAM16-hue; зоны пересекаются, но не совпадают. +- Зона M5 задана в OKLCh (90–140°). Упомянутый в историческом прогоне + `WARNING_HUE_FLOOR_DEG` больше не существует в sentiment V2. - Пороги и протоколы зафиксированы предрегистрационным коммитом до прогона и не подгонялись (см. историю git). diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index b1771b89..8b6830c5 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -50,16 +50,10 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch движок выводит производное — hue, кривую) + ручки подтона (`ratio`, `target_mp`, `hue_stiffness`). - `palette[]` — N семейств `{key, anchor}` (labui: 10 замеренных). -- `sentiments` — категории потребителя → семейство + политика - (`hue_floor_deg`, `preferred_side`, `hardness`, `chroma_fraction`). - `S_PERC_MIN` движок пересчитывает из конфиг-якорей — закон - `2·C_rep·sin(20°/2)` (20° — нижний предел категорий, Witzel & Gegenfurtner - 2013), где C_rep = средняя хрома якорей сентиментов. При labui-якорях - результат совпадает с прежней замороженной константой (деривационная - идентичность — тест `s_perc_min_recomputed_from_config_anchors_matches_frozen`); - при произвольных якорях клиента формула пересчитывается под них. Отдельно: - `s_min_deg` уже использует хрому каждого прототипа (proto_chroma), C_rep - входит лишь в вывод порога. +- `sentiments` — явная версия геометрии `anchor-distance-v2` и отображение + категорий потребителя на семейства. Для каждой пары и режима порогом служит + фактическая Oklab-ab-дистанция её клиентских anchors `Dᵢⱼ`; общего угла, + средней хромы и категориальных hue-границ нет. - `themes` — словарь `имя → пресет условий просмотра (VC)` из закрытого физического меню (`srgb | dim | srgb-ic | dim-ic`, kebab-контракт JSON); произвольных VC-чисел в конфиге нет. @@ -87,9 +81,9 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch - WASM: `engine.load_config(json) -> fingerprint`; сигнатура `resolve_theme(bg_hex, theme)` НЕ меняется — конфиг = состояние движка, кэш по fingerprint. -- Ручки политики подтона (`target_mp`, `hue_stiffness`, `ratio`) и сентиментов - (`hardness`, `chroma_fraction`) экспонируются конфигом с сегодняшними - значениями labui как дефолтами; каждая остаётся SSOT-TRACKED в науч-реестре. +- Ручки политики подтона (`target_mp`, `hue_stiffness`, `ratio`) остаются + конфигом. Sentiment V2 не экспонирует числовую «силу»: клиент передаёт anchors, + а DTO отклоняет legacy-поля с явной ошибкой миграции. Пороги решателя вроде `IC_DECORATIVE_FLOOR_MIN` конфигом НЕ экспонированы — остаются физикой ядра. - **Валидатор конфига обязателен**: каждая экспонируемая ручка несёт пределы @@ -132,10 +126,9 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch ОРАКУЛОМ именованного пути (тесты `resolve_set` ↔ `resolve_named_set`, `agnostic_gates`, r3-пины), но в собранный крейт и публичный API не входит. Кривые `AccentCurve`/`SentimentCurve` оставлены как enum-free инструмент - произвольного seed: `SentimentCurve::{new,with_params}` рефакторены на прямые - параметры (`brand_hue`, `prototype_hue`, `chroma_hex`, `hue_floor`, - `preferred_side`) — витринный энум из сигнатуры убран, честное разделение - «оттенок vs хрома» сохранено. Публичная версия SDK — 0.6.0 (ломающая). + произвольного seed. Sentiment-кривая получает anchor напрямую; брендовый hue, + `hue_floor` и `preferred_side` не являются физическими входами. Публичная + версия геометрии фиксируется строкой `anchor-distance-v2`. Grep-гейт чистоты — `tests/agnostic_cleanliness.rs`: продакшн-`src` (после снятия `#[cfg(test)]` и комментариев) не несёт ни брендовых якорных hex, ни определений витринных типов; гейт с живым RED-proof. @@ -226,12 +219,8 @@ light/dark несут `SkeletonBase`, нейтральная рампа (`Neutra - `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`). +- `sentiment(key)` — пер-темный anchor семейства категории. Наборный V2-резолвер + сохраняет anchor и проверяет все фактические `Dᵢⱼ`; бренд не участвует. - `neutral(pick)` — НЕЙТРАЛЬНЫЙ тинт из `neutral.anchors` (НЕ семейство палитры). Заземление — стаб: `Neutral/Derivable` тинтуется краями нейтральной шкалы. `pick` — 5 вариантов (`NeutralPick`, config.rs): `Mid` (`#787880`) — скелетон/ @@ -257,17 +246,12 @@ Figma-примитивов замерены, не выведены. `{light, mid, dark}`, config/tests.rs) и держатся отдельным пином; `fill-neutral` — PROVISIONAL-литерал. -### Деривационная идентичность сентимента +### Идентичность sentiment-anchor -При бренде 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`, не спрятано и не подогнано. +Во всех четырёх режимах сентимент-тинт совпадает с сырым anchor назначенного +семейства, включая Info рядом с синим брендом. Это не утверждение о «правильной» +семантической зоне: клиентские данные авторитетны, а ядро не перекрашивает их по +имени категории. ### Альфа-аналог (`alpha_analog(of, alpha)`) @@ -280,21 +264,9 @@ Danger (red), Success (green), Warning (orange) — держится (тест заливки labui эмитятся как `ladder(..., FillPrimary)` (тинт-якорь напрямую), т.к. насыщенный солид над белым дал бы `α_min≈1` и перестал быть полупрозрачным. -### Категориальные ручки: `preferred_side` (мёртв для паспорта) vs `hardness` (жив) - -Диагноз D2 (2026-07-03), грунтованный прямым прогоном резолва на -`labui_reference` (4 темы × 6 фонов × все роли): - -- **`SentimentCategory::preferred_side`** — ПРИНИМАЕТСЯ и ВАЛИДИРУЕТСЯ - (`∈ {-1, +1}`), но для эталонного паспорта ЭФФЕКТА НЕ ИМЕЕТ: срабатывает лишь - в вырожденном шве `brand == prototype` (ветка `u == 0` в - `resolve_smooth_hue_among`), а бренд labui (синий) hue-дистантен от всех - прототипов сентиментов. Установка ±1 на любой из 4 категорий НЕ сдвинула НИ - ОДНОГО эмитируемого байта. Класс «принята, эффекта нет» — зарезервированный шов - под клиента, чей бренд совпадёт с прототипом. Вайринг или удаление — отдельное - решение (не вайрили, т.к. это двигало бы значения; не удаляли, т.к. шов - легитимен для иного конфига). -- **`SentimentsConfig::hardness`** — ЖИВА: понижение с дефолтных `5.0` до `1.0` - сдвинуло 1344 role-точки, до `3.0` — 480 (мягче p ⇒ раньше «отпускает» - оттенок). Повышение до `8/20` — 0 точек (сентименты labui уже на p=5-асимптоте). - Ручка честная и функциональная, документируется как живая, не мёртвая. +### Миграция legacy sentiment-полей + +`preferred_side`, `hue_floor_deg`, `hardness` и `chroma_fraction` удалены из +контракта ядра. WASM DTO всё ещё распознаёт их исключительно для диагностической +ошибки: конфиг обязан явно перейти на `geometry: "anchor-distance-v2"`. Ни одно +legacy-значение не может скрыто влиять на цвет. diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index d09f2897..21a33127 100644 --- a/docs/empirical-inventory.md +++ b/docs/empirical-inventory.md @@ -4,7 +4,7 @@ SSOT for the R4 governance gate (`tests/empirical_inventory.rs`). One row per POLICY const detected across the perceptual modules in `PERCEPTUAL_MODULES` (`tests/empirical_inventory.rs`). Join-keyed on `(row#, name)`; `value` and `module` columns are load-bearing (GATE-2). -Standards (`HK_CHROMA_EXPONENT`, `LC_SCALE`, `DELTA_Y_MIN`, `S_PERC_MIN`, WCAG ratios, D65, L_A, Yb) are excluded by `NUMERIC_METHOD_ALLOWLIST` and must NEVER appear here (INV-3, N5). +Standards (`HK_CHROMA_EXPONENT`, `LC_SCALE`, `DELTA_Y_MIN`, WCAG ratios, D65, L_A, Yb) are excluded by `NUMERIC_METHOD_ALLOWLIST` and must NEVER appear here (INV-3, N5). Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation-verified standard/document yet; `GROUNDED` = attested by cited standard/document (GATE-4 verifies the citation). @@ -20,10 +20,6 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 17 | `CUSP_HALF_WINDOW_DEG` | `40.0` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** (НЕ (c), проверено ре-аудитом 2026-07-07) — Полу-окно поиска оттенка (°) в `cusp_attracted_hue`; держит undertone в сине-фиолетовой полосе. ⚠️ Замер: полный дрейф каспа гамута sRGB вблизи 286° достигает ≈42.5° по L-шкале — окно 40° клипует чуть ВНУТРИ полного дрейфа НАМЕРЕННО (движок держит оттенок близко к каноническому, не гонится за магента-каспом; см. комментарий `cusp_attracted_hue`). НЕ «окно покрывает дрейф»; тест `derivator_locks::cusp_window_is_near_measured_gamut_drift`. Калибровочный. **Отличие от `HUE_SEARCH_HALF_WINDOW` (строка 36):** там доказан интерьерный оптимум (окно НЕ связывает), здесь доказано ОБРАТНОЕ (дрейф строго превышает окно) — измеримо связывающий кап, значит точное значение окна материально для крайних светлот; (c) не применим. | | 18 | `LIGHTNESS_SETTLE` | `0.002` | `semantic.rs` | SSOT-TRACKED | **(a) DERIVED** — Порог сходимости неподвижной точки. Вывод держится СТРОГО: 1/256 ≈ 0.00391 — минимальный шаг выходной 8-бит сетки; 0.002 лежит НИЖЕ него, поэтому итерация не застревает на различимой ступени. Тест `derivator_locks::lightness_settle_is_below_min_grid_step`. | | 19 | `STRICT_STEP` | `0.5` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** (НЕ (c), проверено ре-аудитом 2026-07-07) — Граница квантования выходной сетки (Lc), НЕ JND-клейм. Замер: медианный Lc-шаг одного 8-бит кванта серых ≈0.44; STRICT_STEP=0.5 сидит чуть выше типичного шага сетки. (Максимальный шаг ≈7.85 — это обрыв мягкого клампа APCA, разрыв, а не шаг сетки.) Тест `derivator_locks::strict_step_sits_just_above_typical_grid_step`. Калибровочный. **Почему не (c):** в отличие от других низко-экспозиционных ручек, `STRICT_STEP` — прямое слагаемое цели демоции (`senior_mag − STRICT_STEP` в `demote_below`/`demote_below_hued`), поэтому оно НЕПРЕРЫВНО и напрямую сдвигает эмитируемый цвет junior-роли; никакого лока инвариантности выхода по интервалу не существует — честный терминал (e), не (c). | -| 21 | `CHROMA_FRACTION` | `0.88` | `sentiment.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — доля максимума хромы в гамуте, которую несёт каждый сентимент на своей матч-светлоте; `< 1`, чтобы сидеть внутри стены гамута, а не «неоном». **Волна 2:** genuine свободная ручка; легальный диапазон **(0,1]** (валидатор). Sensitivity: свип [0.70,1.0] на реальных якорях danger/warning/success/info → max ΔE_ok≈**0.0421** (>1 JND, материальна) → честный (e), не (c) (лок `chroma_fraction_sensitivity_is_bounded`). Реестровый дефолт 0.88 — для клиентов без якорной калибровки; **labui=1.0** (danger `#FF3B30` > 0.88·C_max, см. S-01). Протокол: подобрать так, чтобы самый насыщенный якорь клиента сел чуть внутри стены. **(Строка #20 `DEFAULT_HARDNESS` УДАЛЕНА Волной 1: p-норма brand-displacement снесена законом категориальных зон — трекать константу удалённого механизма было бы ложью о живости кода.)** | -| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | `1.75` | `neutral.rs` | SSOT-TRACKED | **РЕОТКРЫТ — «закон не найден ЕЩЁ, активные гипотезы»** (co-dependency, #95 2026-07-08 — секция внизу файла): терминал (e) отменён как окончательный. Три метрики step-uniformity рефутируют скалярные прочтения (Oklab 1.07, sRGB 1.10, surround-CAM16-J' 0.99–1.04 — направление право, магнитуда мала vs 1.75). Активная гипотеза: crispening-метрика Уиттла. Гамма светлой ветви (t ≤ 0.5); направление `gamma_light > gamma_dark` мотивировано (асимметрия шага светлоты). Легальный диапазон `>0` (практ. [1.2,2.2]). Sensitivity: свип [1.3,2.2] → max ΔE_ok≈**0.0466** (>1 JND, материальна) (лок `curve_params_sensitivity_is_bounded`). | -| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | `1.5` | `neutral.rs` | SSOT-TRACKED | **РЕОТКРЫТ — «закон не найден ЕЩЁ, активные гипотезы»** (co-dependency, #95 2026-07-08 — секция внизу): терминал (e) отменён как окончательный; три метрики рефутируют скаляр (см. #22, dark-ветвь: 0.95/0.99/0.90–1.00 vs 1.5). Гамма тёмной ветви (t > 0.5); легальный диапазон `>0` (практ. [1.2,1.9]). Sensitivity: свип [1.2,1.9] → max ΔE_ok≈**0.0325** (>1 JND, материальна) (лок `curve_params_sensitivity_is_bounded`). | -| 24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | `0.35` | `neutral.rs` | SSOT-TRACKED | **КОНТЕКСТ-ФУНКЦИЯ (hue) ПОДТВЕРЖДЕНА** (co-dependency, #95 2026-07-08 — секция внизу): `chroma_peak_t(hue) =` t светлотного cusp'а; на акцентах хорошо поставлена, диапазон [0.225, 0.479], среднее **0.379 ≈ shipped 0.35**. Скаляр = приближение центра hue-закона; нейтраль = вырождение (atan2-шум). Эмиссионно-НЕЙТРАЛЕН → hue-функцию можно ввести БАЙТ-ИДЕНТИЧНО (кандидат в объективизацию, не semver-major). Позиция t пика огибающей хромы; легальный диапазон `(0,0.5]` (клампится). Sensitivity: свип [0.2,0.5] → max ΔE_ok≈**0.0042** (< ½ JND, лок `curve_params_sensitivity_is_bounded`). | | 25 | `SOFT_CLAMP_THRESHOLD` | `0.022` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | | 26 | `SOFT_CLAMP_EXP` | `1.414` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | | 27 | `EXP_BG_LIGHT` | `0.56` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | @@ -35,56 +31,57 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation- | 33 | `LO_BOW_OFFSET` | `0.027` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | | 34 | `LO_WOB_OFFSET` | `0.027` | `lpc.rs` | GROUNDED | APCA SAPC-8 `0.0.98G-4g` published set. | | 35 | `IC_DECORATIVE_FLOOR_MIN` | `15.0` | `semantic.rs` | GROUNDED | **(b) GROUNDED** — опубликованный APCA-уровень **Lc 15**: «absolute minimum for any non-text that needs to be discernible», точка невидимости ниже неё (APCA project docs, Somers/Myndex: git.apcacontrast.com `WhyAPCA` + `APCA_in_a_Nutshell`). ⚠️ Провенанс честен: DRAFT/beta, single-origin (apca-w3 0.1.9 beta) — черновиковый дизайн-уровень, **НЕ норматив WCAG 3** и не независимое психофизическое измерение. Наш декоративный `-ic`-пол — ровно этот случай: минимальный различимый не-текстовый контраст. Применяется порядкосохраняющим равномерным сдвигом `+7.5` (= 15 − 7.5) поверх обычного пола, НЕ как `max`: max(m, 15.0) схлопывал весь стек теней (8/9.5/11.5/14) и сепаратор в один идентичный цвет под `-ic`, нарушая строго-возрастающий контракт стека и молча мутируя измеренные Lc-зазоры. Локи: `ic_floor_is_apca_lc15_with_order_preserving_shift`, `shadow_stack_is_strictly_ascending_under_increased_contrast`. | -| 36 | `HUE_SEARCH_HALF_WINDOW` | `30.0` | `scale.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Half-width (degrees) of the hue search window in `find_optimal_hue`; 30° spans the typical sRGB gamut ridge width around the canonical hue. **Доказательство:** при продакшн-наклоне интерьерный оптимум НЕ касается ребра окна ни на одном из 43 хроматических якорей 49-якорного паспорта labui (см. замер строки 37 / `derivation_rejection_locks::chord_derived_slope_rejected_degenerates_to_window_edge`, база-наклон 0 прижатий) — окно нежёсткая нижняя граница, не связывающий кап. **Экспозиция:** 0.93% (l,hue)-сетки, где точное окно (свип [25°,45°]) меняет выбранный оттенок (`scale::exposure_locks::exposure_hue_search_window`). Сравни с `CUSP_HALF_WINDOW_DEG` (строка 17, семантически похожая ручка, но ДОКАЗАННО связывающая — там (c) не применим). | -| 37 | `HUE_DRIFT_PENALTY_SLOPE` | `0.15` | `scale.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — наклон штрафа дрейфа оттенка в `find_optimal_hue` (`penalty_scale = slope/half_window`, `score = c − penalty_scale·drift`): баланс «максимум хромы против ухода от канонического оттенка». Ранее жил незадекларированным поле-литералом (найден инвентарём конфиг-границы 2026-07-02). **Строгий кандидат-вывод:** перцептивная цена дрейфа = длина хорды Oklab `2·C·sin(Δh/2) ≈ C·Δh·π/180` при малых Δh, откуда теоретический `penalty_scale = C·π/180 ≈ 0.0026–0.0035/°` при C≈0.15–0.20 — против текущего `slope/half_window = 0.15/30 = 0.005/°`. **Разрешение (2026-07-06): хордовый кандидат ОТКЛОНЁН по замеру.** На 49-якорном замороженном паспорте labui (l-сетка 0.05–0.95, шаг 0.01) прод-наклон держит интерьерный оптимум на всех 43 хроматических якорях (0 прижатий к ребру), а `penalty_scale = C·π/180` прижимает оптимум к ребру окна ±30° на 12/43 якорях (флип >0.5° на 27/43, ΔE_ok до 0.077) — т.е. передаёт решение произвольной границе `HUE_SEARCH_HALF_WINDOW`, делая интервально-нечувствительную константу окна чувствительной: анти-объективизация. Терминал (e) DESIGN-CHOICE (терминальная таксономия 2026-07-07): свободная ручка, якорь — ИЗМЕРЕННОЕ отклонение строгого кандидата (выше); интервал свипа [0.10, 0.20], экспозиция 14.29% гаммы (`exposure_hue_drift_penalty_slope`); значение 0.15 не меняется (INV-1). Замок: `derivation_rejection_locks::chord_derived_slope_rejected_degenerates_to_window_edge`. **MODEL-CONFLICT: ИЗМЕРЕН И ОТКЛОНЁН** (ре-аудит 2026-07-07) — единственный строгий кандидат-вывод уже проверен и признан хуже shipped 0.15 (вырождение в клип по ребру окна); вопрос закрыт замером, владельцу решать нечего (как и `PAIR_CROSSOVER_Y`, строка 52, разрешён деривацией → (a)). | | 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | SSOT-TRACKED | **(a) DERIVED** — Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. (a) DERIVABLE из арифметики представимости: минимум ненулевой Oklab-хромы 8-битного sRGB ≈ 1.06e-3 (`#FEFFFF`; расчёт конвейером sRGB→Oklab, Ottosson 2020). ⚠️ КОРРЕКЦИЯ (science-wave 2026-07-06): накопленный f64-шум конвейера ЗАМЕРЕН ≈3.7e-8 (потолок хромы истинно-серых R=G=B) — РАНЕЕ реестр ошибочно заявлял ≲1e-12. Значит 1e-7 сидит лишь ≈2.7× выше потолка шума; запас ≥3 порядка держится ТОЛЬКО сверху. Обе границы соблюдены — EPS не переклассифицирует ни один представимый цвет, но полоса снизу тонкая (факт, не подгонка). Замок `sentiment::derivator_locks::achromatic_eps_between_f64_noise_and_min_representable_chroma`. SSOT-TRACKED (не GROUNDED): цитируемого внешнего стандарта нет — таблица и есть носитель. | | 39 | `HUE_DOMAIN_MIN_INCLUSIVE` | `0.0` | `sentiment.rs` | SSOT-TRACKED | **(a) DERIVED** — Нижняя граница канонического домена оттенка `[0, 360)`: определение угла по модулю 360°, не перцептивная величина. Единый дом границ для валидатора конфига и гардов солвера (два литерала одного домена = класс тихого расхождения). | | 40 | `HUE_DOMAIN_MAX_EXCLUSIVE` | `360.0` | `sentiment.rs` | SSOT-TRACKED | **(a) DERIVED** — Верхняя (исключительная) граница того же домена: полный оборот окружности, 360° ≡ 0°. Определение, не политика; пара к строке 39. | -| 47 | `ACHROMATIC_MP_THRESHOLD` | `5.0` | `neutral.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Порог M' (CAM16-UCS) ахроматичности якоря: CAM16 даёт ненулевой M' даже для номинально ахроматических стимулов (≈1.5 белый, ≈2.3 near-black); 5.0 ловит модельный шум, сохраняя хроматические якоря. **Доказательство:** порог сидит в ШИРОКОМ пустом зазоре между потолком ахроматического M'-шума серых и полом хромы подлинно цветных якорей — партиция achromatic↔chromatic ИНВАРИАНТНА для любого θ в этом зазоре (`neutral::exposure_locks::achromatic_threshold_sits_in_empty_noise_to_chroma_gap`). **Экспозиция:** 1.99% гаммы (`exposure_achromatic_mp_threshold`); 4 нейтрали labui (3C3C43, 787880, B0B0B9, F6F8FA) — подстановка штатна, не флип-риск. | -| 48 | `HUE_PURITY_MP_REF_RATIO` | `1.5` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — множитель опорной хромы (`mp_ref = 1.5 × M'` базы) для нормировки чистоты оттенка. Магнитуда — свободная ручка (опубликованного значения не существует; фит к данным Abney дал бы ДРУГУЮ кривую — фейковое (b) запрещено ADR-0002/INV-2). Робастность доказана: хроматические якоря (mp ≥ mp_ref) дают purity = 1 при ЛЮБОМ значении (инвариантны), константа двигает лишь оттенок near-нейтралей, который и так atan2-шум. Терминал закрыт декларацией (решение владельца 2026-07-07, таксономия (a)/(b)/(e)); конфиг-экспорт ручки — вне зоны миссии (flagged follow-up). Пара к строке 49; провенанс ФОРМЫ — там же. Локи: `hue_purity_curve_shape_is_pinned`, `exposure_hue_purity_curve`. | -| 49 | `HUE_PURITY_EXPONENT` | `0.6` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — показатель кривой чистоты `(mp/mp_ref)^0.6`. Магнитуда — свободная ручка. ПРОВЕНАНС ФОРМЫ (не значения): «не доверяй тону у нейтрали» мотивировано двумя сходящимися причинами — численной (atan2 ill-conditioned у серой оси, оттенок near-серых из шума) и перцептивной (эффект Abney: тон монохроматического стимула сдвигается при разбавлении белым — Abney 1909 Proc. R. Soc. Lond. A 83, 120–127, DOI 10.1098/rspa.1909.0085; сдвиг растёт с падением колориметрической чистоты — Kurtenbach-Sternheim-Spillmann 1984 JOSA A 1(4), 365–372, DOI 10.1364/JOSAA.1.000365; обе цитаты перепроверены живьём 2026-07-07: DOI резолвятся, реквизиты совпадают). ⚠️ Перцептивный Abney ОТДЕЛЕН от численного atan2-шума; 0.6/1.5 НЕ выведены из данных Abney (эта кривая эффект Abney не моделирует, issue #27) — поэтому честный терминал (e), не (b). Робастность: хроматические якоря инвариантны (purity = 1 при mp ≥ mp_ref); свип показателя [0.4, 0.9] — max\|Δpurity\| = 0.148 только у near-нейтралей (непрерывный дрейф, не флип). Терминал закрыт декларацией (2026-07-07). Локи: `hue_purity_curve_shape_is_pinned`, `exposure_hue_purity_curve`. | | 50 | `QUANT_BUDGET` | `1.0` | `solve.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Допуск приёмки Lc квант-солвера в единицах шага выходной сетки: цвет принят, когда измеренный Lc в пределах ±QUANT_BUDGET от цели. Замер: медианный Lc-шаг одного 8-бит кванта серых ≈0.44 (максимум ≈7.85 — обрыв мягкого клампа APCA, не шаг сетки); 1.0 Lc ≈ 2.3× типичного шага — контракт «±1 Lc» (`quant_budget_is_a_couple_of_grid_steps`). **Экспозиция:** 1.84% целей флипают приёмку при свипе ±50% (`exposure_quant_and_dj_budgets`) — дискретность 8-бит сетки поглощает почти весь свип. | | 51 | `DJ_BUDGET` | `0.6` | `solve.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Допуск приёмки dJ' декоративного солвера (J'-единицы CAM16-UCS), dJ'-аналог `QUANT_BUDGET`. Замер: медианный dJ'-шаг 8-бит кванта серых ≈0.39; 0.6 J' ≈ 1.5× типичного шага — чуть больше одного шага сетки (`dj_budget_tracks_grid_step`). **Экспозиция:** 1.55% (`exposure_quant_and_dj_budgets`). | | 52 | `PAIR_CROSSOVER_Y` | `0.341955` | `pair.rs` | SSOT-TRACKED | **(a) DERIVED** — Y-порог кроссовера стороны пары «заливка × лейбл» (решается один раз по каноническому светлому якорю семьи). **Вывод:** перелом полярности чисто-люминансного контраст-ядра APCA (`contrast_core`, набор SAPC-8 `0.0.98G-4g`) — фон, где |Lc| чёрного лейбла догоняет белый; бисекция (корень единствен на [0.2, 0.6]) = **0.341955**. Это ДОМЕН РЕШЕНИЯ `pair_side`: оно сравнивает чистый WCAG-люминанс якоря (без хромы/CAM16), потому якорь = ядро — **НЕ** полная метрика `lpc` (byte 155, Y≈0.325: несёт CAM16/Y_hk-реконструкцию яркостного домена, чужую решению; вторичный замер, не якорь) и **НЕ** WCAG-tie 0.179 (`WHITE_WINS_Y`, легальный ПОЛ гарантии, не перцептивный оптимум). Пиннится ре-деривацией `pair_crossover_equals_measured_core_polarity_flip` (дрейф любой APCA-экспоненты ломает лок). **Согласованность:** 0.341955 ∈ интервал 10 Figma-якорей (0.246, 0.423) — вывод совпадает с палитрой, не спорит (экспозиция 21.69% `exposure_pair_crossover`; внутри интервала сторона якорей инвариантна `crossover_side_is_invariant_across_palette_gap`). **Смена с прежнего 0.30 БАЙТ-ИДЕНТИЧНА для labui** (оба значения в зазоре палитры без якорей), но исправляет недодачу светлых лейблов при Y∈[0.30, 0.342), где по модели белый читается лучше. Решение владельца 2026-07-07 (`science/crossover-derived`): перейти на выведенное значение. Прежний клейм «перелом ≈ 0.36» (V3-экстраполяция) опровергнут замером. **Hue-оговорка:** порог сравнивает чистый WCAG-Y — H-K-вклад (Гельмгольц-Кольрауш) в решение НЕ входит, осознанный скоуп ADR-0003 (`decisions/0003-hk-scope.md`). Сосед `WHITE_WINS_Y`=0.179 — граница полярности ТЕКСТА (вывод из WCAG `(Y+0.05)²<1.05·0.05`), не эта константа. | | 53 | `QUANT_GUARD` | `0.2` | `semantic.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — квант-guard над `MODEL_LC_FLOOR` (7.3): зазор `DECORATIVE_FLOOR_MIN − MODEL_LC_FLOOR`, держащий декоративный пол строго выше 7.3, чтобы решатель не садился на инфимум клипа и не возвращал нулевой контраст (issue #44). **Доказательство сильнее обычного (c):** `QUANT_GUARD` НЕ используется в продакшене НЕЗАВИСИМО — единственная величина, которую видит решатель, это байт-идентичный литерал `DECORATIVE_FLOOR_MIN` (7.5); guard существует только как провенанс-разложение этого литерала на DERIVED-член (7.3) и задекларированный запас (лок `decorative_floor_is_model_floor_plus_guard`, компайл-тайм `const _`). Замер (`no_pair_emits_contrast_below_model_floor`, скан квантованных пар): фактический минимальный ненулевой контраст = **7.3005** (сидит 0.0005 над модельным инфимумом 7.3, а не у номинальной цели 7.5) — скачок |Lc| одного 8-бит шага у клипа = **7.85** (жёсткий разрыв, guard не покрывает и не должен). Пока guard положителен и цель остаётся достижимой в пределах `QUANT_BUDGET` = 1.0, точная магнитуда 0.2 не меняет ни одного реального эмитируемого контраста; доминантный член 7.3 остаётся DERIVED. | | 54 | `MODEL_LC_FLOOR` | `7.3` | `lpc.rs` | SSOT-TRACKED | **(a) DERIVED** = (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE = (0.1 − 0.027) × 100 = 7.3 — минимальный ненулевой контраст модели (за низкоконтрастным клипом `contrast_core` стартует ровно с него, внутри клипа — ноль; полярности симметричны, `LO_WOB_OFFSET == LO_BOW_OFFSET`). Алгебраическая идентичность из GROUNDED APCA `0.0.98G-4g` набора; хранится литералом 7.3 (чистое число для инвентаря), тождество с формулой пиннится `model_lc_floor_is_the_published_clip_minimum` (дрейф любого из трёх APCA-входов или правка литерала ломает лок). Опора `DECORATIVE_FLOOR_MIN` (строка 1), issue #44. | -## Muddiness Law constants — `cleanliness.rs` - -> **GATE SCOPE NOTE:** `cleanliness.rs` is NOT in the `PERCEPTUAL_MODULES` audit surface of -> `tests/empirical_inventory.rs` (that gate covers `semantic.rs`, `scale.rs`, `sentiment.rs`, -> `neutral.rs`, `lpc.rs`, `lcs.rs`, `solve.rs`, `pair.rs`). The rows below use non-integer keys (`M-01` … `M-14`) -> so the GATE-2 integer-row parser skips them. Correctness of these constants is now enforced -> by the **in-tree Rust auditor** `mud_rows_match_cleanliness_source` in -> `tests/empirical_inventory.rs`: every non-REMOVED `M-` row must join to a `pub const` in -> `cleanliness.rs` whose value equals the documented value at the row's displayed precision; -> `CUSP_L_TABLE` (M-13, "see code") is verified for existence and declared length 361. REMOVED -> rows are covered by the absent-guard `cal_t_cal_b_absent_from_shipping_code` in `cleanliness.rs`. -> The former external `.agents/tools/mud-oracle/verify_inventory.js` script is **no longer a -> gate** and does not exist on this tree. The module header in `cleanliness.rs` cross-references -> each constant to its row here. - -Science-vs-fit boundary: the **structural form** (warm gate b=C·sin h, neutral gate sigmoid on C, depth term below cusp-L) is derived from Oklab opponent-colour theory and CAM16-UCS. **The confidence layer (M_W / KAPPA_CORE / KAPPA_INTERIOR, M-09/10/11) has been removed entirely** (Front B, 2026-07-04): it was a single-observer DECLARED-CALIBRATION with no reliability model, off the emission path and outside the agnostic contract — see the REMOVED rows below. `muddiness` (distance geometry) remains. The former M-03 light-escape threshold has been **removed entirely** (Zone B, 2026-07-01): its escape term leaked the lightness axis into the neutral gate, which is documented as chroma-only; `neutral_gate` is now `sigmoid((C - C0) / JND)`. **B0 and BW are cited-measured** (Zone B, 2026-06-29). **Platt scalars CAL_T/CAL_B/CAL_EPS (M-06/M-07/M-08) have been removed** (Zone B slice 3, 2026-06-30). **W_HUE[8] and CEIL_N_TABLE have been replaced** (Zone B slice 4, 2026-06-30) by the derived Bezold-Brücke Hanning window `hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2`; H_Y_DEG = 90.4011° (Oklab hue of unique yellow λ=578nm, CIE 1931 2° D65, XYZ→Oklab direct — corrected in Zone B slice 5, 2026-07-03: the slice-4 value 96.9172° rested on transposed CMF rows and a hue-distorting sRGB clamp, and its citation «Parry (1967) JOSA 57, 1130–1134» does not exist; real sources: Purdy (1937) Am. J. Psychol. 49, 313–315; Jacobs & Wascher (1967) JOSA 57, 1155–1156). Zero fitting. CUSP_L_TABLE is cited-and-kept: pure sRGB-gamut geometry, kept as-is per paradigm North. +## Замороженные константы Закона Грязи V1 — `cleanliness_legacy.rs` + +> **Статус после научного реаудита:** таблица сохраняется как журнал +> воспроизводимости исторической гипотезы V1, а не как доказательство +> универсального валидированного классификатора чистого/грязного. В частности, +> точный Oklab JND, центр/ширина тёплого порога и произведение разнородных +> механизмов не выводятся из приведённой литературы. Их байты заморожены ради +> совместимости и фальсификации; новая контекстная модель V2 реализована отдельно +> в `cleanliness.rs` и несёт собственный идентификатор модели. + +> Строки `M-*` не входят в целочисленный реестр GATE-2. Их отдельный тест +> `mud_rows_match_cleanliness_source` связывает каждое активное имя и значение с +> `cleanliness_legacy.rs`; таблица cusp дополнительно проверяется по длине 361. +> Это гейт **воспроизводимости**, а не гейт научной истинности. Удалённые имена +> проверяются на отсутствие в поставляемом коде. + +Структура V1 смешивает opponent-координату Oklab, сигмоиды с неподтверждёнными +масштабами и светлоту относительно cusp. Ни точные параметры, ни произведение +этих механизмов не следуют из процитированных работ. Поэтому все живые значения +ниже имеют единый статус `FROZEN V1`; прежние статусы `cited-measured` и +`cited-and-kept` отозваны. Актуальная операциональная гипотеза и точная граница +её утверждений описаны в [`cleanliness-v2.md`](cleanliness-v2.md). | mud-id | name | value | module | status | rationale | |--------|------|-------|--------|--------|-----------| -| M-01 | `C0` | `0.0395` | `cleanliness.rs` | cited-and-kept | Grey-frontier chroma floor — центр сигмоида перехода ахроматический→хроматический (`sigmoid((C−C0)/JND)`). Единственное честное основание: литература о пороге «нулевой серости» / хроматического порога (Evans, «The Perception of Color»; Xie & Fairchild). **⚠️ Прежняя геометрическая мотивировка** («граница гамута sRGB для серых ≈0.04 при L=0.5») **удалена как некорректная**: у ахроматических серых C=0 по определению, «границы гамута для серых при C≈0.04» не существует. Строгий вывод значения 0.0395 в Oklab из Evans/Xie-Fairchild НЕ установлен — **TODO вывода**. cited-and-kept, значение не меняется (North). Bound: 0.030 ≤ C0 ≤ 0.050 (декларированный диапазон). | -| M-02 | `JND` | `0.012278` | `cleanliness.rs` | cited-and-kept | Chroma just-noticeable difference in Oklab. Oklab is perceptually uniform to first order; one JND on the opponent axes a,b is ≈ 0.012 (derived from the ΔE₂₀₀₀ ≈ 1 JND mapped through the Oklab linearisation). JND = 0.01228 is used as the sigmoid transition width for both the neutral gate and the b-gate, encoding that transitions span ≈ 1 perceptual unit. Cited: Oklab perceptual measurement. Kept as-is per paradigm North. **⚠️ Оговорка:** отображение ΔE₂₀₀₀→Oklab использует линеаризацию (якобиан), но точка пространства, в которой якобиан взят, НЕ зафиксирована в первоисточнике деривации (ΔE₂₀₀₀↔Oklab зависит от точки); значение держится как задекларированное и не округляется. Bound: 0.010 ≤ JND ≤ 0.016. | -| M-04 | `B0` | `0.036` | `cleanliness.rs` | cited-measured | Oklab-b warm-gate centre. Structural form (b = C·sin h threshold) is derived from Oklab opponent-colour theory: the yellow–blue axis b separates warm (b > 0) from cool (b < 0). Value 0.036 is the central of the cited range [0.030, 0.044]: the empirical yellow–brown perceptual boundary from unique-hue experiments (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). Observer-fit replaced by cited-measured central (Zone B). | -| M-05 | `BW` | `0.017` | `cleanliness.rs` | cited-measured | Oklab-b warm-gate transition width. Same structural role as JND but for the b-axis warm gate. Value 0.017 is the central of the cited range [0.013, 0.020]: the perceptual transition width of the warm–cool boundary from the same unique-hue studies (Newhall-Nickerson-Judd 1943; Lindsey-Brown 2014 PNAS; Boynton 1975). Observer-fit replaced by cited-measured central (Zone B). | -| M-06 | `CAL_EPS` | `0.01` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link log-offset — removed together with the Platt sigmoid chain. `pub const CAL_EPS` no longer exists in `cleanliness.rs`; any reference is test-only (absent-constant guard). | -| M-07 | `CAL_T` | `2.356978` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link scale — removed. Was a fitted scalar on v3 train split; violated ZERO observer-fit invariant (North). `pub const CAL_T` no longer exists in `cleanliness.rs`. | -| M-08 | `CAL_B` | `6.445168` _(former)_ | `cleanliness.rs` | REMOVED (Zone B slice 3, 2026-06-30) | Platt link bias — removed. Was a fitted scalar on v3 train split; violated ZERO observer-fit invariant (North). `pub const CAL_B` no longer exists in `cleanliness.rs`. | -| M-09 | `M_W` | `0.181527` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Полуширина доверительного поля confidence-слоя. Была DECLARED-CALIBRATION из однонаблюдательской разметки владельца (N=1, 738 меток, reliability-модели нет) — observer-fit вне агностичного контракта движка и вне пути эмиссии (`confidence` не звался из solve/semantic/resolve). Слой удалён целиком, НЕ ре-калиброван (датасета для честного мультинаблюдательского ре-груна нет — любая новая калибровка была бы театром). `pub const M_W` больше не существует в `cleanliness.rs`; снос байт-идентичен эмиссии. | -| M-10 | `KAPPA_CORE` | `0.34` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Потолок уверенности стабильного ядра confidence-слоя — та же однонаблюдательская калибровка (N=1), без модели надёжности. Удалён вместе с confidence-слоем; `pub const KAPPA_CORE` больше не существует в `cleanliness.rs`. | -| M-11 | `KAPPA_INTERIOR` | `0.10` _(former)_ | `cleanliness.rs` | REMOVED (Front B, 2026-07-04) | Пол уверенности спорной полосы confidence-слоя — та же однонаблюдательская калибровка (N=1), без модели надёжности. Удалён вместе с confidence-слоем; `pub const KAPPA_INTERIOR` больше не существует в `cleanliness.rs`. | -| M-12 | `H_Y_DEG` | `90.4011` | `cleanliness.rs` | cited-derived (Zone B slice 5, 2026-07-03) | Oklab hue уникального жёлтого (λ=578nm, CIE 1931 2° D65). Вывод: CMF при 578nm — линейная интерп. CIE 5нм-таблицы (575nm: 0.8425/0.9154/0.0018; 580nm: 0.9163/0.8700/0.00165) → (0.886780, 0.888160, 0.001710) → XYZ/Y = (0.998446, 1, 0.001925) → Oklab НАПРЯМУЮ (Ottosson 2020; Oklab hue инвариантен к радиансности, sRGB-проекция не нужна; покомпонентный клэмп НЕ сохраняет hue) → atan2(b,a) = 90.4011° (±0.01° — неопределённость 5нм-интерполяции: 1нм-таблица даёт 90.3925°). Центр Hanning-окна `hue_weight(h) = (1 + cos(h − H_Y_DEG)) / 2`; производная поворота Бецольда-Брюкке: dΔH_BB/dh = A_BB·cos(h−h_Y). История: значение slice 4 (96.9172°) ОТОЗВАНО — CMF-строки были перепутаны (заявленные ȳ=0.7070/0.7570 не существуют на 570/580нм; ложный XYZ=(1.207,1,0)), hue брался после sRGB-клэмпа (артефакт: 69.7°→96.9° от клэмпа), а цитата «Parry (1967) JOSA 57, 1130–1134» не существует. Реальные источники: инвариантная точка жёлтого ≈578nm — измерения BB-сдвига Jacobs & Wascher (1967) J. Opt. Soc. Am. 57, 1155–1156; сама рамка инвариантных оттенков (contours for constant hue) — Purdy (1937) Am. J. Psychol. 49, 313–315 (атрибуция уточнена по ревью 2026-07-03: Purdy — феномен BB/контуры, не прямое измерение 578nm). | -| M-12 | ~~`W_HUE[8]`~~ | _(удалён)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Был: K=3 Fourier-базис + CEIL_N, подогнанный логистической регрессией на 738 авторских метках v3. Нарушал ZERO observer-fit (North). Заменён Hanning-окном BB (H_Y_DEG). | -| M-13 | `CUSP_L_TABLE[361]` | see code | `cleanliness.rs` | cited-and-kept | Oklab cusp lightness per integer hue degree (0–360). Derived from the sRGB gamut geometry (kept as-is per paradigm North, never refit): for each hue angle h the cusp L is the Oklab L of the maximum-chroma sRGB-boundary point at that hue. This is a deterministic geometric quantity (no fitting). Reproduced via the Oklab gamut-intersection algorithm (find_cusp in the Björn Ottosson reference implementation). Bound: values lie in [0.45, 0.97] as per full sRGB gamut sweep. | -| M-14 | ~~`CEIL_N_TABLE[361]`~~ | _(удалена)_ | `cleanliness.rs` | REMOVED (Zone B slice 4, 2026-06-30) | Была: нормированный CEIL_N-коэффициент (второй Fourier-базис). Подогнана на v3 для захвата несинусоидальной вариации потолка грязи; не выводима из первых принципов. После замены hue_weight на BB-формулу более не нужна. | -| S-01 | `chroma_fraction` (тинт) | конфиг `(0,1]` | `sentiment.rs` | applied (2026-07-03) | Анти-неоновый потолок продакшн-тинта сентимента: `c = min(c_якоря, f·C_max(L, h_решённый))`. Хрома якоря клиента — авторитет идентичности; потолок кусается только на неоново-насыщенном якоре или суженном гамуте смещённого оттенка. Одновременно закрыт молчаливый канальный клип sRGB (искажал оттенок; усечение теперь по оси хромы). labui-референс ставит `1.0` (чистая стена гамута: Figma-якорь danger #FF3B30 сидит выше 0.88·C_max); реестровый дефолт 0.88 — для клиентов без якорной калибровки. | -| S-02 | Warning↔Success light-ic | ab ≈ 0.042 → ВЫЛЕЧЕНО | `sentiment.rs`, `config.rs` | RESOLVED (2026-07-03, тот же день) | Находка попарного аудита: брендоцентричный закон был попарно слеп, а flip-ветка при ДАЛЁКОМ бренде зеркалила Warning через полкруга (IC-якорь #C93400 ниже пола 45° → телепорт в зелень #508300 к Success #248A3D, ab 0.042 < 0.0687). Лечение — многотельная легальность: `NeighborZone` в is_legal/legalize/resolve + двухфазная оккупация `sentiment_solid_for_mode` (покоящиеся сентименты — неподвижные оккупанты, идентичность якорей цела по построению; смещённые держат угловой отступ — инверсия хорды s_perc_min при средней хроме пары). Warning light-ic ложится в янтарную дугу у пола. Пины: `labui_sentiment_solids_keep_pairwise_ab_distance` (все пары ≥ порога, без исключений), `warning_light_ic_heals_into_amber_arc_not_green` (янтарь + RED-proof однотельного закона). | +| M-01 | `C0` | `0.0395` | `cleanliness_legacy.rs` | FROZEN V1 | Центр исторической сигмоиды хромы. Точный порог `0.0395` в Oklab не выведен из литературы и не называется JND. | +| M-02 | `JND` | `0.012278` | `cleanliness_legacy.rs` | FROZEN V1 | Историческая ширина сигмоиды. Имя сохранено ABI-совместимо, но универсальный Oklab-JND такой величины не установлен. | +| M-04 | `B0` | `0.036` | `cleanliness_legacy.rs` | FROZEN V1 | Центр исторического окна по Oklab `b`; точная граница коричневого контекстна и из этого числа не следует. | +| M-05 | `BW` | `0.017` | `cleanliness_legacy.rs` | FROZEN V1 | Историческая ширина окна `b`; не выдаётся за измеренную межнаблюдательскую дисперсию. | +| M-06 | `CAL_EPS` | `0.01` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый log-offset калибровочного слоя. | +| M-07 | `CAL_T` | `2.356978` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый fit-масштаб по однонаблюдательской разметке. | +| M-08 | `CAL_B` | `6.445168` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый fit-bias по однонаблюдательской разметке. | +| M-09 | `M_W` | `0.181527` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённая ширина псевдо-confidence без модели надёжности. | +| M-10 | `KAPPA_CORE` | `0.34` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый потолок псевдо-confidence. | +| M-11 | `KAPPA_INTERIOR` | `0.10` _(former)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый пол псевдо-confidence. | +| M-12 | `H_Y_DEG` | `90.4011` | `cleanliness_legacy.rs` | FROZEN V1 | Исторический центр hue-окна. Спектральный unique-yellow не доказывает форму окна и не делает весь V1 валидированным. | +| M-12 | ~~`W_HUE[8]`~~ | _(удалён)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый Fourier-fit по авторским отметкам. | +| M-13 | `CUSP_L_TABLE[361]` | see code | `cleanliness_legacy.rs` | FROZEN V1 | Замороженная таблица исторического закона; актуальная геометрия гамута вычисляется алгоритмически в `cleanliness.rs`. | +| M-14 | ~~`CEIL_N_TABLE[361]`~~ | _(удалена)_ | `cleanliness_legacy.rs` | REMOVED | Удалённый fit потолка V1. | +| S-01 | `chroma_fraction` / `hardness` / hue-ограничители | legacy config | `config_dto.rs` | REMOVED (V2) | Эти ручки не имели универсально доказуемой магнитуды и удалены из вычислительной модели. DTO распознаёт их только ради явной миграционной ошибки; скрытое применение запрещено. | +| S-02 | Попарная геометрия anchors | `Dᵢⱼ` для каждой пары и режима | `sentiment.rs`, `config.rs` | RESOLVED (V2) | Порог не усредняется и не берётся из названия категории: `Dᵢⱼ` — фактическая Oklab-ab-дистанция двух клиентских anchors. Для неравных радиусов используется полный закон косинусов; недостижимая пара даёт `Err`; порядок категорий не влияет; финальный проход повторно проверяет все пары. | ## Science wave — объективизация + экспозиция (`science/constants-objectivization`, 2026-07-06) @@ -149,13 +146,14 @@ MODEL-CONFLICT — пометка ВНУТРИ терминала (e), НЕ от | #37 | `HUE_DRIFT_PENALTY_SLOPE` | (e) | **(e), MODEL-CONFLICT: ИЗМЕРЕН И ОТКЛОНЁН** | единственный строгий кандидат (хорда Oklab) уже проверен и отклонён по замеру (`chord_derived_slope_rejected_degenerates_to_window_edge`) — не owner-pending | | #17 | `CUSP_HALF_WINDOW_DEG` | (e) | **(e) — остаётся, НЕ (c)** | замер `cusp_window_is_near_measured_gamut_drift` доказывает ОБРАТНОЕ интервал-нечувствительности: окно ДОКАЗАННО клипует (дрейф 42.5° > окно 40°) — намеренный связывающий кап, не нежёсткая граница | | #19 | `STRICT_STEP` | (e) | **(e) — остаётся, НЕ (c)** | прямое слагаемое цели демоции (`senior_mag − STRICT_STEP`) — непрерывно и напрямую сдвигает эмитируемый цвет; лок лишь характеризует позицию у типичного шага сетки, не доказывает инвариантность выхода | -| #12, #13, #21, #22, #23, #24, #48, #49 | `NEUTRAL_HUE_DEG`, `NEUTRAL_TINT_RATIO`, `CHROMA_FRACTION`, `NEUTRAL_DEFAULT_GAMMA_LIGHT`, `NEUTRAL_DEFAULT_GAMMA_DARK`, `NEUTRAL_DEFAULT_CHROMA_PEAK_T`, `HUE_PURITY_MP_REF_RATIO`, `HUE_PURITY_EXPONENT` | (e) | **(e) — генуинные** | нет модельного якоря; Волна 2 добавила sensitivity/bound/протокол каждой (max ΔE_ok по легальному свипу материален: 0.0288/0.0421/0.0466/0.0325/0.0042/0.148), доказав материальность магнитуды → терминал (e) подтверждён (НЕ понижены до (c)). `#15 TINT_HUE_STIFFNESS` ВЫВЕДЕН Волной 2 из этой группы в (c) — измерена инвариантность выхода. (`#20 DEFAULT_HARDNESS` УДАЛЁН Волной 1.) | +| #12, #13, #22, #23, #24, #48, #49 | `NEUTRAL_HUE_DEG`, `NEUTRAL_TINT_RATIO`, `NEUTRAL_DEFAULT_GAMMA_LIGHT`, `NEUTRAL_DEFAULT_GAMMA_DARK`, `NEUTRAL_DEFAULT_CHROMA_PEAK_T`, `HUE_PURITY_MP_REF_RATIO`, `HUE_PURITY_EXPONENT` | (e) | **(e) — генуинные** | нет модельного якоря; Волна 2 добавила sensitivity/bound/протокол каждой (max ΔE_ok по легальному свипу материален: 0.0288/0.0466/0.0325/0.0042/0.148), доказав материальность магнитуды → терминал (e) подтверждён (НЕ понижены до (c)). `#15 TINT_HUE_STIFFNESS` ВЫВЕДЕН Волной 2 из этой группы в (c) — измерена инвариантность выхода. (`#20 DEFAULT_HARDNESS` и `#21 CHROMA_FRACTION` удалены из sentiment V2.) | -**Итог по 26 трекаемым константам (после Волны 2 «объективизация», 2026-07-08): (a)=7, (b)=1, (c)=7, (e)=11** (сумма 26; было 27 — Волна 1 удалила `#20 DEFAULT_HARDNESS` вместе с механизмом p-нормы brand-displacement), из которых **1 несёт MODEL-CONFLICT** (`HUE_DRIFT_PENALTY_SLOPE` — измерен-и-отклонён) и **10 — генуинный design-choice**. Волна 2 подняла `#15 TINT_HUE_STIFFNESS` (e)→(c) по измеренной инвариантности выхода (единственный честный подъём) и снабдила каждую оставшуюся генуинную (e) полным пакетом sensitivity + легальный диапазон + протокол калибровки + кусающийся пин; понижений завышенных терминалов не потребовалось (все 10 материальны по замеру). `PAIR_CROSSOVER_Y` разрешён деривацией (владелец 2026-07-07) → (a) DERIVED; активных OWNER-PENDING нет. Изменения чисел ядра нет (INV-1) кроме сознательного дрейфа info-сентимента Волны 1 (закон категориальных зон: info покоится на синем фокусе, а не смещается брендом в пурпур — задокументировано в голденах). Строк «нужен эксперимент» по-прежнему НОЛЬ. +**Итог после удаления `#21 CHROMA_FRACTION` в sentiment V2: 25 трекаемых констант: (a)=7, (b)=1, (c)=7, (e)=10.** Историческая Волна 2 классифицировала прежнюю ручку, но V2 больше не выдаёт её за объективный закон. Сентименты сохраняют клиентские anchors и проверяют их фактические попарные дистанции. ### Follow-on: Волна 2 «объективизация» (e)-терминалов (`science/wave2-e-terminal`, 2026-07-08) -Пройдены ВСЕ 12 (e)-строк реестра. Задача — поднять каждую по лестнице настолько, +Исторически были пройдены 12 (e)-строк реестра; одна из них, `CHROMA_FRACTION`, +в V2 удалена. Задача волны — поднять каждую по лестнице настолько, насколько ЧЕСТНО возможно; где вывод невозможен — снабдить sensitivity-анализом, легальным диапазоном, протоколом будущей калибровки и кусающимся тест-пином. @@ -173,7 +171,7 @@ MODEL-CONFLICT — пометка ВНУТРИ терминала (e), НЕ от выхода. Аналог `HUE_SEARCH_HALF_WINDOW` (интерьерный оптимум), НЕ `CUSP_HALF_WINDOW_DEG` (связывающий кап). -**11 остальных (e) — вывод честно невозможен, терминал (e) ПОДТВЕРЖДЁН замером, +**10 оставшихся активных (e) — вывод честно невозможен, терминал (e) ПОДТВЕРЖДЁН замером, добавлен полный пакет (не завышены).** Каждая непрерывная ручка проверена свипом её легального диапазона; max ΔE_ok эмитируемого цвета материален (> ½ JND), значит магнитуда НЕ immaterial → (c) был бы завышением: @@ -186,7 +184,6 @@ MODEL-CONFLICT — пометка ВНУТРИ терминала (e), НЕ от |---|-----------|--------------------|----------------------------------|----------------| | #12 | `NEUTRAL_HUE_DEG` | измер. разброс [285.78°,286.01°] | 0 по разбросу; 0.0114 (~1 JND) при ±20° | `neutral_hue_emits_byte_invariant_across_measured_family_spread` | | #13 | `NEUTRAL_TINT_RATIO` | [0,1] | 0.0288 по [0,0.2] | `neutral_tint_ratio_sensitivity_is_bounded` | -| #21 | `CHROMA_FRACTION` | (0,1] | 0.0421 по [0.70,1.0] | `chroma_fraction_sensitivity_is_bounded` | | #22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | >0 (практ.[1.2,2.2]) | 0.0466 по [1.3,2.2] | `curve_params_sensitivity_is_bounded` | | #23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | >0 (практ.[1.2,1.9]) | 0.0325 по [1.2,1.9] | `curve_params_sensitivity_is_bounded` | | #24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | (0,0.5] | 0.0042 по [0.2,0.5] (низшая) | `curve_params_sensitivity_is_bounded` | diff --git a/docs/empirical-residue.md b/docs/empirical-residue.md index 6aadda3c..9ee6f4e2 100644 --- a/docs/empirical-residue.md +++ b/docs/empirical-residue.md @@ -90,8 +90,8 @@ | 15 | `TINT_HUE_STIFFNESS` | 9.0 | **(c)** | **Волна 2 (e)→(c):** замер `cusp_attracted_hue` — выше порога пиннинга ≈0.36 выход байт-инвариантен по полосе [1,100] (dev=0°), дефолт 9.0 = 25× порога | `stiffness_pins_hue_to_canonical_above_threshold`, `hue_stiffness_negative_is_rejected` | | 13 | `NEUTRAL_TINT_RATIO` | 0.10 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0288 по [0,0.2]; opt-in v1-политика; диапазон [0,1] | `neutral_tint_ratio_sensitivity_is_bounded`, `custom_tint_overrides_hue_and_ratio` | | 12 | `NEUTRAL_HUE_DEG` | 286.0 | (e) | измер. якорь; Волна 2: байт-инвариант по разбросу [285.78,286.01], ±20°→0.0114 (~1 JND) | `neutral_hue_emits_byte_invariant_across_measured_family_spread`, `custom_tint_overrides_hue_and_ratio` | -| 21 | `CHROMA_FRACTION` | 0.88 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0421 по [0.70,1.0]; labui=1.0; диапазон (0,1] | `chroma_fraction_sensitivity_is_bounded`, `chroma_fraction_out_of_bounds_is_rejected` | -| ~~20~~ | ~~`DEFAULT_HARDNESS`~~ | — | — | **УДАЛЁН Волной 1** (снесён механизм p-нормы brand-displacement; config-поле `hardness` живо как vestigial, тест `hardness_below_one_is_rejected` держит его границу) | — | +| ~~21~~ | ~~`CHROMA_FRACTION`~~ | — | — | **УДАЛЁН В V2**: материальная дизайн-ручка не является универсальным законом; клиентский anchor сохраняется без скрытого кэпа | — | +| ~~20~~ | ~~`DEFAULT_HARDNESS`~~ | — | — | **УДАЛЁН** вместе с p-нормой; legacy-поле `hardness` теперь только вызывает явную ошибку миграции DTO | — | | 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | 1.75 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0466 по [1.3,2.2]; диапазон >0 | `curve_params_sensitivity_is_bounded` | | 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | 1.5 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0325 по [1.2,1.9]; диапазон >0 | `curve_params_sensitivity_is_bounded` | | 24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | 0.35 | (e) | genuine; Волна 2: sensitivity max ΔE_ok 0.0042 по [0.2,0.5] (низшая); диапазон (0,0.5] | `curve_params_sensitivity_is_bounded` | diff --git a/docs/verification-map.md b/docs/verification-map.md index d85689af..3471c077 100644 --- a/docs/verification-map.md +++ b/docs/verification-map.md @@ -61,18 +61,14 @@ | формула | чем верифицирована | оракул | |---|---|---| -| `linearise` (порог 0.03928, /12.92, γ 2.4) | `[NEW]` `reference_vectors_deep::wcag_linearise_threshold_is_original_03928` | публикация (W3C WCAG 2.1 §1.4.3, оригинальная версия 2018) | +| `linearise` (порог 0.04045, /12.92, γ 2.4) | `[NEW]` `reference_vectors_deep::wcag_linearise_threshold_is_current_04045` | текущий нормативный текст W3C + IEC sRGB | | `relative_luminance` (0.2126/0.7152/0.0722) | `[NEW]` `reference_vectors::wcag_luminance_coefficients_isolated` (primary-on-black изолирует каждый коэффициент) | публикация (W3C WCAG 2.1) | | `contrast_ratio` (L↑+0.05)/(L↓+0.05) | `wcag::tests::black_on_white_is_twentyone_to_one`, `grey_boundary_matches_published_value` (#767676≈4.54); `[NEW]` `reference_vectors::wcag_published_ratios_via_public_api` | публикация (W3C WCAG 2.1) | -> **Находка (зафиксирована в тесте):** порог `0.03928` — ОРИГИНАЛЬНАЯ версия -> WCAG 2.1 (2018). Erratum W3C от 2022-02-22 (PR #1780, вошёл в Рекомендацию -> 05.2025) поднял порог до `0.04045` (= стык IEC EOTF) — то есть текущий -> нормативный текст говорит `0.04045`, а `0.03928` устарел. lab-colors -> сознательно держит `0.03928`. Ни один 8-битный код не попадает в интервал -> (0.03928, 0.04045) — 10/255 ≈ 0.039216 ниже обоих, 11/255 ≈ 0.043137 выше — то -> есть для КАЖДОГО квантованного цвета обе версии выбирают одну ветвь и -> линеаризуют идентично; расхождение только на суб-квантовых величинах. +> Порог `0.03928` из исходного WCAG 2.1 (2018) заменён erratum W3C от +> 2022-02-22 на `0.04045`, совпадающий со стыком IEC EOTF. lab-colors следует +> текущему нормативному значению. Для 8-битных каналов результат не меняется, +> но непрерывные входы между двумя порогами теперь также считаются корректно. ## LPC (перцептивный контраст) — `lpc.rs` diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 5ebb4990..a570d611 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -189,47 +189,25 @@ task #29). `RoleRecipe::PairLabel` решает лейбл штатным зак проваливает 3:1 на тинте — `pair_label_beats_page_resolved_label` ([`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs)). -### 2.6. Сентименты: категориальные зоны хью - -Сентимент — семантическая роль (Danger/Warning/Success/Info), отображённая на -семейство палитры; отображение заземлено в Figma-структуре владельца -(коллекция «🔵 4.1 Primitives») — -[`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs) (`accent`). -Прототипный оттенок (**фокус категории**) — **фактический Oklab hue якоря -семейства**, не вручную набитый градус: рукописные пики были несогласованы с -якорями (Danger 18° против истинных 28.7°, Info 240° против 257°) — фикс #65, -задокументирован в `prototype_hue` -([`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs)). - -**Закон (правило): сентимент ПОКОИТСЯ на фокусе своей категории.** Бренд оттенок -сентимента НЕ смещает — совпадение сентимента с брендом ВНУТРИ его категории -легитимно (info-зона законно включает синий, даже когда бренд синий). Заземление: -категориальное восприятие цвета даёт стабильные ФОКУСЫ и РАЗМЫТЫЕ границы -(Berlin & Kay 1969; Kay & McDaniel 1978, fuzzy-set модель; Regier, Kay & Cook -2005, PNAS — фокусы кластеризуются теснее границ), и НЕ даёт основания -форсировать разведение от бренда — только держать оттенок внутри своей категории. -Это заменило прежний закон Волны 0 («Sticky Potential Well»), который жёстко -выталкивал каждый сентимент от бренда на `s_min`≈20.7° и для синего бренда labui -уводил Info из синего в пурпур. - -**Исключения (лок — задокументированный, не дефолт):** сентимент двигается с -фокуса лишь (1) полом Warning (вне красной danger-зоны, §3.4) и (2) попарной -различимостью соседей (`NeighborZone`, отступ выведен инверсией хорды `s_perc_min` -при 20°-пороге Witzel & Gegenfurtner 2013). Оба достижимы через floor-кламп -(клиентский orange-якорь ниже пола → кламп вверх → возможное столкновение с -покоящимся соседом → отталкивание); арбитр -`floor_clamp_collision_triggers_neighbor_separation` доказывает, что зоны реально -применяются, а не мёртвый код. - -Единая ручка силы цвета рампы — `CHROMA_FRACTION = 0.88` доли гамутного максимума -(без пер-hue кэпов), `SSOT-TRACKED`. Config-ручки `SentimentsConfig { hardness }` -и `SentimentCategory { preferred_side }` СОХРАНЕНЫ ради стабильности схемы -(течёт в wasm-DTO / JSON-паспорта / JS-зеркало) и валидируются как контракт -границы, но законом категориальных зон **больше НЕ потребляются** (vestigial — -p-норма разведения снесена; прецедент «задокументированной мёртвой ручки»). -Константа `DEFAULT_HARDNESS` и вся p-норм-машинерия -(`smooth_separation`/`resolve_smooth_hue_*`) удалены Волной 1 -([`config.rs`](../crates/labcolors-core/src/config.rs), док полей). +### 2.6. Сентименты: геометрия клиентских anchors V2 + +Сентимент — семантическая роль, отображённая клиентом на семейство палитры. +Названия `danger`, `warning`, `success` и `info` не задают hue-зон и не являются +численными входами. Единственные цветовые данные — четыре клиентских anchors +назначенного семейства, по одному на режим темы. + +Контракт `anchor-distance-v2` рассматривает каждый режим независимо. Для каждой +неупорядоченной пары категорий вычисляется её собственная исходная дистанция +`Dᵢⱼ` в плоскости `a/b` Oklab. Резолв не вправе уменьшить эту дистанцию и сейчас +сохраняет anchors без сдвига. Поэтому близость Info к цвету бренда допустима, +если именно такие данные передал клиент; бренд не создаёт скрытой силы. + +Универсальных 20°, 45°, «правильных» дуг и средней хромы нет. Категории +индексируются по имени, поэтому перестановка массива не меняет результат. После +резолва все пары проверяются повторно; невозможная геометрия возвращает ошибку, +а не пропускается. Версия геометрии записана в JSON явно. Устаревшие +`hardness`, `chroma_fraction`, `hue_floor_deg` и `preferred_side` принимаются DTO +только ради точной ошибки миграции и никогда не участвуют в вычислениях. ### 2.7. Полярность, принцип якоря, честная деградация @@ -420,30 +398,19 @@ scale 1.14, low-clip и офсеты полярности версии 0.0.98G-4 к сепараторам и теням требования читаемости не применяются ([`README.md`](../README.md), «Конформанс»). -### 3.4. Категориальный пол: `WARNING_HUE_FLOOR_DEG = 45.0` - -Нижняя граница легальности оттенка Warning (`is_legal_hue`) — -задокументированное **исключение** закона категориальных зон (§2.6), охраняющее -Warning от сползания в красную (danger) зону. Полный вывод — док константы -[`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs): - -- **Спящий страж.** Под законом зон Warning ОТДЫХАЕТ на своём фокусе - (orange 68.61°), который лежит ВЫШЕ пола 45°, поэтому для labui-якоря пол не - кусается — Warning садится на фокус. Пин: `warning_floor_is_a_dormant_guard_ - below_the_orange_prototype` (прототип > пола, запас 68.607° − 45.0 = 23.607°). -- **Когда срабатывает.** Лишь если оранжевый якорь КЛИЕНТА окажется НИЖЕ 45°: - тогда оттенок клампится вверх к 45°, оставаясь в янтаре, а не соскальзывая в - красный (danger 28.66°). -- **Статус значения.** `45.0` — DECLARED-CALIBRATION (design-choice): - консервативный категориальный пол между danger 28.66° и warning 68.61°. НЕ - точная граница red↔orange — чёткой границы не существует (Berlin & Kay 1969; - Kay & McDaniel 1978, категориальные границы размыты). -- **Убрано Волной 1.** Прежний brand-displacement / C¹-резолвер / flip-ветка и - «натуральный минимум = `prototype − s_min` = 45.528°», вокруг которых - калибровался зазор ≈0.527°, СНЕСЕНЫ вместе с разведением от бренда: пол больше - не шов непрерывности, а чисто категориальный страж. -- Единственный дом значения — фикстура labui ссылается на константу напрямую; - второго литерала не существует. +### 3.4. Попарная геометрия при неравных радиусах + +Если будущая проекция меняет хромы пары на `C₁` и `C₂`, минимальный угловой +разнос выводится из полного закона косинусов: + +`D² = C₁² + C₂² − 2·C₁·C₂·cos(Δh)`. + +Отсюда `Δh = acos((C₁² + C₂² − D²)/(2·C₁·C₂))`. Если `D ≤ |C₁−C₂|`, одного +радиального различия уже достаточно и минимальный угол равен нулю. Если +`D > C₁+C₂`, пара при заданных радиусах недостижима и резолв обязан вернуть +ошибку. Эта проверка выполняется для каждой пары, а затем независимо повторяется +по фактическим выходным координатам. Формула не подменяется приближением через +«среднюю хрому». ### 3.5. Квантование и допуски @@ -487,9 +454,8 @@ Warning от сползания в красную (danger) зону. Полны сверяет эмиссию фикстуры с замороженным golden побайтно; - тонкий конфиг BL-007 компилируется в таблицу, байт-идентичную полному эталону ([`config.rs`](../crates/labcolors-core/src/config.rs), §2.4); -- верификация мёртвой ручки `preferred_side` — прямым прогоном резолва - (4 темы × 6 фонов × все роли): ноль сдвинутых байт - ([`config.rs`](../crates/labcolors-core/src/config.rs)); +- sentiment-конфиг явно фиксирует `geometry: anchor-distance-v2`, а legacy-поля + отклоняются с миграционной ошибкой до компиляции; - WASM-обёртка гоняет headless-Chrome паритет-тест против нативного `resolve_named_set`; наружная форма ролей едина — CSS-эмиссия oklch ([`spaces/oklch.rs`](../crates/labcolors-core/src/spaces/oklch.rs); @@ -567,7 +533,7 @@ sRGB — то же измеренное device-пространство, что константы; - цитируемые стандарты исключены по построению аллоулистом `NUMERIC_METHOD_ALLOWLIST` (INV-3, N5): `HK_CHROMA_EXPONENT`, `LC_SCALE`, - `DELTA_Y_MIN`, `S_PERC_MIN`, WCAG-коэффициенты, D65, L_A, Yb — это не + `DELTA_Y_MIN`, WCAG-коэффициенты, D65, L_A, Yb — это не настраиваемая политика, а имплементация цитированных моделей. Разделение режимов (INV-7): R4 утверждает **только наличие бумажного следа** — @@ -582,8 +548,6 @@ sRGB — то же измеренное device-пространство, что | Константа | Значение | Модуль | Статус источника | |---|---|---|---| -| `WARNING_HUE_FLOOR_DEG` | 45.0 | [`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs) | **DECLARED-CALIBRATION** (design-choice): консервативный категориальный пол между danger 28.66° и warning 68.61°, дремлющий для labui (прототип > пола); прежняя привязка к «натуральному минимуму» относилась к brand-displacement, убранному Волной 1 (§3.4) | -| `CHROMA_FRACTION` | 0.88 | [`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs) | ручка силы рампы: доля гамутного максимума, единая для всех hue; терминал **(e)** design-choice (labui=1.0; sensitivity max ΔE_ok 0.0421 по [0.70,1.0], Волна 2) | | `QUANT_BUDGET` | 1.0 | [`solve.rs`](../crates/labcolors-core/src/solve.rs) | терминал **(c) interval-insensitive**: допуск приёмки Lc, ±1 шаг сетки; экспозиция 1.84% (доказанный ratio-band над медианным шагом) | | `DECORATIVE_FLOOR_MIN` | 7.5 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | **выведен** = `MODEL_LC_FLOOR` 7.3 (клип APCA `0.0.98G-4g`) + `QUANT_GUARD` 0.2 (issue #44) | | `IC_DECORATIVE_FLOOR_MIN` | 15.0 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | **заземлён** — APCA-уровень Lc 15 (различимость не-текста; draft/single-origin, не WCAG 3) | @@ -601,9 +565,7 @@ sRGB — то же измеренное device-пространство, что Инвентарь различает происхождение значений, и это различие — содержательное: - **выведенные** привязки (деривация из других величин системы; пример — - `DECORATIVE_FLOOR_MIN` = `MODEL_LC_FLOOR` 7.3 клипа APCA + guard 0.2; прежний - пример «натуральный минимум Warning-зоны» снят — его деривация опиралась на - brand-displacement, убранный Волной 1, а пол Warning теперь design-choice); + `DECORATIVE_FLOOR_MIN` = `MODEL_LC_FLOOR` 7.3 клипа APCA + guard 0.2); - **заземлённые в опубликованном источнике** (GROUNDED-строки инвентаря; пример — `IC_DECORATIVE_FLOOR_MIN` = APCA-уровень Lc 15, черновиковый дизайн-уровень с явной оговоркой draft/single-origin) — в отличие от жёстких стандартов @@ -665,10 +627,9 @@ sRGB — то же измеренное device-пространство, что Sternheim & Spillmann 1984, JOSA A 1(4)): воспринимаемый тон сдвигается при падении чистоты. Конкретные показатель/множитель (0.6/1.5) — задекларированные дизайн-ручки (терминал (e)), НЕ вывод из данных Эбни. -- **Warning-пол — не перцептивная граница красный↔оранжевый.** Это дремлющий - категориальный страж (дизайн-решение 45°), не порог категории: чёткой границы - красный↔оранжевый не существует, категориальные границы размыты (Berlin & Kay 1969; - Kay & McDaniel 1978) — [`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs). +- **Имена сентиментов не определяют цвет.** Классификация `warning` или `danger` + остаётся семантикой клиента; ядро проверяет только геометрию переданных anchors + и не объявляет универсальную границу красный↔оранжевый. - **Декоративные ВЕЛИЧИНЫ (не пол) — заглушки до JND-калибровки.** Сам пол `DECORATIVE_FLOOR_MIN` теперь выведен (7.3 клипа APCA + guard 0.2, §3.5), а `IC_DECORATIVE_FLOOR_MIN` заземлён в APCA Lc 15; но конкретные Lc теней diff --git a/packages/colors/README.md b/packages/colors/README.md index 34868659..52a5f0c6 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -175,9 +175,13 @@ type RoleResult = SolvedColor | TranslucentRole | NoneRole | UnreachableRole; --- -### `engine.muddiness(hex): number` · `engine.confidence(hex): number` +### `engine.muddiness(hex): number` -`muddiness` — оценка «грязи» цвета `hex` в диапазоне `[0, 1]` (Закон Грязи). `confidence` — надёжность этой оценки: `0` означает, что оценке нельзя доверять (у границы решения или серого фронтира), выше — увереннее. Верхний потолок — деталь калибровки, не контракт: не хардкодить. +Совместимый вход в замороженную историческую гипотезу Dirt Law V1. Значение +лежит в `[0, 1]`, но не является вероятностью и не заявляется как универсальная +психофизическая классификация. Новая контекстная модель публикуется отдельно и +версионированно; старое имя никогда не меняет смысл молча. API `confidence` в +текущем пакете отсутствует. --- diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index 34de4602..25dc408a 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -67,14 +67,14 @@ function easeOut(t) { return 1 - u * u * u; } -/** WCAG 2.1 relative luminance of `#RRGGBB` — a faithful transcription of the - * normative definition (0.03928 split, 2.4 exponent), so the strict floor-clamp - * agrees byte-for-byte with the core's `legalFloor` semantics. */ +/** Относительная яркость WCAG для `#RRGGBB`. Формула продублирована на JS-границе, + * поэтому действующий порог 0.04045 закреплён здесь явно: floor-clamp обязан + * побайтно совпадать с `legalFloor` ядра. */ function relativeLuminanceHex(hex) { const rgb = parseCssColor(hex) ?? [0, 0, 0, 1]; const lin = (c) => { const s = c / 255; - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * lin(rgb[0]) + 0.7152 * lin(rgb[1]) + 0.0722 * lin(rgb[2]); } diff --git a/packages/colors/bench/wasm-boundary.bench.mjs b/packages/colors/bench/wasm-boundary.bench.mjs index 8f5d5ff4..4414d3bf 100644 --- a/packages/colors/bench/wasm-boundary.bench.mjs +++ b/packages/colors/bench/wasm-boundary.bench.mjs @@ -25,7 +25,8 @@ import { performance } from "node:perf_hooks"; import { initSync, LabColors } from "../pkg/labcolors.js"; const here = dirname(fileURLToPath(import.meta.url)); -initSync({ module: readFileSync(resolve(here, "../pkg/labcolors_bg.wasm")) }); +const wasm = initSync({ module: readFileSync(resolve(here, "../pkg/labcolors_bg.wasm")) }); +const initialWasmBytes = wasm.memory.buffer.byteLength; const CONFIG = readFileSync( resolve(here, "../../../crates/labcolors-wasm/tests/data/labui.config.json"), @@ -105,14 +106,32 @@ const recheck3 = () => { // foreground's CAM16 forward is computed once and shared across samples. // Byte-identical to `recheck3`; the public batch API the controller now uses. const recheckMulti3 = () => engine.recheckContrastMulti(SAMPLES, FGS, THEME); -// Re-solve, cache HIT (same bg repeatedly): pays only the JS-object projection. +// Честное попадание: один и тот же ключ остаётся последним в своём тематическом +// слоте. Движок повторно использует и DTO, и готовую JSON-строку. const resolveHit = () => engine.resolveTheme(SOLVE_BG, THEME); -// Re-solve, cache MISS (distinct bg each call): full solve + projection. Sweep -// 4096 distinct backgrounds so we never repeat within the cache window. -let missTone = 0; + +// Чередование двух реально независимых тематических слотов. Этот сценарий +// ловит рассогласование, при котором решатель попадал в многозаписный кэш, а +// одноячеечная сериализация промахивалась на каждом вызове. +const ALTERNATING_SLOTS = [ + ["#F7F8FA", "light"], + ["#101012", "dark"], +]; +for (const [bg, theme] of ALTERNATING_SLOTS) engine.resolveTheme(bg, theme); +let alternatingSlot = 0; +const resolveAlternatingHit = () => { + const [bg, theme] = ALTERNATING_SLOTS[alternatingSlot++ & 1]; + return engine.resolveTheme(bg, theme); +}; + +// Честный промах контрактного кэша: счётчик не зацикливается внутри всего +// запуска benchmark, поэтому прогрев, измерение и allocation-proxy никогда не +// повторяют сгенерированный ключ. Старый 12-битный `& 0xfff` незаметно делал +// большую часть заявленных промахов попаданиями после прогрева. +let missRgb = 0; const resolveMiss = () => { - const t = missTone++ & 0xfff; - const hex = `#${(0x100000 + t * 17).toString(16).slice(-6).toUpperCase()}`; + if (missRgb > 0xffffff) throw new Error("исчерпано пространство уникальных sRGB8"); + const hex = `#${(missRgb++).toString(16).padStart(6, "0").toUpperCase()}`; return engine.resolveTheme(hex, THEME); }; @@ -130,8 +149,9 @@ const plan = [ ["recheckContrast ×1 (28 roles)", recheck1, 40, 20000, 40000, FGS.length], ["recheckContrast ×3 (28 roles)", recheck3, 40, 7000, 15000, FGS.length * 3], ["recheckContrastMulti 3bg", recheckMulti3, 40, 7000, 15000, FGS.length * 3], - ["resolveTheme cache-hit", resolveHit, 25, 4000, 8000, 0], - ["resolveTheme cache-miss", resolveMiss, 20, 1500, 4000, 0], + ["resolveTheme hit (one slot)", resolveHit, 25, 4000, 8000, 0], + ["resolveTheme hit (2 themes)", resolveAlternatingHit, 25, 4000, 8000, 0], + ["resolveTheme honest miss", resolveMiss, 20, 1500, 4000, 0], ]; for (const [label, fn, batches, inner, allocN, perRoleDiv] of plan) { @@ -171,3 +191,10 @@ const vfp = (() => { console.log(""); console.log(`recheck fingerprint: ${fp.toString(16).padStart(8, "0")}`); console.log(`resolve vars fingerprint: ${vfp.toString(16).padStart(8, "0")}`); +// Линейная память WASM не уменьшается обратно, поэтому её high-water после +// полного прогона честных промахов непосредственно показывает, удерживал ли +// кэш тысячи полных тем или переиспользовал ограниченное число слотов. +const finalWasmBytes = wasm.memory.buffer.byteLength; +console.log( + `wasm linear memory: ${(initialWasmBytes / 1048576).toFixed(3)} → ${(finalWasmBytes / 1048576).toFixed(3)} MiB`, +); diff --git a/packages/colors/bench/wasm-boundary.results.md b/packages/colors/bench/wasm-boundary.results.md index c63ffa03..523d7320 100644 --- a/packages/colors/bench/wasm-boundary.results.md +++ b/packages/colors/bench/wasm-boundary.results.md @@ -58,11 +58,32 @@ the forwards — a ~2.5x win at 3 samples. `adaptTheme` now feature-detects the method and calls it once per multi-sample frame, falling back to the per-sample `recheckContrast` loop for single samples or engines that do not expose it. -## resolveTheme (on-breach re-solve; NOT touched by this work) - -Measured separately, unchanged: cache-hit ≈ 657µs, cache-miss ≈ 1303µs. Both are -dominated by the JS-object projection (`project_resolved` builds ~106 role objects -via hundreds of `Reflect.set` FFI calls) — reruns on every call even on a cache -hit. Off the per-frame path (`resolveTheme` fires only on a sustained, debounced -breach), but the projection is the obvious next target if resolve latency ever -matters; flagged for the owner, not addressed here. +## `resolveTheme`: ограниченный и согласованный контрактный кэш + +Повторный аудит 2026-07-10 исправил сам сценарий измерения: прежний генератор +использовал `& 0xfff`, поэтому после прогрева повторял 4096 фонов и называл +попадания промахами. Текущий benchmark не повторяет ни одного sRGB8-ключа за +весь запуск, отдельно измеряет чередование двух тем и печатает high-water +линейной памяти WASM. + +Одинаковый сценарий на Node v24.15.0, `wasm -Oz`, median после прогрева: + +| сценарий | прежний кэш | тематические слоты | изменение | +|---|---:|---:|---:| +| один и тот же ключ | 70,6 µs | 76,3 µs | минимум обоих прогонов ≈67 µs | +| чередование двух тем | 279,1 µs | 81,3 µs | 3,43× быстрее | +| честный уникальный промах | 2,582 ms | 3,052 ms | не относится к ускорению hit-пути | +| память после полного прогона | 68,563 MiB | 1,438 MiB | в 47,7× ниже high-water | + +Почему память теперь ограничена доказуемо: вместо `HashMap` на 4096 полных тем +структура содержит по одному именованному слоту на каждый вариант публичного +`Theme`. DTO и его JSON лежат в одном `ResolvedSnapshot`, поэтому попадание +решателя не может одновременно быть промахом сериализации. Поток произвольной +длины удерживает не более четырёх payload; это отдельно проверяется через +`Weak`, а не только через счётчик записей. + +Число честного промаха приведено для полноты, но не трактуется как A/B самого +кэша: между сборками менялась математика core, и fingerprint `resolve vars` +изменился (`06ea61dc` → `6f222912`). Recheck-физика осталась байт-идентичной +(`2806bf67` в обоих прогонах). Кэш не должен маскировать стоимость настоящего +solve; его контракт — быстрый точный hit и ограниченная память. diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index 2a512b80..76518ebd 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -97,17 +97,14 @@ function clamp255(v) { } /** - * Parse the inside of an `oklch(...)` (the part between the parens) into sRGB - * `[r, g, b, a]`, or `null` on any malformed component. + * Разбирает содержимое `oklch(...)` в sRGB `[r, g, b, a]` либо возвращает + * `null`, если хотя бы одна компонента синтаксически некорректна. * - * The engine emits `oklch(L% C H)` / `oklch(L% C H / A)`; a browser's computed - * form is `oklch( C H [/ A])`. Components are whitespace-separated with - * an optional `/`-separated alpha, exactly like `rgb()`'s modern syntax. - * oklch → Oklab is `a = C·cos(H)`, `b = C·sin(H)`; then the file's own - * `oklabToLinearRgb` + `linearToSrgb` land it in sRGB — the SAME transform (and - * the SAME clamp-then-round the core uses in `hex_from_srgb`) that carries the - * emitter's byte-exact round-trip, so an emitted string decodes to its source - * bytes. Out-of-gamut channels clamp per channel, matching `oklabLerp`/`toHex`. + * Движок эмитирует `oklch(L% C H)` либо `oklch(L% C none)` у powerless hue; + * альфа добавляется через `/ A`. `none` декодируется как missing-компонента и + * численно равен нулю по CSS Color 4, поэтому ахромату не приписывается угол. + * Преобразование Oklch → Oklab → sRGB повторяет путь ядра и сохраняет + * байт-точный round-trip его строк; внегамутные каналы клампятся поканально. * * @param {string} inner the text between `oklch(` and `)` * @returns {Rgba | null} @@ -326,12 +323,12 @@ export function parseCssColorCached(css) { return hit; } -/** WCAG 2.1 relative luminance of r,g,b channels (0..255) — the normative - * 0.03928 / 12.92 / 2.4 constants, matching `adapt-theme`'s floor semantics. */ +/** Относительная яркость WCAG для каналов 0..255. Те же нормативные константы + * 0.04045 / 12.92 / 2.4 не дают этой границе разойтись с `adapt-theme`. */ function wcagLumChannels(r, g, b) { const lin = (c) => { const s = c / 255; - return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); } diff --git a/packages/colors/test/adapt-theme-translucent.test.mjs b/packages/colors/test/adapt-theme-translucent.test.mjs index b3ecc56a..1065a851 100644 --- a/packages/colors/test/adapt-theme-translucent.test.mjs +++ b/packages/colors/test/adapt-theme-translucent.test.mjs @@ -68,7 +68,7 @@ function fakeColors(initial) { }; } -const LIGHT = makeResult("#1A1A1A", "oklch(20.000% 0 0)", "oklch(96.000% 0.01 260 / 0.6)"); +const LIGHT = makeResult("#1A1A1A", "oklch(20.000% 0 none)", "oklch(96.000% 0.01 260 / 0.6)"); function harness(opts = {}) { const colors = fakeColors(LIGHT); @@ -95,16 +95,16 @@ test("initial apply keeps the translucent role's var present (canonical form)", const h = harness(); assert.equal(h.el.props.get("--lab-panel"), "oklch(96.000% 0.01 260 / 0.6)"); // The color role is written in its canonical (vars) form, not a raw hex. - assert.equal(h.el.props.get("--lab-label"), "oklch(20.000% 0 0)"); + assert.equal(h.el.props.get("--lab-label"), "oklch(20.000% 0 none)"); }); test("setTheme updates the translucent role's var to the new theme", () => { const h = harness(); - const DARK = makeResult("#E5E5E5", "oklch(90.000% 0 0)", "oklch(30.000% 0.02 260 / 0.6)"); + const DARK = makeResult("#E5E5E5", "oklch(90.000% 0 none)", "oklch(30.000% 0.02 260 / 0.6)"); h.colors.setResolve(DARK); h.ctrl.setTheme("dark"); assert.equal(h.el.props.get("--lab-panel"), "oklch(30.000% 0.02 260 / 0.6)"); - assert.equal(h.el.props.get("--lab-label"), "oklch(90.000% 0 0)"); + assert.equal(h.el.props.get("--lab-label"), "oklch(90.000% 0 none)"); }); test("an in-flight ease frame does not erase the translucent var", () => { @@ -113,7 +113,7 @@ test("an in-flight ease frame does not erase the translucent var", () => { h.colors.setRecheckLc([10]); h.setBg("#202020"); h.ctrl.tick(); // breachSince = 1000 - h.colors.setResolve(makeResult("#F0F0F0", "oklch(94.000% 0 0)", "oklch(94.000% 0.01 260 / 0.6)")); + h.colors.setResolve(makeResult("#F0F0F0", "oklch(94.000% 0 none)", "oklch(94.000% 0.01 260 / 0.6)")); h.setNow(1300); // past sustain (120) + dwell (250 vs lastSolveAt 1000) h.setBg("#202021"); h.ctrl.tick(); // re-solve + begin ease (#1A1A1A → #F0F0F0) @@ -134,7 +134,7 @@ test("when the ease completes, color vars revert to canonical oklch (not stuck i h.colors.setRecheckLc([10]); h.setBg("#202020"); h.ctrl.tick(); // arm - h.colors.setResolve(makeResult("#F0F0F0", "oklch(94.000% 0 0)", "oklch(94.000% 0.01 260 / 0.6)")); + h.colors.setResolve(makeResult("#F0F0F0", "oklch(94.000% 0 none)", "oklch(94.000% 0.01 260 / 0.6)")); h.setNow(1300); h.setBg("#202021"); h.ctrl.tick(); // re-solve + begin ease @@ -145,7 +145,7 @@ test("when the ease completes, color vars revert to canonical oklch (not stuck i h.ctrl.tick(); assert.equal( h.el.props.get("--lab-label"), - "oklch(94.000% 0 0)", + "oklch(94.000% 0 none)", "settled color role returns to canonical oklch form", ); assert.equal(h.el.props.get("--lab-panel"), "oklch(94.000% 0.01 260 / 0.6)"); @@ -155,7 +155,7 @@ test("current() reports the full applied picture, including translucent roles", const h = harness(); const snap = h.ctrl.current(); assert.equal(snap["--lab-panel"], "oklch(96.000% 0.01 260 / 0.6)"); - assert.equal(snap["--lab-label"], "oklch(20.000% 0 0)"); + assert.equal(snap["--lab-label"], "oklch(20.000% 0 none)"); }); // baseVars is a REPLACE of the last solve's vars, never a merge. These lock the @@ -165,7 +165,7 @@ test("current() reports the full applied picture, including translucent roles", // the role set constant, so it is pinned explicitly here. const THREE = { vars: { - "--lab-label": "oklch(20.000% 0 0)", + "--lab-label": "oklch(20.000% 0 none)", "--lab-panel": "oklch(96.000% 0.01 260 / 0.6)", "--lab-extra": "oklch(50.000% 0.1 120 / 0.4)", }, @@ -176,14 +176,14 @@ const THREE = { }, }; const TWO = { - vars: { "--lab-label": "oklch(90.000% 0 0)", "--lab-panel": "oklch(30.000% 0.02 260 / 0.6)" }, + vars: { "--lab-label": "oklch(90.000% 0 none)", "--lab-panel": "oklch(30.000% 0.02 260 / 0.6)" }, roles: { label: { kind: "color", cssVar: "--lab-label", hex: "#E5E5E5", lc: 100, legalFloor: null }, panel: { kind: "translucent", cssVar: "--lab-panel" }, }, }; const ONE = { - vars: { "--lab-label": "oklch(20.000% 0 0)" }, + vars: { "--lab-label": "oklch(20.000% 0 none)" }, roles: { label: { kind: "color", cssVar: "--lab-label", hex: "#1A1A1A", lc: 100, legalFloor: null } }, }; @@ -198,7 +198,7 @@ test("a later solve that DROPS a role removes its var from target and current()" assert.ok(!("--lab-extra" in ctrl.current()), "and be absent from current()"); // The surviving roles are updated (not stale). assert.equal(el.props.get("--lab-panel"), "oklch(30.000% 0.02 260 / 0.6)"); - assert.equal(el.props.get("--lab-label"), "oklch(90.000% 0 0)"); + assert.equal(el.props.get("--lab-label"), "oklch(90.000% 0 none)"); }); test("a later solve that ADDS a role writes its new var", () => { @@ -222,10 +222,10 @@ test("a later solve that ADDS a role writes its new var", () => { // two colours in each representation, so a correct parse makes the two runs // bit-identical. Two solid fixtures, live-emitted: #1A1A1A / #000000. const BG_HEX = ["#1A1A1A", "#000000"]; -const BG_OKLCH = ["oklch(21.77865% 0.000000 89.876)", "oklch(0.00000% 0.000000 0.000)"]; +const BG_OKLCH = ["oklch(21.77865% 0.000000 none)", "oklch(0.00000% 0.000000 none)"]; function strictEasePaints(seq) { - const colors = fakeColors(makeResult("#000000", "oklch(0.000% 0 0)", "oklch(96.000% 0.01 260 / 0.6)", 4.5)); + const colors = fakeColors(makeResult("#000000", "oklch(0.000% 0 none)", "oklch(96.000% 0.01 260 / 0.6)", 4.5)); const el = fakeElement(); let now = 2000; let i = 0; // constructor reads seq[0]; each tick advances first @@ -242,7 +242,7 @@ function strictEasePaints(seq) { dwellMs: 250, }); colors.setRecheckLc([10]); // black fails on the dark bg → will breach - colors.setResolve(makeResult("#FFFFFF", "oklch(100.000% 0 0)", "oklch(30.000% 0.02 260 / 0.6)", 4.5)); + colors.setResolve(makeResult("#FFFFFF", "oklch(100.000% 0 none)", "oklch(30.000% 0.02 260 / 0.6)", 4.5)); const tickAt = (t) => { i++; now = t; diff --git a/packages/colors/test/data/oklch-core-vectors.txt b/packages/colors/test/data/oklch-core-vectors.txt index aed4ec31..f05e74b3 100644 --- a/packages/colors/test/data/oklch-core-vectors.txt +++ b/packages/colors/test/data/oklch-core-vectors.txt @@ -1,262 +1,262 @@ -#000000|-|oklch(0.00000% 0.000000 0.000) -#FFFFFF|-|oklch(100.00000% 0.000000 89.876) -#000000|-|oklch(0.00000% 0.000000 0.000) -#010101|-|oklch(6.72046% 0.000000 89.876) -#020202|-|oklch(8.46725% 0.000000 89.876) -#030303|-|oklch(9.69258% 0.000000 89.876) -#040404|-|oklch(10.66807% 0.000000 89.876) -#050505|-|oklch(11.49183% 0.000000 89.876) -#060606|-|oklch(12.21189% 0.000000 89.876) -#070707|-|oklch(12.85578% 0.000000 89.876) -#080808|-|oklch(13.44092% 0.000000 89.876) -#090909|-|oklch(13.97912% 0.000000 89.876) -#0A0A0A|-|oklch(14.47880% 0.000000 89.876) -#0B0B0B|-|oklch(14.95771% 0.000000 89.876) -#0C0C0C|-|oklch(15.43400% 0.000000 89.876) -#0D0D0D|-|oklch(15.90664% 0.000000 89.876) -#0E0E0E|-|oklch(16.37579% 0.000000 89.876) -#0F0F0F|-|oklch(16.84161% 0.000000 89.876) -#101010|-|oklch(17.30423% 0.000000 89.876) -#111111|-|oklch(17.76378% 0.000000 89.876) -#121212|-|oklch(18.22037% 0.000000 89.876) -#131313|-|oklch(18.67412% 0.000000 89.876) -#141414|-|oklch(19.12513% 0.000000 89.876) -#151515|-|oklch(19.57350% 0.000000 89.876) -#161616|-|oklch(20.01931% 0.000000 89.876) -#171717|-|oklch(20.46265% 0.000000 89.876) -#181818|-|oklch(20.90361% 0.000000 89.876) -#191919|-|oklch(21.34225% 0.000000 89.876) -#1A1A1A|-|oklch(21.77865% 0.000000 89.876) -#1B1B1B|-|oklch(22.21287% 0.000000 89.876) -#1C1C1C|-|oklch(22.64499% 0.000000 89.876) -#1D1D1D|-|oklch(23.07505% 0.000000 89.876) -#1E1E1E|-|oklch(23.50311% 0.000000 89.876) -#1F1F1F|-|oklch(23.92924% 0.000000 89.876) -#202020|-|oklch(24.35347% 0.000000 89.876) -#212121|-|oklch(24.77587% 0.000000 89.876) -#222222|-|oklch(25.19647% 0.000000 89.876) -#232323|-|oklch(25.61533% 0.000000 89.876) -#242424|-|oklch(26.03248% 0.000000 89.876) -#252525|-|oklch(26.44797% 0.000000 89.876) -#262626|-|oklch(26.86183% 0.000000 89.876) -#272727|-|oklch(27.27410% 0.000000 89.876) -#282828|-|oklch(27.68482% 0.000000 89.876) -#292929|-|oklch(28.09402% 0.000000 89.876) -#2A2A2A|-|oklch(28.50174% 0.000000 89.876) -#2B2B2B|-|oklch(28.90800% 0.000000 89.876) -#2C2C2C|-|oklch(29.31285% 0.000000 89.876) -#2D2D2D|-|oklch(29.71630% 0.000000 89.876) -#2E2E2E|-|oklch(30.11838% 0.000000 89.876) -#2F2F2F|-|oklch(30.51913% 0.000000 89.876) -#303030|-|oklch(30.91856% 0.000000 89.876) -#313131|-|oklch(31.31671% 0.000000 89.876) -#323232|-|oklch(31.71360% 0.000000 89.876) -#333333|-|oklch(32.10925% 0.000000 89.876) -#343434|-|oklch(32.50369% 0.000000 89.876) -#353535|-|oklch(32.89693% 0.000000 89.876) -#363636|-|oklch(33.28900% 0.000000 89.876) -#373737|-|oklch(33.67992% 0.000000 89.876) -#383838|-|oklch(34.06971% 0.000000 89.876) -#393939|-|oklch(34.45838% 0.000000 89.876) -#3A3A3A|-|oklch(34.84597% 0.000000 89.876) -#3B3B3B|-|oklch(35.23247% 0.000000 89.876) -#3C3C3C|-|oklch(35.61793% 0.000000 89.876) -#3D3D3D|-|oklch(36.00234% 0.000000 89.876) -#3E3E3E|-|oklch(36.38572% 0.000000 89.876) -#3F3F3F|-|oklch(36.76811% 0.000000 89.876) -#404040|-|oklch(37.14949% 0.000000 89.876) -#414141|-|oklch(37.52991% 0.000000 89.876) -#424242|-|oklch(37.90936% 0.000000 89.876) -#434343|-|oklch(38.28786% 0.000000 89.876) -#444444|-|oklch(38.66543% 0.000000 89.876) -#454545|-|oklch(39.04208% 0.000000 89.876) -#464646|-|oklch(39.41783% 0.000000 89.876) -#474747|-|oklch(39.79268% 0.000000 89.876) -#484848|-|oklch(40.16665% 0.000000 89.876) -#494949|-|oklch(40.53975% 0.000000 89.876) -#4A4A4A|-|oklch(40.91200% 0.000000 89.876) -#4B4B4B|-|oklch(41.28340% 0.000000 89.876) -#4C4C4C|-|oklch(41.65397% 0.000000 89.876) -#4D4D4D|-|oklch(42.02371% 0.000000 89.876) -#4E4E4E|-|oklch(42.39265% 0.000000 89.876) -#4F4F4F|-|oklch(42.76078% 0.000000 89.876) -#505050|-|oklch(43.12812% 0.000000 89.876) -#515151|-|oklch(43.49468% 0.000000 89.876) -#525252|-|oklch(43.86048% 0.000000 89.876) -#535353|-|oklch(44.22551% 0.000000 89.876) -#545454|-|oklch(44.58978% 0.000000 89.876) -#555555|-|oklch(44.95332% 0.000000 89.876) -#565656|-|oklch(45.31612% 0.000000 89.876) -#575757|-|oklch(45.67820% 0.000000 89.876) -#585858|-|oklch(46.03956% 0.000000 89.876) -#595959|-|oklch(46.40021% 0.000000 89.876) -#5A5A5A|-|oklch(46.76017% 0.000000 89.876) -#5B5B5B|-|oklch(47.11943% 0.000000 89.876) -#5C5C5C|-|oklch(47.47801% 0.000000 89.876) -#5D5D5D|-|oklch(47.83591% 0.000000 89.876) -#5E5E5E|-|oklch(48.19315% 0.000000 89.876) -#5F5F5F|-|oklch(48.54972% 0.000000 89.876) -#606060|-|oklch(48.90564% 0.000000 89.876) -#616161|-|oklch(49.26092% 0.000000 89.876) -#626262|-|oklch(49.61555% 0.000000 89.876) -#636363|-|oklch(49.96956% 0.000000 89.876) -#646464|-|oklch(50.32293% 0.000000 89.876) -#656565|-|oklch(50.67569% 0.000000 89.876) -#666666|-|oklch(51.02784% 0.000000 89.876) -#676767|-|oklch(51.37937% 0.000000 89.876) -#686868|-|oklch(51.73031% 0.000000 89.876) -#696969|-|oklch(52.08065% 0.000000 89.876) -#6A6A6A|-|oklch(52.43041% 0.000000 89.876) -#6B6B6B|-|oklch(52.77958% 0.000000 89.876) -#6C6C6C|-|oklch(53.12818% 0.000000 89.876) -#6D6D6D|-|oklch(53.47621% 0.000000 89.876) -#6E6E6E|-|oklch(53.82367% 0.000000 89.876) -#6F6F6F|-|oklch(54.17057% 0.000000 89.876) -#707070|-|oklch(54.51691% 0.000000 89.876) -#717171|-|oklch(54.86271% 0.000000 89.876) -#727272|-|oklch(55.20796% 0.000000 89.876) -#737373|-|oklch(55.55268% 0.000000 89.876) -#747474|-|oklch(55.89686% 0.000000 89.876) -#757575|-|oklch(56.24051% 0.000000 89.876) -#767676|-|oklch(56.58363% 0.000000 89.876) -#777777|-|oklch(56.92624% 0.000000 89.876) -#787878|-|oklch(57.26833% 0.000000 89.876) -#797979|-|oklch(57.60991% 0.000000 89.876) -#7A7A7A|-|oklch(57.95099% 0.000000 89.876) -#7B7B7B|-|oklch(58.29157% 0.000000 89.876) -#7C7C7C|-|oklch(58.63165% 0.000000 89.876) -#7D7D7D|-|oklch(58.97123% 0.000000 89.876) -#7E7E7E|-|oklch(59.31033% 0.000000 89.876) -#7F7F7F|-|oklch(59.64895% 0.000000 89.876) -#808080|-|oklch(59.98708% 0.000000 89.876) -#818181|-|oklch(60.32474% 0.000000 89.876) -#828282|-|oklch(60.66193% 0.000000 89.876) -#838383|-|oklch(60.99864% 0.000000 89.876) -#848484|-|oklch(61.33490% 0.000000 89.876) -#858585|-|oklch(61.67069% 0.000000 89.876) -#868686|-|oklch(62.00603% 0.000000 89.876) -#878787|-|oklch(62.34092% 0.000000 89.876) -#888888|-|oklch(62.67535% 0.000000 89.876) -#898989|-|oklch(63.00934% 0.000000 89.876) -#8A8A8A|-|oklch(63.34289% 0.000000 89.876) -#8B8B8B|-|oklch(63.67600% 0.000000 89.876) -#8C8C8C|-|oklch(64.00868% 0.000000 89.876) -#8D8D8D|-|oklch(64.34092% 0.000000 89.876) -#8E8E8E|-|oklch(64.67274% 0.000000 89.876) -#8F8F8F|-|oklch(65.00413% 0.000000 89.876) -#909090|-|oklch(65.33509% 0.000000 89.876) -#919191|-|oklch(65.66564% 0.000000 89.876) -#929292|-|oklch(65.99578% 0.000000 89.876) -#939393|-|oklch(66.32550% 0.000000 89.876) -#949494|-|oklch(66.65481% 0.000000 89.876) -#959595|-|oklch(66.98372% 0.000000 89.876) -#969696|-|oklch(67.31222% 0.000000 89.876) -#979797|-|oklch(67.64032% 0.000000 89.876) -#989898|-|oklch(67.96803% 0.000000 89.876) -#999999|-|oklch(68.29534% 0.000000 89.876) -#9A9A9A|-|oklch(68.62226% 0.000000 89.876) -#9B9B9B|-|oklch(68.94879% 0.000000 89.876) -#9C9C9C|-|oklch(69.27493% 0.000000 89.876) -#9D9D9D|-|oklch(69.60069% 0.000000 89.876) -#9E9E9E|-|oklch(69.92607% 0.000000 89.876) -#9F9F9F|-|oklch(70.25107% 0.000000 89.876) -#A0A0A0|-|oklch(70.57570% 0.000000 89.876) -#A1A1A1|-|oklch(70.89995% 0.000000 89.876) -#A2A2A2|-|oklch(71.22384% 0.000000 89.876) -#A3A3A3|-|oklch(71.54735% 0.000000 89.876) -#A4A4A4|-|oklch(71.87050% 0.000000 89.876) -#A5A5A5|-|oklch(72.19329% 0.000000 89.876) -#A6A6A6|-|oklch(72.51572% 0.000000 89.876) -#A7A7A7|-|oklch(72.83778% 0.000000 89.876) -#A8A8A8|-|oklch(73.15950% 0.000000 89.876) -#A9A9A9|-|oklch(73.48086% 0.000000 89.876) -#AAAAAA|-|oklch(73.80187% 0.000000 89.876) -#ABABAB|-|oklch(74.12253% 0.000000 89.876) -#ACACAC|-|oklch(74.44284% 0.000000 89.876) -#ADADAD|-|oklch(74.76281% 0.000000 89.876) -#AEAEAE|-|oklch(75.08244% 0.000000 89.876) -#AFAFAF|-|oklch(75.40173% 0.000000 89.876) -#B0B0B0|-|oklch(75.72068% 0.000000 89.876) -#B1B1B1|-|oklch(76.03929% 0.000000 89.876) -#B2B2B2|-|oklch(76.35757% 0.000000 89.876) -#B3B3B3|-|oklch(76.67552% 0.000000 89.876) -#B4B4B4|-|oklch(76.99314% 0.000000 89.876) -#B5B5B5|-|oklch(77.31043% 0.000000 89.876) -#B6B6B6|-|oklch(77.62740% 0.000000 89.876) -#B7B7B7|-|oklch(77.94405% 0.000000 89.876) -#B8B8B8|-|oklch(78.26037% 0.000000 89.876) -#B9B9B9|-|oklch(78.57638% 0.000000 89.876) -#BABABA|-|oklch(78.89206% 0.000000 89.876) -#BBBBBB|-|oklch(79.20743% 0.000000 89.876) -#BCBCBC|-|oklch(79.52249% 0.000000 89.876) -#BDBDBD|-|oklch(79.83724% 0.000000 89.876) -#BEBEBE|-|oklch(80.15167% 0.000000 89.876) -#BFBFBF|-|oklch(80.46580% 0.000000 89.876) -#C0C0C0|-|oklch(80.77962% 0.000000 89.876) -#C1C1C1|-|oklch(81.09314% 0.000000 89.876) -#C2C2C2|-|oklch(81.40636% 0.000000 89.876) -#C3C3C3|-|oklch(81.71927% 0.000000 89.876) -#C4C4C4|-|oklch(82.03188% 0.000000 89.876) -#C5C5C5|-|oklch(82.34420% 0.000000 89.876) -#C6C6C6|-|oklch(82.65622% 0.000000 89.876) -#C7C7C7|-|oklch(82.96795% 0.000000 89.876) -#C8C8C8|-|oklch(83.27938% 0.000000 89.876) -#C9C9C9|-|oklch(83.59052% 0.000000 89.876) -#CACACA|-|oklch(83.90138% 0.000000 89.876) -#CBCBCB|-|oklch(84.21194% 0.000000 89.876) -#CCCCCC|-|oklch(84.52223% 0.000000 89.876) -#CDCDCD|-|oklch(84.83222% 0.000000 89.876) -#CECECE|-|oklch(85.14193% 0.000000 89.876) -#CFCFCF|-|oklch(85.45136% 0.000000 89.876) -#D0D0D0|-|oklch(85.76052% 0.000000 89.876) -#D1D1D1|-|oklch(86.06939% 0.000000 89.876) -#D2D2D2|-|oklch(86.37799% 0.000000 89.876) -#D3D3D3|-|oklch(86.68631% 0.000000 89.876) -#D4D4D4|-|oklch(86.99435% 0.000000 89.876) -#D5D5D5|-|oklch(87.30213% 0.000000 89.876) -#D6D6D6|-|oklch(87.60963% 0.000000 89.876) -#D7D7D7|-|oklch(87.91687% 0.000000 89.876) -#D8D8D8|-|oklch(88.22383% 0.000000 89.876) -#D9D9D9|-|oklch(88.53053% 0.000000 89.876) -#DADADA|-|oklch(88.83696% 0.000000 89.876) -#DBDBDB|-|oklch(89.14313% 0.000000 89.876) -#DCDCDC|-|oklch(89.44904% 0.000000 89.876) -#DDDDDD|-|oklch(89.75469% 0.000000 89.876) -#DEDEDE|-|oklch(90.06007% 0.000000 89.876) -#DFDFDF|-|oklch(90.36520% 0.000000 89.876) -#E0E0E0|-|oklch(90.67007% 0.000000 89.876) -#E1E1E1|-|oklch(90.97468% 0.000000 89.876) -#E2E2E2|-|oklch(91.27904% 0.000000 89.876) -#E3E3E3|-|oklch(91.58315% 0.000000 89.876) -#E4E4E4|-|oklch(91.88700% 0.000000 89.876) -#E5E5E5|-|oklch(92.19060% 0.000000 89.876) -#E6E6E6|-|oklch(92.49395% 0.000000 89.876) -#E7E7E7|-|oklch(92.79706% 0.000000 89.876) -#E8E8E8|-|oklch(93.09991% 0.000000 89.876) -#E9E9E9|-|oklch(93.40252% 0.000000 89.876) -#EAEAEA|-|oklch(93.70489% 0.000000 89.876) -#EBEBEB|-|oklch(94.00701% 0.000000 89.876) -#ECECEC|-|oklch(94.30889% 0.000000 89.876) -#EDEDED|-|oklch(94.61053% 0.000000 89.876) -#EEEEEE|-|oklch(94.91193% 0.000000 89.876) -#EFEFEF|-|oklch(95.21308% 0.000000 89.876) -#F0F0F0|-|oklch(95.51400% 0.000000 89.876) -#F1F1F1|-|oklch(95.81469% 0.000000 89.876) -#F2F2F2|-|oklch(96.11514% 0.000000 89.876) -#F3F3F3|-|oklch(96.41535% 0.000000 89.876) -#F4F4F4|-|oklch(96.71533% 0.000000 89.876) -#F5F5F5|-|oklch(97.01508% 0.000000 89.876) -#F6F6F6|-|oklch(97.31459% 0.000000 89.876) -#F7F7F7|-|oklch(97.61388% 0.000000 89.876) -#F8F8F8|-|oklch(97.91293% 0.000000 89.876) -#F9F9F9|-|oklch(98.21176% 0.000000 89.876) -#FAFAFA|-|oklch(98.51036% 0.000000 89.876) -#FBFBFB|-|oklch(98.80874% 0.000000 89.876) -#FCFCFC|-|oklch(99.10689% 0.000000 89.876) -#FDFDFD|-|oklch(99.40482% 0.000000 89.876) -#FEFEFE|-|oklch(99.70252% 0.000000 89.876) -#FFFFFF|-|oklch(100.00000% 0.000000 89.876) -#000000|-|oklch(0.00000% 0.000000 0.000) +#000000|-|oklch(0.00000% 0.000000 none) +#FFFFFF|-|oklch(100.00000% 0.000000 none) +#000000|-|oklch(0.00000% 0.000000 none) +#010101|-|oklch(6.72046% 0.000000 none) +#020202|-|oklch(8.46725% 0.000000 none) +#030303|-|oklch(9.69258% 0.000000 none) +#040404|-|oklch(10.66807% 0.000000 none) +#050505|-|oklch(11.49183% 0.000000 none) +#060606|-|oklch(12.21189% 0.000000 none) +#070707|-|oklch(12.85578% 0.000000 none) +#080808|-|oklch(13.44092% 0.000000 none) +#090909|-|oklch(13.97912% 0.000000 none) +#0A0A0A|-|oklch(14.47880% 0.000000 none) +#0B0B0B|-|oklch(14.95771% 0.000000 none) +#0C0C0C|-|oklch(15.43400% 0.000000 none) +#0D0D0D|-|oklch(15.90664% 0.000000 none) +#0E0E0E|-|oklch(16.37579% 0.000000 none) +#0F0F0F|-|oklch(16.84161% 0.000000 none) +#101010|-|oklch(17.30423% 0.000000 none) +#111111|-|oklch(17.76378% 0.000000 none) +#121212|-|oklch(18.22037% 0.000000 none) +#131313|-|oklch(18.67412% 0.000000 none) +#141414|-|oklch(19.12513% 0.000000 none) +#151515|-|oklch(19.57350% 0.000000 none) +#161616|-|oklch(20.01931% 0.000000 none) +#171717|-|oklch(20.46265% 0.000000 none) +#181818|-|oklch(20.90361% 0.000000 none) +#191919|-|oklch(21.34225% 0.000000 none) +#1A1A1A|-|oklch(21.77865% 0.000000 none) +#1B1B1B|-|oklch(22.21287% 0.000000 none) +#1C1C1C|-|oklch(22.64499% 0.000000 none) +#1D1D1D|-|oklch(23.07505% 0.000000 none) +#1E1E1E|-|oklch(23.50311% 0.000000 none) +#1F1F1F|-|oklch(23.92924% 0.000000 none) +#202020|-|oklch(24.35347% 0.000000 none) +#212121|-|oklch(24.77587% 0.000000 none) +#222222|-|oklch(25.19647% 0.000000 none) +#232323|-|oklch(25.61533% 0.000000 none) +#242424|-|oklch(26.03248% 0.000000 none) +#252525|-|oklch(26.44797% 0.000000 none) +#262626|-|oklch(26.86183% 0.000000 none) +#272727|-|oklch(27.27410% 0.000000 none) +#282828|-|oklch(27.68482% 0.000000 none) +#292929|-|oklch(28.09402% 0.000000 none) +#2A2A2A|-|oklch(28.50174% 0.000000 none) +#2B2B2B|-|oklch(28.90801% 0.000000 none) +#2C2C2C|-|oklch(29.31285% 0.000000 none) +#2D2D2D|-|oklch(29.71630% 0.000000 none) +#2E2E2E|-|oklch(30.11838% 0.000000 none) +#2F2F2F|-|oklch(30.51913% 0.000000 none) +#303030|-|oklch(30.91856% 0.000000 none) +#313131|-|oklch(31.31671% 0.000000 none) +#323232|-|oklch(31.71360% 0.000000 none) +#333333|-|oklch(32.10925% 0.000000 none) +#343434|-|oklch(32.50369% 0.000000 none) +#353535|-|oklch(32.89693% 0.000000 none) +#363636|-|oklch(33.28900% 0.000000 none) +#373737|-|oklch(33.67992% 0.000000 none) +#383838|-|oklch(34.06971% 0.000000 none) +#393939|-|oklch(34.45838% 0.000000 none) +#3A3A3A|-|oklch(34.84597% 0.000000 none) +#3B3B3B|-|oklch(35.23247% 0.000000 none) +#3C3C3C|-|oklch(35.61793% 0.000000 none) +#3D3D3D|-|oklch(36.00234% 0.000000 none) +#3E3E3E|-|oklch(36.38572% 0.000000 none) +#3F3F3F|-|oklch(36.76811% 0.000000 none) +#404040|-|oklch(37.14949% 0.000000 none) +#414141|-|oklch(37.52991% 0.000000 none) +#424242|-|oklch(37.90936% 0.000000 none) +#434343|-|oklch(38.28786% 0.000000 none) +#444444|-|oklch(38.66543% 0.000000 none) +#454545|-|oklch(39.04208% 0.000000 none) +#464646|-|oklch(39.41783% 0.000000 none) +#474747|-|oklch(39.79268% 0.000000 none) +#484848|-|oklch(40.16665% 0.000000 none) +#494949|-|oklch(40.53975% 0.000000 none) +#4A4A4A|-|oklch(40.91200% 0.000000 none) +#4B4B4B|-|oklch(41.28340% 0.000000 none) +#4C4C4C|-|oklch(41.65397% 0.000000 none) +#4D4D4D|-|oklch(42.02371% 0.000000 none) +#4E4E4E|-|oklch(42.39265% 0.000000 none) +#4F4F4F|-|oklch(42.76078% 0.000000 none) +#505050|-|oklch(43.12812% 0.000000 none) +#515151|-|oklch(43.49469% 0.000000 none) +#525252|-|oklch(43.86048% 0.000000 none) +#535353|-|oklch(44.22551% 0.000000 none) +#545454|-|oklch(44.58978% 0.000000 none) +#555555|-|oklch(44.95332% 0.000000 none) +#565656|-|oklch(45.31612% 0.000000 none) +#575757|-|oklch(45.67820% 0.000000 none) +#585858|-|oklch(46.03956% 0.000000 none) +#595959|-|oklch(46.40021% 0.000000 none) +#5A5A5A|-|oklch(46.76017% 0.000000 none) +#5B5B5B|-|oklch(47.11943% 0.000000 none) +#5C5C5C|-|oklch(47.47801% 0.000000 none) +#5D5D5D|-|oklch(47.83591% 0.000000 none) +#5E5E5E|-|oklch(48.19315% 0.000000 none) +#5F5F5F|-|oklch(48.54972% 0.000000 none) +#606060|-|oklch(48.90564% 0.000000 none) +#616161|-|oklch(49.26092% 0.000000 none) +#626262|-|oklch(49.61555% 0.000000 none) +#636363|-|oklch(49.96956% 0.000000 none) +#646464|-|oklch(50.32293% 0.000000 none) +#656565|-|oklch(50.67569% 0.000000 none) +#666666|-|oklch(51.02784% 0.000000 none) +#676767|-|oklch(51.37937% 0.000000 none) +#686868|-|oklch(51.73031% 0.000000 none) +#696969|-|oklch(52.08066% 0.000000 none) +#6A6A6A|-|oklch(52.43041% 0.000000 none) +#6B6B6B|-|oklch(52.77958% 0.000000 none) +#6C6C6C|-|oklch(53.12818% 0.000000 none) +#6D6D6D|-|oklch(53.47621% 0.000000 none) +#6E6E6E|-|oklch(53.82367% 0.000000 none) +#6F6F6F|-|oklch(54.17057% 0.000000 none) +#707070|-|oklch(54.51691% 0.000000 none) +#717171|-|oklch(54.86271% 0.000000 none) +#727272|-|oklch(55.20796% 0.000000 none) +#737373|-|oklch(55.55268% 0.000000 none) +#747474|-|oklch(55.89686% 0.000000 none) +#757575|-|oklch(56.24051% 0.000000 none) +#767676|-|oklch(56.58363% 0.000000 none) +#777777|-|oklch(56.92624% 0.000000 none) +#787878|-|oklch(57.26833% 0.000000 none) +#797979|-|oklch(57.60992% 0.000000 none) +#7A7A7A|-|oklch(57.95099% 0.000000 none) +#7B7B7B|-|oklch(58.29157% 0.000000 none) +#7C7C7C|-|oklch(58.63165% 0.000000 none) +#7D7D7D|-|oklch(58.97123% 0.000000 none) +#7E7E7E|-|oklch(59.31033% 0.000000 none) +#7F7F7F|-|oklch(59.64895% 0.000000 none) +#808080|-|oklch(59.98708% 0.000000 none) +#818181|-|oklch(60.32474% 0.000000 none) +#828282|-|oklch(60.66193% 0.000000 none) +#838383|-|oklch(60.99864% 0.000000 none) +#848484|-|oklch(61.33490% 0.000000 none) +#858585|-|oklch(61.67069% 0.000000 none) +#868686|-|oklch(62.00603% 0.000000 none) +#878787|-|oklch(62.34092% 0.000000 none) +#888888|-|oklch(62.67535% 0.000000 none) +#898989|-|oklch(63.00934% 0.000000 none) +#8A8A8A|-|oklch(63.34289% 0.000000 none) +#8B8B8B|-|oklch(63.67600% 0.000000 none) +#8C8C8C|-|oklch(64.00868% 0.000000 none) +#8D8D8D|-|oklch(64.34092% 0.000000 none) +#8E8E8E|-|oklch(64.67274% 0.000000 none) +#8F8F8F|-|oklch(65.00413% 0.000000 none) +#909090|-|oklch(65.33510% 0.000000 none) +#919191|-|oklch(65.66564% 0.000000 none) +#929292|-|oklch(65.99578% 0.000000 none) +#939393|-|oklch(66.32550% 0.000000 none) +#949494|-|oklch(66.65481% 0.000000 none) +#959595|-|oklch(66.98372% 0.000000 none) +#969696|-|oklch(67.31222% 0.000000 none) +#979797|-|oklch(67.64032% 0.000000 none) +#989898|-|oklch(67.96803% 0.000000 none) +#999999|-|oklch(68.29534% 0.000000 none) +#9A9A9A|-|oklch(68.62226% 0.000000 none) +#9B9B9B|-|oklch(68.94879% 0.000000 none) +#9C9C9C|-|oklch(69.27493% 0.000000 none) +#9D9D9D|-|oklch(69.60069% 0.000000 none) +#9E9E9E|-|oklch(69.92607% 0.000000 none) +#9F9F9F|-|oklch(70.25107% 0.000000 none) +#A0A0A0|-|oklch(70.57570% 0.000000 none) +#A1A1A1|-|oklch(70.89995% 0.000000 none) +#A2A2A2|-|oklch(71.22384% 0.000000 none) +#A3A3A3|-|oklch(71.54735% 0.000000 none) +#A4A4A4|-|oklch(71.87050% 0.000000 none) +#A5A5A5|-|oklch(72.19329% 0.000000 none) +#A6A6A6|-|oklch(72.51572% 0.000000 none) +#A7A7A7|-|oklch(72.83779% 0.000000 none) +#A8A8A8|-|oklch(73.15950% 0.000000 none) +#A9A9A9|-|oklch(73.48086% 0.000000 none) +#AAAAAA|-|oklch(73.80187% 0.000000 none) +#ABABAB|-|oklch(74.12253% 0.000000 none) +#ACACAC|-|oklch(74.44284% 0.000000 none) +#ADADAD|-|oklch(74.76281% 0.000000 none) +#AEAEAE|-|oklch(75.08244% 0.000000 none) +#AFAFAF|-|oklch(75.40173% 0.000000 none) +#B0B0B0|-|oklch(75.72068% 0.000000 none) +#B1B1B1|-|oklch(76.03929% 0.000000 none) +#B2B2B2|-|oklch(76.35757% 0.000000 none) +#B3B3B3|-|oklch(76.67552% 0.000000 none) +#B4B4B4|-|oklch(76.99314% 0.000000 none) +#B5B5B5|-|oklch(77.31044% 0.000000 none) +#B6B6B6|-|oklch(77.62740% 0.000000 none) +#B7B7B7|-|oklch(77.94405% 0.000000 none) +#B8B8B8|-|oklch(78.26037% 0.000000 none) +#B9B9B9|-|oklch(78.57638% 0.000000 none) +#BABABA|-|oklch(78.89206% 0.000000 none) +#BBBBBB|-|oklch(79.20743% 0.000000 none) +#BCBCBC|-|oklch(79.52249% 0.000000 none) +#BDBDBD|-|oklch(79.83724% 0.000000 none) +#BEBEBE|-|oklch(80.15167% 0.000000 none) +#BFBFBF|-|oklch(80.46580% 0.000000 none) +#C0C0C0|-|oklch(80.77962% 0.000000 none) +#C1C1C1|-|oklch(81.09314% 0.000000 none) +#C2C2C2|-|oklch(81.40636% 0.000000 none) +#C3C3C3|-|oklch(81.71927% 0.000000 none) +#C4C4C4|-|oklch(82.03188% 0.000000 none) +#C5C5C5|-|oklch(82.34420% 0.000000 none) +#C6C6C6|-|oklch(82.65622% 0.000000 none) +#C7C7C7|-|oklch(82.96795% 0.000000 none) +#C8C8C8|-|oklch(83.27938% 0.000000 none) +#C9C9C9|-|oklch(83.59053% 0.000000 none) +#CACACA|-|oklch(83.90138% 0.000000 none) +#CBCBCB|-|oklch(84.21195% 0.000000 none) +#CCCCCC|-|oklch(84.52223% 0.000000 none) +#CDCDCD|-|oklch(84.83222% 0.000000 none) +#CECECE|-|oklch(85.14193% 0.000000 none) +#CFCFCF|-|oklch(85.45137% 0.000000 none) +#D0D0D0|-|oklch(85.76052% 0.000000 none) +#D1D1D1|-|oklch(86.06939% 0.000000 none) +#D2D2D2|-|oklch(86.37799% 0.000000 none) +#D3D3D3|-|oklch(86.68631% 0.000000 none) +#D4D4D4|-|oklch(86.99435% 0.000000 none) +#D5D5D5|-|oklch(87.30213% 0.000000 none) +#D6D6D6|-|oklch(87.60963% 0.000000 none) +#D7D7D7|-|oklch(87.91687% 0.000000 none) +#D8D8D8|-|oklch(88.22383% 0.000000 none) +#D9D9D9|-|oklch(88.53053% 0.000000 none) +#DADADA|-|oklch(88.83697% 0.000000 none) +#DBDBDB|-|oklch(89.14313% 0.000000 none) +#DCDCDC|-|oklch(89.44904% 0.000000 none) +#DDDDDD|-|oklch(89.75469% 0.000000 none) +#DEDEDE|-|oklch(90.06007% 0.000000 none) +#DFDFDF|-|oklch(90.36520% 0.000000 none) +#E0E0E0|-|oklch(90.67007% 0.000000 none) +#E1E1E1|-|oklch(90.97468% 0.000000 none) +#E2E2E2|-|oklch(91.27904% 0.000000 none) +#E3E3E3|-|oklch(91.58315% 0.000000 none) +#E4E4E4|-|oklch(91.88700% 0.000000 none) +#E5E5E5|-|oklch(92.19060% 0.000000 none) +#E6E6E6|-|oklch(92.49395% 0.000000 none) +#E7E7E7|-|oklch(92.79706% 0.000000 none) +#E8E8E8|-|oklch(93.09991% 0.000000 none) +#E9E9E9|-|oklch(93.40252% 0.000000 none) +#EAEAEA|-|oklch(93.70489% 0.000000 none) +#EBEBEB|-|oklch(94.00701% 0.000000 none) +#ECECEC|-|oklch(94.30889% 0.000000 none) +#EDEDED|-|oklch(94.61053% 0.000000 none) +#EEEEEE|-|oklch(94.91193% 0.000000 none) +#EFEFEF|-|oklch(95.21308% 0.000000 none) +#F0F0F0|-|oklch(95.51401% 0.000000 none) +#F1F1F1|-|oklch(95.81469% 0.000000 none) +#F2F2F2|-|oklch(96.11514% 0.000000 none) +#F3F3F3|-|oklch(96.41535% 0.000000 none) +#F4F4F4|-|oklch(96.71533% 0.000000 none) +#F5F5F5|-|oklch(97.01508% 0.000000 none) +#F6F6F6|-|oklch(97.31459% 0.000000 none) +#F7F7F7|-|oklch(97.61388% 0.000000 none) +#F8F8F8|-|oklch(97.91294% 0.000000 none) +#F9F9F9|-|oklch(98.21176% 0.000000 none) +#FAFAFA|-|oklch(98.51037% 0.000000 none) +#FBFBFB|-|oklch(98.80874% 0.000000 none) +#FCFCFC|-|oklch(99.10689% 0.000000 none) +#FDFDFD|-|oklch(99.40482% 0.000000 none) +#FEFEFE|-|oklch(99.70252% 0.000000 none) +#FFFFFF|-|oklch(100.00000% 0.000000 none) +#000000|-|oklch(0.00000% 0.000000 none) #000033|-|oklch(14.51382% 0.100571 264.052) #000066|-|oklch(23.06528% 0.159827 264.052) #000099|-|oklch(30.87043% 0.213911 264.052) @@ -289,7 +289,7 @@ #00FF00|-|oklch(86.64396% 0.294827 142.495) #00FF33|-|oklch(86.77694% 0.283811 143.718) #00FF66|-|oklch(87.17657% 0.254719 147.638) -#00FF99|-|oklch(87.91444% 0.213409 155.953) +#00FF99|-|oklch(87.91444% 0.213408 155.953) #00FFCC|-|oklch(89.03000% 0.173905 171.269) #00FFFF|-|oklch(90.53992% 0.154550 194.769) #330000|-|oklch(20.16318% 0.082740 29.234) @@ -299,7 +299,7 @@ #3300CC|-|oklch(40.16884% 0.253566 274.448) #3300FF|-|oklch(46.64348% 0.303903 270.669) #333300|-|oklch(31.08120% 0.067752 109.769) -#333333|-|oklch(32.10925% 0.000000 89.876) +#333333|-|oklch(32.10925% 0.000000 none) #333366|-|oklch(34.88270% 0.087099 280.897) #333399|-|oklch(39.09911% 0.161635 275.911) #3333CC|-|oklch(44.18763% 0.225649 272.281) @@ -342,7 +342,7 @@ #6633FF|-|oklch(53.05264% 0.272253 283.519) #666600|-|oklch(49.39406% 0.107672 109.769) #666633|-|oklch(49.80852% 0.071948 108.750) -#666666|-|oklch(51.02784% 0.000000 89.876) +#666666|-|oklch(51.02784% 0.000000 none) #666699|-|oklch(53.16287% 0.079460 283.365) #6666CC|-|oklch(56.14213% 0.153866 280.235) #6666FF|-|oklch(59.80956% 0.221291 277.362) @@ -358,15 +358,15 @@ #66CC99|-|oklch(76.99775% 0.121830 159.813) #66CCCC|-|oklch(78.45275% 0.095177 195.387) #66CCFF|-|oklch(80.39337% 0.119229 232.686) -#66FF00|-|oklch(88.15315% 0.275048 138.487) -#66FF33|-|oklch(88.28194% 0.263962 139.610) +#66FF00|-|oklch(88.15316% 0.275048 138.487) +#66FF33|-|oklch(88.28195% 0.263962 139.610) #66FF66|-|oklch(88.66904% 0.234404 143.281) #66FF99|-|oklch(89.38410% 0.191571 151.431) #66FFCC|-|oklch(90.46610% 0.149298 167.720) #66FFFF|-|oklch(91.93241% 0.128501 195.150) -#990000|-|oklch(42.88642% 0.175986 29.234) +#990000|-|oklch(42.88643% 0.175986 29.234) #990033|-|oklch(43.46497% 0.173710 13.840) -#990066|-|oklch(45.12965% 0.189051 348.880) +#990066|-|oklch(45.12965% 0.189051 348.879) #990099|-|oklch(47.92105% 0.220246 328.363) #9900CC|-|oklch(51.62326% 0.256666 313.234) #9900FF|-|oklch(55.97084% 0.295635 301.912) @@ -385,14 +385,14 @@ #999900|-|oklch(66.10871% 0.144107 109.769) #999933|-|oklch(66.34066% 0.122559 109.322) #999966|-|oklch(67.03410% 0.070044 108.149) -#999999|-|oklch(68.29534% 0.000000 89.876) +#999999|-|oklch(68.29534% 0.000000 none) #9999CC|-|oklch(70.15283% 0.074239 284.324) #9999FF|-|oklch(72.58107% 0.146226 282.133) -#99CC00|-|oklch(78.02584% 0.197760 125.667) +#99CC00|-|oklch(78.02584% 0.197759 125.667) #99CC33|-|oklch(78.19131% 0.182490 126.753) #99CC66|-|oklch(78.68788% 0.143013 130.931) -#99CC99|-|oklch(79.60088% 0.088882 144.684) -#99CCCC|-|oklch(80.97033% 0.052998 196.153) +#99CC99|-|oklch(79.60089% 0.088882 144.684) +#99CCCC|-|oklch(80.97033% 0.052998 196.154) #99CCFF|-|oklch(82.80334% 0.089756 248.959) #99FF00|-|oklch(90.16556% 0.252742 132.597) #99FF33|-|oklch(90.28908% 0.241609 133.525) @@ -428,7 +428,7 @@ #CCCC33|-|oklch(81.96760% 0.163865 109.529) #CCCC66|-|oklch(82.42268% 0.125317 108.861) #CCCC99|-|oklch(83.26089% 0.067602 107.782) -#CCCCCC|-|oklch(84.52223% 0.000000 89.876) +#CCCCCC|-|oklch(84.52223% 0.000000 none) #CCCCFF|-|oklch(86.21799% 0.070458 284.827) #CCFF00|-|oklch(93.07076% 0.228585 123.090) #CCFF33|-|oklch(93.18724% 0.217548 123.612) @@ -471,7 +471,7 @@ #FFFF66|-|oklch(97.23230% 0.171321 109.204) #FFFF99|-|oklch(97.83526% 0.124440 108.495) #FFFFCC|-|oklch(98.75130% 0.065409 107.540) -#FFFFFF|-|oklch(100.00000% 0.000000 89.876) +#FFFFFF|-|oklch(100.00000% 0.000000 none) #E15333|-|oklch(62.80701% 0.183248 34.160) #EAD5ED|-|oklch(89.72747% 0.039413 321.988) #1CE189|-|oklch(80.18685% 0.189388 156.128) @@ -550,7 +550,7 @@ #DF5FEA|-|oklch(69.69204% 0.226870 324.453) #732BF5|-|oklch(52.58028% 0.266884 289.761) #5E5EB2|-|oklch(52.30977% 0.129682 280.944) -#0CA899|-|oklch(65.79708% 0.115157 183.659) +#0CA899|-|oklch(65.79709% 0.115157 183.659) #A49005|-|oklch(65.14377% 0.134085 99.277) #245E30|-|oklch(43.10572% 0.095966 147.946) #0A6F31|-|oklch(47.47207% 0.128092 149.575) @@ -572,7 +572,7 @@ #AC0D61|-|oklch(48.85072% 0.193014 357.237) #EDF73B|-|oklch(93.84594% 0.193878 112.741) #620E2B|-|oklch(32.88419% 0.117048 6.666) -#E7AD05|-|oklch(78.03992% 0.159425 84.106) +#E7AD05|-|oklch(78.03993% 0.159425 84.106) #87EB88|-|oklch(85.53341% 0.164077 144.179) #F4D39E|-|oklch(88.22362% 0.077724 79.261) #068DE0|-|oklch(62.35893% 0.158822 245.977) @@ -588,7 +588,7 @@ #952A84|-|oklch(48.25580% 0.173621 335.075) #AB09F0|-|oklch(57.20381% 0.285560 310.326) #F495FF|-|oklch(80.37323% 0.174601 323.230) -#8A9AB7|-|oklch(68.36941% 0.046663 262.322) +#8A9AB7|-|oklch(68.36941% 0.046664 262.322) #61828F|-|oklch(58.62568% 0.042278 224.134) #C0AE57|-|oklch(74.82982% 0.109953 97.844) #34F11C|-|oklch(83.49908% 0.272269 141.810) @@ -628,7 +628,7 @@ #E09C14|-|oklch(74.05705% 0.151355 76.666) #06DE6C|-|oklch(78.79697% 0.212924 150.638) #D3DDA0|-|oklch(87.51044% 0.080578 115.662) -#182A14|-|oklch(26.23945% 0.046105 139.967) +#182A14|-|oklch(26.23946% 0.046105 139.967) #2DF4A4|-|oklch(85.68918% 0.186304 159.671) #C145C4|-|oklch(60.84817% 0.214429 326.846) #5965C8|-|oklch(54.76031% 0.152410 275.000) @@ -646,7 +646,7 @@ #FF111C|-|oklch(63.20899% 0.253323 27.733) #3E62D4|-|oklch(53.49071% 0.180265 266.895) #EB90B5|-|oklch(75.78818% 0.118149 354.370) -#B8D96E|-|oklch(83.83134% 0.139302 123.529) +#B8D96E|-|oklch(83.83135% 0.139302 123.529) #421017|-|oklch(26.11098% 0.076935 16.178) #57B20B|-|oklch(68.01554% 0.200053 136.182) #3F9906|-|oklch(60.39515% 0.184380 137.956) @@ -662,7 +662,7 @@ #B23B10|-|oklch(51.94510% 0.162290 37.601) #854BD9|-|oklch(55.48824% 0.206706 298.085) #22D29A|-|oklch(76.79605% 0.155957 164.752) -#C97A35|-|oklch(65.24002% 0.128477 58.397) +#C97A35|-|oklch(65.24002% 0.128476 58.397) #C92730|-|oklch(54.42350% 0.196354 24.415) #8D7076|-|oklch(57.51575% 0.037521 4.453) #55ADB7|-|oklch(69.72929% 0.085434 205.262) @@ -715,7 +715,7 @@ #3023BD|-|oklch(40.40464% 0.222573 273.726) #0B3CA2|-|oklch(39.93719% 0.172300 262.483) #6AB154|-|oklch(69.20469% 0.146281 138.755) -#179314|-|oklch(57.75528% 0.187097 142.494) +#179314|-|oklch(57.75529% 0.187097 142.494) #66A328|-|oklch(64.99646% 0.163954 132.713) #1155D5|-|oklch(49.61203% 0.207194 261.789) #E9A1BC|-|oklch(78.62460% 0.091570 355.225) @@ -738,7 +738,7 @@ #C7CC3C|-|oklch(81.60968% 0.159891 111.445) #F84131|-|oklch(64.71717% 0.221298 29.482) #A1E366|-|oklch(84.52830% 0.170901 132.234) -#686F8C|-|oklch(54.66819% 0.046057 274.197) +#686F8C|-|oklch(54.66819% 0.046058 274.197) #B5709C|-|oklch(63.31597% 0.104052 341.768) #A39E2B|-|oklch(68.29254% 0.131357 106.885) #EF0A88|-|oklch(62.13741% 0.250620 357.230) @@ -762,23 +762,23 @@ #ECFF34|-|oklch(95.43561% 0.205076 115.266) #574F4D|-|oklch(43.47404% 0.011162 34.298) #10589D|-|oklch(45.78442% 0.130137 252.670) -#E3AC1D|-|oklch(77.47310% 0.153045 84.329) +#E3AC1D|-|oklch(77.47310% 0.153044 84.329) #D75AE5|-|oklch(67.83374% 0.225442 323.545) #95F435|-|oklch(87.48487% 0.230332 133.241) #2E85C5|-|oklch(59.55513% 0.127098 244.905) #BCA23B|-|oklch(71.63173% 0.123724 94.771) #B6CC3A|-|oklch(80.25941% 0.166151 117.832) -#861304|-|oklch(39.90630% 0.150622 31.100) -#FD73DD|-|oklch(75.32867% 0.205702 337.266) +#861304|-|oklch(39.90631% 0.150622 31.100) +#FD73DD|-|oklch(75.32868% 0.205702 337.266) #99DD26|-|oklch(82.15347% 0.208222 129.199) #ED01FB|-|oklch(67.25417% 0.313798 325.248) #C9A0EB|-|oklch(76.94764% 0.113060 308.898) #53FE57|-|oklch(87.75811% 0.250420 143.378) -#1F6227|-|oklch(43.94441% 0.113441 145.320) +#1F6227|-|oklch(43.94441% 0.113441 145.321) #71C565|-|oklch(74.76400% 0.155066 141.276) #7752BD|-|oklch(53.10652% 0.161847 296.426) #8AFE99|-|oklch(90.44200% 0.173185 146.805) -#93B79A|-|oklch(74.47650% 0.056834 150.613) +#93B79A|-|oklch(74.47651% 0.056834 150.613) #0844F3|-|oklch(49.48076% 0.262569 263.939) #A6EB8A|-|oklch(87.16692% 0.145334 137.133) #897324|-|oklch(56.14937% 0.099560 93.040) @@ -793,7 +793,7 @@ #54D28A|-|oklch(77.50198% 0.152948 155.140) #C1F0CE|-|oklch(91.31155% 0.066851 153.516) #184438|-|oklch(35.19746% 0.053101 172.401) -#F8F22C|-|oklch(93.61886% 0.192992 107.699) +#F8F22C|-|oklch(93.61887% 0.192992 107.699) #DCC50B|-|oklch(81.75949% 0.168951 100.755) #E8041F|-|oklch(58.67202% 0.237499 26.401) #07E5C5|-|oklch(82.49097% 0.151605 177.647) @@ -806,7 +806,7 @@ #410648|-|oklch(27.42159% 0.119548 323.119) #2C986E|-|oklch(60.82775% 0.116350 162.800) #B3E0E2|-|oklch(87.56953% 0.046713 199.697) -#2FEFEB|-|oklch(86.40579% 0.141182 192.608) +#2FEFEB|-|oklch(86.40580% 0.141182 192.608) #BD30D1|-|oklch(59.22508% 0.246050 322.312) #64F7CA|-|oklch(88.46437% 0.141972 169.642) #CDFC97|-|oklch(93.56360% 0.137339 129.102) @@ -817,9 +817,9 @@ #2DDBF1|-|oklch(81.85579% 0.133581 208.345) #19C8E7|-|oklch(76.72737% 0.132066 214.544) #E83EC2|-|oklch(65.78481% 0.242360 338.639) -#16E98D|-|oklch(82.24859% 0.196270 156.044) +#16E98D|-|oklch(82.24860% 0.196270 156.044) #A734BC|-|oklch(55.10919% 0.216101 320.981) -#B1F874|-|oklch(90.45978% 0.178954 132.452) +#B1F874|-|oklch(90.45979% 0.178954 132.452) #423FA7|-|oklch(43.52073% 0.162107 278.539) #70AB1A|-|oklch(67.51646% 0.175156 130.625) #99596F|-|oklch(54.35126% 0.088311 358.401) @@ -869,7 +869,7 @@ #26E8A4|-|oklch(82.61459% 0.173357 162.489) #606870|-|oklch(51.32004% 0.016056 248.141) #589380|-|oklch(61.70516% 0.068763 171.389) -#644AC6|-|oklch(50.67311% 0.184017 287.591) +#644AC6|-|oklch(50.67311% 0.184018 287.591) #3A0030|-|oklch(23.73715% 0.104599 336.924) #FD8FFC|-|oklch(80.37387% 0.186950 327.467) #FEC274|-|oklch(85.24765% 0.117700 72.384) @@ -896,16 +896,16 @@ #035F75|-|oklch(44.98553% 0.081626 221.066) #03F3DD|-|oklch(86.60038% 0.153707 183.343) #260082|-|oklch(29.57165% 0.179734 279.367) -#63816D|-|oklch(57.39920% 0.046025 155.508) +#63816D|-|oklch(57.39920% 0.046024 155.508) #4EDA5C|-|oklch(78.60170% 0.206645 144.891) #DBE4C2|-|oklch(90.31098% 0.046197 119.380) #C13E9F|-|oklch(58.28546% 0.195659 339.843) -#38A374|-|oklch(64.19199% 0.120993 160.758) +#38A374|-|oklch(64.19200% 0.120993 160.758) #FE442A|-|oklch(65.93405% 0.225560 31.235) #93CBB8|-|oklch(79.79594% 0.064056 171.612) #4D791A|-|oklch(52.47823% 0.131316 131.714) #6F2AE5|-|oklch(50.57394% 0.251754 291.032) -#D09FD4|-|oklch(76.51747% 0.091624 323.924) +#D09FD4|-|oklch(76.51748% 0.091624 323.924) #1D3D7B|-|oklch(37.30102% 0.112298 261.974) #897894|-|oklch(59.79737% 0.046239 312.076) #092771|-|oklch(30.79328% 0.132356 263.597) @@ -923,7 +923,7 @@ #2AA59B|-|oklch(65.48780% 0.104481 186.773) #9C3A87|-|oklch(51.18221% 0.158697 337.008) #339D3F|-|oklch(61.48667% 0.163915 145.034) -#D7732D|-|oklch(65.83150% 0.148676 51.491) +#D7732D|-|oklch(65.83151% 0.148676 51.491) #F31A62|-|oklch(62.21747% 0.239184 11.091) #03A269|-|oklch(62.83945% 0.142780 159.491) #39E788|-|oklch(82.07616% 0.191691 153.774) @@ -948,7 +948,7 @@ #152184|-|oklch(32.15759% 0.162458 268.529) #8C156C|-|oklch(43.90634% 0.173408 342.164) #E2CB0A|-|oklch(83.55154% 0.173044 101.025) -#7FC496|-|oklch(76.17726% 0.096465 154.319) +#7FC496|-|oklch(76.17727% 0.096465 154.319) #E45846|-|oklch(64.11244% 0.177759 30.030) #EF5292|-|oklch(66.81239% 0.199584 358.938) #DE461F|-|oklch(60.68059% 0.194991 34.792) @@ -978,51 +978,51 @@ #568A70|-|oklch(58.89895% 0.069066 161.530) #5FC7DC|-|oklch(77.59401% 0.100283 212.755) #548023|-|oklch(54.83525% 0.131034 131.557) -#000000|0.05|oklch(0.00000% 0.000000 0.000 / 0.05) -#FFFFFF|0.05|oklch(100.00000% 0.000000 89.876 / 0.05) -#101012|0.05|oklch(17.39406% 0.004094 285.967 / 0.05) +#000000|0.05|oklch(0.00000% 0.000000 none / 0.05) +#FFFFFF|0.05|oklch(100.00000% 0.000000 none / 0.05) +#101012|0.05|oklch(17.39406% 0.004094 285.966 / 0.05) #3E87FF|0.05|oklch(64.04613% 0.193058 259.892 / 0.05) #FF3B30|0.05|oklch(65.42146% 0.232135 28.659 / 0.05) #373B5E|0.05|oklch(36.51887% 0.060455 277.977 / 0.05) -#000000|0.122|oklch(0.00000% 0.000000 0.000 / 0.122) -#FFFFFF|0.122|oklch(100.00000% 0.000000 89.876 / 0.122) -#101012|0.122|oklch(17.39406% 0.004094 285.967 / 0.122) +#000000|0.122|oklch(0.00000% 0.000000 none / 0.122) +#FFFFFF|0.122|oklch(100.00000% 0.000000 none / 0.122) +#101012|0.122|oklch(17.39406% 0.004094 285.966 / 0.122) #3E87FF|0.122|oklch(64.04613% 0.193058 259.892 / 0.122) #FF3B30|0.122|oklch(65.42146% 0.232135 28.659 / 0.122) #F58F98|0.122|oklch(76.00527% 0.123686 14.775 / 0.122) -#000000|0.25|oklch(0.00000% 0.000000 0.000 / 0.25) -#FFFFFF|0.25|oklch(100.00000% 0.000000 89.876 / 0.25) -#101012|0.25|oklch(17.39406% 0.004094 285.967 / 0.25) +#000000|0.25|oklch(0.00000% 0.000000 none / 0.25) +#FFFFFF|0.25|oklch(100.00000% 0.000000 none / 0.25) +#101012|0.25|oklch(17.39406% 0.004094 285.966 / 0.25) #3E87FF|0.25|oklch(64.04613% 0.193058 259.892 / 0.25) #FF3B30|0.25|oklch(65.42146% 0.232135 28.659 / 0.25) #FC4CFB|0.25|oklch(72.63187% 0.277193 328.214 / 0.25) -#000000|0.361|oklch(0.00000% 0.000000 0.000 / 0.361) -#FFFFFF|0.361|oklch(100.00000% 0.000000 89.876 / 0.361) -#101012|0.361|oklch(17.39406% 0.004094 285.967 / 0.361) +#000000|0.361|oklch(0.00000% 0.000000 none / 0.361) +#FFFFFF|0.361|oklch(100.00000% 0.000000 none / 0.361) +#101012|0.361|oklch(17.39406% 0.004094 285.966 / 0.361) #3E87FF|0.361|oklch(64.04613% 0.193058 259.892 / 0.361) #FF3B30|0.361|oklch(65.42146% 0.232135 28.659 / 0.361) #5F01B0|0.361|oklch(41.26726% 0.222261 297.888 / 0.361) -#000000|0.5|oklch(0.00000% 0.000000 0.000 / 0.5) -#FFFFFF|0.5|oklch(100.00000% 0.000000 89.876 / 0.5) -#101012|0.5|oklch(17.39406% 0.004094 285.967 / 0.5) +#000000|0.5|oklch(0.00000% 0.000000 none / 0.5) +#FFFFFF|0.5|oklch(100.00000% 0.000000 none / 0.5) +#101012|0.5|oklch(17.39406% 0.004094 285.966 / 0.5) #3E87FF|0.5|oklch(64.04613% 0.193058 259.892 / 0.5) #FF3B30|0.5|oklch(65.42146% 0.232135 28.659 / 0.5) #A5D66A|0.5|oklch(81.81982% 0.147409 129.330 / 0.5) -#000000|0.8|oklch(0.00000% 0.000000 0.000 / 0.8) -#FFFFFF|0.8|oklch(100.00000% 0.000000 89.876 / 0.8) -#101012|0.8|oklch(17.39406% 0.004094 285.967 / 0.8) +#000000|0.8|oklch(0.00000% 0.000000 none / 0.8) +#FFFFFF|0.8|oklch(100.00000% 0.000000 none / 0.8) +#101012|0.8|oklch(17.39406% 0.004094 285.966 / 0.8) #3E87FF|0.8|oklch(64.04613% 0.193058 259.892 / 0.8) #FF3B30|0.8|oklch(65.42146% 0.232135 28.659 / 0.8) -#FCF0F9|0.8|oklch(96.70735% 0.017436 334.179 / 0.8) -#000000|0.9|oklch(0.00000% 0.000000 0.000 / 0.9) -#FFFFFF|0.9|oklch(100.00000% 0.000000 89.876 / 0.9) -#101012|0.9|oklch(17.39406% 0.004094 285.967 / 0.9) +#FCF0F9|0.8|oklch(96.70735% 0.017436 334.178 / 0.8) +#000000|0.9|oklch(0.00000% 0.000000 none / 0.9) +#FFFFFF|0.9|oklch(100.00000% 0.000000 none / 0.9) +#101012|0.9|oklch(17.39406% 0.004094 285.966 / 0.9) #3E87FF|0.9|oklch(64.04613% 0.193058 259.892 / 0.9) #FF3B30|0.9|oklch(65.42146% 0.232135 28.659 / 0.9) #E99B87|0.9|oklch(76.20911% 0.098555 35.039 / 0.9) -#000000|1|oklch(0.00000% 0.000000 0.000 / 1) -#FFFFFF|1|oklch(100.00000% 0.000000 89.876 / 1) -#101012|1|oklch(17.39406% 0.004094 285.967 / 1) +#000000|1|oklch(0.00000% 0.000000 none / 1) +#FFFFFF|1|oklch(100.00000% 0.000000 none / 1) +#101012|1|oklch(17.39406% 0.004094 285.966 / 1) #3E87FF|1|oklch(64.04613% 0.193058 259.892 / 1) #FF3B30|1|oklch(65.42146% 0.232135 28.659 / 1) #999864|1|oklch(66.77488% 0.071267 107.242 / 1) diff --git a/packages/colors/test/oklch-parse.test.mjs b/packages/colors/test/oklch-parse.test.mjs index 3e881418..14d2c9c2 100644 --- a/packages/colors/test/oklch-parse.test.mjs +++ b/packages/colors/test/oklch-parse.test.mjs @@ -38,19 +38,19 @@ const FIXTURES = ` #FF0000|-|oklch(62.79554% 0.257683 29.234) #00FF00|-|oklch(86.64396% 0.294827 142.495) #0000FF|-|oklch(45.20137% 0.313214 264.052) -#FFFFFF|-|oklch(100.00000% 0.000000 89.876) -#000000|-|oklch(0.00000% 0.000000 0.000) -#808080|-|oklch(59.98708% 0.000000 89.876) +#FFFFFF|-|oklch(100.00000% 0.000000 none) +#000000|-|oklch(0.00000% 0.000000 none) +#808080|-|oklch(59.98708% 0.000000 none) #787880|-|oklch(57.53363% 0.012136 286.012) #3E87FF|-|oklch(64.04613% 0.193058 259.892) #FF3B30|-|oklch(65.42146% 0.232135 28.659) #FFD700|-|oklch(88.67711% 0.182186 95.330) #2563EB|-|oklch(54.61497% 0.215208 262.881) -#1A1A1A|-|oklch(21.77865% 0.000000 89.876) -#101012|0.122|oklch(17.39406% 0.004094 285.967 / 0.122) +#1A1A1A|-|oklch(21.77865% 0.000000 none) +#101012|0.122|oklch(17.39406% 0.004094 285.966 / 0.122) #3E87FF|0.8|oklch(64.04613% 0.193058 259.892 / 0.8) -#FFFFFF|0.5|oklch(100.00000% 0.000000 89.876 / 0.5) -#000000|0.361|oklch(0.00000% 0.000000 0.000 / 0.361)` +#FFFFFF|0.5|oklch(100.00000% 0.000000 none / 0.5) +#000000|0.361|oklch(0.00000% 0.000000 none / 0.361)` .trim() .split("\n") .map((line) => { @@ -95,8 +95,10 @@ test("effectiveBackground composites oklch layers (translucent over opaque)", () // A translucent white oklch panel over an opaque near-black oklch base — the // exact self-composed case the package produces. Oracle is independent of // oklch parsing: compositeOver + toHex on the KNOWN source bytes. - const leaf = "oklch(100.00000% 0.000000 89.876 / 0.5)"; // #FFFFFF @ 0.5 - const base = "oklch(21.77865% 0.000000 89.876)"; // #1A1A1A opaque + // Эти строки взяты у текущего emitter: у ахромата нет измеримого угла, + // поэтому CSS несёт missing-компонент `none`, а не фиктивное число. + const leaf = "oklch(100.00000% 0.000000 none / 0.5)"; + const base = "oklch(21.77865% 0.000000 none)"; const tree = fakeTree([leaf, base]); const expected = toHex(compositeOver([255, 255, 255, 0.5], [26, 26, 26, 1])); assert.equal(effectiveBackground(tree.leaf, tree), expected); diff --git a/packages/colors/test/reference-vectors.test.mjs b/packages/colors/test/reference-vectors.test.mjs index 069656d1..1425eb3d 100644 --- a/packages/colors/test/reference-vectors.test.mjs +++ b/packages/colors/test/reference-vectors.test.mjs @@ -106,10 +106,9 @@ test("Chrome computed form (L as 0..1) decodes to the same core bytes", () => { }); test("`none` components decode to the core's achromatic bytes (CSS Color 4)", () => { - // Per CSS Color 4 a missing component (`none`) is 0. A core-emitted grey is - // `oklch(L% 0.000000 )`; the `none`-chroma/hue spelling must land on the - // same bytes (C=0 makes hue powerless). Validates the parser's `none` path - // against the core's grey emissions. + // Ядро само эмитирует missing hue как `none`: числовой угол на оси C=0 не + // является свойством цвета. Подмена также и C на `none` обязана сохранить + // байты, поскольку CSS Color 4 вычисляет обе missing-компоненты как ноль. const greys = VECTORS.filter((v) => v.alpha === "-" && /^#(..)\1\1$/.test(v.hex)); assert.ok(greys.length >= 200, "need the grey axis for the none check"); for (const { css, bytes } of greys) { diff --git a/packages/colors/test/wasm-boundary.golden.json b/packages/colors/test/wasm-boundary.golden.json index 4bdcef4c..b933237c 100644 --- a/packages/colors/test/wasm-boundary.golden.json +++ b/packages/colors/test/wasm-boundary.golden.json @@ -1 +1 @@ -{"_meta":{"purpose":"Pre-optimisation byte-identity lock for the JS↔WASM recheck boundary (recheckContrast / _recheckContrastMulti) plus a resolveTheme vars snapshot.","regenerated":"2026-07-08","regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","recheck_unchanged":"recheck section: 2752/2752 contrast pairs (lc, wcag) byte-identical. No recheckContrast / contrast value changed. The WCAG relative-luminance + contrast_core + ratio_from_luminances chain is untouched by #185/#186.","numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 89.876): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); hue_vanished flag fires honestly for the achromatic sentiment."},"wcag_floors_not_weakened":"Glow-centre is a decorative FX bloom colour, NOT a text/border/focus-ring role — no WCAG contrast floor or focus-appearance (WCAG 2.4.13) floor applies to it. Every actual contrast value (recheck section, 2752 pairs) is byte-identical, so no measured contrast dropped below any floor. North invariant #2 (accessibility-by-construction) is preserved: measured, not merely asserted.","isolated_verification_required":"Regen accepted only after an isolated adversarial verifier (not seeing the self-claim) confirms RED→GREEN and that no WCAG floor weakened.","drift_scope_wave1":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"1.0000","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"1.0000","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"1.0000","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"1.0000","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65156% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.055)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65156% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.833)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98123% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10085% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.0123","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.0104","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0093","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0059","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(69.23260% 0.013029 286.055)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1070","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1063","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0686","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0433","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.70211% 0.013945 291.371)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.054)","--lab-label-danger-secondary":"oklch(83.38591% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42821% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.0690","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.0584","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0381","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0279","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90400% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96697% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1336","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.1787","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.0893","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0639","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.31157% 0.013986 291.359)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.967 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.967 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.967 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.967 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31782% 0.156989 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(78.76611% 0.108476 257.256)","--lab-fx-glow-brand-alpha":"0.1812","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(81.77103% 0.102764 29.025)","--lab-fx-glow-danger-alpha":"0.4300","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(88.88181% 0.082838 68.777)","--lab-fx-glow-warning-alpha":"0.1737","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 89.876)","--lab-fx-glow-neutral-alpha":"0.0995","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.967 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 89.876 / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(36.40556% 0.013673 291.446)"}}]} \ No newline at end of file +{"_meta":{"purpose":"Pre-optimisation byte-identity lock for the JS↔WASM recheck boundary (recheckContrast / _recheckContrastMulti) plus a resolveTheme vars snapshot.","regenerated":"2026-07-10","regen_reason":"Glow core ?????? ???????? ?????? ??????????? ???????? screen (#FFFFFF), ? ?? ???????????? ????????? 0.5; resolveVars ???????? ?? ?????????? WASM. Recheck-????????? ?? ??????????.","drift_scope":"resolveVars: exactly 24 leaves changed, all --lab-fx-glow-{brand,danger,warning,neutral}-core (glow-centre colour, background-independent → 4 distinct values). No other var moved. glow base/alpha, labels, borders, fills, backgrounds all byte-identical.","recheck_unchanged":"recheck section: 2752/2752 contrast pairs (lc, wcag) byte-identical. No recheckContrast / contrast value changed. The WCAG relative-luminance + contrast_core + ratio_from_luminances chain is untouched by #185/#186.","numeric_provenance":{"glow-brand-core":"oklch(78.84894% 0.062240 265.472) -> oklch(78.76611% 0.108476 257.256): chroma rises to the blue gamut wall at the functional lightness (fixed-fraction under-saturated it); L ±0.08pp, hue shifts to the cusp.","glow-danger-core":"oklch(81.62208% 0.103545 27.533) -> oklch(81.77103% 0.102764 29.025): near-identical (red already sat near its wall).","glow-warning-core":"oklch(88.80612% 0.081204 68.866) -> oklch(88.88181% 0.082838 68.777): near-identical.","glow-neutral-core":"oklch(99.97226% 0.001314 106.423) -> oklch(100.00000% 0.000000 none): neutral hue is achromatic → gamut wall chroma is 0 (was fixed-fraction residue noise); hue_vanished flag fires honestly for the achromatic sentiment."},"wcag_floors_not_weakened":"Glow-centre is a decorative FX bloom colour, NOT a text/border/focus-ring role — no WCAG contrast floor or focus-appearance (WCAG 2.4.13) floor applies to it. Every actual contrast value (recheck section, 2752 pairs) is byte-identical, so no measured contrast dropped below any floor. North invariant #2 (accessibility-by-construction) is preserved: measured, not merely asserted.","isolated_verification_required":"Regen accepted only after an isolated adversarial verifier (not seeing the self-claim) confirms RED→GREEN and that no WCAG floor weakened.","drift_scope_wave1":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched.","oklch_option_hue":"Только сериализация сохранённых 8-битных цветов пересчитана текущим Rust API: изменились 18 уникальных Oklch-строк и 67 листьев; у двух ахроматических форм missing hue записан как `none`. Исходные hex, контрасты и семантика resolve не менялись.","glow_core_contract":"??? --lab-fx-glow-*-core ????? oklch(100% 0 none); halo ? alpha ?? dJ-????????? ????????? ? ??????????."},"recheck":[{"theme":"light","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#00030E","#0057BB","#037BFF","#78AFFF","#150000","#BE0005","#FF070E","#FF9A8C","#1B0D00","#A06300","#D28300","#FFAA3A","#000B01","#007C2D","#00A73F","#00D452","#020013","#4D00F9","#6D6EFF","#9FA9FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.59876563358553,20.60103636973117,79.91552226990729,6.816302360996974,63.95198968935827,3.97647793086433,41.89234722606997,2.2400949113548063,105.32470288110206,20.35108757719671,78.97384099389187,6.579727828852003,63.90360859563009,3.970705427078051,37.83221395673913,2.048194921340976,103.87315757816087,19.018778265876662,70.62528347734393,4.90716956443272,53.97808064225952,3.0056474129326713,34.232350404909745,1.8982213760304238,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.76444562516849,20.751302844974063,83.48658341134008,7.825697912025839,63.67798680778349,3.9439477616440737,40.86459333479343,2.1890112231357013]},{"theme":"light","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E6F0FF","#60A1FF","#0074F3","#004CA4","#FFF4F2","#FF8273","#F40009","#A80004","#FFF7EE","#F79C00","#C97E00","#8F5800","#C2FFC9","#00C84D","#00A03C","#006E26","#EEF0FF","#9099FF","#6662FF","#4300DB"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-97.97941453819666,18.27263411531394,-53.55180732861014,8.039306941172763,-34.54692380627581,4.792369408161928,-17.418806999279397,2.56984376276454,-102.41236835704379,19.474434420712562,-56.92703132210305,8.692613650917824,-34.93266406694178,4.850563950189369,-18.308962222075785,2.6667193472488013,-103.5613239139708,19.790915044333452,-61.97487030298502,9.71021814570805,-44.901407836051874,6.467843223430967,-25.783665577412222,3.5638173443979557,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.707239087904505,6.093571182387199,-23.370874926227668,3.2583602660985775,-98.9883689300502,18.54346384276384,-54.32153113615954,8.18635152260471,-34.304902287955215,4.756029101219059,-14.435475219756622,2.261552965854495]},{"theme":"light","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00163A","#003272","#004494","#040000","#3A0000","#760002","#990003","#0B0400","#261300","#512F00","#845100","#000200","#001D05","#004114","#006322","#000002","#160059","#2B0097","#3B00C5"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.89312592554248,4.521969910173767,28.754774555141132,3.1292028976206594,22.090354756391577,2.35851620111828,40.23106479600237,5.289901483963394,36.823385380889306,4.5064610721321205,27.786796188195932,3.002060282138028,21.02583740042693,2.2570951143110336,39.64148780057383,5.154352713354357,36.89828467875601,4.523118840482662,27.981450040156208,3.027209986332121,13.567318350507929,1.6836851754208233,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.3306726916817,5.312553092992316,36.84988795298528,4.512349526507044,30.916517468784825,3.4313626667845316,25.033605894158644,2.6690076358452903]},{"theme":"light","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#E7F1FF","#73ADFF","#2985FF","#0060CB","#FFF5F4","#FF9687","#FF4336","#CD0006","#FFF8F0","#FFA62C","#DC8A00","#AC6B00","#C7FFCC","#00D251","#00B043","#008631","#EFF1FF","#9CA6FF","#767AFF","#532BFF"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.32090210121694,9.956824779623638,-48.0046310941281,4.945145596338778,-30.285195552724065,3.1849116126306156,-14.342034884792934,1.9106934427040745,-91.71639880150975,10.602849175762582,-51.97724928251108,5.385053682831725,-31.570923815646545,3.301015045785777,-14.82740446463819,1.94471301206827,-92.86717481243348,10.774663089384916,-55.64998948221256,5.805759082702303,-40.41406674606042,4.149700467279057,-23.7537806706175,2.6249801387527425,-87.99158651504085,10.054343074329001,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-88.33220875070053,10.104016013712561,-48.80525652513833,5.032519885671211,-30.915189659062865,3.2415642790980215,-11.323318014385267,1.7063261265170042]},{"theme":"light","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000103","#001536","#003374","#0058BC","#070000","#360000","#730002","#BE0005","#0F0500","#241200","#4F2F00","#A16400","#000400","#001B05","#003F13","#007C2D","#000005","#140055","#3300AC","#4E00FA"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.65687576920386,5.198408498908002,36.59714090951085,4.504660010775197,27.83078380746869,3.029349552004155,13.31049770996219,1.6744770124926043,39.57933742278427,5.181007308853048,36.66186584356392,4.51899176883356,27.77510172821011,3.0221297438766705,12.721590912296357,1.6379804580418844,38.8564182220264,5.016999835671664,36.579572970042136,4.500775701626332,27.65113132417139,3.006116231224861,0,1.205042331050678,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.737197591996,5.216381929144518,36.59037871155487,4.5031645842195545,27.68585404738298,3.010592990868115,17.038043883395044,1.933101474636693]},{"theme":"light","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000104","#00204D","#003B83","#005CC4","#080000","#4C0001","#830002","#C70006","#0F0600","#331C00","#5B3600","#A76800","#000400","#002809","#004917","#00822F","#000005","#1F0075","#3A00C3","#511AFF"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.87966672163739,5.884401548408216,38.30767260595269,4.50632138120243,29.16726657041677,3.0308664461202253,15.823096179531001,1.7789458759332724,43.782795788734404,5.8597651770218455,38.402450836988166,4.527103297945568,29.016037258963053,3.011424325654164,14.9864556348382,1.7254955647131072,42.99821792375369,5.657954629703501,38.41622407485322,4.5301350068404425,28.93769973409828,3.0014083479801514,0,1.28054929135364,43.62397616937584,5.819206103341516,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.970091731453365,5.907320242919889,38.28628402517786,4.501651045763603,28.99214635836292,3.0083657301901288,19.11088367642911,2.0125842767555064]},{"theme":"light","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E6F0FF","#64A4FF","#0077F9","#0050AC","#FFF4F3","#FF8777","#F9000A","#B00005","#FFF7EF","#FA9E00","#CD8000","#955C00","#C3FFC9","#00CA4E","#00A33D","#007329","#EEF0FF","#939CFF","#6967FF","#4600E6"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.3221346483494,16.53807546575917,-53.23458352949761,7.5085332653696355,-34.299151794570456,4.531377660721796,-17.34040000814869,2.4827755446650244,-100.79897840003042,17.636700891614694,-56.73966900010428,8.131153426700068,-34.4661942600266,4.554628420782135,-18.26813498707333,2.5778416349364046,-101.94670279716274,17.922901715066683,-61.50032492399525,9.010497025774457,-44.48245067627942,6.049054371037485,-25.983086445749187,3.447217107352778,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-97.33108904020293,16.783196253636362,-53.9780650339559,7.638815670143083,-34.15649094558268,4.511565332868746,-14.178333309149869,2.174938899863266]},{"theme":"light","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#00030D","#0057B9","#0079FD","#74ADFF","#150000","#BC0005","#FD000A","#FF9788","#1B0D00","#9F6200","#CB7F00","#FFA730","#000B01","#007B2C","#00A63E","#00D251","#020013","#4C00F7","#6C6CFF","#9DA6FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.48497819102282,19.361251289328656,75.9364891028185,6.438321694919453,60.636932359482174,3.8278153397206016,38.76699274290234,2.152541489746932,101.19913515757631,19.11640864166299,75.28055256994783,6.281220843840182,60.46094873358925,3.807428346232839,34.70184973776556,1.9658801332164664,99.74758985463511,17.864929125608796,66.9127104243937,4.673002507305443,52.13428535623209,3.0001216660302674,31.16352525448906,1.8227359456035261,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.421867335088024,3.0235758695027473,33.32486172802245,1.9082022515525083,101.63887790164272,19.492343272893066,79.67393134344084,7.442688338927282,60.17031912503092,3.7741072518393395,37.967753096619255,2.113772626279229]},{"theme":"light","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E6F1FF","#67A5FF","#0079FD","#0053B2","#FFF5F3","#FF8A7A","#FD000A","#B60005","#FFF7EF","#FC9F00","#D08200","#9A5F00","#C4FFCA","#00CB4E","#00A53E","#00772A","#EEF1FF","#949EFF","#6B6BFF","#4900EE"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-94.97224440690107,14.900628115594163,-51.99577035710509,6.808135431331698,-33.457001357615106,4.175350120738554,-16.755883343489252,2.3335896778079332,-99.44736446714371,15.886233758087014,-55.869159717403925,7.428487313030341,-33.63398343987836,4.1977071628099685,-17.709012021143003,2.4235530906296177,-100.15616825242344,16.04465607216319,-60.39258141645385,8.181935837052826,-43.77686148964125,5.57032618168592,-25.67861999448382,3.2497240951569792,-95.39392806862337,14.992417327878547,-58.246405974587134,7.820609198173194,-41.3596549106396,5.227261702394524,-23.16505433719768,2.9752806858720673,-95.97861011098902,15.120061263203354,-52.9765437482588,6.963016139185695,-33.57839540296167,4.190679013723035,-13.485138200601865,2.040069034387161]},{"theme":"light","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EBF3FF","#D5E6FF","#92BFFF","#4291FF","#FFF8F7","#FFDDD8","#FFA597","#FF6253","#FFFAF5","#FFE0BE","#FFAA39","#E79100","#D0FFD4","#7DFF93","#00DA54","#00B947","#F1F4FF","#DFE4FF","#AEB8FF","#8087FF"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.43421936609613,5.100687932363686,-64.08586870464498,4.502412953317652,-41.11294430990105,3.017135229848019,-18.888486914529935,1.8251241221961068,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-40.940708789235714,3.0069340578770496,-21.122834074579163,1.9331720160112165,-77.70409895709017,5.493642615376888,-64.34147182620212,4.520282197700913,-40.849183913847796,3.0015190917179395,-28.18413111501551,2.292444746213784,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-41.2870784084647,3.027463512408998,-26.013778316259767,2.1791858289450103,-73.64084437140687,5.1896286230945075,-64.42349258712335,4.5260223420377566,-41.02780633656069,3.0120908713744514,-19.464187683477977,1.8526993919522168]},{"theme":"light","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#E8F1FF","#79B0FF","#368BFF","#0065D6","#FFF6F4","#FF9B8D","#FF5446","#D80007","#FFF8F1","#FFAB3F","#E18E00","#B47000","#C8FFCD","#00D552","#00B545","#008D34","#EFF1FF","#A0AAFF","#7B81FF","#573FFF"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-89.77514075602531,11.177468536043195,-51.89752791553575,5.733156384550998,-34.996215684604906,3.8114980409485155,-18.92394323577027,2.320895932067837,-94.48648764812391,11.954651961610136,-56.02375667309079,6.252768618847473,-36.81873931987068,4.002056146272223,-19.591172380553846,2.375625103310219,-95.24016771148194,12.080915286992608,-59.85145182180837,6.751641124414219,-44.82390104707481,4.887508677146329,-28.683175933367217,3.184369254481423,-90.45906258458693,11.288985892577246,-57.499124974387314,6.443154991463433,-42.83495757582209,4.660276446547384,-26.099142978345885,2.9428672820648862,-90.66219607331998,11.322193559065964,-52.9772743130274,5.867283737103295,-35.76790053375578,3.8916727925995533,-16.863337707955058,2.1560372721390864]},{"theme":"light","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#000209","#00479B","#0065D5","#3289FF","#100000","#9F0004","#D70007","#FF4F41","#170A00","#7A4A00","#9F6200","#DF8C00","#000801","#006322","#00822F","#00B344","#01000D","#3E00CE","#573EFF","#797EFF"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.22533906008685,12.552007092013115,56.99670466281028,5.352680994694784,44.43662180236468,3.3253647200326193,29.122033451491582,2.0641033245604916,76.01516726706762,12.436514137733148,55.97147975339624,5.133613537650522,43.91010121645754,3.2654000659595037,27.461071919951625,1.9716236837030474,74.81611736383091,11.770648316029503,52.816193854903005,4.528105409994289,41.51373908005361,3.011071841176318,19.679891349505557,1.6112041351931181,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.42233250009274,3.001939806510638,21.536768417458934,1.687644517337887,76.3513076320574,12.62087594492386,59.99704797613187,6.063965240585196,46.62694808670478,3.592268428661898,28.6381619838115,2.036506690369533]},{"theme":"light","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#DA7A00","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.42046624663428,20.43859328774187,80.31672424485741,6.9206271592755195,64.53598724808649,4.047139863973963,42.92136358171458,2.2930773168482466,105.12312344447312,20.1664284240786,79.32640956847692,6.666965706057784,64.47466861031803,4.0396342482899685,38.694434835096864,2.0867987099188317,103.94997559222902,19.08898290736351,71.78028267942507,5.099770465607366,55.19920557284124,3.104259347966041,35.17783363962355,1.9359471784913758,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.57179613789485,3.2209892436744982,37.49012931672191,2.03317973864909,105.58294145619523,20.58664787402852,83.26627791326895,7.757864847967472,64.57504887916579,4.051931818616954,42.090577540190225,2.2501559682241092]},{"theme":"dark","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#E3EEFF","#639EFF","#006DFE","#0048AD","#FFF4F3","#FF7F74","#F1001F","#A80012","#FFF7F1","#FF921B","#D17400","#955100","#B8FFBE","#00C649","#009F39","#006E25","#EDEEFF","#9794FF","#725BFF","#4E00D6"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.73192161780433,17.93999294470544,-52.63286661912575,7.865273702162647,-33.381528921151094,4.618607106385301,-17.0501576858861,2.5303719690800346,-102.4562582898777,19.486486384763445,-56.148341062851905,8.53993960492931,-34.330992536827104,4.759940239487347,-18.37234103122508,2.6737006902768288,-103.68997976483836,19.82648045930809,-60.35961607275431,9.379380118591195,-43.39157032733722,6.209218370112115,-24.93643207012505,3.4548840566407524,-97.27916136850891,18.085610527348415,-59.34389647192141,9.173841233004914,-42.25887199411116,6.018336366435839,-23.360562325895305,3.2570864547421428,-97.9872053947921,18.274719246988955,-53.323021296304695,7.99582368531923,-33.63351308594995,4.6559156780457185,-14.771084781300106,2.2949481607054216]},{"theme":"dark","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000104","#00153F","#00317C","#00429F","#070000","#3A0002","#750009","#9B0010","#0C0300","#291200","#562D00","#8B4B00","#000300","#001D05","#004112","#006521","#010005","#1A0057","#350098","#4700C4"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.20955594591163,5.284998875760269,36.83828085440759,4.509769828130583,28.330761142327287,3.0728704517581886,21.819504217645104,2.3321848000622474,40.142120786515875,5.269603471071473,36.80831529061993,4.503115584720142,27.930310922781615,3.0205822411426944,20.559717681283935,2.214401964672886,39.70511745091868,5.169077378914306,36.80795164205375,4.503034882134433,27.78871707797606,3.002307432697674,14.025581970431295,1.7128949100718707,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.795231110735635,4.500212645278562,30.09285126953815,3.3133648365325183,24.185949678819867,2.5748913454036786]},{"theme":"dark","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E4EFFF","#75AAFF","#3482FF","#005AD5","#FFF6F4","#FF9389","#FF3C3B","#CC0019","#FFF8F2","#FFA350","#E58000","#B36300","#BCFFC2","#00D04D","#00AF3F","#00862E","#EEEFFF","#A2A1FF","#7F74FF","#601DFF"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-86.07057705487603,9.776047393674569,-47.005198509651805,4.836992692850337,-29.617188814986996,3.125340410912816,-13.465221764046625,1.8500449052652255,-92.15650032550448,10.66842694645063,-51.122226750198095,5.289032001141636,-30.69564720892069,3.221769980092269,-14.697188723997265,1.9355550836437716,-92.95341233620333,10.787583067801071,-55.01595924446716,5.732183607436098,-39.01368610071904,4.00959458617392,-22.717127042375882,2.5408179478715844,-86.56463892830851,9.847321829129962,-52.766100540364825,5.474290510931042,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.32881842479753,9.957973594755346,-47.6452882768299,4.906141960838558,-29.99057738527554,3.1585747205458907,-10.990631160230787,1.6845762464445797]},{"theme":"dark","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000107","#00133B","#00317C","#0054C7","#0B0000","#360002","#730009","#BF0016","#100500","#261000","#542C00","#A85D00","#000501","#001B04","#004012","#007D2B","#010009","#180052","#3B00A6","#5A00F5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.616537893860624,5.189361828546554,36.671589094740156,4.521147554384925,27.767977778595686,3.0212072610618272,13.560368041515986,1.6902988819780091,39.46146930028483,5.1544680971312,36.646426655727396,4.515570174173857,27.733751845040576,3.016779185756929,12.426352071081123,1.6200937148513173,38.82022467282525,5.008731246970903,36.66757146424993,4.520256699490143,27.6755983348751,3.009270049666561,0,1.2356833222737893,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.666598999480925,5.200587158978297,36.5946359240296,4.504106003407952,27.684199915070796,3.0103795763779906,16.582944142840912,1.898781610023956]},{"theme":"dark","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000107","#001E53","#00398D","#0058CF","#0C0000","#4C0004","#83000C","#C60018","#110500","#371A00","#603300","#AE6000","#000601","#002808","#004916","#00822D","#01000A","#250070","#4400BC","#5E01FF"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.84943203331798,5.876721182230661,38.357196991852035,4.517162998132957,28.969046969390032,3.0054117916551304,16.078397205708793,1.7957132708136676,43.66281217184597,5.829141814712315,38.38176722933373,4.522556047220382,28.96308732518828,3.004650205577566,15.121014183458136,1.7339400568635355,43.01534021323247,5.66238735865634,38.36369120360285,4.518587529333456,28.956857085057415,3.003854272405285,0,1.32359073643957,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.88937786124038,5.8868666342953615,38.32550423955951,4.510220548286027,28.94413025668513,3.002229117722787,19.264807559936084,2.024522036885816]},{"theme":"dark","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E3EEFF","#66A0FF","#0E71FF","#004CB5","#FFF5F3","#FF8378","#F60020","#AF0013","#FFF7F1","#FF962A","#D57700","#9B5500","#B9FFBF","#00C849","#00A23A","#007226","#EDEEFF","#9997FF","#7460FF","#5200DF"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-95.07464172795706,16.23701078357789,-51.883866319617056,7.27432706128743,-33.10437599843017,4.366728841176053,-16.962076325331164,2.444617555127284,-101.23789901188303,17.745933910216884,-55.660852739640006,7.937252290960222,-33.86013630190819,4.470540437936136,-18.127983335051407,2.5633448249431416,-102.03269987499108,17.94442049171427,-60.01549050114797,8.732123022916136,-43.24478677485694,5.853976070416037,-25.11442835418257,3.342537791539289,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.852118066322895,5.637910373772879,-23.122294138224145,3.108849028833141,-96.32992550494482,16.539962663017093,-52.86610846158114,7.444323350911882,-33.336065162067605,4.398429825394501,-14.383943910910213,2.194178799785328]},{"theme":"dark","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000414","#0053C4","#1F78FF","#79ACFF","#190000","#BC0016","#FD0021","#FF978D","#1D0B00","#A65B00","#D67800","#FFA658","#000E02","#007C2A","#00A63C","#00D24E","#04001A","#5900F2","#7867FF","#A4A4FF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.29489852310851,19.198605473400605,76.19115652133168,6.500760037097026,60.41041952456072,3.801604173546957,38.795795858188825,2.153958743950203,100.99755572094737,18.94295256384705,75.20084184495119,6.262487955668726,60.349100886792264,3.7945539156195487,34.56886711157111,1.9601948416949677,99.82440786870325,17.93087452582946,67.65471495589931,4.7903727911067815,52.29898260017819,3.0135204434714913,31.0522659160978,1.8184953129572932,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.44622841436909,3.025575236651027,33.364561593196164,1.9098288766402804,101.45737373266948,19.337677744697757,79.14071018974319,7.287203401685083,60.44948115564005,3.8061054053754897,37.96500981666448,2.1136413881018767]},{"theme":"dark","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E3EEFF","#69A2FF","#1975FF","#004FBB","#FFF5F3","#FF877C","#FA0021","#B50014","#FFF7F1","#FF9833","#D87900","#A05800","#B9FFC0","#00CA4A","#00A43B","#007628","#EDEEFF","#9B99FF","#7664FF","#5500E7"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-93.28410718321774,14.535439506623602,-51.011442228336165,6.654206660130301,-32.65955270222981,4.075313081844305,-16.370466566638967,2.297770413224217,-99.44736446714371,15.886233758087014,-55.07262883280597,7.299026812522581,-33.02825162884881,4.121422426501604,-17.569909707299868,2.4103015359642868,-100.24216533025177,16.063919770413232,-58.9183675206033,7.932997007393653,-42.52780974275247,5.391835753982905,-24.79898612463087,3.1522681596337323,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.905085629416114,5.1638402024196015,-22.782862127638978,2.93464773079297,-94.53939096020551,14.80664328764592,-51.95162400089297,6.801199303451522,-32.707141977036414,4.081250761977603,-13.69412377461175,2.0581050526656677]},{"theme":"dark","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E8F1FF","#D5E6FF","#95BEFF","#498FFF","#FFF9F8","#FFDDD8","#FFA59B","#FF5F57","#FFFAF6","#FFDFC5","#FFA95E","#F18700","#C6FFCB","#7DFF91","#00DA51","#00B943","#F0F1FF","#E1E3FF","#B4B6FF","#8982FF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-71.17583391665679,5.008590480308264,-64.08586870464498,4.502412953317652,-40.969901639859465,3.0086620775128243,-18.492326685553408,1.8062558438836862,-77.39176152066469,5.470028106981068,-64.06334225320066,4.50083949888721,-41.06186552000793,3.0141084284175577,-20.58109958398818,1.9067218663179524,-77.74802214310357,5.496966722090798,-64.16882972322054,4.508209630901898,-40.97688137217631,3.00907529195687,-26.93048349706687,2.2267208299060117,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.24499881268402,3.0249663274797034,-25.961518214862377,2.176489332337808,-72.19208007215988,5.082914152992658,-64.24580556994597,4.513590807561596,-40.91738125342747,3.005553523356943,-18.74578210381434,1.818317299241165]},{"theme":"dark","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E5EFFF","#7AADFF","#3D87FF","#005FDF","#FFF6F5","#FF988E","#FF4D48","#D5001A","#FFF8F3","#FFA75A","#EA8300","#BA6700","#BDFFC3","#00D34E","#00B341","#008C31","#EEEFFF","#A5A5FF","#837AFF","#6433FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.52359832003891,10.974551054175622,-50.82334067294272,5.601029591424501,-33.90019185783247,3.6989242542195706,-17.94937633357589,2.2421371448335408,-94.53061684959366,11.962030249162733,-55.11693338695639,6.136943021617957,-35.65662848796355,3.880065704029081,-19.010500723408704,2.3279588656978594,-95.32685818617963,12.095472485308715,-58.832789738116276,6.617312123916825,-43.099834270009964,4.690265818580579,-27.202021451205145,3.0448373808729228,-89.02366089613628,11.055448870118658,-56.507119659393844,6.314879036055816,-41.91393002442956,4.556652813501584,-25.675484951698337,2.9041378624661793,-89.65880574741696,11.158543725868578,-51.69548126800453,5.708204299589472,-34.40239467659594,3.750316223165759,-16.078998268001875,2.0949612264666513]},{"theme":"dark","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00020F","#0045A5","#0060E2","#3F88FF","#140000","#A10011","#D7001B","#FF4F49","#190900","#814600","#A85D00","#EA8300","#000A01","#006320","#00822D","#00B341","#030014","#4A00CC","#6537FF","#847BFF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.15878035868923,12.51550617725489,56.792209524954714,5.308053794065892,44.82231091099692,3.370285744389143,29.010809014184062,2.057710850183084,75.84645644595844,12.343365988484052,55.500674792931,5.036843578382145,43.81499067450534,3.25473208522667,27.348178994525146,1.9655632934834584,74.80511978941351,11.764533211175078,52.793708756474935,4.52413702940673,41.44647230556496,3.0043476386355414,20.422200521634597,1.6411239547419576,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.19495675605003,12.535354496536943,59.24767070343283,5.876074797996776,46.58341111523108,3.5866744685580465,28.54022915013225,2.030988108287992]},{"theme":"dark","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"light-ic","bg":"#FFFFFF","fgs":["#F7F7FF","#F7F7FF","#0A0A10","#71717A","#94949E","#BDBDC7","#94949E","#0A0A10","#000110","#0042E2","#3B78FF","#85AEFF","#150000","#BD0011","#FF001B","#FF998D","#1A0500","#B04A00","#E96400","#FFA174","#000B01","#007C2D","#00A73F","#00D452","#00040B","#00639C","#0089D6","#4DB3FF"],"flat":[0,1.0658201232723763,0,1.0658201232723763,104.65963437608701,19.74046779866239,70.1623092547118,4.832895561154151,53.96706418364319,3.004779237664955,33.34341874861831,1.8637664731545371,53.96706418364319,3.004779237664955,104.65963437608701,19.74046779866239,105.76809306682351,20.754602558566376,81.70242596755779,7.298044196933449,63.72862579130605,3.9499301799077116,41.321899652634976,2.211520376313395,105.32470288110206,20.35108757719671,79.14032751842986,6.6207260543602775,64.03544238362204,3.986463961054262,38.10332212672181,2.0602150617562085,104.62285904737456,19.706640245810163,73.93672186564804,5.489431142391079,57.895853978119064,3.3399091341000195,36.26437665881752,1.9807324696579875,104.97702200816887,20.032297013031588,73.2226464627747,5.355840815772706,56.123729301186145,3.182173571701684,36.54497093010938,1.9925549350335838,105.53601527934143,20.543945015271454,78.36297683006073,6.432254688155311,62.2832355663668,3.784280156272109,42.68471757482252,2.28072562681704]},{"theme":"light-ic","bg":"#000000","fgs":["#010103","#010103","#F1F1FC","#B3B3BC","#909099","#65656D","#909099","#F1F1FC","#E8F0FF","#709FFF","#2D6DFF","#0038C6","#FFF4F3","#FF8275","#F30019","#A7000E","#FFF6F1","#FF8C52","#DF5F00","#9D4100","#C2FFC9","#00C84D","#009F3C","#006E27","#DFF0FF","#10A5FF","#0082CB","#005689"],"flat":[0,1.0069471255994658,0,1.0069471255994658,-99.67218963360757,18.727928195985914,-63.80513707672789,10.090949031745104,-45.863656882792206,6.635143500688325,-26.33477890397734,3.635637824948841,-45.863656882792206,6.635143500688325,-99.67218963360757,18.727928195985914,-98.22794037003116,18.339196272629252,-53.8293996799751,8.092203727319841,-34.21813658812841,4.7430333536498335,-15.622162303180753,2.381112763636304,-102.4562582898777,19.486486384763445,-56.97432424245902,8.701923574139988,-34.76332175899368,4.824974728357804,-18.151652148372637,2.6494391947645526,-103.24826961310278,19.70448161329645,-59.10147589327934,9.125072654774526,-40.787696351505694,5.7744786453226835,-22.81212855995232,3.1897383862050708,-98.60837652742211,18.44127602271432,-60.30793382928855,9.368875012646658,-42.29572232514961,6.0245037012352585,-23.381466511664765,3.259668821930569,-97.12909045511768,18.04563033112125,-52.54162933208091,7.848085216280267,-36.273216344700636,5.055412083856902,-18.54110013749204,2.69234396770907]},{"theme":"light-ic","bg":"#808080","fgs":["#7A7A82","#7A7A82","#010103","#18171E","#37363E","#5B5B64","#37363E","#010103","#000002","#00104D","#002488","#0032B2","#040000","#3A0002","#750007","#98000B","#080100","#2E0E00","#602500","#8F3B00","#000200","#001D05","#004114","#006322","#000102","#00192E","#003A5F","#004D7C"],"flat":[0,1.0777393702545288,0,1.0777393702545288,40.18994509287661,5.280525528202377,36.81932911616927,4.505560397043893,27.93898600302036,3.0217055248761144,13.845788611978202,1.7013528597992067,27.93898600302036,3.0217055248761144,40.18994509287661,5.280525528202377,40.3306726916817,5.312553092992316,36.799792142283025,4.501224406456354,30.04772679653348,3.3069997994324063,23.797727835383583,2.533076638038498,40.23106479600237,5.289901483963394,36.80831529061993,4.503115584720142,27.941932493369514,3.022087143525339,21.190215752118235,2.2723970292907865,40.01441400964806,5.240352767492206,36.83929336929951,4.509994812829007,27.908315541084118,3.0177360720288915,16.77490328531677,1.9034128352995776,40.150141663678234,5.271436537678366,36.85731353296706,4.514000513189092,27.852320260403722,3.0105025419148825,16.643886294769857,1.8937047658181707,40.22977489962569,5.289607582090012,36.84755783504637,4.511831555904809,27.796281383025352,3.0032808914145437,21.058698283639867,2.260143784790762]},{"theme":"light-ic","bg":"#3A3A3C","fgs":["#3E3E45","#3E3E45","#F2F2FD","#BBBBC5","#9E9EA7","#7A7A82","#9E9EA7","#F2F2FD","#EAF1FF","#81ABFF","#4982FF","#0048F5","#FFF5F4","#FF9589","#FF403B","#CC0013","#FFF7F3","#FF9E6E","#F46900","#BD5000","#C6FFCC","#00D251","#00B043","#008631","#E1F1FF","#45B0FF","#0090E1","#006CA9"],"flat":[0,1.0699484563148034,0,1.0699484563148034,-89.06520303275792,10.211241554507897,-56.964055924386784,5.959497869408446,-41.60773958742316,4.2707825803649335,-24.25691832785775,2.6663016206717356,-41.60773958742316,4.2707825803649335,-89.06520303275792,10.211241554507897,-87.69551010135778,10.011246151186945,-48.263751486481056,4.97335266465752,-30.44825366943375,3.199530942583609,-12.043546933756078,1.753943193668956,-91.71639880150975,10.602849175762582,-51.72679347557047,5.3568510977778265,-31.21621892784593,3.268795397886287,-14.661523244642579,1.9330507439017046,-92.55409442667087,10.727809933310253,-53.89547122979923,5.603122076267749,-35.95806640240085,3.7112655792375078,-20.04367187639212,2.329915503818876,-87.89039906251095,10.039605852107476,-53.75183524673264,5.586666992924044,-38.313874917805634,3.940371955014153,-21.037647044419426,2.407284189061088,-86.58838125370931,9.850752189993974,-46.78872683576137,4.81370216625391,-31.383244337404896,3.2839492580871354,-15.736559790538532,2.009284883552937]},{"theme":"light-ic","bg":"#007AFF","fgs":["#797981","#797981","#020205","#16151C","#36353D","#72727A","#36353D","#020205","#000004","#000E48","#002895","#0042E3","#070000","#360001","#730006","#BE0011","#0C0200","#2B0D00","#5F2400","#B04A00","#000400","#001B05","#003F13","#007C2D","#000103","#00172B","#00395C","#00639D"],"flat":[0,1.0745907693903731,0,1.0745907693903731,39.47891404105317,5.158402202047827,36.63906407938327,4.513939148043301,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.47891404105317,5.158402202047827,39.74741500641279,5.218664231374835,36.670023859840576,4.5208004701163915,27.672031623266886,3.008810094200282,15.351341296852958,1.8098737923818007,39.57933742278427,5.181007308853048,36.654144364688555,4.517280323585742,27.751464091733354,3.019069960427185,12.671992881770905,1.6349566993987985,39.23716411822194,5.10371254671437,36.601471319299684,4.505617849545331,27.652865692456807,3.006339685224649,7.684471784052528,1.3665581876464832,39.39108202991847,5.138573889881769,36.624715730830154,4.510761781161014,27.902773646365304,3.038708829709556,0,1.3333017445114326,39.65687576920386,5.198408498908002,36.65150522712613,4.5166954713046765,27.64129393913477,3.0048491045091428,12.054359140378313,1.5979376774972005]},{"theme":"light-ic","bg":"#FF3B30","fgs":["#8A8A92","#8A8A92","#030306","#212129","#3E3D45","#76767F","#3E3D45","#030306","#000004","#001864","#002FA8","#0045ED","#080000","#4C0003","#830008","#C60012","#0D0200","#3E1500","#6C2A00","#B84D00","#000401","#002809","#004917","#00822F","#000103","#00233D","#00416A","#0068A4"],"flat":[0,1.0356140935636031,0,1.0356140935636031,43.57584669970237,5.806877937534237,38.3004661979112,4.504747008392574,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.57584669970237,5.806877937534237,43.98030914587015,5.909904848562026,38.293478372508915,4.503221171740978,28.94317626341717,3.002107337321695,17.95394033246701,1.9258322867444517,43.782795788734404,5.8597651770218455,38.38866051296957,4.524070782124866,28.984608537472,3.0074014381148784,15.156957289606964,1.7362054736619685,43.437607341847624,5.771384990646562,38.33399448678559,4.512078832150148,28.981628167432756,3.007020263830111,9.892674202630067,1.4441010369850493,43.614187977218386,5.816700203526642,38.406807106466914,4.528061863682665,29.022563392947294,3.012260432085235,8.92751562582044,1.3982348229239339,43.88976990866122,5.886966133555759,38.401524303038336,4.526899458852529,29.03631728859714,3.0140233900654434,14.306925700285792,1.683714708917422]},{"theme":"light-ic","bg":"#101012","fgs":["#15151B","#15151B","#F2F2FC","#B4B4BE","#93939C","#696972","#93939C","#F2F2FC","#E9F0FF","#74A2FF","#3371FF","#003CD0","#FFF4F3","#FF8679","#F8001A","#AF000F","#FFF6F2","#FF9058","#E36100","#A44400","#C3FFC9","#00CA4E","#00A33D","#007329","#E0F0FF","#21A8FF","#0085D0","#005B90"],"flat":[0,1.045340264415452,0,1.045340264415452,-98.5838326466521,17.08955684747884,-62.72698384194396,9.243246994613658,-45.67498009680545,6.239717532288448,-26.395025771311026,3.4974414103185016,-45.67498009680545,6.239717532288448,-98.5838326466521,17.08955684747884,-96.69585202312288,16.62869936569993,-53.53835262595417,7.561646720391368,-33.90853669724825,4.477228400656394,-15.643424374972522,2.314414627530853,-100.79897840003042,17.636700891614694,-56.5097869254783,8.089667628698802,-34.29633291417122,4.530985783372177,-18.10601775891745,2.56107712884258,-101.63443561648342,17.844851007069916,-58.71630179001587,8.4915976550775,-40.34850051574898,5.4087697654311055,-22.889197327723014,3.0820931228283226,-97.04991927160123,16.714741751700544,-59.578454823729174,8.650894122604043,-42.30409033566848,5.707629679225331,-23.50941826359813,3.1535587190594883,-95.5914257275877,16.361459308701495,-52.26386479821355,7.339891411251783,-35.96637326948896,4.765958094131511,-18.710865986535588,2.6239504003951875]},{"theme":"light-ic","bg":"#F6F8FA","fgs":["#F0F0FA","#F0F0FA","#0A0910","#707079","#8F8F99","#BCBCC5","#8F8F99","#0A0910","#000110","#0041DF","#3876FF","#82ACFF","#140000","#BB0011","#FC001B","#FF968A","#1A0500","#AE4900","#E76300","#FF9F70","#000B01","#007B2C","#00A63F","#00D252","#00040B","#00629B","#0088D4","#47B1FF"],"flat":[0,1.0634215248159875,0,1.0634215248159875,100.62201386979996,18.61882254505944,66.47667620763235,4.605948460093869,52.23653324893716,3.0084294463712395,29.780700684603257,1.7710808257265196,52.23653324893716,3.0084294463712395,100.62201386979996,18.61882254505944,101.64252534329776,19.49544279635548,78.08171358923367,6.990900026896573,60.31711036263554,3.7908833425501425,38.14751646036453,2.122399526754989,101.24542779029852,19.156162076239593,75.44574141529571,6.3202869230281635,60.62673294996029,3.82662941345977,34.94277926170305,1.9762445240799382,100.4972913238488,18.51105924752946,70.36281029021609,5.256397052662421,54.37780741212484,3.1906001595018556,32.840214506002376,1.8885138214456323,100.85145428464311,18.816958763489637,69.48045983543288,5.0977335979175304,52.4093478352768,3.0225491335598114,33.31123457222728,1.9076443919396007,101.41044755581565,19.297565623167603,74.60712675826916,6.125340559948453,58.63177587043786,3.604653750954651,39.55009522975671,2.1915837971666887]},{"theme":"light-ic","bg":"#1C1C1E","fgs":["#202028","#202028","#F1F1FD","#B6B6BF","#95959E","#6D6C75","#95959E","#F1F1FD","#E9F1FF","#76A4FF","#3875FF","#003ED7","#FFF4F3","#FF897C","#FC001B","#B50010","#FFF6F2","#FF935D","#E66300","#A94600","#C4FFCA","#00CB4F","#00A53F","#00772B","#E0F0FF","#2BA9FF","#0087D3","#005E95"],"flat":[0,1.0522076023272475,0,1.0522076023272475,-96.27140987076255,15.184146579953019,-61.97466067394124,8.452688508186561,-44.87094637123084,5.728792540137694,-25.977188885961883,3.283145241842075,-44.87094637123084,5.728792540137694,-96.27140987076255,15.184146579953019,-95.34499825068502,14.98175500729861,-52.621417231568934,6.906762415988252,-33.47849770408375,4.178062617945301,-14.893749254597887,2.163552488849563,-99.00844385529109,15.788447906049281,-55.63494162352738,7.390318911682041,-33.467257033822236,4.176644120449875,-17.547592326855693,2.408179342981014,-99.84390107174409,15.974784754119627,-57.911424929350886,7.764836527735112,-39.59013024218227,4.982347876116714,-22.33006111099907,2.8868875311347497,-95.39392806862337,14.992417327878547,-58.26045217149408,7.822951516486187,-41.372488477704024,5.229057297458628,-23.176065198031452,2.976455642863797,-93.8008911828484,14.646846343308852,-51.05892490110174,6.66159703590636,-35.06283530237813,4.380256350040106,-18.07990151348513,2.4590883821528062]},{"theme":"light-ic","bg":"#7C3AED","fgs":["#707078","#707078","#F4F4FE","#E3E4ED","#BDBDC6","#A6A6B0","#BDBDC6","#F4F4FE","#EDF3FF","#D8E6FF","#9ABDFF","#598FFF","#FFF8F7","#FFDDD8","#FFA59A","#FF6156","#FFF9F7","#FFDECE","#FFA77C","#FF7112","#D0FFD4","#7DFF93","#00D955","#00B947","#E5F3FF","#CEE8FF","#79C3FF","#0099EE"],"flat":[0,1.1608373681184396,0,1.1608373681184396,-73.98396246220521,5.215032461901156,-64.0643931275358,4.500912896664839,-41.75099800228171,3.055051474145459,-29.486682699107686,2.3616035737730514,-41.75099800228171,3.055051474145459,-73.98396246220521,5.215032461901156,-72.68698719753608,5.119268434409956,-64.43727527190525,4.526987198998707,-41.01457995095163,3.0113075347043563,-19.296469937711088,1.8446469211142276,-76.90365537296871,5.4332059719499135,-64.06334225320066,4.50083949888721,-41.0312066106476,3.012292267380192,-20.969079339398604,1.925648566411713,-77.34727635116013,5.466668081501757,-64.08510797117285,4.502359813071005,-40.89305028080999,3.004113887137535,-23.84545366268376,2.068528569757355,-72.9506558335481,5.138679113149092,-64.10668565953954,4.5038671985064,-40.827825177303865,3.0002560129496776,-26.013778316259767,2.1791858289450103,-71.69069599862902,5.046190228559761,-64.15987367011111,4.5075837056249375,-40.85863800181002,3.0020782433474276,-19.258832894479415,1.8428420367238694]},{"theme":"light-ic","bg":"#123456","fgs":["#36363D","#36363D","#F2F2FD","#BEBEC8","#A2A2AC","#808089","#A2A2AC","#F2F2FD","#EAF1FF","#86AEFF","#5189FF","#0650FF","#FFF6F4","#FF9B8F","#FF5349","#D70015","#FFF7F3","#FFA376","#FA6C00","#C75400","#C8FFCD","#00D553","#00B445","#008D34","#E2F1FF","#50B4FF","#0094E7","#0072B3"],"flat":[0,1.0609289970892934,0,1.0609289970892934,-91.39519035537737,11.4423466076867,-60.94367397154904,6.896920022598847,-46.020165423777584,5.026435795944436,-29.36949737122932,3.250019698594834,-46.020165423777584,5.026435795944436,-91.39519035537737,11.4423466076867,-90.02549742397721,11.218239018758677,-52.117590678997175,5.760385990810306,-35.453068631322715,3.8588722418561026,-17.16315496591595,2.1796295803105563,-94.48648764812391,11.954651961610136,-56.08049309972586,6.260045669338659,-36.68653001054492,3.9880923379680597,-19.42498133620683,2.3619323117143227,-94.88408174929032,12.021194381023134,-58.024655972390946,6.511549338355004,-40.19653971715428,4.366167533936967,-25.014063203920518,2.8441658803090872,-90.45906258458693,11.288985892577246,-57.51342916294683,6.44501257640493,-42.39985031117064,4.611196053798779,-26.099142978345885,2.9428672820648862,-89.03890865199516,11.05791933389335,-51.142776043878534,5.640183677550314,-35.55629387962385,3.86961291577043,-20.57349953874105,2.4573829383449617]},{"theme":"light-ic","bg":"#ABCDEF","fgs":["#C2C2CB","#C2C2CB","#06060C","#55545D","#706F78","#A1A1AA","#706F78","#06060C","#00010B","#0035BB","#044EFF","#4E86FF","#0F0000","#9F000C","#D60015","#FF4D45","#150400","#903B00","#BB4F00","#F86B00","#000801","#006322","#008230","#00B344","#000307","#005182","#0071B2","#0093E5"],"flat":[0,1.0705446339746656,0,1.0705446339746656,75.64582413051765,12.232204997602986,52.76667497081496,4.519371986307913,41.423484798334584,3.0020546798073307,18.127484103049046,1.5512322351556214,41.423484798334584,3.0020546798073307,75.64582413051765,12.232204997602986,76.30385323493446,12.594966222228084,58.55230095328762,5.70794052541694,46.62204712410211,3.5916381056717506,29.005214449343097,2.0573900879913625,76.05323451380515,12.457481581934719,55.93606418979168,5.126251708978212,44.077718465236906,3.284322001440957,27.730078776096413,1.986177453897667,75.43283879959024,12.113874624281086,52.77586207835023,4.520990578468311,41.44187963136357,3.003889327255304,23.868980516769618,1.7916338607346896,75.72958089661125,12.278654436624837,52.80881394476793,4.526802423347816,41.41072061989075,3.0007825637465393,21.536768417458934,1.687644517337887,76.14729893560808,12.509202438867769,55.739334564053664,5.085602713702602,43.07798550292857,3.173723442083943,28.271676351762963,2.015968674447834]},{"theme":"light-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]},{"theme":"dark-ic","bg":"#FFFFFF","fgs":["#E7E7F1","#D9D9E2","#0D0D12","#707079","#94949D","#BCBCC6","#94949D","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#D38200","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E2","#8F5BFF","#B49FFF"],"flat":[10.564452802176923,1.2281904700369508,18.372353597432387,1.402460683906872,104.27090588423802,19.38311547953439,70.6022439311581,4.903434648448188,54.00450766949271,3.0077315832596954,33.86624309585679,1.8839141891835494,54.00450766949271,3.0077315832596954,104.27090588423802,19.38311547953439,105.36973966240839,20.39226096069594,79.16304803673168,6.626348275559002,63.912632944603466,3.971781216419874,43.318131952880584,2.3140155027501375,105.16667360757414,20.20636839933495,79.43361118518992,6.693805381831162,64.79709340122825,4.079330310903514,38.90470830731088,2.096381256670562,103.76723276112338,18.92212884965907,70.7453629185683,4.926701923645705,54.109273202798526,3.0160152686700097,34.512224892089854,1.9092708836328856,104.66191026229393,19.742561272328775,73.25578885512941,5.361938351796974,56.58363991444959,3.2220248289870597,37.502971296697396,2.0337403797475795,105.5639998705614,20.569417077144905,82.76169093861434,7.605398027541734,65.05029978451972,4.11090668779679,42.03743585537794,2.2474520733771812]},{"theme":"dark-ic","bg":"#000000","fgs":["#030205","#08080D","#F0F0FA","#B1B1BB","#8F8F98","#64646D","#8F8F98","#F0F0FA","#DFEEFF","#4AA0FF","#007AE0","#005198","#FFF4F3","#FF7D79","#EF0030","#A6001E","#FFF7EF","#F99A00","#CA7D00","#915800","#B8FFBE","#00C648","#009F38","#006E24","#F0EDFF","#A98DFF","#894CFF","#6000C7"],"flat":[0,1.0147465549687367,0,1.0508696650285665,-99.01083964835996,18.549513785944853,-62.774763175831374,9.875849762659984,-45.37711439886134,6.5503119047070335,-25.95111170367181,3.5855589616463144,-45.37711439886134,6.5503119047070335,-99.01083964835996,18.549513785944853,-96.24720863865953,17.81140932471069,-52.09132740819334,7.763491481432673,-34.99617079306692,4.860177125212619,-17.977340963301042,2.6303715448994067,-102.4562582898777,19.486486384763445,-55.7461567674855,8.461541475204797,-34.016004840410574,4.712824540535038,-18.066789785866806,2.6401456993132153,-103.60398268701002,19.802704734429785,-61.68353878496926,9.650187857341214,-44.76775468944945,6.444757556916112,-26.060791619056427,3.599837857825526,-97.27916136850891,18.085610527348415,-59.33082759509665,9.171209294707863,-42.24705692654848,6.016359585841944,-23.350526713793172,3.2558471191579894,-97.94239544297146,18.262727694350296,-53.03355221198167,7.94095381214551,-32.857248309508414,4.541449578831631,-15.041171256747345,2.322063533056465]},{"theme":"dark-ic","bg":"#808080","fgs":["#707078","#66666E","#030305","#18171D","#37363E","#5D5D64","#37363E","#030305","#000103","#001834","#00376B","#004A8C","#070000","#390005","#750012","#99001B","#0D0400","#261300","#522F00","#865100","#000300","#001D05","#004112","#006521","#010005","#22004F","#43008F","#5800B7"],"flat":[0,1.24296889833444,9.274501031402123,1.4407196981948422,39.91548097777799,5.217615096019717,36.836632862345205,4.5094036578339445,27.93898600302036,3.0217055248761144,13.086665786299626,1.6537770693375544,27.93898600302036,3.0217055248761144,39.91548097777799,5.217615096019717,40.21965913293546,5.287302224624218,36.81767378273908,4.505192880903515,27.950777846769416,3.023233051937069,21.000772856623527,2.25477321245894,40.142120786515875,5.269603471071473,36.88792093057972,4.5208109211231795,27.866909320024153,3.0123854877509646,20.876190217747006,2.243276608438576,39.57947058767665,5.139984882718895,36.89828467875601,4.523118840482662,27.8635036155662,3.011945822214043,13.292640012092916,1.6665035199669143,40.051433392362526,5.248844071123953,36.85731353296706,4.514000513189092,27.870384117529113,3.0128341396537346,15.95953035602916,1.844055168824021,40.26997047853993,5.298759395894901,36.80600977189143,4.502603953746083,29.651273965169533,3.2515347547731954,23.802145579236715,2.533547958530533]},{"theme":"dark-ic","bg":"#3A3A3C","fgs":["#46464E","#4F4F57","#F1F1FA","#BABAC4","#9D9DA6","#797982","#9D9DA6","#F1F1FA","#E1EFFF","#62ACFF","#0087F7","#0065BB","#FFF5F4","#FF928C","#FF3644","#CA0027","#FFF8F1","#FFA533","#DD8900","#AD6A00","#BCFFC2","#00D04C","#00AF3F","#00862E","#F1EFFF","#B29CFF","#956AFF","#7700F5"],"flat":[0,1.2140718035663858,0,1.3988859584097761,-88.35603271482154,10.107493939375463,-56.41682578047019,5.8952705955406675,-41.10063170437542,4.219157833086703,-23.840055313660137,2.6320437618590202,-41.10063170437542,4.219157833086703,-88.35603271482154,10.107493939375463,-85.70519954500536,9.72347127561709,-46.57313915269262,4.790554592637207,-29.772868283167547,3.1391774083572677,-14.678197720495364,1.9342213705917548,-91.71639880150975,10.602849175762582,-50.907190680193835,5.264997878479995,-30.103011927821978,3.1686137953283313,-14.381529067368087,1.9134497328938256,-92.9101803888625,10.781105347058572,-55.36632323767275,5.772792653136124,-40.27550194757439,4.135743433221995,-23.629833528615176,2.614848030728214,-86.56463892830851,9.847321829129962,-52.75275099572737,5.472775232316544,-37.83315401910713,3.893128667535661,-21.00389596511428,2.404636707585164,-87.71836703917482,10.014570561299498,-47.715877818006604,4.91379345402092,-29.619990398118716,3.125589171245837,-10.872649839108671,1.6769004806806627]},{"theme":"dark-ic","bg":"#007AFF","fgs":["#6F6F77","#65656D","#040408","#16151B","#36353D","#72727A","#36353D","#040408","#000106","#001632","#00366A","#005EAF","#0B0000","#360004","#730011","#BD0023","#100600","#241200","#502E00","#A36300","#000501","#001B04","#004012","#007D2B","#020009","#20004A","#48009A","#6F00E5"],"flat":[0,1.2399763057879079,9.137153276370956,1.4379357071162235,39.199487305374596,5.095159290353486,36.655643103420836,4.517612479062309,27.726113654933034,3.015791844568742,0,1.187212547453551,27.726113654933034,3.015791844568742,39.199487305374596,5.095159290353486,39.62660370456575,5.191620542906115,36.592103069401674,4.503545882983851,27.695057695096033,3.0117807086970667,12.482735608358766,1.623488823202914,39.46146930028483,5.1544680971312,36.631002505660305,4.512153756975423,27.677944212691237,3.0095726068531357,12.751768680957815,1.6398240151911772,38.729462058672325,4.987982714010286,36.579572970042136,4.500775701626332,27.747483785710887,3.0185550274007946,0,1.2039987886628472,39.28492308543262,5.114543979353608,36.632422880871374,4.512468289727634,27.610461383700073,3.00088106913278,0,1.3173837917708568,39.63684952401951,5.1939188146581685,36.601297620325695,4.505579426399357,27.609531401873983,3.0007614645527125,16.085712499624492,1.86220122646402]},{"theme":"dark-ic","bg":"#FF3B30","fgs":["#7F7F87","#74747D","#040408","#212128","#3E3D45","#76767F","#3E3D45","#040408","#000206","#002245","#003E78","#0062B6","#0B0000","#4C0008","#820015","#C50025","#110600","#341C00","#5B3600","#A96700","#000601","#002808","#004916","#00822D","#02000A","#2E0067","#5300AD","#7400EE"],"flat":[0,1.119564506502328,0,1.304916965157494,43.43238144483196,5.770041002680692,38.322111022898454,4.5094781825179275,29.108000765094282,3.0232304751839747,0,1.268226675946831,29.108000765094282,3.0232304751839747,43.43238144483196,5.770041002680692,43.7603252928922,5.854038847974277,38.29235457140463,4.502975854531618,29.083956028514045,3.020138635078335,15.054852030501879,1.729780743830801,43.694363439742204,5.837205585263101,38.354206470247746,4.516507237210082,29.087182234169767,3.020553277003921,15.230546565278194,1.740856512459411,42.92491991360228,5.63897133999608,38.33365423714702,4.512004338988724,28.93769973409828,3.0014083479801514,0,1.2794758858630608,43.42248485866928,5.7674953973596095,38.41370496023468,4.529580289565808,29.031977011275746,3.0134669332394415,8.949958172317846,1.3992774406692907,43.85966524740055,5.879321607935794,38.335090707081115,4.512318847459295,28.952846517354917,3.003342035233188,18.7969313728254,1.9885281036480773]},{"theme":"dark-ic","bg":"#101012","fgs":["#1C1C22","#24242A","#F0F0FA","#B3B3BD","#92929B","#686871","#92929B","#F0F0FA","#E0EEFF","#4FA3FF","#007DE4","#00559F","#FFF4F3","#FF817D","#F40031","#AD001F","#FFF7EF","#FB9C00","#CE7F00","#965B00","#B9FFBF","#00C849","#00A239","#007226","#F0EEFF","#AA90FF","#8C52FF","#6500D0"],"flat":[0,1.1210017368042908,0,1.2316217101091165,-97.35355975851269,16.788671896406843,-62.18912907483276,9.1408846014785,-45.183954583215964,6.160892481462449,-25.971291980892453,3.445784610130369,-45.183954583215964,6.160892481462449,-97.35355975851269,16.788671896406843,-94.7101738760577,16.149472101206463,-51.76844025191021,7.254462182627787,-34.605057818633654,4.573999892517157,-17.84521283217997,2.534242228411278,-100.79897840003042,17.636700891614694,-55.24750846744172,7.863491890193123,-33.54190885081095,4.42668652015819,-17.815136446883052,2.5311583129351276,-101.94670279716274,17.922901715066683,-61.02785966423507,8.921519321161206,-44.34904450398409,6.02788949767693,-25.86724540779468,3.4331609270477186,-95.74776769254933,16.399183837627458,-58.596724506079966,8.469603286006345,-41.840254848173814,5.636085625741139,-23.122294138224145,3.108849028833141,-96.71860922522349,16.634224259107555,-52.4354046324141,7.369572153296626,-32.71416296796099,4.3135863728502315,-14.766203746026353,2.2302399315399106]},{"theme":"dark-ic","bg":"#F6F8FA","fgs":["#E0E0EA","#D2D2DC","#0D0D12","#717179","#8F8F98","#BCBCC6","#8F8F98","#0D0D12","#000511","#005DAD","#0080EB","#67AEFF","#180001","#BB0023","#FB0032","#FF9690","#1D0D00","#A16200","#CC7E00","#FFA93F","#000E02","#007C2A","#00A63B","#00D24D","#060018","#6E00E1","#8F5BFF","#B49FFF"],"flat":[10.34292311627503,1.2313464424988367,18.024461551245693,1.4097592283337514,100.14533816071224,18.207162386274927,66.07133001527934,4.544848448940367,52.273633182943,3.011452358576948,29.740675372331037,1.769619110018133,52.273633182943,3.011452358576948,100.14533816071224,18.207162386274927,101.24417193888262,19.155084079579783,75.03748031320593,6.224334741672483,59.78706522107772,3.730817455350078,39.19256422935483,2.1736266322828217,101.04110588404839,18.980469423093126,75.30804346166417,6.287699296730837,60.6715256777025,3.8318416601446055,34.77914058378512,1.9691960254812915,99.64166503759763,17.774143327140518,66.61979519504256,4.627804134340593,52.265155427331,3.010761189364384,30.386657168564103,1.79343744066295,100.53634253876818,18.544800983402446,69.13022113160365,5.036635229225181,52.45807219092383,3.026547994099155,33.37740357317164,1.9103555042369325,101.43843214703563,19.321492322017065,78.73738474892319,7.17242701812758,60.92473206099399,3.861502331684902,37.91186813185219,2.111101535692422]},{"theme":"dark-ic","bg":"#1C1C1E","fgs":["#28282E","#303036","#F0F1FA","#B5B5BE","#94949D","#6C6C74","#94949D","#F0F1FA","#E0EEFF","#53A4FF","#007FE8","#0058A5","#FFF4F3","#FF8580","#F80032","#B30021","#FFF7F0","#FD9D00","#D18100","#9B5E00","#B9FFC0","#00CA4A","#00A43A","#007628","#F0EEFF","#AC93FF","#8D57FF","#6800D8"],"flat":[0,1.1612081374032734,0,1.2977584057597347,-96.00102363023473,15.124963081358866,-61.434200333395395,8.359778481952823,-44.37695589123639,5.656999216809453,-25.852207004995158,3.269134017520218,-44.37695589123639,5.656999216809453,-96.00102363023473,15.124963081358866,-92.91963933131836,14.457074514504098,-50.53751898728166,6.580638229837842,-33.762466759799054,4.213972880586457,-17.265259153354464,2.3814244063048684,-99.00844385529109,15.788447906049281,-54.621704682580784,7.2261694596451385,-32.708249696736004,4.081389020198301,-17.26088719592284,2.381011454094406,-100.19905346524207,16.054261379481122,-59.919533209261445,8.101702037099825,-43.64240726631874,5.55098822531801,-25.561703474916055,3.236683846808585,-93.99035989085725,14.687777164248876,-57.733604751916815,7.735299184328377,-40.893113879964474,5.162174617584753,-22.782862127638978,2.93464773079297,-94.92807468048419,14.891026660055605,-51.83505302329671,6.7828987244211,-32.07221654016657,4.002369709649521,-14.066989001346808,2.090531498955086]},{"theme":"dark-ic","bg":"#7C3AED","fgs":["#7A7A83","#85858D","#F2F2FD","#E3E4ED","#BBBCC5","#A6A6AF","#BBBCC5","#F2F2FD","#E5F1FF","#D2E7FF","#8AC0FF","#2091FF","#FFF9F8","#FFDDDA","#FFA49E","#FF5C5D","#FFFAF5","#FFE0C0","#FFAA42","#E99000","#C6FFCB","#7DFF91","#00DA50","#00B943","#F3F1FF","#E6E2FF","#C1B2FF","#9D7AFF"],"flat":[-8.022975593833932,1.3402714370624624,-13.06659651622355,1.5567826841029107,-72.79588351600884,5.127281558149197,-64.0643931275358,4.500912896664839,-41.08533982136385,3.0154992993085026,-29.446612137967147,2.3594628663401775,-41.08533982136385,3.0154992993085026,-72.79588351600884,5.127281558149197,-70.80493468751378,4.981574213000164,-64.1741844582375,4.5085838821814495,-40.86541522684489,3.0024791016401666,-17.915271666932362,1.7789289638170434,-77.39176152066469,5.470028106981068,-64.14420274941838,4.506488572747314,-40.82594869447877,3.0001450551263997,-20.102672577261966,1.8834970356793017,-77.70409895709017,5.493642615376888,-64.40976920815079,4.525061719384059,-40.93902681557826,3.00683450902807,-28.2154352545988,2.2940964528512438,-71.5855645088526,5.038503428518723,-64.0583201718401,4.500488740880354,-41.23137463469248,3.02415799426954,-25.961518214862377,2.176489332337808,-72.5833510184408,5.111647048756453,-64.45521953810703,4.528243512077896,-40.9348520371047,3.006587427640115,-18.521762858381592,1.807654812879132]},{"theme":"dark-ic","bg":"#123456","fgs":["#3E3E45","#47474E","#F0F0FC","#BDBDC6","#A1A1AA","#7F7F88","#A1A1AA","#F0F0FC","#E1EFFF","#68AEFF","#008BFD","#006AC4","#FFF6F5","#FF9791","#FF494F","#D30029","#FFF8F1","#FFA941","#E28C00","#B46E00","#BEFFC3","#00D34D","#00B340","#008C31","#F1EFFF","#B49FFF","#9971FF","#7D09FF"],"flat":[0,1.1989453999458657,0,1.3802668927515667,-90.21171279539291,11.248602775503162,-60.350304841650235,6.81783465078813,-45.467647083484636,4.962060105853706,-28.90763677945104,3.2057711392885686,-45.467647083484636,4.962060105853706,-90.21171279539291,11.248602775503162,-88.0351868676248,10.895768939711282,-50.01919534416472,5.502978516969682,-33.92042169291382,3.7009882121251625,-19.118481017052428,2.3367853131295435,-94.53061684959366,11.962030249162733,-54.89593536437227,6.108854061876529,-35.13675470036395,3.8260433618990066,-18.695569008603144,2.3023140000481574,-95.24016771148194,12.080915286992608,-59.18458546592085,6.6635751999987605,-44.37309985295556,4.8355935478331284,-28.125674388660414,3.1315039953558483,-89.11957165172129,11.070992163190612,-56.49372778683512,6.313154752438012,-41.901500594929324,4.555261373045625,-25.675484951698337,2.9041378624661793,-90.04835436179425,11.221964232051386,-51.2922333885473,5.658542947646642,-34.21126239038315,3.7307191421666435,-15.254686874750304,2.03178289437105]},{"theme":"dark-ic","bg":"#ABCDEF","fgs":["#B4B4BE","#A8A8B1","#09090F","#55545C","#706F78","#A1A1AB","#706F78","#09090F","#00030D","#004D92","#006CC7","#008BFE","#130001","#9F001C","#D60029","#FF4B50","#190B00","#7B4A00","#A06100","#E38C00","#000A01","#006320","#00822D","#00B341","#040013","#5B00BE","#7E16FF","#9971FF"],"flat":[8.489900665165298,1.2445786124510558,14.633674840112363,1.428005718026211,75.26124671135368,12.018395958758797,52.798813706484495,4.5250375920814765,41.423484798334584,3.0020546798073307,18.08917030758312,1.5497949541740348,41.423484798334584,3.0020546798073307,75.26124671135368,12.018395958758797,76.08600684668272,12.475516218126787,55.96473752983635,5.13221099097022,43.3369682989835,3.2018590596167122,29.318326289650187,2.0754572365320043,75.88156299830736,12.362776535253154,55.82217638546274,5.102669267083783,43.91931922104849,3.266436635421996,27.87409429449102,1.9940348577564484,74.63415754799017,11.66954886455628,52.68236753448497,4.504554642142053,41.632593181405234,3.023005829909607,19.021862742942993,1.5853635699211195,75.54552640875083,12.176514522007174,52.82929323710673,4.530419449281277,41.44477504659015,3.004178254346042,21.57431352051131,1.6892460110571705,76.18160530883091,12.528031661692317,58.96992438550627,5.808209180198148,47.36214126331064,3.68860336114861,29.106756402593504,2.0632235490419992]},{"theme":"dark-ic","bg":"#808080","fgs":["#F7F7FF","#0A0A10","#71717A","#fff","abcdef","#Ff3B30","#123","112233"],"flat":[-64.57463009518332,3.7055405145881393,38.97016765822309,4.9982958489854346,0,1.2236914579873204,-69.21596068658343,3.9494396480491156,-37.021615579693034,2.3904478169235754,0,1.1134158562260283,34.84722274356656,4.0887512719822805,34.84722274356656,4.0887512719822805]}],"resolveVars":[{"theme":"light","bg":"#FFFFFF","vars":{"--lab-bg-tone-2":"oklch(97.84197% 0.010603 286.202)","--lab-bg-tone-3":"oklch(97.84197% 0.010603 286.202)","--lab-label-primary":"oklch(19.12257% 0.014100 291.556)","--lab-label-secondary":"oklch(56.54196% 0.013721 285.953)","--lab-label-tertiary":"oklch(66.97448% 0.014606 285.999)","--lab-label-quaternary":"oklch(81.39504% 0.013897 286.087)","--lab-border-strong":"oklch(19.12257% 0.014100 291.556)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(19.39430% 0.069681 257.297)","--lab-label-brand-secondary":"oklch(57.33316% 0.205939 257.291)","--lab-label-brand-tertiary":"oklch(67.12978% 0.175419 257.690)","--lab-label-brand-quaternary":"oklch(81.31388% 0.094313 257.967)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(20.16318% 0.082740 29.234)","--lab-label-danger-secondary":"oklch(59.62536% 0.244212 28.655)","--lab-label-danger-tertiary":"oklch(69.12968% 0.198650 28.574)","--lab-label-danger-quaternary":"oklch(82.55135% 0.097676 29.389)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(19.32631% 0.042728 66.944)","--lab-label-warning-secondary":"oklch(57.27465% 0.125021 68.799)","--lab-label-warning-tertiary":"oklch(67.73500% 0.148484 68.161)","--lab-label-warning-quaternary":"oklch(82.04043% 0.139600 68.374)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(67.73500% 0.148484 68.161 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(18.59864% 0.054816 147.311)","--lab-label-success-secondary":"oklch(54.91588% 0.161394 147.418)","--lab-label-success-tertiary":"oklch(64.88263% 0.190555 147.443)","--lab-label-success-quaternary":"oklch(79.02491% 0.232159 147.432)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(64.96409% 0.172888 147.450 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(19.54489% 0.077696 259.509)","--lab-label-info-secondary":"oklch(57.68997% 0.232691 259.838)","--lab-label-info-tertiary":"oklch(67.26428% 0.173598 259.980)","--lab-label-info-quaternary":"oklch(81.49851% 0.092914 259.576)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"1.0000","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"1.0000","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"1.0000","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"1.0000","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(66.97448% 0.014606 285.999)"}},{"theme":"light","bg":"#000000","vars":{"--lab-bg-tone-2":"oklch(7.11623% 0.013707 282.350)","--lab-bg-tone-3":"oklch(7.11623% 0.013707 282.350)","--lab-label-primary":"oklch(98.65157% 0.006606 286.278)","--lab-label-secondary":"oklch(80.14049% 0.013952 286.081)","--lab-label-tertiary":"oklch(69.23260% 0.013029 286.054)","--lab-label-quaternary":"oklch(55.16643% 0.013808 285.938)","--lab-border-strong":"oklch(98.65157% 0.006606 286.278)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-brand-secondary":"oklch(80.06451% 0.101154 257.822)","--lab-label-brand-tertiary":"oklch(69.35433% 0.162198 257.610)","--lab-label-brand-quaternary":"oklch(55.96293% 0.201872 257.393)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.64532% 0.006567 28.832)","--lab-label-danger-secondary":"oklch(81.38408% 0.105319 28.863)","--lab-label-danger-tertiary":"oklch(71.19739% 0.181099 28.350)","--lab-label-danger-quaternary":"oklch(58.10538% 0.238004 28.677)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.72985% 0.009577 72.664)","--lab-label-warning-secondary":"oklch(80.98124% 0.149940 68.750)","--lab-label-warning-tertiary":"oklch(70.11346% 0.152989 68.857)","--lab-label-warning-quaternary":"oklch(55.86543% 0.122417 68.219)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.31403% 0.027558 147.033)","--lab-label-success-secondary":"oklch(77.69544% 0.228159 147.448)","--lab-label-success-tertiary":"oklch(67.10086% 0.197388 147.383)","--lab-label-success-quaternary":"oklch(53.45797% 0.157009 147.442)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.69528% 0.006207 255.474)","--lab-label-info-secondary":"oklch(80.30515% 0.099317 259.806)","--lab-label-info-tertiary":"oklch(69.49027% 0.160387 260.058)","--lab-label-info-quaternary":"oklch(56.23879% 0.227606 259.853)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"0.0123","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"0.0104","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"0.0093","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"0.0059","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(69.23260% 0.013029 286.054)"}},{"theme":"light","bg":"#808080","vars":{"--lab-bg-tone-2":"oklch(58.21536% 0.012099 286.018)","--lab-bg-tone-3":"oklch(58.21536% 0.012099 286.018)","--lab-label-primary":"oklch(13.77177% 0.013406 284.503)","--lab-label-secondary":"oklch(20.89710% 0.013787 291.711)","--lab-label-tertiary":"oklch(33.70211% 0.013945 291.371)","--lab-label-quaternary":"oklch(49.18380% 0.012632 285.926)","--lab-border-strong":"oklch(13.77177% 0.013406 284.503)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.89968% 0.076454 257.725)","--lab-label-brand-tertiary":"oklch(34.17043% 0.122830 257.309)","--lab-label-brand-quaternary":"oklch(49.70427% 0.178878 257.337)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(21.88171% 0.089792 29.234)","--lab-label-danger-tertiary":"oklch(35.54757% 0.145582 28.627)","--lab-label-danger-quaternary":"oklch(51.73054% 0.211869 28.643)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.90753% 0.046395 66.438)","--lab-label-warning-tertiary":"oklch(34.00266% 0.074904 67.452)","--lab-label-warning-quaternary":"oklch(49.79045% 0.108745 68.715)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(91.20808% 0.064865 69.118 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(20.10741% 0.059351 147.255)","--lab-label-success-tertiary":"oklch(32.71649% 0.095649 147.616)","--lab-label-success-quaternary":"oklch(47.52181% 0.139141 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(88.67895% 0.198079 147.416 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(21.26627% 0.087439 260.146)","--lab-label-info-tertiary":"oklch(34.39884% 0.140838 260.069)","--lab-label-info-quaternary":"oklch(50.11242% 0.202893 259.861)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"0.1070","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"0.1063","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"0.0686","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"0.0433","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.70211% 0.013945 291.371)"}},{"theme":"light","bg":"#3A3A3C","vars":{"--lab-bg-tone-2":"oklch(36.65084% 0.011912 285.797)","--lab-bg-tone-3":"oklch(36.65084% 0.011912 285.797)","--lab-label-primary":"oklch(98.92134% 0.005280 286.303)","--lab-label-secondary":"oklch(82.53513% 0.012530 281.039)","--lab-label-tertiary":"oklch(72.82755% 0.014297 286.039)","--lab-label-quaternary":"oklch(61.25907% 0.011943 286.042)","--lab-border-strong":"oklch(98.92134% 0.005280 286.303)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-brand-secondary":"oklch(82.44675% 0.088464 257.008)","--lab-label-brand-tertiary":"oklch(73.08383% 0.140495 257.442)","--lab-label-brand-quaternary":"oklch(61.77245% 0.208353 257.413)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-strong":"oklch(61.75781% 0.208491 257.338 / 1)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(98.86605% 0.005516 31.053)","--lab-label-danger-secondary":"oklch(83.38592% 0.092088 28.223)","--lab-label-danger-tertiary":"oklch(74.63167% 0.153784 28.842)","--lab-label-danger-quaternary":"oklch(64.10554% 0.244701 28.706)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-strong":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(98.75683% 0.008520 67.727)","--lab-label-warning-secondary":"oklch(83.08426% 0.131366 68.787)","--lab-label-warning-tertiary":"oklch(73.86147% 0.161425 68.614)","--lab-label-warning-quaternary":"oklch(61.96370% 0.135496 68.532)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(98.51065% 0.024195 147.300)","--lab-label-success-secondary":"oklch(80.08887% 0.235054 147.469)","--lab-label-success-tertiary":"oklch(70.67977% 0.207519 147.455)","--lab-label-success-quaternary":"oklch(59.24524% 0.173588 147.533)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(98.77036% 0.005722 264.533)","--lab-label-info-secondary":"oklch(82.42822% 0.087905 259.666)","--lab-label-info-tertiary":"oklch(73.17409% 0.139071 259.898)","--lab-label-info-quaternary":"oklch(61.97090% 0.205878 259.740)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-strong":"oklch(64.04613% 0.193058 259.892 / 1)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"0.0690","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"0.0584","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"0.0381","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"0.0279","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(72.82755% 0.014297 286.039)"}},{"theme":"light","bg":"#007AFF","vars":{"--lab-bg-tone-2":"oklch(57.87475% 0.012117 286.015)","--lab-bg-tone-3":"oklch(57.87475% 0.012117 286.015)","--lab-label-primary":"oklch(13.41536% 0.016099 291.497)","--lab-label-secondary":"oklch(20.01464% 0.013939 291.638)","--lab-label-tertiary":"oklch(33.31157% 0.013986 291.359)","--lab-label-quaternary":"oklch(48.82850% 0.012656 285.922)","--lab-border-strong":"oklch(13.41536% 0.016099 291.497)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(13.49058% 0.050205 258.119)","--lab-label-brand-secondary":"oklch(20.23588% 0.071518 256.892)","--lab-label-brand-tertiary":"oklch(33.52307% 0.122654 257.729)","--lab-label-brand-quaternary":"oklch(49.33014% 0.177503 257.333)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.22004% 0.058352 29.234)","--lab-label-danger-secondary":"oklch(20.90401% 0.085780 29.234)","--lab-label-danger-tertiary":"oklch(34.90075% 0.142917 28.593)","--lab-label-danger-quaternary":"oklch(51.33845% 0.210254 28.629)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(13.56514% 0.029751 68.088)","--lab-label-warning-secondary":"oklch(20.32691% 0.044866 67.175)","--lab-label-warning-tertiary":"oklch(33.70499% 0.073308 69.356)","--lab-label-warning-quaternary":"oklch(49.32505% 0.107532 68.994)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(90.50991% 0.070001 68.839 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.01439% 0.039836 145.937)","--lab-label-success-secondary":"oklch(19.36939% 0.056254 147.869)","--lab-label-success-tertiary":"oklch(32.05045% 0.093836 147.561)","--lab-label-success-quaternary":"oklch(47.21271% 0.138736 147.423)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(87.96698% 0.214930 147.350 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(13.83602% 0.055501 259.684)","--lab-label-info-secondary":"oklch(20.33878% 0.084139 260.257)","--lab-label-info-tertiary":"oklch(33.98919% 0.139378 260.097)","--lab-label-info-quaternary":"oklch(49.73960% 0.201561 259.877)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"0.1336","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"0.1787","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"0.0893","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"0.0639","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(33.31157% 0.013986 291.359)"}},{"theme":"light","bg":"#FF3B30","vars":{"--lab-bg-tone-2":"oklch(63.60048% 0.011829 286.059)","--lab-bg-tone-3":"oklch(63.60048% 0.011829 286.059)","--lab-label-primary":"oklch(13.83495% 0.015576 284.180)","--lab-label-secondary":"oklch(25.12484% 0.015017 285.269)","--lab-label-tertiary":"oklch(36.40556% 0.013673 291.446)","--lab-label-quaternary":"oklch(51.30243% 0.012497 285.951)","--lab-border-strong":"oklch(13.83495% 0.015576 284.180)","--lab-border-base":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-border-soft":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-primary":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-fill-secondary":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fill-tertiary":"oklch(57.53363% 0.012136 286.012 / 0.122)","--lab-fill-quaternary":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fx-shadow-minor":"oklch(17.39406% 0.004094 285.966 / 0.012)","--lab-fx-shadow-ambient":"oklch(17.39406% 0.004094 285.966 / 0.02)","--lab-fx-shadow-penumbra":"oklch(17.39406% 0.004094 285.966 / 0.039)","--lab-fx-shadow-major":"oklch(17.39406% 0.004094 285.966 / 0.122)","--lab-label-brand-primary":"oklch(14.05977% 0.051776 257.879)","--lab-label-brand-secondary":"oklch(25.33060% 0.091490 257.423)","--lab-label-brand-tertiary":"oklch(36.69778% 0.133197 257.540)","--lab-label-brand-quaternary":"oklch(51.93456% 0.187074 257.359)","--lab-fill-brand-primary":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-brand-secondary":"oklch(60.27647% 0.217712 257.424 / 0.078)","--lab-fill-brand-tertiary":"oklch(60.27647% 0.217712 257.424 / 0.039)","--lab-fill-brand-quaternary":"oklch(60.27647% 0.217712 257.424 / 0.02)","--lab-border-brand-base":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-brand-soft":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-label-danger-primary":"oklch(14.49010% 0.059461 29.234)","--lab-label-danger-secondary":"oklch(26.17120% 0.107130 28.474)","--lab-label-danger-tertiary":"oklch(38.31783% 0.156988 28.748)","--lab-label-danger-quaternary":"oklch(53.87750% 0.220648 28.624)","--lab-fill-danger-primary":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-fill-danger-secondary":"oklch(65.42146% 0.232135 28.659 / 0.078)","--lab-fill-danger-tertiary":"oklch(65.42146% 0.232135 28.659 / 0.039)","--lab-fill-danger-quaternary":"oklch(65.42146% 0.232135 28.659 / 0.02)","--lab-border-danger-base":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-danger-soft":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-warning-primary":"oklch(14.09840% 0.030367 70.955)","--lab-label-warning-secondary":"oklch(25.16306% 0.055216 68.014)","--lab-label-warning-tertiary":"oklch(36.95716% 0.080989 68.209)","--lab-label-warning-quaternary":"oklch(51.83499% 0.113346 68.533)","--lab-fill-warning-primary":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-fill-warning-secondary":"oklch(78.56796% 0.171718 68.607 / 0.078)","--lab-fill-warning-tertiary":"oklch(78.56796% 0.171718 68.607 / 0.039)","--lab-fill-warning-quaternary":"oklch(78.56796% 0.171718 68.607 / 0.02)","--lab-border-warning-strong":"oklch(94.55166% 0.039779 69.746 / 1)","--lab-border-warning-base":"oklch(78.56796% 0.171718 68.607 / 0.2)","--lab-border-warning-soft":"oklch(78.56796% 0.171718 68.607 / 0.122)","--lab-label-success-primary":"oklch(13.47470% 0.038038 148.984)","--lab-label-success-secondary":"oklch(24.13011% 0.070822 147.468)","--lab-label-success-tertiary":"oklch(35.33425% 0.103721 147.463)","--lab-label-success-quaternary":"oklch(49.62199% 0.145290 147.559)","--lab-fill-success-primary":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-fill-success-secondary":"oklch(73.03242% 0.194381 147.444 / 0.078)","--lab-fill-success-tertiary":"oklch(73.03242% 0.194381 147.444 / 0.039)","--lab-fill-success-quaternary":"oklch(73.03242% 0.194381 147.444 / 0.02)","--lab-border-success-strong":"oklch(93.08890% 0.111383 147.112 / 1)","--lab-border-success-base":"oklch(73.03242% 0.194381 147.444 / 0.2)","--lab-border-success-soft":"oklch(73.03242% 0.194381 147.444 / 0.122)","--lab-label-info-primary":"oklch(14.07469% 0.058967 260.480)","--lab-label-info-secondary":"oklch(25.53067% 0.104920 260.137)","--lab-label-info-tertiary":"oklch(37.14168% 0.149591 259.762)","--lab-label-info-quaternary":"oklch(52.13660% 0.212021 259.943)","--lab-fill-info-primary":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fill-info-secondary":"oklch(64.04613% 0.193058 259.892 / 0.078)","--lab-fill-info-tertiary":"oklch(64.04613% 0.193058 259.892 / 0.039)","--lab-fill-info-quaternary":"oklch(64.04613% 0.193058 259.892 / 0.02)","--lab-border-info-base":"oklch(64.04613% 0.193058 259.892 / 0.2)","--lab-border-info-soft":"oklch(64.04613% 0.193058 259.892 / 0.122)","--lab-fx-focus-ring-brand":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-fx-focus-ring-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-fx-focus-ring-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-fx-focus-ring-neutral":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-fx-glow-brand":"oklch(60.27647% 0.217712 257.424)","--lab-fx-glow-brand-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-brand-alpha":"0.1812","--lab-fx-glow-danger":"oklch(65.42146% 0.232135 28.659)","--lab-fx-glow-danger-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-danger-alpha":"0.4300","--lab-fx-glow-warning":"oklch(78.56796% 0.171718 68.607)","--lab-fx-glow-warning-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-warning-alpha":"0.1737","--lab-fx-glow-neutral":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-core":"oklch(100.00000% 0.000000 none)","--lab-fx-glow-neutral-alpha":"0.0995","--lab-fx-glow-inverted":"oklch(75.99692% 0.012723 286.093 / 0.522)","--lab-fx-skeleton-highlight":"oklch(57.53363% 0.012136 286.012 / 0.039)","--lab-fill-neutral":"oklch(57.53363% 0.012136 286.012 / 1)","--lab-fill-accent-tinted":"oklch(60.27647% 0.217712 257.424 / 0.122)","--lab-fill-danger-tinted":"oklch(65.42146% 0.232135 28.659 / 0.122)","--lab-label-accent":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-label-danger":"oklch(65.42146% 0.232135 28.659 / 1)","--lab-border-accent":"oklch(60.27647% 0.217712 257.424 / 0.2)","--lab-border-danger":"oklch(65.42146% 0.232135 28.659 / 0.2)","--lab-border-focus":"oklch(60.27647% 0.217712 257.424 / 1)","--lab-badge-fill-brand":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-badge-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-badge-fill-warning":"oklch(78.56796% 0.171718 68.607 / 1)","--lab-badge-fill-success":"oklch(73.03242% 0.194381 147.444 / 1)","--lab-badge-fill-info":"oklch(56.86744% 0.193154 259.910 / 1)","--lab-badge-fill-static-dark":"oklch(17.39406% 0.004094 285.966 / 1)","--lab-badge-fill-static-light":"oklch(100.00000% 0.000000 none / 1)","--lab-fill-neutral-tinted":"oklch(57.53363% 0.012136 286.012 / 0.2)","--lab-border-neutral":"oklch(57.53363% 0.012136 286.012 / 0.161)","--lab-fx-skeleton-base":"oklch(57.53363% 0.012136 286.012 / 0.078)","--lab-fill-accent":"oklch(57.10674% 0.215668 258.445 / 1)","--lab-fill-danger":"oklch(59.02521% 0.231590 28.749 / 1)","--lab-icon":"oklch(36.40556% 0.013673 291.446)"}}]} \ No newline at end of file diff --git a/reference/labui-accent-primitives.md b/reference/labui-accent-primitives.md index 0e5f0870..1a291105 100644 --- a/reference/labui-accent-primitives.md +++ b/reference/labui-accent-primitives.md @@ -81,10 +81,9 @@ | Success | Green | `#34C759` | 147.4439 | | Info | Blue | `#3E87FF` | 259.8918 | -Эти четыре значения совпадают с прежними константами `sentiment.rs` -(и с ранее заземлёнными в `reference/labui-figma-structure.md` / -`s_perc_min_derivation_identity`), поэтому делегирование ничего не сдвинуло: -существующие тесты сентиментов остаются зелёными. +В sentiment V2 эти значения остаются данными конкретного клиента, а не +универсальными прототипами. Движок сохраняет anchors и для каждой пары проверяет +её собственную Oklab-ab-дистанцию; названия строк не задают цветовую зону. ## 5. Статус провенанса