diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf24bcba..8ff93203 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -240,6 +240,14 @@ jobs: core = packages["labcolors-core"] if core["features"].get("default") != []: raise SystemExit("labcolors-core default capability set drifted") + runtime_dependencies = [ + dependency + for dependency in core["dependencies"] + if dependency["kind"] is None + ] + if runtime_dependencies: + names = sorted(dependency["name"] for dependency in runtime_dependencies) + raise SystemExit(f"labcolors-core must have zero runtime dependencies: {names}") # Оффлайн protocol/compiler линия вырезана целиком: анти-воскрешение — # самих крейтов больше не существует в воркспейсе. for erased in ("labcolors-protocol", "labcolors-compiler"): diff --git a/.gitignore b/.gitignore index 2c827c59..058fa071 100644 --- a/.gitignore +++ b/.gitignore @@ -19,14 +19,5 @@ packages/colors/evidence/ mutants.out/ mutants.out.old/ -# Splice/restore/backup sidecars left by on-disk probe tests on abnormal exit -# (SIGKILL between write and rename, or rename failure during Drop unwind). -# These are safe to remove; the backup is the recovery path. -# See crates/labcolors-core/tests/splice_support.rs module doc. -*.splice_tmp -*.restore_tmp -*.on_disk_probe_backup -*.s2b_test5_backup - # локальные worktree агентов — не часть проекта .claude/worktrees/ diff --git a/Cargo.lock b/Cargo.lock index 45d573ed..80672b26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,17 +346,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-task" version = "0.3.32" @@ -495,7 +484,7 @@ dependencies = [ [[package]] name = "labcolors-conformance" -version = "0.2.0" +version = "0.3.0" dependencies = [ "labcolors-core", "serde", @@ -504,18 +493,17 @@ dependencies = [ [[package]] name = "labcolors-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "criterion", "pretty_assertions", "proptest", - "serial_test", "tempfile", ] [[package]] name = "labcolors-ffi" -version = "0.2.0" +version = "0.3.0" dependencies = [ "labcolors-core", "thiserror", @@ -524,7 +512,7 @@ dependencies = [ [[package]] name = "labcolors-wasm" -version = "0.2.0" +version = "0.3.0" dependencies = [ "console_error_panic_hook", "criterion", @@ -556,15 +544,6 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.33" @@ -634,29 +613,6 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -787,15 +743,6 @@ dependencies = [ "rand_core", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.13.0" @@ -871,12 +818,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "scroll" version = "0.12.0" @@ -949,31 +890,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serial_test" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" -dependencies = [ - "futures-executor", - "futures-util", - "log", - "once_cell", - "parking_lot", - "serial_test_derive", -] - -[[package]] -name = "serial_test_derive" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "shlex" version = "2.0.1" @@ -992,12 +908,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - [[package]] name = "smawk" version = "0.3.3" diff --git a/Cargo.toml b/Cargo.toml index cd0c2d36..8b359932 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2024" authors = ["Lab Pics "] license = "MIT" @@ -11,10 +11,9 @@ repository = "https://github.com/Labpics-Team/lab-colors" rust-version = "1.85" # Centralised dependency pins. `labcolors-core` stays zero-RUNTIME-deps (issue #29): -# only the runtime and offline-compiler WASM edge crates use this wasm-bindgen pin. -# `proptest` is a DEV-only dependency of `labcolors-core` (property/fuzz invariants); -# it never enters the runtime graph — proven by the supply-chain guards in -# `tests/s2b_baseline_guards.rs` (`supply_chain_proptest_*`). +# only the runtime WASM edge crate uses this wasm-bindgen pin. +# `proptest` is a DEV-only dependency of `labcolors-core`; the CI metadata gate +# rejects any normal dependency edge from Core, including target-specific ones. [workspace.dependencies] wasm-bindgen = "0.2" proptest = "1" diff --git a/README.md b/README.md index 9b15e8c5..83ddd6be 100644 --- a/README.md +++ b/README.md @@ -6,30 +6,39 @@ Это собственные концепции Labpics; текущие CAM16/Oklab/APCA-shaped компоненты не являются их полным определением и не доказывают перцептуальное превосходство. -Lab Colors принимает конфиг клиента, компилирует его в `NamedRoleTable` и решает всю таблицу для одного локального фона и темы. Зависимые роли, уже представленные специальными рецептами, используют фактически полученные композиты. Браузерные помощники применяют результат, перепроверяют его при изменении окружения и при необходимости запускают новый resolve. +Lab Colors принимает `ThemeConfig` клиента, строго разбирает его и компилирует +закрытые рецепты ролей в `NamedRoleTable`. `resolveTheme` решает всю объявленную +таблицу для одного локального фона и выбранной клиентской темы. Специализированные +зависимости исполняются только внутри уже существующих recipe-путей; произвольную +топологию графа клиент объявить не может. + +`watchTheme` при изменении опорной оценки фона вызывает полный `resolveTheme`. +`adaptTheme` перепроверяет контраст solid-ролей `kind: "color"`, отдельно +перепроверяет класс stable Glow и при необходимости также вызывает полный +`resolveTheme`. Translucent, Material и legacy Glow не получают от этого +контрастного recheck отдельной гарантии. Публичного generic dependency graph, +совместного SCC-solver и selective role resolver нет. Выборочное принятие +переменных из свежего полного снимка в `adaptTheme` не является selective +re-resolve. + +Текущий pipeline: ```text ThemeConfig клиента -→ проверка и компиляция -→ NamedRoleTable - -NamedRoleTable -+ локальный фон -+ тема -→ resolve всей таблицы -→ значения и статусы ролей - -изменение окружения -→ recheck -→ сохранить текущий результат, если он допустим -→ выполнить новый resolve, если это требуется +→ strict parse + compile в NamedRoleTable +→ resolve всей таблицы относительно фона и темы +→ проверка финального encoded output + типизированные исходы +→ browser apply +→ изменение контекста: полный resolve либо ограниченный recheck с полным resolve ``` ## Что уже работает - **Клиентский словарь.** Имена ролей, тем и алиасов приходят из `ThemeConfig`. Core не выводит смысл из слов `primary`, `danger`, `hover` или имени компонента. - **Контекстный resolve.** Одна таблица решается заново для переданного локального фона и темы. -- **Вложенные зависимости через generic-компонент.** Например, `PairLabel` решается против объявленной derived-поверхности (точный source-over композит своего тинта над локальным фоном), которую собирает приватный appearance-граф; поверхность не является эмитированным `PairFill`, и роль не решается против фона страницы. +- **Вложенные зависимости.** Приватный appearance-граф уже связывает foreground + с вычисленной поверхностью и проверяет конечный композит. Публичная схема пока + не позволяет клиенту объявить произвольную топологию. - **Несколько форм результата.** Роль может вернуть solid, translucent, material, точечный glow, явное отсутствие значения либо типизированный локальный failure: доказанную недостижимость или исчерпанный ограниченный поиск. Отклонённый запрос, неподдержанная возможность и внутренний дефект атомарно отклоняют весь resolve без частичного набора. - **Версионированные численные свидетельства.** Exact encoded-sRGB8 source-over/screen операции несут проверяемый профиль и `bit-exact` @@ -41,7 +50,8 @@ NamedRoleTable ## Что не следует приписывать текущей реализации - Произвольный generic dependency graph и совместный SCC-solver ещё не являются публичным API. Сейчас есть resolve всей таблицы и отдельные специализированные пути зависимостей. -- Legacy-решения на основе CAM16, CAM16-UCS, Oklab и LPC не доказаны как +- Legacy-решения на основе CAM16, CAM16-UCS, Oklab и замороженной SAPC-shaped + compatibility-кривой не доказаны как битово-точные на всех средах выполнения и платформах. Exact-гарантия распространяется только на явно зарегистрированные конечные операции. - Обход DOM не является измерением фактически нарисованных пикселей браузера. @@ -129,25 +139,20 @@ Core владеет: - построением производных цветов; - контрастом и композитингом; - конечной эмиссией; -- специализированными физическими контрактами; +- общими физическими операторами, ограничениями и сертификатами; - результатами и диагностикой. Одинаковое клиентское слово не обязано означать одинаковую числовую координату. Например, `text.secondary` и `border.secondary` могут принадлежать одному клиентскому уровню, но решать разные физические задачи и иметь разные `Lc`, `J′`, alpha и hex. -## Текущие рецепты - -| Рецепт | Для чего используется | Чего не доказывает | -|---|---|---| -| `TextAnchor` | foreground относительно текущего фона | универсальную силу любого стимула | -| `DjAnchor` | заданный шаг appearance-коррелята | читаемость или WCAG | -| `DecorativeLc` | декоративную контрастную величину | нормативную доступность текста | -| `Ladder` | клиентский preset тинта и alpha | общий закон уровней core | -| `AlphaAnalog` | точечный композит заданной solid-цели | воспринимаемую глубину | -| `PairFill` | сдвинутую до победы лейбл-стороны солид-заливку | подложку `PairLabel` | -| `PairLabel` | foreground против derived тинт-поверхности (generic source-over компонент) | публичный произвольный graph API | -| `Material` | объявленную многослойную точечную композицию | blur, преломление или физическое стекло | -| `Glow` | рецепт точечного эффекта | пространственный glow после blur | -| `Zero` | явное отсутствие цветового значения | пропущенный ключ | +## Граница конфигурации + +Публичная JSON-схема принимает закрытое меню `RoleRecipe`, описанное в +[`packages/colors/README.md`](packages/colors/README.md). `loadConfig` компилирует +его в `NamedRoleTable`, а `resolveTheme` исполняет эту таблицу целиком. + +Это поддерживаемая форма ввода, а не extension point: имена ролей остаются +непрозрачными идентификаторами, а конфиг не может добавлять физику по имени +токена, вводить новый recipe kind или объявлять произвольные graph edges. ## Численные гарантии @@ -171,7 +176,7 @@ CSS-строка, CSSOM и обход DOM сами по себе не повыш | **Exact encoded-sRGB8 операции** | конечные source-over/screen композиторы, выбранная binary64 alpha и её канонический CSS round-trip; сертификат относится к point-reference, не к renderer/display | | **Stable Glow decision** | exact point-no-op даёт `Determinate` с sealed `bit-exact` evidence без CAM16-профиля; нетривиальный target/max без sound bound даёт typed `Indeterminate`, не platform-selected fallback | | **Explicit compatibility (legacy)** | явный legacy execution mode даёт атомарный `Compatibility`-результат с registered release (`glow-cam16-ucs-jprime-target-or-max-v1`) и provenance-классом `legacy-platform-dependent-v1`; это НЕ determinate: результат идентифицирует воспроизводимый АЛГОРИТМ, а не cross-runtime bit-exact значение | -| **Унаследованное платформенно охарактеризованное поведение** | target-driven CAM16/CAM16-UCS/Oklab/LPC, neutral/accent/sentiment policies и связанные поиски по `f64`; они поддерживают совместимость, но не дают cross-runtime bit-exact guarantee | +| **Платформенно охарактеризованное поведение** | target-driven CAM16/CAM16-UCS/Oklab и frozen contrast-score, neutral/accent policies и связанные поиски по `f64`; они не дают cross-runtime bit-exact guarantee и не являются LPC/readability verdict | | **Точность в отдельном эталонном профиле** | только операции, для которых конкретный release объявляет эталонный профиль и проверяемый конечный контракт | | **Явно не поддержано как стабильная гарантия** | Display-P3 solving, HDR/PQ/HLG, пространственное поле Glow/Material, индивидуальное восприятие, неизвестный browser/display pipeline | @@ -213,7 +218,7 @@ anchors - Одинаковый клиентский уровень разных семейств не обязан иметь одинаковый `t`. - Непрерывный результат не доказывает оптимальность на конечном выходном домене. - Текущие gamma, chroma и hue policies являются политиками совместимости или продукта, пока более сильный статус не доказан. -- Стабильный конечный runtime в целевой архитектуре использует заранее скомпилированный набор состояний, а не семантическое ветвление по произвольному `ColorCurve::at(f64)`. +- Стабильный конечный runtime в целевой архитектуре использует заранее скомпилированный набор состояний, а не семантическое ветвление по произвольному `ColorCurve::at(CurvePosition)`. ## Контраст и доступность @@ -221,7 +226,7 @@ anchors - Нормативный floor применяется только там, где его требует контракт клиента или компонента. - Core не определяет размер текста, essentialness, disabled/decorative status по имени роли. -- Экспериментальный компонент формы APCA текущего LPC или результат модели внешнего вида не меняет WCAG pass/fail. +- Frozen SAPC-shaped candidate-score или результат модели внешнего вида не меняет WCAG pass/fail и не является LPC/readability verdict. - Для финальной пары sRGB8 новый `wcag22-srgb8-contrast-v1` принимает явно объявленный критерий и возвращает строгий `Pass | Fail`; профиль, Q55-артефакт и full-domain proof входят в релиз. - Старое поле `wcagRatio` остаётся compatibility-диагностикой текущего resolver/runtime и не может автоматически рекламироваться как результат нового evaluator-а. - Цвет не должен быть единственным носителем смысла; текст, иконка и форма принадлежат компоненту. @@ -248,10 +253,11 @@ anchors ### `adaptTheme` Текущий контроллер является охарактеризованным механизмом совместимости. Он -валидирует stable Glow evidence и селективно перерешивает роль при переходе -между exact-no-op и `Indeterminate`, не ломая уже идущую цветовую анимацию. Это -не универсальная гарантия нормативного floor на каждом промежуточном кадре и -на всех возможных образцах. +валидирует stable Glow evidence и при переходе между exact-no-op и +`Indeterminate` использует свежий результат полного `resolveTheme`. Из него +селективно принимаются только stable Glow satellite variables, поэтому остальные +роли не обрывают уже идущую цветовую анимацию. Это не универсальная гарантия +нормативного floor на каждом промежуточном кадре и на всех возможных образцах. Нормативный целевой контракт строже: diff --git a/conformance/README.md b/conformance/README.md index ebfdbcdd..5fba329a 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -19,7 +19,7 @@ `crates/labcolors-conformance/tests/pack_v10_contract.rs`. Удаление/изменение семейства = major-bump с absence-законом на снятые файлы; история составов — в git. -- **Версия ядра** (`manifest.coreVersion`, для этого пака `0.2.0`) — версия +- **Версия ядра** (`manifest.coreVersion`, для этого пака `0.3.0`) — версия `labcolors-core`, из канона которой сгенерированы значения. Пак действителен ровно для этой версии ядра; при легитимной смене канона (значения якорей/ручек, формулы) генератор перегенерирует векторы и `coreVersion` @@ -41,6 +41,10 @@ | `wcag22.json` | финальная sRGB8-пара и явно выбранный критерий WCAG 2.2 | `{foreground, background, criterion, decision, *Q55, evidence*}` | | `manifest.json` | метаданные и capability manifest численных решений | `{packVersion, coreVersion, packDigest, counts, numericalCapabilities}` | +Поле `lc` — знаковая кандидатная оценка по `Ys` из версионированной замороженной +SAPC-shaped кривой; оно не валидирует LPC или читаемость. `wcagRatio` и +exact-семейство `wcag22` являются отдельными нормативными выходами. + Точные решения нейтральной оси — не параметры solver-а, а вычисленные мощности допустимых подмножеств полной 256-точечной оси `#000000…#FFFFFF`: @@ -85,20 +89,23 @@ adjacent bytes или нормативного отношения пересчи внешних клиентов промежуточная Glow-only capability-схема V1 удалена из public API: один `numericalCapabilityManifest()` сразу возвращает V2, без второго version-suffixed entrypoint. Это намеренная pre-client breaking - коррекция ложной схемы, а не поддержка двух конкурирующих контрактов. Форма V2: - `{schemaVersion, coverage, sites[], checksum}`, где `schemaVersion` — - независимый version domain capability-схемы (сейчас `2`); `coverage` — - `migrated-sites-only-v1` (перечислены только **уже мигрированные** - branch-sensitive sites, не утверждение полного аудита исторических - `f64`-ветвлений — он остаётся в scope #291); каждая строка `sites[]` несёт - `siteId` и семь списков стабильных ключей (`stableOutcomes`, - `compatibilityReleases`, `evidenceClasses`, `artifactIds`, `boundIds`, - `proofIds`, `runtimeAttestations`; пустой список — явное «evidence отсутствует», - не пропуск); `checksum` — FNV-1a-32 (8 lowercase hex) над canonical + коррекция ложной схемы, а не поддержка двух конкурирующих контрактов. Форма + manifest: `{schemaVersion, coverage, sites[], checksum}`. `schemaVersion` + независимо версионирует capability-схему. `coverage` — + `migrated-sites-only-v1`: `sites[]` является канонической проекцией только тех + branch-sensitive точек, которые внесены в типизированный реестр Core. Core- и + conformance-тесты проверяют уникальность строк и равенство проекции реестру; + release verifier проверяет форму и независимо пересчитывает `checksum`. Эти + gates не сканируют все `f64`-ветвления Core, поэтому отсутствие `siteId` + означает отсутствие capability claim, а не доказательство полного аудита. + + Каждая строка `sites[]` несёт `siteId` и семь списков стабильных ключей: + `stableOutcomes`, `compatibilityReleases`, `evidenceClasses`, `artifactIds`, + `boundIds`, `proofIds`, `runtimeAttestations`. Пустой список явно означает + отсутствие соответствующего evidence. `checksum` — FNV-1a-32 над canonical length-prefixed preimage с домен-сепаратором - `labcolors.numerical-capability.v2`. Release verifier и Swift-тесты - пересчитывают checksum НЕЗАВИСИМО от Rust-кода. Manifest содержит - `glow-target-or-maximum-v1` и proof-bound `wcag22-srgb8-contrast-v1`. + `labcolors.numerical-capability.v2`; release verifier и Swift-тесты + пересчитывают его независимо от Rust-кода. Отдельно full-domain WCAG proof несёт SHA-256 private admission-row: ровно десять live typed полей, которые разрешают mint terminal evidence, включая `boundStatus` и `fallbackStatus`. Это site-local proof binding, а не новые @@ -148,17 +155,21 @@ descriptor не являются solve-векторами: `Pack::generate()` в прогоняет все перечисленные manifest-ом семейства в pinned Linux x86_64 container. -Активный browser-gate теперь воспроизводит каждый вектор всех перечисленных -manifest-ом семейств внутри фактического wasm32 core runtime; anti-vacuum total -вычисляется из длин самих replayed family files, а не поддерживается отдельным -числом. Targeted parity-тесты отдельно проверяют публичную JS-границу. Это -доказывает wasm32-исполнение ядра против независимых байтов пака, но ещё не -прогоняет каждый вектор непосредственно через публичный JS API. Поэтому полная -conformance именно JS-поверхности текущего пака пока не заявляется. В -`native-conformance.yml` сохранён ручной macOS/arm64 reference path, но он не -запускается на PR/push и не считается достигнутой аттестацией текущего пака. -Полная runtime-матрица остаётся scope #258; допуск `DRIFT_TOL` задаёт правило -сравнения и не заменяет отсутствующий прогон. +Исполняемое runtime-покрытие задаётся самими workflow. `ci.yml` прогоняет +reference runner всего пака в Core на Linux x86_64 и отдельно воспроизводит +каждое manifest-семейство внутри wasm32 Core под закреплённым Chrome. Targeted +WASM-тесты проверяют публичную JS-границу, но каждый вектор пака через неё не +проходит, поэтому полная conformance JS API не заявляется. +`native-conformance.yml` воспроизводит все семейства через сгенерированный +Swift/UniFFI binding в закреплённом Linux x86_64 container. macOS/arm64 job +запускается только вручную и не является release gate. `publish.yml` принимает +тег только после успешных canonical exact-SHA запусков CI и Linux Swift/UniFFI +gate. + +Workflow-прогон сам по себе не добавляет запись в `runtimeAttestations`; +capability manifest заявляет только evidence, фактически записанное в его +строках. `DRIFT_TOL` определяет правило сравнения чисел и не расширяет +runtime-матрицу. ## Регенерация diff --git a/conformance/vectors/manifest.json b/conformance/vectors/manifest.json index 3d896cb3..8e8d0184 100644 --- a/conformance/vectors/manifest.json +++ b/conformance/vectors/manifest.json @@ -1,6 +1,6 @@ { "packVersion": "10.0.0", - "coreVersion": "0.2.0", + "coreVersion": "0.3.0", "packDigest": "15b8444d", "counts": { "contrasts": 40, diff --git a/crates/labcolors-conformance/src/lib.rs b/crates/labcolors-conformance/src/lib.rs index 5c154e0f..19690e9f 100644 --- a/crates/labcolors-conformance/src/lib.rs +++ b/crates/labcolors-conformance/src/lib.rs @@ -15,14 +15,15 @@ //! генератором ([`bin/gen`](../gen/index.html)) и закоммичены. Ожидаемые //! значения — это то, что выдаёт ядро, а не то, что «должно бы». Внешняя правда //! (опубликованные WCAG-якоря) сверяется ОТДЕЛЬНО раннером-референсом -//! (`tests/reference_runner.rs`), замыкая цепочку: закоммиченные векторы == -//! выход генератора == математика ядра == опубликованный стандарт. +//! (`tests/reference_runner.rs`). Равенство с опубликованным стандартом относится +//! только к проверяемому WCAG-подмножеству; остальные векторы характеризуют +//! версионированное поведение ядра, не доказывая человеческий смысл координат. //! //! # Семейства векторов //! //! | Файл | Что фиксирует | Источник в ядре | //! |------|---------------|-----------------| -//! | `contrasts.json` | (fg, bg, тема) → (Lc, WCAG) | `recheck_against` | +//! | `contrasts.json` | (fg, bg, тема) → (Ys candidate score, WCAG) | `recheck_against` | //! | `ladders.json` | позиция лестницы → (α_light, α_dark) | `LadderPosition::alpha_pair` | //! | `alpha.json` | подложка→α: композит и α_min | `alpha::composite_hex` / `alpha::min_alpha_hex` | //! | `solve.json` | (bg, контракт, тема) → цвет или типизированный failure | `solve` | @@ -88,9 +89,9 @@ const THEMES: [&str; 4] = ["light", "dark", "light-ic", "dark-ic"]; // Семейство: контрасты // ───────────────────────────────────────────────────────────────────────────── -/// Один контраст-вектор: перцептивный `Lc` и юридический WCAG-ratio переднего -/// плана на фоне под темой. Два числа отчитываются РАЗДЕЛЬНО — они измеряют -/// разное и никогда не смешиваются (инвариант ядра). +/// Один контраст-вектор: знаковая кандидатная оценка `Lc` по `Ys` и +/// юридический WCAG-ratio переднего плана на фоне под темой. Два числа +/// отчитываются раздельно и не смешиваются. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ContrastVector { @@ -100,7 +101,8 @@ pub struct ContrastVector { pub bg: String, /// Тема просмотра (kebab-ключ). pub theme: String, - /// Знаковый перцептивный контраст (LPC `Lc`). + /// Знаковая кандидатная оценка `Lc` по `Ys` из замороженной SAPC-shaped кривой; + /// не LPC/readability evidence. pub lc: f64, /// Контраст-ratio WCAG 2.1 (1–21). pub wcag_ratio: f64, @@ -243,14 +245,14 @@ pub fn generate_alpha() -> Vec { rename_all_fields = "camelCase" )] pub enum ContractSpec { - /// Контраст текста: цель `Lc`, юридический пол WCAG AA-text (4.5:1). + /// Текст: цель Ys candidate score `Lc`, юридический пол WCAG AA-text (4.5:1). Text { - /// Целевой перцептивный `Lc`. + /// Целевая кандидатная оценка `Lc` по `Ys`. lc: f64, }, - /// Контраст UI-элемента: цель `Lc`, пол WCAG AA-UI (3:1). + /// UI-элемент: цель Ys candidate score `Lc`, пол WCAG AA-UI (3:1). Ui { - /// Целевой перцептивный `Lc`. + /// Целевая кандидатная оценка `Lc` по `Ys`. lc: f64, }, /// Декоративная полоса `[floor, ceiling]` без юридического пола. @@ -286,11 +288,11 @@ pub enum SolveOutcome { Solved { /// Резолвнутый цвет, `#RRGGBB`. hex: String, - /// Знаковый перцептивный `Lc` на отданном hex. + /// Знаковая кандидатная оценка `Lc` по `Ys` на отданном hex. lc: f64, /// WCAG-ratio на отданном hex. wcag_ratio: f64, - /// Юридический пол переопределил перцептивную цель. + /// Юридический пол переопределил Ys candidate-score цель. floor_override: bool, }, /// Resolver не вернул цвет; category отделяет доказанную недостижимость от @@ -381,7 +383,7 @@ const SOLVE_CASES: [(&str, ContractSpec, &str); 8] = [ ), ("#101012", ContractSpec::Text { lc: 75.0 }, "dark"), ("#007AFF", ContractSpec::Text { lc: 60.0 }, "light"), - // Floorless target lies in the open LPC dead-zone gap: neither exact zero + // Floorless target lies in the frozen candidate curve's open dead-zone gap: neither exact zero // nor the minimum non-zero boundary is within the declared ±1 Lc budget. ( "#FFFFFF", @@ -975,8 +977,8 @@ mod tests { ); assert_eq!(manifest.pack_version, PACK_VERSION); assert_eq!( - manifest.core_version, "0.2.0", - "pack v10 остаётся привязан к core 0.2.0" + manifest.core_version, "0.3.0", + "pack v10 остаётся привязан к core 0.3.0" ); assert_eq!( pack.alpha.len(), diff --git a/crates/labcolors-core/Cargo.toml b/crates/labcolors-core/Cargo.toml index b15873b9..57142977 100644 --- a/crates/labcolors-core/Cargo.toml +++ b/crates/labcolors-core/Cargo.toml @@ -19,23 +19,11 @@ default = [] [dev-dependencies] pretty_assertions = "1.4" criterion = { version = "0.5", default-features = false, features = ["cargo_bench_support"] } -# Dev-only serialisation for the tree-mutating on-disk audit probe (#[serial]). -# Zero runtime deps: serial_test never appears in the normal dependency graph -# (verified by s2b_baseline_guards::supply_chain_serial_test_absent_from_normal_dep_tree). -serial_test = "3" -# Dev-only temp directory for isolation-safe on-disk probe: the real tree is never mutated. -# Zero runtime deps (verified by s2b_baseline_guards supply-chain test). +# Isolated on-disk scanner mutation proof; the real tree is never mutated. tempfile = "3.10" -# Dev-only property/fuzz engine for the mathematical-invariant tests -# (tests/property_invariants.rs). Zero runtime deps: proptest never appears in the -# normal dependency graph (verified by s2b_baseline_guards::supply_chain_proptest_*). -# Pin centralised in the workspace Cargo.toml (issue #29 dependency discipline). +# Property/fuzz engine for mathematical invariants; pin is workspace-owned. proptest = { workspace = true } -[[bench]] -name = "y_hk" -harness = false - [[bench]] name = "jp_inv" harness = false @@ -51,4 +39,3 @@ harness = false [[bench]] name = "glow_alpha" harness = false - diff --git a/crates/labcolors-core/benches/neutral_at.rs b/crates/labcolors-core/benches/neutral_at.rs index aaa61458..c4387ef9 100644 --- a/crates/labcolors-core/benches/neutral_at.rs +++ b/crates/labcolors-core/benches/neutral_at.rs @@ -13,8 +13,8 @@ //! Run: `cargo bench -p labcolors-core --bench neutral_at`. use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; -use labcolors_core::ViewingConditions; -use labcolors_core::neutral::{CurveParams, NeutralCurve}; +use labcolors_core::neutral::NeutralCurve; +use labcolors_core::{CurvePosition, ViewingConditions}; /// Interior curve positions on both branches (light `t ≤ 0.5`, dark `t > 0.5`), /// away from the exact-anchor short-circuits at 0, 0.5 and 1. @@ -22,22 +22,17 @@ const TARGETS: [f64; 8] = [0.05, 0.15, 0.30, 0.45, 0.55, 0.70, 0.85, 0.95]; fn bench_neutral_at(c: &mut Criterion) { let mut group = c.benchmark_group("neutral_at"); + let positions = TARGETS.map(|t| CurvePosition::new(t).expect("bench positions are valid")); for (label, vc) in [ ("srgb", ViewingConditions::srgb()), ("dim", ViewingConditions::dim_surround()), ] { - let curve = NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &vc, - ) - .expect("the canonical neutral anchors are valid"); + let curve = NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &vc) + .expect("the canonical neutral anchors are valid"); group.bench_with_input(BenchmarkId::new("interior", label), &curve, |b, curve| { b.iter(|| { - for &t in &TARGETS { - black_box(curve.at(black_box(t))); + for &position in &positions { + black_box(curve.at(black_box(position))); } }); }); diff --git a/crates/labcolors-core/benches/y_hk.rs b/crates/labcolors-core/benches/y_hk.rs deleted file mode 100644 index 4e386957..00000000 --- a/crates/labcolors-core/benches/y_hk.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Grey-axis inverse benchmark: analytic closed-form vs bisection. -//! -//! `y_hk` is the hottest link in `resolve_set`'s finishing path — it runs once -//! per resolved role to turn a target H-K-corrected lightness back into a grey -//! luminance. The reference path is a 64-iteration bisection, each iteration a -//! full CAM16 grey-axis forward pass (`grey_j`); the production path inverts the -//! chain analytically and polishes with two Newton steps. This bench fixes the -//! speedup factor between them under both viewing conditions. -//! -//! Run: `cargo bench -p labcolors-core --bench y_hk`. - -use criterion::{BenchmarkId, Criterion, black_box, criterion_group, criterion_main}; -use labcolors_core::ViewingConditions; -use labcolors_core::lpc::bench_support::{y_hk_analytic, y_hk_bisect}; - -/// A spread of target `J_HK` values across the achromatic range, including the -/// near-white band where `J_HK > 100` and both inverses must saturate at Y = 1. -const TARGETS: [f64; 8] = [2.0, 12.5, 28.0, 45.0, 58.4, 72.0, 88.0, 102.5]; - -fn bench_y_hk(c: &mut Criterion) { - let mut group = c.benchmark_group("y_hk"); - for (label, vc) in [ - ("srgb", ViewingConditions::srgb()), - ("dim", ViewingConditions::dim_surround()), - ] { - group.bench_with_input(BenchmarkId::new("analytic", label), &vc, |b, vc| { - b.iter(|| { - for &j in &TARGETS { - black_box(y_hk_analytic(black_box(j), vc)); - } - }); - }); - group.bench_with_input(BenchmarkId::new("bisect", label), &vc, |b, vc| { - b.iter(|| { - for &j in &TARGETS { - black_box(y_hk_bisect(black_box(j), vc)); - } - }); - }); - } - group.finish(); -} - -criterion_group!(benches, bench_y_hk); -criterion_main!(benches); diff --git a/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json b/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json index b600b8a3..f778ca88 100644 --- a/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json +++ b/crates/labcolors-core/contracts/solve-characterization-v1-linux-x64.json @@ -1,16 +1,16 @@ { "bg=#000000 contract=range(-20,-12) floor=default hue=200 chroma=relative(0.2)": "ok hex=#4C5859 lc_bits=c033eb2f56d7953a wcag_ratio_bits=4006c7903da3d9f9 floor_override=false jp_bits=4043484774ea7f53 h_ok_bits=4069582eba54d826 s_bits=3fc8040dd55b0ea2", - "bg=#000000 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#757575 lc_bits=c0407bf78d795563 wcag_ratio_bits=40123b279e4ef457 floor_override=true jp_bits=404a01cd7cd18ef1 h_ok_bits=4056780935e40784 s_bits=3f92b3eaf5dd28fb", - "bg=#000000 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#ADADAD lc_bits=c04e206032be5432 wcag_ratio_bits=4022b724a3df2cfd floor_override=false jp_bits=40526a89b1df20eb h_ok_bits=4056780938405de8 s_bits=3f90b66d677d74e9", - "bg=#000000 contract=text(-7.3) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.35) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=40567809384f18c2 s_bits=3f993b7f9da97591", - "bg=#000000 contract=text(-7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=40567809384f18c2 s_bits=3f993b7f9da97591", - "bg=#000000 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#C8C8C8 lc_bits=c052c005f67406b8 wcag_ratio_bits=40291a6c78422f5e floor_override=false jp_bits=4054c1f701e27089 h_ok_bits=40567809383d8de9 s_bits=3f902d390e925099", - "bg=#000000 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E2E2E2 lc_bits=c05685b4a1c4b863 wcag_ratio_bits=403035e2adc3bd56 floor_override=false jp_bits=4056d93b434508db h_ok_bits=405678093b47262a s_bits=3f8f9f3b38f57a7b", + "bg=#000000 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#757575 lc_bits=c0407bf78d795563 wcag_ratio_bits=40123b279e4ef457 floor_override=true jp_bits=404a01cd7cd18ef1 h_ok_bits=0000000000000000 s_bits=3f92b3eaf5dd28fb", + "bg=#000000 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#ADADAD lc_bits=c04e206032be5432 wcag_ratio_bits=4022b724a3df2cfd floor_override=false jp_bits=40526a89b1df20eb h_ok_bits=0000000000000000 s_bits=3f90b66d677d74e9", + "bg=#000000 contract=text(-7.3) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.35) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=0000000000000000 s_bits=3f993b7f9da97591", + "bg=#000000 contract=text(-7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=0000000000000000 s_bits=3f993b7f9da97591", + "bg=#000000 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#C8C8C8 lc_bits=c052c005f67406b8 wcag_ratio_bits=40291a6c78422f5e floor_override=false jp_bits=4054c1f701e27089 h_ok_bits=0000000000000000 s_bits=3f902d390e925099", + "bg=#000000 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E2E2E2 lc_bits=c05685b4a1c4b863 wcag_ratio_bits=403035e2adc3bd56 floor_override=false jp_bits=4056d93b434508db h_ok_bits=0000000000000000 s_bits=3f8f9f3b38f57a7b", "bg=#000000 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=0000000000000000", "bg=#000000 contract=text(30) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=403e000000000000 max_achievable_bits=0000000000000000", "bg=#000000 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=0000000000000000", @@ -21,30 +21,30 @@ "bg=#007AFF contract=text(-75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c052c00000000000 max_achievable_bits=c0517234ae859911", "bg=#007AFF contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=c0517234ae859911", "bg=#007AFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=4043e4ead7083128", - "bg=#007AFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#161616 lc_bits=40424c97c00ffa8c wcag_ratio_bits=4012050d7f553963 floor_override=true jp_bits=4026af7a61be9dc3 h_ok_bits=405678093a3690f4 s_bits=3fa0a9f5b9b7f5c6", + "bg=#007AFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#161616 lc_bits=40424c97c00ffa8c wcag_ratio_bits=4012050d7f553963 floor_override=true jp_bits=4026af7a61be9dc3 h_ok_bits=0000000000000000 s_bits=3fa0a9f5b9b7f5c6", "bg=#007AFF contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=4043e4ead7083128", "bg=#007AFF contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=4043e4ead7083128", "bg=#007AFF contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=4043e4ead7083128", - "bg=#101012 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#7C7C7C lc_bits=c0413aabbf5937d3 wcag_ratio_bits=401236d28e5eb135 floor_override=true jp_bits=404b703ca59c53df h_ok_bits=405678093959f144 s_bits=3f925ba04d130db6", - "bg=#101012 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#B0B0B0 lc_bits=c04e17cd17d4d882 wcag_ratio_bits=4021871cd1e7a8bf floor_override=false jp_bits=4052af5f842a02f0 h_ok_bits=405678093a0624e6 s_bits=3f90a4aec55c3f00", - "bg=#101012 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#CBCBCB lc_bits=c052c29b17b6cafc wcag_ratio_bits=40276e3c28d759ca floor_override=false jp_bits=405501cc74f52e6c h_ok_bits=405678093ab1fa8f s_bits=3f9020bdf8416400", - "bg=#101012 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E5E5E5 lc_bits=c0568e7f7b53513f wcag_ratio_bits=402e2d302749fa72 floor_override=false jp_bits=4057146c003d4a49 h_ok_bits=405678093a443f87 s_bits=3f8f8d6ff945ddf2", + "bg=#101012 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#7C7C7C lc_bits=c0413aabbf5937d3 wcag_ratio_bits=401236d28e5eb135 floor_override=true jp_bits=404b703ca59c53df h_ok_bits=0000000000000000 s_bits=3f925ba04d130db6", + "bg=#101012 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#B0B0B0 lc_bits=c04e17cd17d4d882 wcag_ratio_bits=4021871cd1e7a8bf floor_override=false jp_bits=4052af5f842a02f0 h_ok_bits=0000000000000000 s_bits=3f90a4aec55c3f00", + "bg=#101012 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#CBCBCB lc_bits=c052c29b17b6cafc wcag_ratio_bits=40276e3c28d759ca floor_override=false jp_bits=405501cc74f52e6c h_ok_bits=0000000000000000 s_bits=3f9020bdf8416400", + "bg=#101012 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E5E5E5 lc_bits=c0568e7f7b53513f wcag_ratio_bits=402e2d302749fa72 floor_override=false jp_bits=4057146c003d4a49 h_ok_bits=0000000000000000 s_bits=3f8f8d6ff945ddf2", "bg=#101012 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(30) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=403e000000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=0000000000000000", - "bg=#101012 contract=ui(-45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#929292 lc_bits=c0466d4fc2116bd4 wcag_ratio_bits=40186ec556f960d0 floor_override=false jp_bits=404fcbce5051da8e h_ok_bits=405678093ab1edbb s_bits=3f917ac53b790163", + "bg=#101012 contract=ui(-45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#929292 lc_bits=c0466d4fc2116bd4 wcag_ratio_bits=40186ec556f960d0 floor_override=false jp_bits=404fcbce5051da8e h_ok_bits=0000000000000000 s_bits=3f917ac53b790163", "bg=#101012 contract=ui(-45) floor=default hue=30 chroma=relative(0.6)": "ok hex=#DA7667 lc_bits=c0467b775ffca0ab wcag_ratio_bits=401880d70a1d09c9 floor_override=false jp_bits=4050528c88ca1229 h_ok_bits=403dba4820849792 s_bits=3fdc3aa9098e6b07", "bg=#6E6E6E contract=text(20) floor=default hue=145 chroma=relative(0.35)": "err floor_unreachable floor_bits=4012000000000000 max_ratio_bits=4010795fbcf07be5", "bg=#6E6E6E contract=text(35) floor=default hue=145 chroma=relative(0.35)": "err exceeds_range target_bits=4041800000000000 max_achievable_bits=4040274550888fa0", "bg=#6E6E6E contract=text(45) floor=default hue=145 chroma=relative(0.35)": "err exceeds_range target_bits=4046800000000000 max_achievable_bits=4040274550888fa0", - "bg=#767676 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=4056780938a3dab2 s_bits=3f8f11e24d3c081e", - "bg=#767676 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=4056780938a3dab2 s_bits=3f8f11e24d3c081e", + "bg=#767676 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=0000000000000000 s_bits=3f8f11e24d3c081e", + "bg=#767676 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=0000000000000000 s_bits=3f8f11e24d3c081e", "bg=#767676 contract=text(-75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c052c00000000000 max_achievable_bits=c0526fc4767ff28a", "bg=#767676 contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=c0526fc4767ff28a", "bg=#767676 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=4041eafa25e9ad94", - "bg=#767676 contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#040404 lc_bits=4041a482ea107a48 wcag_ratio_bits=40120e02e539145b floor_override=true jp_bits=401059092a4614da h_ok_bits=4056780939c7cfcc s_bits=3fa699b120176455", + "bg=#767676 contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#040404 lc_bits=4041a482ea107a48 wcag_ratio_bits=40120e02e539145b floor_override=true jp_bits=401059092a4614da h_ok_bits=0000000000000000 s_bits=3fa699b120176455", "bg=#767676 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=4041eafa25e9ad94", "bg=#767676 contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=4041eafa25e9ad94", "bg=#767676 contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=4041eafa25e9ad94", @@ -61,19 +61,19 @@ "bg=#FFFFFF contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=0000000000000000", "bg=#FFFFFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=405a829a490ad002", "bg=#FFFFFF contract=text(3) floor=none hue=0 chroma=neutral": "err below_contrast_floor target_bits=4008000000000000", - "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=405678093d6dbcc5 s_bits=3f92a6b801503a78", + "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", "bg=#FFFFFF contract=text(60) floor=default hue=0 chroma=neutral gamut=display-p3": "err gamut_unsupported", - "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=405678093d6dbcc5 s_bits=3f92a6b801503a78", - "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(75) floor=default hue=264 chroma=neutral": "ok hex=#676767 lc_bits=4052b2df683f84d1 wcag_ratio_bits=4016a01035db8f1f floor_override=false jp_bits=404715e2dbaab4e2 h_ok_bits=4056780936c282c8 s_bits=3f9384586968a014", - "bg=#FFFFFF contract=text(90) floor=default hue=264 chroma=neutral": "ok hex=#414141 lc_bits=40567dc4663d6f83 wcag_ratio_bits=40246a7d157dd3fe floor_override=false jp_bits=403dbb13a4f6a259 h_ok_bits=40567809376d897f s_bits=3f970efa32c221ba", - "bg=#FFFFFF contract=ui(45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#949494 lc_bits=404b2a103b08aa26 wcag_ratio_bits=4008448bd3f798b7 floor_override=true jp_bits=40501730c25c7ca2 h_ok_bits=405678093dcd90a5 s_bits=3f91699d6ed58610", + "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", + "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(75) floor=default hue=264 chroma=neutral": "ok hex=#676767 lc_bits=4052b2df683f84d1 wcag_ratio_bits=4016a01035db8f1f floor_override=false jp_bits=404715e2dbaab4e2 h_ok_bits=0000000000000000 s_bits=3f9384586968a014", + "bg=#FFFFFF contract=text(90) floor=default hue=264 chroma=neutral": "ok hex=#414141 lc_bits=40567dc4663d6f83 wcag_ratio_bits=40246a7d157dd3fe floor_override=false jp_bits=403dbb13a4f6a259 h_ok_bits=0000000000000000 s_bits=3f970efa32c221ba", + "bg=#FFFFFF contract=ui(45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#949494 lc_bits=404b2a103b08aa26 wcag_ratio_bits=4008448bd3f798b7 floor_override=true jp_bits=40501730c25c7ca2 h_ok_bits=0000000000000000 s_bits=3f91699d6ed58610", "bg=#FFFFFF contract=ui(45) floor=default hue=30 chroma=relative(0.6)": "ok hex=#DB796A lc_bits=404b13688ecb8170 wcag_ratio_bits=400827ad86a35ba7 floor_override=true jp_bits=40508499c7c25fa2 h_ok_bits=403dcf871712bc67 s_bits=3fdb57f17946ab92", "bg=not-a-color contract=text(60) floor=default hue=0 chroma=neutral": "err invalid_input message=\"expected #RRGGBB, got #not-a-color\"" } diff --git a/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json b/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json index 794e40ea..e3d55f3f 100644 --- a/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json +++ b/crates/labcolors-core/contracts/solve-characterization-v1-macos-aarch64.json @@ -1,16 +1,16 @@ { "bg=#000000 contract=range(-20,-12) floor=default hue=200 chroma=relative(0.2)": "ok hex=#4C5859 lc_bits=c033eb2f56d7953a wcag_ratio_bits=4006c7903da3d9f9 floor_override=false jp_bits=4043484774ea7f53 h_ok_bits=4069582eba54d826 s_bits=3fc8040dd55b0ea2", - "bg=#000000 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#757575 lc_bits=c0407bf78d795563 wcag_ratio_bits=40123b279e4ef457 floor_override=true jp_bits=404a01cd7cd18ef1 h_ok_bits=4056780935e40784 s_bits=3f92b3eaf5dd28fb", - "bg=#000000 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#ADADAD lc_bits=c04e206032be5432 wcag_ratio_bits=4022b724a3df2cfd floor_override=false jp_bits=40526a89b1df20eb h_ok_bits=4056780938405de8 s_bits=3f90b66d677d74e9", - "bg=#000000 contract=text(-7.3) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.35) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=40567809399b9177 s_bits=3f996ab47c9e865d", - "bg=#000000 contract=text(-7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=40567809384f18c2 s_bits=3f993b7f9da97591", - "bg=#000000 contract=text(-7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=40567809384f18c2 s_bits=3f993b7f9da97591", - "bg=#000000 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#C8C8C8 lc_bits=c052c005f67406b8 wcag_ratio_bits=40291a6c78422f5e floor_override=false jp_bits=4054c1f701e27089 h_ok_bits=40567809383d8de9 s_bits=3f902d390e925099", - "bg=#000000 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E2E2E2 lc_bits=c05685b4a1c4b863 wcag_ratio_bits=403035e2adc3bd56 floor_override=false jp_bits=4056d93b434508db h_ok_bits=405678093b47262a s_bits=3f8f9f3b38f57a7b", + "bg=#000000 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#757575 lc_bits=c0407bf78d795563 wcag_ratio_bits=40123b279e4ef457 floor_override=true jp_bits=404a01cd7cd18ef1 h_ok_bits=0000000000000000 s_bits=3f92b3eaf5dd28fb", + "bg=#000000 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#ADADAD lc_bits=c04e206032be5432 wcag_ratio_bits=4022b724a3df2cfd floor_override=false jp_bits=40526a89b1df20eb h_ok_bits=0000000000000000 s_bits=3f90b66d677d74e9", + "bg=#000000 contract=text(-7.3) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.35) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#323232 lc_bits=c01d669a65189e80 wcag_ratio_bits=3ffa34ec4ba6a14b floor_override=false jp_bits=40372a75abaac644 h_ok_bits=0000000000000000 s_bits=3f996ab47c9e865d", + "bg=#000000 contract=text(-7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=0000000000000000 s_bits=3f993b7f9da97591", + "bg=#000000 contract=text(-7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#333333 lc_bits=c01ea8482ca51395 wcag_ratio_bits=3ffa97f146345f78 floor_override=false jp_bits=40379a02890521a3 h_ok_bits=0000000000000000 s_bits=3f993b7f9da97591", + "bg=#000000 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#C8C8C8 lc_bits=c052c005f67406b8 wcag_ratio_bits=40291a6c78422f5e floor_override=false jp_bits=4054c1f701e27089 h_ok_bits=0000000000000000 s_bits=3f902d390e925099", + "bg=#000000 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E2E2E2 lc_bits=c05685b4a1c4b863 wcag_ratio_bits=403035e2adc3bd56 floor_override=false jp_bits=4056d93b434508db h_ok_bits=0000000000000000 s_bits=3f8f9f3b38f57a7b", "bg=#000000 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=0000000000000000", "bg=#000000 contract=text(30) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=403e000000000000 max_achievable_bits=0000000000000000", "bg=#000000 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=0000000000000000", @@ -21,30 +21,30 @@ "bg=#007AFF contract=text(-75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c052c00000000000 max_achievable_bits=c0517234ae859911", "bg=#007AFF contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=c0517234ae859911", "bg=#007AFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=4043e4ead7083128", - "bg=#007AFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#161616 lc_bits=40424c97c00ffa8c wcag_ratio_bits=4012050d7f553963 floor_override=true jp_bits=4026af7a61be9dc3 h_ok_bits=405678093a3690f4 s_bits=3fa0a9f5b9b7f5c6", + "bg=#007AFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#161616 lc_bits=40424c97c00ffa8c wcag_ratio_bits=4012050d7f553963 floor_override=true jp_bits=4026af7a61be9dc3 h_ok_bits=0000000000000000 s_bits=3fa0a9f5b9b7f5c6", "bg=#007AFF contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=4043e4ead7083128", "bg=#007AFF contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=4043e4ead7083128", "bg=#007AFF contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=4043e4ead7083128", - "bg=#101012 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#7C7C7C lc_bits=c0413aabbf5937d3 wcag_ratio_bits=401236d28e5eb135 floor_override=true jp_bits=404b703ca59c53df h_ok_bits=405678093959f144 s_bits=3f925ba04d130db6", - "bg=#101012 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#B0B0B0 lc_bits=c04e17cd17d4d882 wcag_ratio_bits=4021871cd1e7a8bf floor_override=false jp_bits=4052af5f842a02f0 h_ok_bits=405678093a0624e6 s_bits=3f90a4aec55c3f00", - "bg=#101012 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#CBCBCB lc_bits=c052c29b17b6cafc wcag_ratio_bits=40276e3c28d759ca floor_override=false jp_bits=405501cc74f52e6c h_ok_bits=405678093ab1fa8f s_bits=3f9020bdf8416400", - "bg=#101012 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E5E5E5 lc_bits=c0568e7f7b53513f wcag_ratio_bits=402e2d302749fa72 floor_override=false jp_bits=4057146c003d4a49 h_ok_bits=405678093a443f87 s_bits=3f8f8d6ff945ddf2", + "bg=#101012 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#7C7C7C lc_bits=c0413aabbf5937d3 wcag_ratio_bits=401236d28e5eb135 floor_override=true jp_bits=404b703ca59c53df h_ok_bits=0000000000000000 s_bits=3f925ba04d130db6", + "bg=#101012 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#B0B0B0 lc_bits=c04e17cd17d4d882 wcag_ratio_bits=4021871cd1e7a8bf floor_override=false jp_bits=4052af5f842a02f0 h_ok_bits=0000000000000000 s_bits=3f90a4aec55c3f00", + "bg=#101012 contract=text(-75) floor=default hue=264 chroma=neutral": "ok hex=#CBCBCB lc_bits=c052c29b17b6cafc wcag_ratio_bits=40276e3c28d759ca floor_override=false jp_bits=405501cc74f52e6c h_ok_bits=0000000000000000 s_bits=3f9020bdf8416400", + "bg=#101012 contract=text(-90) floor=default hue=264 chroma=neutral": "ok hex=#E5E5E5 lc_bits=c0568e7f7b53513f wcag_ratio_bits=402e2d302749fa72 floor_override=false jp_bits=4057146c003d4a49 h_ok_bits=0000000000000000 s_bits=3f8f8d6ff945ddf2", "bg=#101012 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(30) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=403e000000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=0000000000000000", "bg=#101012 contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=0000000000000000", - "bg=#101012 contract=ui(-45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#929292 lc_bits=c0466d4fc2116bd4 wcag_ratio_bits=40186ec556f960d0 floor_override=false jp_bits=404fcbce5051da8e h_ok_bits=405678093ab1edbb s_bits=3f917ac53b790163", + "bg=#101012 contract=ui(-45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#929292 lc_bits=c0466d4fc2116bd4 wcag_ratio_bits=40186ec556f960d0 floor_override=false jp_bits=404fcbce5051da8e h_ok_bits=0000000000000000 s_bits=3f917ac53b790163", "bg=#101012 contract=ui(-45) floor=default hue=30 chroma=relative(0.6)": "ok hex=#DA7667 lc_bits=c0467b775ffca0ab wcag_ratio_bits=401880d70a1d09c9 floor_override=false jp_bits=4050528c88ca1229 h_ok_bits=403dba4820849792 s_bits=3fdc3aa9098e6b07", "bg=#6E6E6E contract=text(20) floor=default hue=145 chroma=relative(0.35)": "err floor_unreachable floor_bits=4012000000000000 max_ratio_bits=4010795fbcf07be5", "bg=#6E6E6E contract=text(35) floor=default hue=145 chroma=relative(0.35)": "err exceeds_range target_bits=4041800000000000 max_achievable_bits=4040274550888fa0", "bg=#6E6E6E contract=text(45) floor=default hue=145 chroma=relative(0.35)": "err exceeds_range target_bits=4046800000000000 max_achievable_bits=4040274550888fa0", - "bg=#767676 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=4056780938a3dab2 s_bits=3f8f11e24d3c081e", - "bg=#767676 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=4056780938a3dab2 s_bits=3f8f11e24d3c081e", + "bg=#767676 contract=text(-30) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=0000000000000000 s_bits=3f8f11e24d3c081e", + "bg=#767676 contract=text(-60) floor=default hue=264 chroma=neutral": "ok hex=#FEFEFE lc_bits=c05247730308def7 wcag_ratio_bits=401203d2a75973ae floor_override=true jp_bits=4058edc95c7d0739 h_ok_bits=0000000000000000 s_bits=3f8f11e24d3c081e", "bg=#767676 contract=text(-75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c052c00000000000 max_achievable_bits=c0526fc4767ff28a", "bg=#767676 contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=c0526fc4767ff28a", "bg=#767676 contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=4041eafa25e9ad94", - "bg=#767676 contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#040404 lc_bits=4041a482ea107a48 wcag_ratio_bits=40120e02e539145b floor_override=true jp_bits=401059092a4614da h_ok_bits=4056780939c7cfcc s_bits=3fa699b120176455", + "bg=#767676 contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#040404 lc_bits=4041a482ea107a48 wcag_ratio_bits=40120e02e539145b floor_override=true jp_bits=401059092a4614da h_ok_bits=0000000000000000 s_bits=3fa699b120176455", "bg=#767676 contract=text(60) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=404e000000000000 max_achievable_bits=4041eafa25e9ad94", "bg=#767676 contract=text(75) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4052c00000000000 max_achievable_bits=4041eafa25e9ad94", "bg=#767676 contract=text(90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4056800000000000 max_achievable_bits=4041eafa25e9ad94", @@ -61,19 +61,19 @@ "bg=#FFFFFF contract=text(-90) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=c056800000000000 max_achievable_bits=0000000000000000", "bg=#FFFFFF contract=text(150) floor=default hue=264 chroma=neutral": "err exceeds_range target_bits=4062c00000000000 max_achievable_bits=405a829a490ad002", "bg=#FFFFFF contract=text(3) floor=none hue=0 chroma=neutral": "err below_contrast_floor target_bits=4008000000000000", - "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=405678093d6dbcc5 s_bits=3f92a6b801503a78", + "bg=#FFFFFF contract=text(30) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", "bg=#FFFFFF contract=text(60) floor=default hue=0 chroma=neutral gamut=display-p3": "err gamut_unsupported", - "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=405678093d6dbcc5 s_bits=3f92a6b801503a78", - "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=40567809393247c1 s_bits=3f8f614c4436cac4", - "bg=#FFFFFF contract=text(75) floor=default hue=264 chroma=neutral": "ok hex=#676767 lc_bits=4052b2df683f84d1 wcag_ratio_bits=4016a01035db8f1f floor_override=false jp_bits=404715e2dbaab4e2 h_ok_bits=4056780936c282c8 s_bits=3f9384586968a014", - "bg=#FFFFFF contract=text(90) floor=default hue=264 chroma=neutral": "ok hex=#414141 lc_bits=40567dc4663d6f83 wcag_ratio_bits=40246a7d157dd3fe floor_override=false jp_bits=403dbb13a4f6a259 h_ok_bits=40567809376d897f s_bits=3f970efa32c221ba", - "bg=#FFFFFF contract=ui(45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#949494 lc_bits=404b2a103b08aa26 wcag_ratio_bits=4008448bd3f798b7 floor_override=true jp_bits=40501730c25c7ca2 h_ok_bits=405678093ba724ad s_bits=3f91699d6ed58610", + "bg=#FFFFFF contract=text(60) floor=default hue=264 chroma=neutral": "ok hex=#767676 lc_bits=40510fca61d5b9ee wcag_ratio_bits=40122b3d05125164 floor_override=true jp_bits=404a3678a8f0c874 h_ok_bits=0000000000000000 s_bits=3f92a6b801503a78", + "bg=#FFFFFF contract=text(7.3) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.35) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.3999999999999995) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.449999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.499999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.549999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(7.599999999999999) floor=none hue=0 chroma=neutral": "ok hex=#EDEDED lc_bits=401e6aa8625b8742 wcag_ratio_bits=3ff2bb539763a677 floor_override=false jp_bits=4057afba8603b8da h_ok_bits=0000000000000000 s_bits=3f8f614c4436cac4", + "bg=#FFFFFF contract=text(75) floor=default hue=264 chroma=neutral": "ok hex=#676767 lc_bits=4052b2df683f84d1 wcag_ratio_bits=4016a01035db8f1f floor_override=false jp_bits=404715e2dbaab4e2 h_ok_bits=0000000000000000 s_bits=3f9384586968a014", + "bg=#FFFFFF contract=text(90) floor=default hue=264 chroma=neutral": "ok hex=#414141 lc_bits=40567dc4663d6f83 wcag_ratio_bits=40246a7d157dd3fe floor_override=false jp_bits=403dbb13a4f6a259 h_ok_bits=0000000000000000 s_bits=3f970efa32c221ba", + "bg=#FFFFFF contract=ui(45) floor=aa-ui hue=30 chroma=neutral": "ok hex=#949494 lc_bits=404b2a103b08aa26 wcag_ratio_bits=4008448bd3f798b7 floor_override=true jp_bits=40501730c25c7ca2 h_ok_bits=0000000000000000 s_bits=3f91699d6ed58610", "bg=#FFFFFF contract=ui(45) floor=default hue=30 chroma=relative(0.6)": "ok hex=#DB796A lc_bits=404b13688ecb8170 wcag_ratio_bits=400827ad86a35ba7 floor_override=true jp_bits=40508499c7c25fa2 h_ok_bits=403dcf871712bc67 s_bits=3fdb57f17946ab92", "bg=not-a-color contract=text(60) floor=default hue=0 chroma=neutral": "err invalid_input message=\"expected #RRGGBB, got #not-a-color\"" } diff --git a/crates/labcolors-core/examples/bg_ladder_anchors.rs b/crates/labcolors-core/examples/bg_ladder_anchors.rs index f12da7c8..7b181ca7 100644 --- a/crates/labcolors-core/examples/bg_ladder_anchors.rs +++ b/crates/labcolors-core/examples/bg_ladder_anchors.rs @@ -38,7 +38,7 @@ fn main() { for (label, hex) in light { let jp = LcsColor::from_hex_with_vc(hex, &vc_light) .expect("hex-литерал константный и валиден") - .jp; + .jp(); let step = prev.map(|p| jp - p).unwrap_or(0.0); println!("{label:48} {hex} J' = {jp:7.3} ΔJ' от пред. = {step:+.3}"); prev = Some(jp); @@ -49,7 +49,7 @@ fn main() { for (label, hex) in dark { let jp = LcsColor::from_hex_with_vc(hex, &vc_dark) .expect("hex-литерал константный и валиден") - .jp; + .jp(); let step = prev.map(|p| jp - p).unwrap_or(0.0); println!("{label:48} {hex} J' = {jp:7.3} ΔJ' от пред. = {step:+.3}"); prev = Some(jp); @@ -59,18 +59,22 @@ fn main() { let (label, hex) = elevated; let jp = LcsColor::from_hex_with_vc(hex, &vc_dark) .expect("hex-литерал константный и валиден") - .jp; + .jp(); println!("{label:48} {hex} J' = {jp:7.3}"); } - // Пары СОБСТВЕННОГО Figma-якоря labui (colors-stub/figma-reference.css) — - // по ADR-0001 конфиг labui меряет свои опоры, HIG выше — методологический референс. + // Клиентская фикстура из коммита `Labpics-Team/labui` + // `7cb2dc6de832c660bc3ff43472f2e6ab296d085b`, + // `packages/colors-stub/figma-reference.css` + // (blob `fda578b0b9dd52ac0c38927f7b47a803188fb012`). + // Литералы ниже характеризуют этот immutable snapshot, а не defaults Core. + // Значения HIG выше служат только методологическим ориентиром. println!("\n— labui-якоря, светлая —"); let mut prev: Option = None; for (label, hex) in [("bg-primary", "#FFFFFF"), ("bg-secondary", "#F7F8FA")] { let jp = LcsColor::from_hex_with_vc(hex, &vc_light) .expect("hex-литерал константный и валиден") - .jp; + .jp(); let step = prev.map(|p| jp - p).unwrap_or(0.0); println!("{label:12} {hex} J' = {jp:7.3} ΔJ' = {step:+.3}"); prev = Some(jp); @@ -84,14 +88,14 @@ fn main() { ] { let jp = LcsColor::from_hex_with_vc(hex, &vc_dark) .expect("hex-литерал константный и валиден") - .jp; + .jp(); let step = prev.map(|p| jp - p).unwrap_or(0.0); println!("{label:12} {hex} J' = {jp:7.3} ΔJ' = {step:+.3}"); prev = Some(jp); } // Прежний хвост печатал FILL_QUATERNARY_DJ через встроенную `RoleTable::default()`; - // та витрина вынесена из прод-API (ADR-0001 PR-c), а dJ'-контракты теперь живут + // та витрина вынесена из прод-API (ADR-0001), а dJ'-контракты теперь живут // в конфиге потребителя. Этот пример остаётся инструментом замера J'-якорей // (единственная физика, что ему нужна) — сам контракт dJ' инспектируется через // паспорт labui, не через встроенную таблицу. diff --git a/crates/labcolors-core/examples/figma_anchor_provenance.rs b/crates/labcolors-core/examples/figma_anchor_provenance.rs index afdd6969..05647a80 100644 --- a/crates/labcolors-core/examples/figma_anchor_provenance.rs +++ b/crates/labcolors-core/examples/figma_anchor_provenance.rs @@ -97,7 +97,7 @@ fn hex_of(rgb: [f64; 3]) -> String { fn dj(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> f64 { let fg = LcsColor::from_hex_with_vc(fg_hex, vc).expect("valid fg hex"); let bg = LcsColor::from_hex_with_vc(bg_hex, vc).expect("valid bg hex"); - (fg.jp - bg.jp).abs() + (fg.jp() - bg.jp()).abs() } fn main() { diff --git a/crates/labcolors-core/examples/tint_target_sweep.rs b/crates/labcolors-core/examples/tint_target_sweep.rs deleted file mode 100644 index 36c93e49..00000000 --- a/crates/labcolors-core/examples/tint_target_sweep.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Reproduces the RMS sweep that pins `TINT_TARGET_MP` (semantic.rs) using the -//! REAL engine curve. Run: -//! -//! ```sh -//! cargo +1.96.0 run -p labcolors-core --example tint_target_sweep -//! ``` -//! -//! Value-preserving provenance: this changes NO shipped value; it only measures -//! and prints. The metric is identical to the in-crate `#[cfg(test)]` test -//! `curve_fits_reference_plateau_colorfulness`: the residual is the RMS over the -//! reference plateau (Oklab L in `[0.45, 0.90]`) of -//! `|realised_curve_M'(l, target) − reference_M'|`, where the curve `M'` is the -//! GAMUT-CLAMPED engine curve built to `target` (NOT the raw target). All engine -//! work runs inside `labcolors_core::semantic::tint_target_sweep_repro`, so these -//! numbers cannot drift from the test. - -fn main() { - // Oklab-L plateau window (matches the in-crate test). - let (l_min, l_max) = (0.45_f64, 0.90_f64); - // Candidate CAM16-UCS M' targets, 0.01 resolution over [4.0, 8.0]. - let targets: Vec = (400..=800).map(|i| f64::from(i) / 100.0).collect(); - - let (plateau, sweep) = - labcolors_core::semantic::tint_target_sweep_repro(&targets, l_min, l_max); - - println!( - "plateau nodes (Oklab L in [{l_min}, {l_max}]): {}", - plateau.len() - ); - println!(" Oklab-L reference M'"); - for (l, mp) in &plateau { - println!(" {l:>7.4} {mp:>6.3}"); - } - - println!("\ntarget RMS MAX (|curve M' - ref M'|)"); - for (t, rms, max) in &sweep { - // Print every 0.2 M' to keep the table short. - if ((t * 100.0).round() as i64) % 20 == 0 { - println!(" {t:>5.2} {rms:>6.3} {max:>6.3}"); - } - } - - let (best_t, best_rms) = - sweep - .iter() - .copied() - .fold((f64::NAN, f64::INFINITY), |acc, (t, r, _)| { - if r < acc.1 { (t, r) } else { acc } - }); - let (rms_declared, max_declared) = sweep - .iter() - .find(|(t, _, _)| (t - 6.1).abs() < 1e-9) - .map_or((f64::NAN, f64::NAN), |&(_, r, m)| (r, m)); - - println!("\nplateau nodes: {}", plateau.len()); - println!("swept RMS-argmin target t* = {best_t:.3} (RMS {best_rms:.3})"); - println!( - "at declared TINT_TARGET_MP = 6.1 : RMS {rms_declared:.3}, MAX per-node {max_declared:.3}" - ); -} diff --git a/crates/labcolors-core/src/accent.rs b/crates/labcolors-core/src/accent.rs deleted file mode 100644 index 79ada5f8..00000000 --- a/crates/labcolors-core/src/accent.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Палитра акцентов как ДАННЫЕ: 10 именованных цветовых семейств, каждое — -//! якорный hex из Figma-примитивов, а не копия кода. -//! -//! Где [`sentiment`](crate::sentiment) отвечает на семантический вопрос «какой -//! оттенок у *Danger* при данном бренде, чтобы он не слился с ним», этот модуль -//! отвечает на более низкий вопрос — «каковы якорные цвета палитры акцентов -//! дизайн-системы». `Accent` — это перечисление 10 семейств (Red … Pink); -//! каждое несёт единственный измеренный якорный hex (`anchor_hex`), -//! из которого выводится якорный Oklab-оттенок (`prototype_hue`) -//! и полная рампа через `curve` (та же `AccentCurve`, что -//! строит ремень акцента на общей нейтральной лестнице). -//! -//! # Почему данные, а не 10 копий кода -//! -//! Рампа акцента строится одним законом (`AccentCurve`): общая -//! perceived-lightness лестница нейтрали + фиксированная доля граничной хромы -//! гамута на резолвленном оттенке. Отличие семейства от семейства — ровно один -//! якорный оттенок. Поэтому семейство — это *строка данных* (`anchor_hex`), а не -//! отдельный блок логики: добавить семейство = добавить измеренный hex, ноль -//! нового кода рампы. Это же устраняет дублирование с [`sentiment`](crate::sentiment): -//! четыре сентимента (Danger/Warning/Success/Info) больше не хранят собственные копии -//! hex — они ссылаются на семейство палитры -//! (`Sentiment::accent`). -//! -//! # Провенанс якорей — измерение, не выдумка -//! -//! Все 10 hex сняты с живой Figma-коллекции «🔵 4.1 Primitives» (переменные -//! `Accent/*`, режим Light-mode) через figma-console MCP, 2026-07-02. Протокол -//! замера, все режимы (Light / Dark / IC) и вычисленные Oklab-оттенки — -//! `reference/labui-accent-primitives.md`. Якорный hex — это -//! *измеренное* значение (не выведенное): его правильность фиксирует тест -//! `accent_anchor_hex_matches_figma_primitives_light_mode` против буквальных -//! значений Figma. -//! -//! # Только оттенок якоря — прототип -//! -//! Как и в [`sentiment`](crate::sentiment), из якоря берётся *только Oklab-оттенок*. Светлота и -//! хрома якоря не применяются: рампа кладёт оттенок на общую -//! perceived-lightness лестницу при фиксированной доле граничной хромы (см. -//! `AccentCurve::at`). Поэтому тёмный/IC-вариант -//! Figma-якоря не участвует в построении рампы — он задокументирован в -//! reference как часть замера, но прототипом служит светлый якорь. - -#[cfg(test)] -use crate::neutral::NeutralCurve; -#[cfg(test)] -use crate::scale::AccentCurve; -use crate::spaces::oklab::srgb_linear_to_oklab; -use crate::spaces::srgb::srgb_from_hex; - -/// Именованное цветовое семейство палитры акцентов. -/// -/// Ровно 10 семейств — фиксированная палитра дизайн-системы (Figma -/// `Accent/{Red,Orange,Yellow,Green,Teal,Mint,Blue,Indigo,Purple,Pink}`). -/// `Accent/Brand` намеренно НЕ входит: бренд — это *вход* (настраиваемый -/// пользователем оттенок, по умолчанию `#007AFF`), а не фиксированное семейство -/// палитры, ровно как фон — вход движка, а не роль. -/// -/// Порядок вариантов повторяет порядок семейств в коллекции Figma-примитивов -/// (семейства системных цветов HIG). Это НЕ монотонный Oklab-оттенок: Teal -/// (≈230.8°) идёт раньше Mint (≈189.0°), а Pink заворачивается к ≈17.9°. Порядок -/// — это порядок источника (Figma), а не сортировка по оттенку; единственный -/// инвариант, который проверяется тестом, — попарная различимость оттенков -/// (см. `all_ten_families_are_distinct_hues`), не их монотонность. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[non_exhaustive] -#[cfg(test)] -pub enum Accent { - /// Красный. Figma `Accent/Red` = `#FF3B30`. Прототип сентимента Danger. - Red, - /// Оранжевый. Figma `Accent/Orange` = `#FFA100`. Прототип сентимента Warning. - Orange, - /// Жёлтый. Figma `Accent/Yellow` = `#FFD000`. - Yellow, - /// Зелёный. Figma `Accent/Green` = `#34C759`. Прототип сентимента Success. - Green, - /// Бирюзовый (голубой). Figma `Accent/Teal` = `#5AC8FA`. - Teal, - /// Мятный. Figma `Accent/Mint` = `#00C7BE`. - Mint, - /// Синий. Figma `Accent/Blue` = `#3E87FF`. Прототип сентимента Info. - Blue, - /// Индиго. Figma `Accent/Indigo` = `#5856D6`. - Indigo, - /// Фиолетовый. Figma `Accent/Purple` = `#AF52DE`. - Purple, - /// Розовый. Figma `Accent/Pink` = `#FF2D55`. - Pink, -} - -#[cfg(test)] -impl Accent { - /// Все 10 семейств палитры в порядке коллекции Figma-примитивов (Red → Pink, - /// порядок семейств HIG — не сортировка по оттенку) — поверхность для - /// property-свипов в тестах и для будущей роль-таблицы акцентов (#59). - pub const ALL: [Accent; 10] = [ - Accent::Red, - Accent::Orange, - Accent::Yellow, - Accent::Green, - Accent::Teal, - Accent::Mint, - Accent::Blue, - Accent::Indigo, - Accent::Purple, - Accent::Pink, - ]; - - /// Якорный hex семейства — измеренное значение Figma-примитива `Accent/*` - /// (коллекция «🔵 4.1 Primitives», режим **Light-mode**, замер 2026-07-02). - /// - /// Это *измеренное*, а не выведенное значение: единственный вход, из которого - /// строится всё семейство. Провенанс и остальные режимы (Dark / IC) — - /// `reference/labui-accent-primitives.md`. Тёмный/IC-вариант не участвует в - /// построении рампы (прототип — светлый якорь, см. модульную документацию). - pub fn anchor_hex(self) -> &'static str { - match self { - Accent::Red => "#FF3B30", - Accent::Orange => "#FFA100", - Accent::Yellow => "#FFD000", - Accent::Green => "#34C759", - Accent::Teal => "#5AC8FA", - Accent::Mint => "#00C7BE", - Accent::Blue => "#3E87FF", - Accent::Indigo => "#5856D6", - Accent::Purple => "#AF52DE", - Accent::Pink => "#FF2D55", - } - } - - /// Стабильный kebab-ключ семейства (`"red"`, `"orange"`, …) — часть будущего - /// контракта имён ролей (`--lab-*-{key}`), фиксируется здесь, чтобы имена - /// семейств не расходились между модулями. - pub fn key(self) -> &'static str { - match self { - Accent::Red => "red", - Accent::Orange => "orange", - Accent::Yellow => "yellow", - Accent::Green => "green", - Accent::Teal => "teal", - Accent::Mint => "mint", - Accent::Blue => "blue", - Accent::Indigo => "indigo", - Accent::Purple => "purple", - Accent::Pink => "pink", - } - } - - /// Якорный **Oklab-оттенок** (градусы `[0, 360)`) семейства — оттенок его - /// якорного цвета, из которого строится рампа. Выводится из - /// [`anchor_hex`](Accent::anchor_hex), а не вводится вручную (то же правило, - /// что в [`Sentiment::prototype_hue`](crate::sentiment)): это исключает дрейф - /// между цветовыми моделями (HSB-оттенок отличается от Oklab на 12–46°). - pub fn prototype_hue(self) -> f64 { - oklab_hue_of(self.anchor_hex()) - } - - /// Рампа этого семейства на нейтральной лестнице `neutral` — та же - /// [`AccentCurve`], которой строится любой акцент: общая - /// perceived-lightness лестница + фиксированная доля граничной хромы на - /// резолвленном оттенке. Единственный вход, отличающий семейства, — якорный - /// hex. - /// - /// # Errors - /// - /// Возвращает `Err`, если [`AccentCurve::new`] не смогла разобрать якорный hex - /// (для встроенных якорей не наступает — все 10 валидны; ошибка возможна лишь - /// при будущем изменении константы на невалидную). - pub fn curve(self, neutral: &NeutralCurve) -> Result { - AccentCurve::new(self.anchor_hex(), neutral) - } -} - -/// Oklab-оттенок (градусы, `[0, 360)`) hex-цвета — ЕДИНСТВЕННАЯ реализация -/// формулы якорного оттенка в движке: `pub(crate)`, чтобы [`sentiment`](crate::sentiment) -/// потреблял её, а не держал вторую копию физики. -/// -/// # Panics -/// -/// Паникует на невалидном hex — вызывается только с встроенными якорями палитры, -/// корректность которых фиксируют тесты обоих модулей. -pub(crate) fn oklab_hue_of(hex: &str) -> f64 { - let lab = srgb_linear_to_oklab(srgb_from_hex(hex).expect("valid anchor hex")); - lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::curve::ColorCurve; - - fn neutral() -> NeutralCurve { - NeutralCurve::new("#FFFFFF", "#787880", "#101012") - .expect("канонические нейтральные якоря валидны — ошибка означает регресс парсера hex") - } - - /// Якорные hex, kebab-ключи и Oklab-оттенки заземлены в живой Figma - /// (коллекция «🔵 4.1 Primitives», переменные `Accent/*`, режим Light-mode, - /// обход через figma-console MCP, 2026-07-02 MSK). Тест фиксирует ЛЮБОЙ дрейф - /// константы от измеренного примитива, опечатку в `key()` (часть будущего - /// контракта имён `--lab-*-{key}`) и — числовым пином h° из reference §3 — - /// поломку самой цепочки srgb→Oklab→atan2 (нетавтологичное ожидание: числа - /// взяты из документа, не из вызова той же функции). - /// - /// Доказательство из Figma (Light-mode): - /// Accent/Red=#FF3B30 · Accent/Orange=#FFA100 · Accent/Yellow=#FFD000 - /// Accent/Green=#34C759 · Accent/Teal=#5AC8FA · Accent/Mint=#00C7BE - /// Accent/Blue=#3E87FF · Accent/Indigo=#5856D6 · Accent/Purple=#AF52DE - /// Accent/Pink=#FF2D55 - #[test] - fn accent_anchor_hex_matches_figma_primitives_light_mode() { - // (Accent, якорный hex Figma, kebab-ключ, Oklab h° из reference §3). - let expected: &[(Accent, &str, &str, f64)] = &[ - (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::Teal, "#5AC8FA", "teal", 230.8271), - (Accent::Mint, "#00C7BE", "mint", 189.0284), - (Accent::Blue, "#3E87FF", "blue", 259.8918), - (Accent::Indigo, "#5856D6", "indigo", 278.3368), - (Accent::Purple, "#AF52DE", "purple", 312.4106), - (Accent::Pink, "#FF2D55", "pink", 17.8982), - ]; - for (accent, hex, key, hue) in expected { - assert_eq!( - accent.anchor_hex(), - *hex, - "{accent:?}: anchor_hex() дрейфанул от заземлённого Figma-примитива" - ); - assert_eq!( - accent.key(), - *key, - "{accent:?}: key() разошёлся с контрактом имён семейств" - ); - // Допуск 5e-5: значения §3 напечатаны с 4 знаками после запятой. - assert!( - (accent.prototype_hue() - hue).abs() < 5e-5, - "{accent:?}: prototype_hue {} != {hue} (reference §3)", - accent.prototype_hue() - ); - } - // ALL покрывает ровно перечисленные семейства — сравнение по СОДЕРЖИМОМУ - // (дубликат в ALL при сохранении длины 10 тоже упадёт). - let all: Vec = Accent::ALL.to_vec(); - let listed: Vec = expected.iter().map(|(a, ..)| *a).collect(); - assert_eq!( - all, listed, - "Accent::ALL разошёлся с заземлённым списком семейств" - ); - } - - /// Деривационная идентичность: якорный оттенок читается из Oklab-оттенка - /// якорного цвета, а не вводится вручную — исключает дрейф между цветовыми - /// моделями. Допуск < 1e-9 (точное равенство одной и той же функции); - /// нетавтологичный числовой пин h° живёт в табличном тесте выше. - #[test] - fn prototype_hue_is_the_anchor_oklab_hue() { - for a in Accent::ALL { - let want = oklab_hue_of(a.anchor_hex()); - assert!( - (a.prototype_hue() - want).abs() < 1e-9, - "{a:?}: prototype_hue {} != anchor Oklab hue {want}", - a.prototype_hue() - ); - } - } - - /// Все 10 якорных оттенков различимы (перцептивно разные семейства): любые - /// два отстоят более чем на 8° Oklab. Ловит опечатку в hex, из-за которой - /// два семейства схлопнулись бы в один оттенок. - #[test] - fn all_ten_families_are_distinct_hues() { - let hues: Vec = Accent::ALL.iter().map(|a| a.prototype_hue()).collect(); - for i in 0..hues.len() { - for j in (i + 1)..hues.len() { - let d = { - let raw = (hues[i] - hues[j]).rem_euclid(360.0); - raw.min(360.0 - raw) - }; - assert!( - d > 8.0, - "{:?} и {:?} слились по оттенку ({:.2}° < 8°)", - Accent::ALL[i], - Accent::ALL[j], - d - ); - } - } - } - - /// Каждое семейство строит валидную рампу той же [`AccentCurve`]: якорь - /// разбирается, hex-сэмплы валидны. Доказывает, что семейство — это данные - /// поверх одного закона рампы, а не 10 отдельных реализаций. - #[test] - fn every_family_builds_a_valid_ramp() { - let n = neutral(); - for a in Accent::ALL { - let curve = a.curve(&n).unwrap_or_else(|e| panic!("{a:?}: {e}")); - for hex in curve.sample_hex(13) { - assert!( - srgb_from_hex(&hex).is_ok(), - "{a:?}: невалидный hex в рампе {hex}" - ); - } - } - } -} diff --git a/crates/labcolors-core/src/accent_balance.rs b/crates/labcolors-core/src/accent_balance.rs index 12deb05c..14dac6af 100644 --- a/crates/labcolors-core/src/accent_balance.rs +++ b/crates/labcolors-core/src/accent_balance.rs @@ -1,90 +1,52 @@ -//! Агностичный примитив **БАЛАНСА АКЦЕНТА** — единый закон цветной детали -//! (директива владельца 2026-07-07). +//! Внутренний max-chroma-at-lightness recipe старого Glow-пути. //! -//! # Закон, который кодифицирует модуль -//! -//! У любой цветной/акцентной детали два КОНКУРИРУЮЩИХ требования: -//! **идентичность** (быть узнаваемой по своему оттенку) и **функция** (у -//! свечения — требуемая яркость центра, у текста — контраст-пол, у бейджа — -//! читаемая заливка). Наивно их мирят фикс-дельтой (например, «взять половину -//! хромы источника»), и на функциональной светлоте оттенок размывается к -//! near-white/near-black — идентичность теряется молча. -//! -//! Разрешение конфликта одно: найти светлоту `L`, удовлетворяющую ФУНКЦИИ, и на -//! ней взять **МАКСИМАЛЬНУЮ** хрому оттенка (`max_chroma` — стена гамута). -//! Тогда идентичность держится ровно так сильно, как позволяет гамут на нужной -//! светлоте, а не срезается произвольной долей. Если даже стена гамута на этой -//! `L` оставляет красочность ниже перцептивного пола — оттенок физически -//! вырожден, и это объявляется ЧЕСТНЫМ флагом [`BalancedAccent::hue_vanished`], -//! не молчанием. -//! -//! # Строгая агностичность (frozen northInvariant) -//! -//! Ноль labui-имён ролей, ноль хардкод-оттенков, ноль резерваций -//! (сентимент→оттенок): целевая светлота, оттенок и тема приходят ПАРАМЕТРАМИ. -//! Порог вырождения — НЕ новая ручка, а уже заземлённый перцептивный пол крейта -//! `TINT_PERCEPTIBLE_MP_FLOOR` (`M' = 1.5`, тот же, которым -//! [`crate::semantic`] объявляет `hue_vanished` у лейблов). Провенанс нового -//! кода: единственная новая величина — этот примитив — не несёт НИ ОДНОЙ новой -//! числовой константы (переиспользует `max_chroma` и существующий пол). +//! Он строит непрерывный Oklab-кандидат на стене sRGB-гамута для переданных +//! lightness и hue. Это точная геометрическая операция, а не доказательство +//! узнаваемости или сохранения перцептивной идентичности после преобразований и +//! квантования. Публичный occurrence-контракт не экспортирует этот recipe. 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; /// Результат применения закона баланса на требуемой светлоте. #[derive(Debug, Clone, PartialEq)] -pub struct BalancedAccent { +pub(crate) struct BalancedAccent { /// Целевая Oklab-светлота (функциональный пол), на которой взят баланс. pub l_ok: f64, /// Взятая Oklab-хрома — РАВНА стене гамута `max_chroma(l_ok, hue_deg)`. pub c_ok: f64, - /// Оттенок идентичности (градусы `[0, 360)`), сохранён по построению. + /// Переданная hue-координата (градусы `[0, 360)`), скопированная без изменения. pub hue_deg: f64, /// Отрисованный цвет ([`LcsColor`] под переданными `vc`). pub color: LcsColor, - /// Честный флаг вырождения: на этой светлоте даже МАКСИМАЛЬНАЯ хрома - /// оставляет красочность `M'` ниже `TINT_PERCEPTIBLE_MP_FLOOR` — оттенок - /// физически потерян (near-white/near-black), функция задавила - /// идентичность. Вызывающий видит потерю цвета флагом, а не тихо. - pub hue_vanished: bool, } /// Закон баланса акцента: на требуемой Oklab-светлоте `target_l_ok` взять /// МАКСИМАЛЬНУЮ хрому оттенка `hue_deg` в гамуте sRGB. /// -/// `target_l_ok` — функциональный пол (для свечения — целевая яркость центра; -/// для текста/бейджа — светлота, дающая контраст). `hue_deg` — оттенок -/// идентичности. `vc` — тема (физическое условие просмотра, не роль). Всё — -/// параметры: ядро агностично. +/// `target_l_ok` и `hue_deg` — геометрические параметры; `vc` задаёт viewing +/// conditions. Интерпретация этих координат не является частью примитива. /// -/// Идентичность сохраняется по построению (хрома максимальна, оттенок задан); -/// вырождение у краёв гамута объявляется флагом [`BalancedAccent::hue_vanished`]. -pub fn accent_balanced(target_l_ok: f64, hue_deg: f64, vc: &ViewingConditions) -> BalancedAccent { +pub(crate) fn accent_balanced( + target_l_ok: f64, + hue_deg: f64, + vc: &ViewingConditions, +) -> BalancedAccent { // ЗАКОН: максимум хромы на требуемой светлоте (не фикс-доля источника). 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; BalancedAccent { l_ok: target_l_ok, c_ok, hue_deg, color, - hue_vanished, } } #[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): на ЛЮБОЙ светлоте и оттенке баланс /// берёт РОВНО стену гамута — не долю. Мутация примитива к фикс-дельте @@ -108,52 +70,4 @@ mod tests { } } } - - /// ГЛАВНОЕ СВОЙСТВО (RED-proof): на ФУНКЦИОНАЛЬНОМ ПОЛУ glow-центра для - /// каждого оттенка баланс держит красочность выше порога вырождения — - /// оттенок НЕ вымывается. Пол намеренно яркий (пересвет центра), это - /// маргинальный режим: под фикс-дельтой (`0.5 × max_chroma`) красочность - /// падает ниже пола и `hue_vanished` краснит; под законом (полная стена) — - /// держится. Мутация примитива к доле → тест краснит. - #[test] - fn hue_survives_at_glow_core_functional_floor() { - 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}°" - ); - } - } - } - - /// ЧЕСТНОСТЬ ФЛАГА (обе стороны): у самой стены белого даже МАКСИМАЛЬНАЯ - /// хрома оставляет цвет near-white — `hue_vanished` обязан краснеть (не - /// молчать). Ловит примитив, который никогда не выставляет флаг. - #[test] - fn flag_fires_when_hue_physically_degenerates() { - 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; - } - } - 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..f8b10c56 100644 --- a/crates/labcolors-core/src/accent_golden_tests.rs +++ b/crates/labcolors-core/src/accent_golden_tests.rs @@ -1,56 +1,38 @@ -//! Contract 7 — AccentCurve / SentimentCurve golden snapshots. +//! Контракт 7 — эталонный снимок `AccentCurve`. //! -//! 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. +//! Защищает от тихого дрейфа значений. Остальные тесты проверяют свойства: +//! попадание в гамму, монотонность J′, неотрицательную насыщенность и достижение +//! контраста. Они не фиксируют фактически эмитированные цвета. Изменение +//! коэффициента кривой, огибающей хромы, поиска оттенка или шкалы CAM16-UCS +//! может сдвинуть каждый образец на несколько байтов, не нарушив этих свойств. +//! Так выглядел дрейф Bracket-path LUT (#50/#53). +//! Поэтому здесь побайтно зафиксированы 13 точек одной репрезентативной кривой. //! -//! 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. +//! Снимок получен 2026-06-12 через собственный `sample_hex(13)` кривой и её +//! унаследованные условия просмотра sRGB. use crate::curve::ColorCurve; use crate::neutral::NeutralCurve; use crate::scale::AccentCurve; -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") } -/// AccentCurve::new("#007AFF", neutral).sample_hex(13) — frozen. -/// Recalibration = a conscious, reviewed change to this constant. +/// Зафиксированный результат +/// `AccentCurve::new("#007AFF", neutral).sample_hex(13)`. +/// Перекалибровка требует осознанного и проверенного изменения этой константы. 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() { let neutral = neutral(); @@ -64,51 +46,17 @@ fn accent_curve_007af_sample_hex_13_matches_golden() { ); } -#[test] -fn sentiment_info_curve_sample_hex_13_matches_golden() { - 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); - assert_eq!( - got, SENTIMENT_INFO_GOLDEN, - "SentimentCurve(Info) ladder drifted from its golden snapshot. Golden перегенерирован \ - под закон Волны 1 (Info покоится на синем фокусе); будущий дрейф = регрессия." - ); -} - #[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 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(ACCENT_007AFF_GOLDEN[0]) > luma(ACCENT_007AFF_GOLDEN[12]), + "golden ladder must darken from first to last stop" + ); } diff --git a/crates/labcolors-core/src/accent_surface.rs b/crates/labcolors-core/src/accent_surface.rs deleted file mode 100644 index 445237c0..00000000 --- a/crates/labcolors-core/src/accent_surface.rs +++ /dev/null @@ -1,206 +0,0 @@ -//! Агностичный примитив акцентных **Background**-рамп (первый вертикальный -//! под-срез EC6+EC7). -//! -//! ЗАКОН ОДНОУРОВНЕВОСТИ. Из нейтральной surface-рампы Backgrounds, акцентного -//! оттенка, темы (viewing conditions) и материала выводится акцентная -//! Background-рампа, КАЖДАЯ ступень которой сидит на ТОЙ ЖЕ перцептивной -//! светлоте (CAM16-UCS J'), что одноимённая нейтральная ступень. Светлота не -//! решается заново — она наследуется из нейтрального скелета, поэтому -//! пер-уровневые ШАГИ светлоты акцентной рампы по построению равны шагам -//! нейтрали (`property_accent_surface.rs` доказывает это на ≥20 случайных -//! оттенках в обеих темах). -//! -//! # Строгая агностичность (frozen northInvariant) -//! -//! Ноль labui-имён ролей, ноль хардкод-оттенков, ноль резерваций -//! (сентимент→оттенок) в ядре: оттенок, нейтральная рампа и тема приходят -//! ПАРАМЕТРАМИ. Порядок выходной рампы = порядок входной (иерархия -//! Primary..Tertiary — семантика ПОТРЕБИТЕЛЯ, ядро лишь сохраняет порядок). -//! Гейты `agnostic_production_surface` это стерегут. -//! -//! # Хрома — ЕДИНЫЙ закон баланса, не второй рецепт (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' — а с ним -//! одноуровневость — наследуется алгеброй, а не переизобретается. - -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::spaces::vc::ViewingConditions; - -/// Материал выдачи ступени акцентного фона. Флаг представления — деривация -/// светлоты от материала не зависит. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SurfaceMaterial { - /// Непрозрачный цвет. - Solid, - /// Полупрозрачный тинт над базой темы; композит == солиду (см. модульную доку). - Alpha, -} - -/// Одна отрисованная ступень акцентного фона. -#[derive(Debug, Clone, PartialEq)] -pub enum AccentSurface { - /// Непрозрачный `#RRGGBB`. - Solid { hex: String }, - /// Тинт `#RRGGBB` + фактическая α, чей композит над базой равен солиду. - 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) -} - -/// Вывести акцентную Background-рампу из нейтральной surface-рампы. -/// -/// Для каждой нейтральной ступени — акцентный фон на ТОЙ ЖЕ перцептивной -/// светлоте; порядок сохраняется (иерархия входа). Пер-уровневые шаги светлоты -/// выхода по построению равны шагам нейтрали — ЗАКОН ОДНОУРОВНЕВОСТИ. Хрома — -/// ЕДИНЫЙ закон баланса ([`accent_balanced`]), НЕ отдельная ручка; каждый -/// [`BalancedAccent`] несёт свой `hue_vanished` (честность вырождения у краёв). -/// -/// `hue_deg` — акцентный оттенок (градусы, параметр); `vc` — тема (srgb=светлая, -/// dim=тёмная). -pub fn derive_accent_surface_ramp( - neutral: &[LcsColor], - hue_deg: f64, - vc: &ViewingConditions, -) -> Vec { - neutral - .iter() - .map(|n| accent_level(n, hue_deg, vc)) - .collect() -} - -/// Отрисовать ступень акцентного фона в выбранном материале. -/// -/// `Solid` → hex цвета через `vc` кривой. `Alpha` → альфа-аналог над `base_hex` -/// с запрошенной прозрачностью `requested_alpha`; фактическая α поднимается до -/// разрешимой в гамуте, композит остаётся ТОЧНО равным солиду (см. -/// [`crate::alpha`]). База — Background-ступень темы, над которой лежит фон. -/// -/// # Errors -/// -/// `Err` при невалидном `base_hex`. `Alpha` над мусор-цветом невозможен — но -/// `color`/`vc` из нашей математики, поэтому солид-hex всегда валиден. -pub fn render_surface( - color: &LcsColor, - material: SurfaceMaterial, - base_hex: &str, - requested_alpha: f64, - vc: &ViewingConditions, -) -> Result { - let solid_hex = color.to_hex_with_vc(vc); - match material { - SurfaceMaterial::Solid => Ok(AccentSurface::Solid { hex: solid_hex }), - SurfaceMaterial::Alpha => { - let (tint_hex, alpha) = - resolve_alpha_analog_hex(&solid_hex, requested_alpha, base_hex)?; - Ok(AccentSurface::Alpha { tint_hex, alpha }) - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::neutral::NeutralCurve; - - /// Нейтральная surface-рампа из шести ступеней (образец для in-crate проверок). - fn neutral_ramp(vc: &ViewingConditions) -> Vec { - let curve = NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &crate::neutral::CurveParams::default(), - vc, - ) - .unwrap(); - [0.15, 0.30, 0.45, 0.62, 0.80, 0.95] - .iter() - .map(|&t| curve.at(t)) - .collect() - } - - /// GREEN: выведенная акцентная рампа держит светлотные шаги нейтрали. - #[test] - fn accent_ramp_inherits_neutral_lightness_steps() { - let vc = ViewingConditions::srgb(); - let neutral = neutral_ramp(&vc); - let accent = derive_accent_surface_ramp(&neutral, 264.0, &vc); - 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}" - ); - } - } - - /// Хрома в гамуте: эмиссия акцента не выходит за sRGB-куб на всех ступенях. - #[test] - 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 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}" - ); - } - } - } - - /// Материал — флаг: 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 base_hex = "#FFFFFF"; - for surface in &accent { - let color = &surface.color; - let solid_hex = color.to_hex_with_vc(&vc); - let AccentSurface::Alpha { tint_hex, alpha } = - render_surface(color, SurfaceMaterial::Alpha, base_hex, 0.5, &vc).unwrap() - else { - panic!("Alpha материал обязан дать Alpha-вариант"); - }; - let recomposed = crate::alpha::composite_hex(&tint_hex, alpha, base_hex).unwrap(); - assert_eq!(recomposed, solid_hex, "альфа-композит разошёлся с солидом"); - } - } -} diff --git a/crates/labcolors-core/src/agnostic_gates.rs b/crates/labcolors-core/src/agnostic_gates.rs index bdd48d88..376cc5bd 100644 --- a/crates/labcolors-core/src/agnostic_gates.rs +++ b/crates/labcolors-core/src/agnostic_gates.rs @@ -1,4 +1,4 @@ -//! Agnostic-core gates (ADR-0001 PR-c). Three guarantees, all in-crate `#[cfg(test)]` +//! Agnostic-core gates (ADR-0001). Three guarantees, all in-crate `#[cfg(test)]` //! because they consume the relocated labui fixture (`crate::config::fixture`): //! //! 1. **Frozen golden** — `resolve_named_set` of the labui fixture is byte-for-byte @@ -16,8 +16,8 @@ use crate::ladder::{LadderPosition, ThemeAnchors}; use crate::solve::Floor; use crate::{ BgInput, Brand, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, - PaletteFamily, Resolved, RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, - ThemesConfig, VcPreset, ViewingConditions, resolve_named_set, + PaletteFamily, Resolved, RoleRecipe, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, + resolve_named_set, }; // ───────────────────────────────────────────────────────────────────────────── @@ -169,24 +169,9 @@ fn accepted_endpoint_recovery_keeps_previously_false_failed_borders_legal() { } // ───────────────────────────────────────────────────────────────────────────── -// 2. Named-path text-hierarchy compression +// 2. Opaque named roles never acquire undeclared hierarchy // ───────────────────────────────────────────────────────────────────────────── -const LABELS: [&str; 4] = [ - "label-primary", - "label-secondary", - "label-tertiary", - "label-quaternary", -]; - -fn abs_lc(set: &[(String, Resolved)], name: &str) -> f64 { - set.iter() - .find(|(n, _)| n == name) - .and_then(|(_, r)| r.lc()) - .map(f64::abs) - .unwrap_or_else(|| panic!("role `{name}` missing/unreachable")) -} - fn compressed(set: &[(String, Resolved)], name: &str) -> bool { set.iter() .find(|(n, _)| n == name) @@ -203,91 +188,64 @@ fn hex(set: &[(String, Resolved)], name: &str) -> String { } #[test] -fn hierarchy_pass_fires_and_flags_when_ladder_is_squeezed() { - // `#767676` — a near-AA mid-grey where the readable window is narrower than the - // label steps: primary and secondary are floored onto one colour. The pass - // makes that HONEST (compressed flag), not a silent collapse. A neutral anchor - // is flagged compressed ONLY by the pass (`Resolved::color` => compressed:false), - // so this is the RED-proof: disabling the pass drops the flag. - // - // Was `#747474` before the constructive-grey fix in `build_color`: the - // synchronized neutral anchor moved primary two quanta stronger there - // (#FAFBFF→#FCFCFF), which re-opened a distinguishable legal step — the - // squeeze band shifted to {#757575,#767676} (light-on-dark) / - // {#777777,#787878} (dark-on-light). Mid-band keeps the copy-collapse - // branch (`demote_below` → None → copy of the senior) under test. - let table = labui_reference().compile_named_role_table().unwrap(); +fn named_roles_do_not_gain_hierarchy_from_declaration_order() { + // Этот фон — anti-vacuum witness: два независимо решённых якоря фикстуры + // квантуются в один байтовый цвет. Соседство в декларации и client-owned + // имена не дают Core права изобретать между ними ребро и менять результат. + let config = labui_reference(); + let table = config.compile_named_role_table().unwrap(); let bg = BgInput::solid("#767676").unwrap(); let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) - .expect("валидная hierarchy-фикстура обязана резолвиться"); + .expect("valid opaque-role fixture must resolve"); + let mut reversed_config = config; + let declared_names: Vec<_> = reversed_config + .roles + .iter() + .map(|(name, _)| name.clone()) + .collect(); + reversed_config.roles.reverse(); + let reversed_names: Vec<_> = reversed_config + .roles + .iter() + .map(|(name, _)| name.clone()) + .collect(); assert_eq!( - hex(&set, "label-primary"), - hex(&set, "label-secondary"), - "on #767676 secondary is expected floored onto primary" + reversed_names, + declared_names.into_iter().rev().collect::>(), + "anti-vacuum: контрольная конфигурация обязана реально сменить порядок" ); - assert!( - compressed(&set, "label-secondary"), - "squeezed junior MUST carry the compressed flag — the pass fired" + let reversed_table = reversed_config.compile_named_role_table().unwrap(); + let reversed = resolve_named_set(&bg, &reversed_table, &ViewingConditions::srgb()) + .expect("тот же контракт в обратном порядке обязан резолвиться"); + let by_name: std::collections::BTreeMap<_, _> = + set.iter().map(|(name, value)| (name, value)).collect(); + let reversed_by_name: std::collections::BTreeMap<_, _> = + reversed.iter().map(|(name, value)| (name, value)).collect(); + assert_eq!( + by_name, reversed_by_name, + "физический результат не должен зависеть от порядка client-owned ролей" ); - assert!( - !compressed(&set, "label-primary"), - "senior must not be flagged compressed" + + assert_eq!( + hex(&set, "label-primary"), + hex(&set, "label-secondary"), + "fixture must exercise the equality that used to trigger inferred hierarchy" ); - let mags: Vec = LABELS.iter().map(|l| abs_lc(&set, l)).collect(); - for w in mags.windows(2) { + for role in ["label-primary", "label-secondary", "border-strong"] { assert!( - w[0] + 1e-9 >= w[1], - "label ladder must stay non-strict-descending, got {mags:?}" + !compressed(&set, role), + "opaque role `{role}` acquired an undeclared order relation" ); } } -#[test] -fn hierarchy_pass_does_not_sweep_in_lone_anchors() { - // `border-strong` (0.9734) is a lone anchor, not a label-ladder rung: the - // grouping reads strictly-descending runs off the config, so it is never - // compressed. (`icon` был вторым lone-anchor; канон #92 снёс роль — глиф - // теперь label-tertiary, штатный rung лестницы.) `#767676` sits mid squeeze - // band (see `hierarchy_pass_fires_and_flags_when_ladder_is_squeezed`), so the - // pass demonstrably fires on the labels while leaving `border-strong` alone. - let table = labui_reference().compile_named_role_table().unwrap(); - let bg = BgInput::solid("#767676").unwrap(); - let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) - .expect("валидная hierarchy-фикстура обязана резолвиться"); - assert!( - !compressed(&set, "border-strong"), - "border-strong must not join the label ladder" - ); -} - -#[test] -fn hierarchy_pass_is_a_noop_on_the_golden_grid() { - // No golden background sits in the squeeze band — why the frozen golden stays - // byte-for-byte green after the port. - let table = labui_reference().compile_named_role_table().unwrap(); - let (vcs, bgs) = grid(); - for (vc, _) in vcs { - for bg_hex in bgs { - let bg = BgInput::solid(bg_hex).unwrap(); - let set = resolve_named_set(&bg, &table, &vc) - .expect("golden hierarchy-фикстура обязана резолвиться"); - for l in LABELS { - assert!( - !compressed(&set, l), - "no label may be compressed on golden bg {bg_hex}: `{l}` was" - ); - } - } - } -} - // ───────────────────────────────────────────────────────────────────────────── // 3. Agnosticism proof — a SECOND, non-Daniel config // ───────────────────────────────────────────────────────────────────────────── /// A synthetic "Acme" design system: a warm brand hue, families and a neutral -/// unlike labui's Apple palette, its own sentiment and a small role set. Built +/// unlike the Lab UI fixture, with its own opaque family IDs and a small role set. Built /// entirely through the PUBLIC `ThemeConfig` surface — no engine constants, no /// fixture reuse — so compiling and resolving it proves the core carries no /// baked-in taxonomy. @@ -322,7 +280,6 @@ fn acme_config() -> ThemeConfig { dark: "#1A1614".to_string(), }, tint: NeutralTint { - ratio: 0.10, target_mp: 5.0, hue_stiffness: 8.0, // Derive the undertone hue from the client's own dark anchor — the @@ -343,16 +300,6 @@ fn acme_config() -> ThemeConfig { anchors: anchors("#5A7D2C", "#8FB65A"), }, ], - sentiments: SentimentsConfig { - 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![ ("day".to_string(), VcPreset::Srgb), diff --git a/crates/labcolors-core/src/alpha.rs b/crates/labcolors-core/src/alpha.rs index 9879201a..62ab6e9d 100644 --- a/crates/labcolors-core/src/alpha.rs +++ b/crates/labcolors-core/src/alpha.rs @@ -167,7 +167,7 @@ pub(crate) fn encoded_to_srgb8(rgb: [f64; 3], label: &str) -> Result<[u8; 3], St /// Форматирование эмитируемых sRGB8-байт в `#RRGGBB` — единая точка формата /// для композитора и потребителей его байтовых результатов (appearance-граф). pub(crate) fn hex_from_srgb8(rgb: [u8; 3]) -> String { - format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]) + crate::Srgb8::new(rgb).to_hex() } /// Общий внутренний путь для semantic-метрик: вход уже квантован для эмиссии, diff --git a/crates/labcolors-core/src/appearance.rs b/crates/labcolors-core/src/appearance.rs index 58b722a6..ea6f45dc 100644 --- a/crates/labcolors-core/src/appearance.rs +++ b/crates/labcolors-core/src/appearance.rs @@ -2,7 +2,7 @@ //! «input-слои → derived-поверхности → foreground occurrences». //! //! Граф владеет render-топологией, НЕ клиентским словарём: здесь нет имён -//! ролей, сентиментов и позиций лестницы — только непрозрачные typed handles и +//! ролей, семейств и позиций лестницы — только непрозрачные typed handles и //! физические байты. Любое поведение выводится из объявленных рёбер, никогда — //! из значений ID (ID структурны и не участвуют в физике). //! diff --git a/crates/labcolors-core/src/config.rs b/crates/labcolors-core/src/config.rs index 5189f364..24a1b0e3 100644 --- a/crates/labcolors-core/src/config.rs +++ b/crates/labcolors-core/src/config.rs @@ -23,23 +23,24 @@ //! //! # Рецепты лестницы и альфа-аналога //! -//! [`RoleRecipe::Ladder`] (акцентная/сентимент/бренд-лестница, поглощает GAP #59) +//! [`RoleRecipe::Ladder`] (семейная/бренд-лестница) //! компилируется в [`RoleSpec::Ladder`]: источник раскладывается в пер-темный //! тинт-якорь ([`crate::ladder::LadderTint`]), позиция несёт альфу Figma-рампы. //! [`RoleRecipe::AlphaAnalog`] компилируется в [`RoleSpec::AlphaAnalog`] (солид- //! цель источника + запрошенная альфа, композит-инверсия — [`crate::alpha`], #119). //! Резолв обоих — [`crate::semantic::Resolved::Translucent`] (тинт×альфа напрямую + солид- -//! композит на фоне резолва для замера контраста). Меню позиций + провенанс — -//! приложение A к `docs/decisions/0001-config-boundary.md`. +//! композит на фоне резолва для замера контраста). Исполняемый канон позиций и +//! альф — [`LadderPosition::ALL`] и [`LadderPosition::alpha_pair`]. //! //! # Агностичность: конфиг несёт СВОЙ словарь ролей //! //! Ядро не знает ни одной роли дизайн-системы — [`ThemeConfig`] обязан нести //! собственные `roles`/`aliases` (клиент вносит и значения, и семантику). Пустой //! контракт (без `roles` и без `aliases`) отклоняется на загрузке — -//! [`ConfigError::EmptyContract`]. Фоновая лестница дельтами (§4 плана BL-007) — -//! отдельный заход; сейчас фоны едут якорями конфига как есть. +//! [`ConfigError::EmptyContract`]. Текущая схема принимает фоновые якоря как есть; +//! произвольная graph/constraint-топология ещё не является публичным API. +use crate::Srgb8; use crate::ladder::{LadderPosition, LadderTint, ThemeAnchors}; use crate::semantic::{ DECORATIVE_FLOOR_MIN, DjMagnitude, NamedRoleTable, RoleChroma, RoleSpec, TextAnchor, @@ -85,29 +86,17 @@ const DJ_MIN_EXCLUSIVE: f64 = 0.0; const DECORATIVE_FLOOR_BOUND: &str = "magnitude ≥ 7.5 Lc (граница декоративной Lc-цели)"; // Lc-величина декоративной роли (тени) обязана лежать не ниже физического -// декоративного пола ядра. Единица — воспринимаемый контраст `Lc`; знак выбирает +// декоративного пола ядра. Единица — переходный Ys candidate score `Lc`; знак выбирает // физика от фона, поэтому конфиг несёт величину (модуль). Значение ниже // `DECORATIVE_FLOOR_MIN` попадает в квантованный low-contrast gap; ядро не // переписывает такую декларацию в другой контракт, а отклоняет её на загрузке. -/// Коэффициент хромы подтона (`neutral.tint.ratio`) обязан лежать в `[0, 1]`. -/// -/// Абсолютная хрома подтона = `ratio · max_chroma(L)`. `ratio = 0` — чистый серый -/// (допустимо: явный отказ от подтона), `ratio = 1` — максимум гамута. Значения вне -/// `[0, 1]` не имеют физического смысла (отрицательная хрома, либо запрос за стеной -/// гамута). Используется v1-путём ([`RoleChroma::Tinted`]); в дефолтном v2-пути -/// ([`RoleChroma::Curve`]) сила задаётся `target_mp`, но ручка всё равно -/// валидируется, т.к. экспонирована. -const TINT_RATIO_MIN_INCLUSIVE: f64 = 0.0; -/// Верхний предел коэффициента хромы подтона (включительно). -const TINT_RATIO_MAX_INCLUSIVE: f64 = 1.0; - /// Целевая красочность подтона (`neutral.tint.target_mp`, CAM16-UCS `M'`) обязана /// быть строго положительной. /// -/// `M'` — перцептивная красочность; отрицательная бессмысленна, нулевая = серый (для -/// серого есть явный `ratio = 0`). Реестр держит дефолт `6.1` на плато измеренной -/// референс-рампы; предел лишь отсекает нефизичное `≤ 0`. +/// `M'` — перцептивная красочность; отрицательная или нулевая цель вырождает +/// chromatic curve. Exact-gray anchor выбирает [`RoleChroma::Neutral`] до +/// применения этой ручки. Предел лишь отсекает нефизичное `≤ 0`. const TARGET_MP_MIN_EXCLUSIVE: f64 = 0.0; /// Жёсткость прижатия оттенка (`neutral.tint.hue_stiffness`) обязана быть @@ -120,33 +109,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` — не альфа. Резолвер поднимает @@ -157,9 +119,7 @@ const ALPHA_MIN_EXCLUSIVE: f64 = 0.0; /// Верхний предел запрошенной альфы (включительно; α = 1 = солид). const ALPHA_MAX_INCLUSIVE: f64 = 1.0; -// Канонический домен оттенка [0, 360) — единый дом в crate::sentiment -// (два независимых литерала одного домена = класс тихого расхождения). -use crate::sentiment::{HUE_DOMAIN_MAX_EXCLUSIVE, HUE_DOMAIN_MIN_INCLUSIVE}; +use crate::spaces::oklab::{HUE_DEG_MAX_EXCLUSIVE, HUE_DEG_MIN_INCLUSIVE, OklabHue, hue_of_srgb8}; // ───────────────────────────────────────────────────────────────────────────── // Ошибки валидации конфига. @@ -190,11 +150,6 @@ pub enum ConfigError { referenced_by: String, family: String, }, - /// Ссылка на категорию сентиментов, которой нет в `sentiments.categories`. - UnknownSentiment { - referenced_by: String, - sentiment: String, - }, /// Ссылка (алиас/alpha_analog) на роль, которой нет в `roles`. UnknownRole { referenced_by: String, role: String }, /// Дубликат ключа в словаре конфига или итоговом CSS-namespace: повтор имени @@ -210,9 +165,6 @@ pub enum ConfigError { referenced_by: String, field: &'static str, }, - /// Источник вывода оттенка ахроматичен (Oklab-хрома ≈ 0): hue математически - /// не определён — требуется явный hue_override_deg. - AchromaticHueSource { field: String }, /// Значение ручки вне допустимого предела. `handle` — путь до ручки, `bound` — /// человеко-читаемое описание нарушенного предела с обоснованием. OutOfBounds { @@ -220,16 +172,6 @@ pub enum ConfigError { value: f64, bound: &'static str, }, - /// Сентимент-солвер не смог развести оттенок (пустая легальная дуга, - /// недоменные углы/пороги). Отдельный вариант, а не - /// [`InvalidHex`](Self::InvalidHex): ошибка политики/геометрии, - /// замаскированная под ошибку парсинга hex, ломала бы матчинг - /// потребителя по вариантам. - SentimentResolution { - role: String, - sentiment: String, - reason: String, - }, /// Контракт пуст: ни ролей, ни алиасов. Резолв не эмитил бы ни одной роли — /// молчаливый приём увёл бы дефект на использование. Отказ обязан быть НА /// ЗАГРУЗКЕ (`#[serde(default)]` на `roles` на границе WASM разрешает ОПУСТИТЬ @@ -249,6 +191,10 @@ pub enum ConfigError { /// occurrence; для translucent не объявлено, какую occurrence ограничивать /// и разрешено ли менять tint, alpha или оба параметра. InvalidLadderFloor { role: String, reason: &'static str }, + /// Рецепт и общетабличная chroma-policy противоречат друг другу. Ошибка + /// принадлежит preflight: executable-таблица не должна откладывать её до + /// конкретной темы или первого runtime-resolve. + IncompatibleRolePolicy { role: String }, } impl std::fmt::Display for ConfigError { @@ -261,13 +207,6 @@ impl std::fmt::Display for ConfigError { f, "невалидное имя в поле `{field}`: {value:?} (допустимо [a-z0-9-]+, не пусто)" ), - ConfigError::UnknownSentiment { - referenced_by, - sentiment, - } => write!( - f, - "`{referenced_by}` ссылается на категорию сентиментов `{sentiment}`, которой нет в sentiments" - ), ConfigError::UnknownRole { referenced_by, role, @@ -282,10 +221,6 @@ impl std::fmt::Display for ConfigError { f, "`{referenced_by}` требует пер-темной нейтральной четвёрки `{field}`, которой нет в конфиге" ), - ConfigError::AchromaticHueSource { field } => write!( - f, - "источник оттенка `{field}` ахроматичен — hue не определён, задай hue_override_deg" - ), ConfigError::DuplicateKey { dictionary, key } => write!( f, "дубликат ключа `{key}` в словаре `{dictionary}` — lookup был бы неоднозначным" @@ -302,11 +237,6 @@ impl std::fmt::Display for ConfigError { value, bound, } => write!(f, "ручка `{handle}` = {value} вне предела: {bound}"), - ConfigError::SentimentResolution { - role, - sentiment, - reason, - } => write!(f, "сентимент `{sentiment}` (роль `{role}`): {reason}"), ConfigError::EmptyContract => write!(f, "контракт пуст: передайте roles"), ConfigError::EmptyThemes => write!(f, "словарь тем пуст: передайте themes"), ConfigError::MaterialFloorRequired { role } => write!( @@ -316,6 +246,10 @@ impl std::fmt::Display for ConfigError { ConfigError::InvalidLadderFloor { role, reason } => { write!(f, "ladder-роль `{role}` несёт невалидный floor: {reason}") } + ConfigError::IncompatibleRolePolicy { role } => { + write!(f, "material `{role}`: ")?; + f.write_str(RoleSpec::INCOMPATIBLE_CHROMA_REASON) + } } } } @@ -331,7 +265,7 @@ impl std::error::Error for ConfigError {} /// `rgba(якорь, α)` напрямую, а якорь берётся по теме резолва. /// /// Пер-темность (а не один якорь + вывод) — из заземления -/// (`reference/labui-accent-primitives.md` §2: Brand light `#007AFF` / +/// (`reference/labui-accent-primitives.md`, раздел «Якоря»: Brand light `#007AFF` / /// dark `#4A8FFF` / light-ic `#0040DD` / dark-ic `#409CFF`): тёмный/IC-вариант /// измерен, не выведен из светлого. #[derive(Debug, Clone, PartialEq)] @@ -355,16 +289,14 @@ pub struct NeutralAnchors { /// Ручки нейтрального подтона (политика силы и удержания оттенка). #[derive(Debug, Clone, PartialEq)] pub struct NeutralTint { - /// Коэффициент хромы подтона (v1 flat-путь): `[0, 1]`. - pub ratio: f64, /// Целевая перцептивная красочность CAM16-UCS `M'` (v2-кривая, «сила»): `> 0`. pub target_mp: f64, /// Жёсткость прижатия оттенка к каноническому (v2-кривая): `≥ 0`. pub hue_stiffness: f64, /// Явный оттенок подтона (градусы `[0, 360)`), если у потребителя есть - /// ИЗМЕРЕННАЯ величина (labui: SSOT 286.0°). `None` — движок выводит оттенок - /// из тёмного якоря нейтрали (та же деривация, которой была получена - /// labui-константа: `#101012` → 285.97°). + /// ИЗМЕРЕННАЯ величина (labui: SSOT 286.0°). `None` — движок классифицирует + /// точные sRGB8-байты тёмного якоря: равные каналы выбирают neutral policy, + /// неравные выводят направление hue (`#101012` → 285.97°). pub hue_override_deg: Option, } @@ -389,7 +321,7 @@ pub struct NeutralConfig { /// Именованное семейство палитры: ключ + пер-темные якорные hex. /// /// Якорь несётся отдельно для каждого режима (light/dark/light-ic/dark-ic): -/// заземление `reference/labui-accent-primitives.md` §2 показывает, что тёмный и +/// заземление `reference/labui-accent-primitives.md`, раздел «Якоря», показывает, что тёмный и /// IC-варианты Figma-примитивов `Accent/*` замерены, а не выведены из светлого /// (Red light `#FF3B30` / dark `#FF3A3A` / light-ic `#D70015` / dark-ic `#FF6161`). /// Лестница семейства ([`RoleRecipe::Ladder`] с [`LadderSource::Family`]) выбирает @@ -402,50 +334,6 @@ pub struct PaletteFamily { pub anchors: ThemeAnchors, } -/// Политика одной семантической категории потребителя: маппинг на семейство -/// палитры + категориальные ручки. -#[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, -} - -/// Конфиг сентиментов: категории + общие ручки различимости. -#[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, -} - /// Пресет условий просмотра из ЗАКРЫТОГО физического меню движка. /// /// Произвольных VC-чисел в конфиге нет — только выбор из четырёх калиброванных @@ -483,9 +371,12 @@ pub struct ThemesConfig { pub entries: Vec<(String, VcPreset)>, } -/// Рецепт роли из ФИЗИЧЕСКОГО меню (типология из [`crate::semantic`]). +/// Рецепт роли из закрытого физического меню текущего resolver-а. +/// +/// Это переходная compatibility surface, не target IR и не extension point. +/// Новая физика не должна добавляться новым recipe variant. /// -/// Все рецепты компилируются в [`RoleSpec`]: текст/dJ'/Lc/zero — солвер-роли, +/// Все рецепты компилируются в [`RoleSpec`]: текст/dJ'/Ys candidate score/zero — солвер-роли, /// [`Ladder`](Self::Ladder) / [`AlphaAnalog`](Self::AlphaAnalog) — полупрозрачная эмиссия. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] @@ -496,12 +387,11 @@ pub enum RoleRecipe { fraction: f64, /// WCAG-пол читаемости. floor: Floor, - /// Опциональный источник ОТТЕНКА семьи (ратификация ch5c, M1). `None` — - /// нейтральный лейбл (подтон таблицы, прежний путь). `Some(source)` — - /// ЦВЕТНОЙ лейбл: держит ТОТ ЖЕ контракт уровня (`fraction`/`floor`), что - /// нейтральный (одноуровневость по построению), но решённый в чистом - /// оттенке семьи-источника. Источник валидируется как у лестницы - /// (существование семейства/сентимента); аддитивен в JSON/DTO — + /// Опциональный источник физической цветовой идентичности. `None` берёт + /// neutral policy таблицы. `Some(source)` держит тот же контракт уровня + /// (`fraction`/`floor`), а точные sRGB8-байты источника определяют режим: + /// равные каналы остаются нейтральными, неравные задают направление hue. + /// Ссылка валидируется как у лестницы; JSON/DTO — /// `{kind:"text-anchor", fraction, floor, hue?: source}`. hue: Option, }, @@ -512,25 +402,24 @@ pub enum RoleRecipe { /// dJ'-якорь под тёмное окружение (`> 0`). dark: f64, }, - /// Декоративная роль в единице `Lc` (стек теней): величина, знак — от фона. + /// Декоративная роль в переходной Ys candidate-score единице `Lc` (стек теней): + /// величина, знак — от фона. Это не LPC/readability evidence. DecorativeLc { /// Величина `Lc` (`> 0`). magnitude: f64, }, - /// Ступень лестницы акцента/сентимента/бренда/нейтрали: `rgba(якорь, α)` - /// напрямую (поглощает акцентный GAP #59). `source` — откуда берётся тинт, - /// `position` — позиция закрытого меню (несёт свою альфу; перечень — - /// приложение A к ADR-0001). Компилируется в [`RoleSpec::Ladder`]. + /// Ступень переходной лестницы: `rgba(якорь, α)` напрямую. `source` — откуда берётся тинт, + /// `position` — позиция закрытого меню (несёт свою альфу; исполняемый канон — + /// [`LadderPosition::ALL`]). Компилируется в [`RoleSpec::Ladder`]. Ladder { - /// Источник тинта: бренд, семейство палитры, сентимент или нейтраль. + /// Источник тинта: бренд, семейство палитры или нейтраль. source: LadderSource, - /// Позиция меню (несёт пер-темную пару альф из стаба labui). + /// Позиция закрытого меню с собственной парой alpha по контекстам. position: LadderPosition, - /// Опциональный юр. пол UI (ратификация ch5c, M2). `None` — прежний путь - /// (тинт эмитится как есть). `Some(floor)` — только для СОЛИДНОЙ позиции - /// (`α=1`, напр. `BorderStrong`): семейный солид обязан держать пол - /// (3:1); если не держит — минимальный легальный сдвиг по кривой семьи с - /// честным флагом. Аддитивен в JSON — `{..., floor?: "aa-ui"}`. + /// Опциональный юридический пол UI. `None` эмитит тинт как есть; + /// `Some(floor)` допустим только для solid-позиции (`α=1`, например + /// `BorderStrong`): семейный solid обязан держать пол, иначе выполняется + /// минимальный легальный сдвиг по кривой семьи с явным флагом. floor: Option, }, /// Свечение: screen-слои цвета источника, интенсивность @@ -544,64 +433,49 @@ pub enum RoleRecipe { /// Обязательный numerical-decision profile; implicit legacy запрещён. decision_profile: crate::glow::GlowDecisionProfileV1, }, - /// Заливка пары «поверхность × лейбл» ([`crate::pair`]): якорь источника, - /// минимально сдвинутый по светлоте до победы перцептивно правильной - /// стороны лейбла в штатной полярности (Oklab: оттенок/хрома идентичности - /// целы). Лейбл на такой заливке решается ОБЫЧНЫМ nested resolve — пара - /// не второй текстовый закон, а подготовка поверхности. Эмиссия — солид - /// (лестничная сантехника с α = 1). + /// Переходная solid-эмиссия пары (внутренний модуль `pair`). Текущий heuristic + /// выбирает сторону и при необходимости сдвигает светлоту источника; это не + /// валидированный перцептивный закон. Результат не является поверхностью + /// [`PairLabel`](Self::PairLabel) и удаляется вместе с pair façade. PairFill { - /// Источник якоря: бренд, семейство, сентимент или нейтраль. + /// Источник якоря: бренд, семейство или нейтраль. source: LadderSource, }, - /// Лейбл ТИНТ-бейджа — лейбл-сторона пары «поверхность × лейбл» - /// ([`crate::pair`]). Семейно-оттеночный лейбл, чей WCAG-пол энфорсится - /// ПРОТИВ объявленной тинт-поверхности (композит семейного тинта при - /// compatibility-альфе позиции `fill-*-primary` над фоном резолва), а НЕ - /// против фона страницы и НЕ против эмитированного - /// [`PairFill`](Self::PairFill) — тот решается отдельно и не является - /// подложкой лейбла. Закрывает класс «контраст label↔tinted-fill - /// эмерджентен, не гарантирован»: обычные `label-*`/`fill-*-tinted` роли - /// решаются независимо против фона страницы, и их взаимный контраст на - /// тинт-подложке бейджа никем не констрейнится (для warning/статусных семей - /// на кривой оседает к ~3:1 и ниже). Здесь foreground решается ШТАТНЫМ - /// законом НА тинт-поверхности, собранной appearance-графом (#307), поэтому - /// пол гарантирован против той подложки, на которой лейбл реально стоит. - /// Недостижимость пола на кривой семьи выражается флагом `compressed` рядом - /// с фактическим результатом. Компилируется в - /// [`RoleSpec::PairLabel`]. + /// Переходный foreground пары. Он решается против внутренне синтезированной + /// tint-поверхности с alpha закрытой позиции `FillPrimary`, а не против + /// страницы и не против эмитированного [`PairFill`](Self::PairFill). + /// Наличие двух несвязанных поверхностей является известным разрывом SSOT; + /// target occurrence-граф заменяет оба варианта одной композицией. PairLabel { - /// Источник семьи оттенка/тинта: бренд, семейство, сентимент или нейтраль. + /// Источник физической цветовой идентичности: бренд, семейство или нейтраль. source: LadderSource, /// Доля максимума контраста тинт-поверхности `(0, 1]` (как у - /// [`TextAnchor`](Self::TextAnchor)): низкая доля = максимально «цветной» - /// лейбл у пола, высокая = ближе к нейтральному пределу. + /// [`TextAnchor`](Self::TextAnchor)): низкая доля оставляет больше места + /// для хромы источника у пола, высокая тянет к контрастному пределу. + /// Точный серый source при любой доле остаётся нейтральным. fraction: f64, /// WCAG-пол, энфорсимый ПРОТИВ тинт-поверхности (а не фона страницы). floor: Floor, }, - /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], - /// #119): `(tint, α)`, чей композит на фоне резолва равен солиду `of`. Даёт - /// `-tinted`-роли labui. Компилируется в [`RoleSpec::AlphaAnalog`]. + /// Альфа-аналог solid-источника через точечную композит-инверсию + /// ([`crate::alpha`]): `(tint, α)`, чей композит на объявленном фоне равен + /// solid-цели `of`. Компилируется в [`RoleSpec::AlphaAnalog`]. AlphaAnalog { - /// Источник солид-цели (бренд/семейство/сентимент), чей аналог берётся. + /// Источник солид-цели (бренд/семейство/нейтраль), чей аналог берётся. of: LadderSource, /// Запрошенная альфа `(0, 1]` (поднимается до `α_min`, если ниже). alpha: f64, }, - /// Двухслойный материал (стекло/акрил; канон — `docs/whitepaper.md` §3.7): пара «тинт `01` (с выведенной α) - /// и опаковая база `02`». База — семейно-оттеночная поверхность на целевом - /// перцептивном шаге тира; тинт — тот же тон, а альфа выведена из - /// композит-неравенства над коридором фонов. Компилируется в - /// [`RoleSpec::Material`]. + /// Переходная двухслойная point-композиция: tint с вычисленной alpha и + /// opaque base на заданном численном шаге `J'`. Она не моделирует glass, + /// blur или spatial field. Компилируется в [`RoleSpec::Material`]. /// /// Тир (`base/muted/soft/subtle`) — величина `tone` (|ΔJ'|): крупнее = - /// заметнее/плотнее. Семья — `source`: [`Neutral`](LadderSource::Neutral) даёт - /// нейтральный материал (подтон таблицы), остальные — семейно-оттеночный - /// (акцент-стекло/сентимент). + /// заметнее/плотнее. `source` задаёт физическую идентичность: точные равные + /// sRGB8-каналы дают нейтральный материал, неравные — материал в направлении + /// hue источника. Material { - /// Источник ОТТЕНКА семьи: бренд/семейство/сентимент/нейтраль. Нейтраль → - /// нейтральный материал; иное → семейно-оттеночный. + /// Источник физической цветовой идентичности: бренд/семейство/нейтраль. source: LadderSource, /// Целевой |ΔJ'| тона-базы под светлое окружение (`> 0`). tone_light: f64, @@ -668,11 +542,8 @@ fn reserve_css_names( /// Источник тинта лестницы/альфа-аналога: откуда берётся якорный цвет. /// /// Тинт bg-независим (это якорь источника), только пер-темен. Для [`Family`](Self::Family) -/// и [`Sentiment`](Self::Sentiment) `key` — ссылка на семейство/категорию конфига -/// (валидатор проверяет существование). Сентимент-источник разводит оттенок с -/// брендом сентимент-солвером ([`crate::sentiment`]); при бренде labui резолв -/// сентимента совпадает с сырым якорем семейства (деривационная идентичность — -/// тестом). +/// У [`Family`](Self::Family) `key` — непрозрачная ссылка на семейство конфига; +/// валидатор проверяет только существование и никогда не выводит смысл из имени. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub enum LadderSource { @@ -680,9 +551,6 @@ pub enum LadderSource { Brand, /// Семейство палитры по ключу (пер-темный якорь [`PaletteFamily`]). Family(String), - /// Сентимент-категория по имени: оттенок семейства, разведённый с брендом - /// сентимент-солвером (пер-темный солид на разрешённом оттенке). - Sentiment(String), /// Нейтральный тинт из [`NeutralConfig::anchors`] — семейство `Neutral/Derivable` /// стаба labui (`rgb(120 120 128 / …)` = `neutral.anchors.mid`). Скелетон и /// нейтральные fill/border/glow/focus-роли берут ЭТОТ источник, НЕ семейство @@ -716,10 +584,10 @@ pub enum NeutralPick { /// Полный конфиг темы потребителя (без сериализации — она на границе WASM). /// -/// `#[non_exhaustive]`: будущие поля (напр. фоновая лестница дельтами, §4 плана -/// BL-007) станут неломающими. Внешние крейты собирают конфиг через -/// [`ThemeConfig::new`](Self::new), не struct-литералом; поля остаются `pub` для -/// чтения и мутации. +/// Внешние крейты собирают конфиг через [`ThemeConfig::new`](Self::new), а не +/// struct-литералом. `#[non_exhaustive]` запрещает внешний struct-литерал, но не +/// обещает, что обязательный аргумент конструктора никогда не изменится; поля +/// остаются `pub` для чтения и мутации. #[derive(Debug, Clone, PartialEq)] #[non_exhaustive] pub struct ThemeConfig { @@ -729,8 +597,6 @@ pub struct ThemeConfig { pub neutral: NeutralConfig, /// Семейства палитры. pub palette: Vec, - /// Сентимент-политика. - pub sentiments: SentimentsConfig, /// Словарь тем. pub themes: ThemesConfig, /// Роли: имя (`[a-z0-9-]+`) → рецепт, в порядке объявления. @@ -751,19 +617,6 @@ fn is_valid_name(name: &str) -> bool { .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') } -use crate::sentiment::ACHROMATIC_CHROMA_EPS; - -/// Oklab-хрома hex-цвета (для гарда ахроматичности источников оттенка). -fn oklab_chroma_of_hex(hex: &str) -> f64 { - match crate::spaces::srgb::srgb_from_hex(hex) { - Ok(lin) => { - let lab = crate::spaces::oklab::srgb_linear_to_oklab(lin); - (lab[1] * lab[1] + lab[2] * lab[2]).sqrt() - } - Err(_) => 0.0, // невалидный hex ловится валидатором раньше - } -} - /// Проверить, что hex парсится ядром (только `#RRGGBB`). fn check_hex(field: &str, value: &str) -> Result<(), ConfigError> { crate::spaces::srgb::srgb_from_hex(value) @@ -813,25 +666,6 @@ fn check_in_excl_incl( } } -/// Проверить `min ≤ value ≤ max` (замкнутый интервал). -fn check_in_incl_incl( - handle: &str, - value: f64, - min_incl: f64, - max_incl: f64, - bound: &'static str, -) -> Result<(), ConfigError> { - if value >= min_incl && value <= max_incl { - Ok(()) - } else { - Err(ConfigError::OutOfBounds { - handle: handle.to_string(), - value, - bound, - }) - } -} - /// Проверить `value > min` (строго положительно). Неконечные значения (±∞, /// NaN) отвергаются всегда: открытый сверху предел — не лазейка для мусора. fn check_gt( @@ -872,15 +706,13 @@ fn check_ge( impl ThemeConfig { /// Собрать конфиг из полного набора полей. /// - /// Конструктор существует, потому что [`ThemeConfig`] помечен - /// `#[non_exhaustive]` (будущие поля — неломающие): внешние крейты (граница - /// WASM) собирают конфиг через него, а не struct-литералом. - #[allow(clippy::too_many_arguments)] + /// [`ThemeConfig`] помечен `#[non_exhaustive]`, поэтому внешние крейты + /// (включая WASM-границу) собирают его через этот конструктор, а не + /// struct-литералом. pub fn new( brand: Brand, neutral: NeutralConfig, palette: Vec, - sentiments: SentimentsConfig, themes: ThemesConfig, roles: Vec<(String, RoleRecipe)>, aliases: Vec<(String, String)>, @@ -889,7 +721,6 @@ impl ThemeConfig { brand, neutral, palette, - sentiments, themes, roles, aliases, @@ -903,9 +734,9 @@ impl ThemeConfig { /// отброшенным результатом (единый код-путь; паритет validate/compile не /// может разъехаться, потому что второго списка проверок не существует). /// Ловится и структурное (hex, имена, ссылки, пределы ручек), и - /// деривационное (ахроматичный источник оттенка, отсутствующие - /// edge/inverted-четвёрки, пустая легальная дуга сентимента). Первая - /// найденная ошибка возвращается сразу — клиент чинит по одной. + /// деривационное (например, ссылка роли на отсутствующую + /// edge/inverted-четвёрку). Первая найденная ошибка возвращается сразу — + /// клиент чинит по одной. /// /// # Errors /// @@ -916,9 +747,9 @@ impl ThemeConfig { /// Структурная фаза валидации: hex, имена, ссылки на семейства/источники /// лестницы, дубликаты словарей и пределы каждой экспонируемой ручки. - /// НЕ полный preflight: деривационные ошибки (ахроматичность, пустая дуга - /// сентимента) всплывают только в фазе компиляции — снаружи полноту даёт - /// [`validate`](Self::validate). + /// НЕ полный preflight: деривационные ошибки (например, ссылка роли на + /// отсутствующую edge/inverted-четвёрку) всплывают только в фазе компиляции — + /// полноту даёт [`validate`](Self::validate). fn validate_syntactic(&self) -> Result<(), ConfigError> { // Бренд: пер-темная четвёрка hex. check_theme_anchors("brand.anchors", &self.brand.anchors)?; @@ -939,13 +770,6 @@ impl ThemeConfig { } // Нейтраль: ручки подтона. - check_in_incl_incl( - "neutral.tint.ratio", - self.neutral.tint.ratio, - TINT_RATIO_MIN_INCLUSIVE, - TINT_RATIO_MAX_INCLUSIVE, - "0 ≤ ratio ≤ 1 (доля хромы подтона; 0 = серый, 1 = максимум гамута)", - )?; check_gt( "neutral.tint.target_mp", self.neutral.tint.target_mp, @@ -967,55 +791,8 @@ 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 = за стеной гамута)", - )?; - for cat in &self.sentiments.categories { - let name_field = format!("sentiments.{}.name", cat.name); - check_name(&name_field, &cat.name)?; - if !self.palette.iter().any(|f| f.key == cat.family) { - return Err(ConfigError::UnknownFamily { - referenced_by: format!("sentiments.{}", cat.name), - family: cat.family.clone(), - }); - } - if let Some(side) = cat.preferred_side { - if 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 { - if !(hue.is_finite() - && (HUE_DOMAIN_MIN_INCLUSIVE..HUE_DOMAIN_MAX_EXCLUSIVE).contains(&hue)) - { + if !(hue.is_finite() && (HUE_DEG_MIN_INCLUSIVE..HUE_DEG_MAX_EXCLUSIVE).contains(&hue)) { return Err(ConfigError::OutOfBounds { handle: "neutral.tint.hue_override_deg".to_string(), value: hue, @@ -1041,10 +818,6 @@ impl ThemeConfig { Ok(()) } check_unique("palette", self.palette.iter().map(|f| f.key.as_str()))?; - check_unique( - "sentiments.categories", - self.sentiments.categories.iter().map(|c| c.name.as_str()), - )?; check_unique( "themes", self.themes.entries.iter().map(|(n, _)| n.as_str()), @@ -1124,8 +897,8 @@ impl ThemeConfig { FRACTION_MAX_INCLUSIVE, "0 < fraction ≤ 1 (доля максимального контраста фона)", )?; - // Цветной лейбл (M1): источник оттенка обязан существовать — - // та же проверка, что у источника лестницы. + // Явный source-slot обязан ссылаться на существующий физический + // источник; chromatic/neutral режим определяется позже из байтов. if let Some(source) = hue { self.check_ladder_source(role, source)?; } @@ -1222,9 +995,8 @@ impl ThemeConfig { } /// Проверить, что источник лестницы разрешим: [`LadderSource::Family`] - /// ссылается на существующее семейство `palette`, [`LadderSource::Sentiment`] - /// — на существующую категорию `sentiments`; [`LadderSource::Brand`] всегда - /// разрешим (бренд — обязательный вход конфига). + /// ссылается на существующее семейство `palette`; [`LadderSource::Brand`] + /// всегда разрешим (бренд — обязательный вход конфига). fn check_ladder_source(&self, role: &str, source: &LadderSource) -> Result<(), ConfigError> { match source { LadderSource::Brand => Ok(()), @@ -1238,16 +1010,6 @@ impl ThemeConfig { }) } } - LadderSource::Sentiment(name) => { - if self.sentiments.categories.iter().any(|c| &c.name == name) { - Ok(()) - } else { - Err(ConfigError::UnknownSentiment { - referenced_by: format!("roles.{role}"), - sentiment: name.clone(), - }) - } - } // Нейтральный источник всегда разрешим (neutral.anchors — обязательный // вход конфига, провалидирован check_theme_anchors как тройка hex). LadderSource::Neutral(_) => Ok(()), @@ -1293,31 +1055,40 @@ impl ThemeConfig { entries.push((name.clone(), spec)); } - // Нейтраль-подтон: v2-кривая. Оттенок подтона — ИЗ КОНФИГА: явная ручка - // hue_override (labui несёт измеренную SSOT-величину 286.0°), иначе — - // деривация из ТЁМНОГО якоря нейтрали клиента (NEUTRAL_HUE_DEG сам был - // измерен по #101012 → 285.97°; labui-константа для чужой нейтрали была - // бы чужим подтоном — дефект агностичности). `ratio` в v2-кривую не - // входит (поле v1 flat-пути), но валидируется как ручка. - let canonical_hue_deg = match self.neutral.tint.hue_override_deg { - Some(hue) => hue, - None => { - // Ахроматичный якорь не несёт оттенка: atan2(0,0) дал бы - // произвольный 0° — тихо чужой подтон. Порог технический - // (числовая определённость), не перцептивный. - if oklab_chroma_of_hex(&self.neutral.anchors.dark) < ACHROMATIC_CHROMA_EPS { - return Err(ConfigError::AchromaticHueSource { - field: "neutral.anchors.dark".to_string(), - }); - } - crate::accent::oklab_hue_of(&self.neutral.anchors.dark) - } - }; - let chroma = RoleChroma::Curve { + // Neutral policy comes only from client data. An override explicitly + // supplies hue; otherwise exact emitted bytes decide whether a direction + // exists. Equal channels stay neutral instead of receiving matrix-noise + // hue. The retired schema-level flat-ratio handle is intentionally absent. + let curve = |canonical_hue_deg| RoleChroma::Curve { canonical_hue_deg, target_mp: self.neutral.tint.target_mp, hue_stiffness: self.neutral.tint.hue_stiffness, }; + let chroma = match self.neutral.tint.hue_override_deg { + Some(hue) => curve(hue), + None => { + let dark_bytes = + crate::srgb8::hex_bytes(&self.neutral.anchors.dark).map_err(|_| { + ConfigError::InvalidHex { + field: "neutral.anchors.dark".to_string(), + value: self.neutral.anchors.dark.clone(), + } + })?; + match hue_of_srgb8(Srgb8::new(dark_bytes)) { + OklabHue::Achromatic => RoleChroma::Neutral, + OklabHue::Chromatic { degrees } => curve(degrees), + } + } + }; + + // Тот же меж-полевой закон, что у публичного конструктора + // `NamedRoleTable::new`: preflight не вправе создавать таблицу, которая + // отвергнется только при первом runtime-resolve. + for (role, spec) in &entries { + if !spec.is_chroma_compatible(chroma) { + return Err(ConfigError::IncompatibleRolePolicy { role: role.clone() }); + } + } Ok(NamedRoleTable::from_validated_parts( entries, @@ -1342,10 +1113,9 @@ impl ThemeConfig { value: *fraction, bound: "0 < fraction ≤ 1 (доля максимального контраста фона)", })?; - // Цветной лейбл (M1): источник оттенка раскладывается в пер-темный - // тинт-якорь тем же механизмом, что тинт лестницы (для сентимента - // — солид, разведённый с брендом). Резолв держит контракт уровня в - // этом оттенке. + // Source-slot раскладывается в пер-темный якорь тем же механизмом, + // что тинт лестницы. Резолв классифицирует точные байты и держит + // контракт уровня без изобретения hue для серого источника. let anchor = match hue { Some(source) => anchor.with_hue(self.compile_ladder_tint(role, source)?), None => anchor, @@ -1394,12 +1164,10 @@ impl ThemeConfig { tone_dark, floor, } => { - // Нейтральный источник → нейтральный материал (`hue = None`, подтон - // ТАБЛИЦЫ); семейный → оттенок якоря подставляется в резолве. - let hue = match source { - LadderSource::Neutral(_) => None, - _ => Some(self.compile_ladder_tint(role, source)?), - }; + // Любой source, включая Neutral(pick), передаёт выбранные точные + // байты. Резолв сам классифицирует их как achromatic/chromatic; + // общая policy таблицы не подменяет client-owned выбор источника. + let hue = Some(self.compile_ladder_tint(role, source)?); Ok(RoleSpec::Material { hue, tone: DjMagnitude::new(*tone_light, *tone_dark), @@ -1418,9 +1186,8 @@ impl ThemeConfig { // (@12) над фоном резолва. Альфа берётся из ЗАКРЫТОГО меню позиции // (не литерал), поэтому tinted-badge лейбл и `fill-*-tinted` // заливка всегда садятся на одну и ту же подложку по построению. - // `FillPrimary` здесь — ТОЛЬКО источник legacy-alpha-данных на - // этапе lowering (compatibility datum клиентской калибровки); - // core-семантика и appearance-граф это имя/позицию не знают. + // `FillPrimary` здесь — ТОЛЬКО источник client-calibrated alpha + // на этапе lowering; физика и appearance-граф это имя не знают. let (surface_alpha_light, surface_alpha_dark) = crate::ladder::LadderPosition::FillPrimary.alpha_pair(); Ok(RoleSpec::PairLabel { @@ -1438,9 +1205,6 @@ impl ThemeConfig { /// /// - [`LadderSource::Brand`] / [`LadderSource::Family`]: сырая пер-темная /// четвёрка якорей (эмитится напрямую как `rgba`). - /// - [`LadderSource::Sentiment`]: пер-темный СОЛИД, чей оттенок разведён с - /// брендом сентимент-солвером (`crate::sentiment`); светлота/хрома — - /// исходного якоря семейства категории. fn compile_ladder_tint( &self, role: &str, @@ -1449,7 +1213,6 @@ impl ThemeConfig { let anchors = match source { LadderSource::Brand => self.brand.anchors.clone(), LadderSource::Family(key) => self.family_anchors(role, key)?.clone(), - LadderSource::Sentiment(name) => return self.compile_sentiment_tint(role, name), LadderSource::Neutral(pick) => self.neutral_anchors(role, *pick)?, }; let quad = anchors @@ -1516,208 +1279,10 @@ impl ThemeConfig { family: key.to_string(), }) } - - /// Пер-темный сентимент-солид: для каждой темы взять якорь семейства категории - /// и разрешить его оттенок законом категориальных зон (покой на прототипе; - /// пол/зоны — исключения), сохранив светлоту/хрому якоря. Бренд оттенок больше - /// НЕ смещает (Волна 1). `s_perc_min` — пересчёт из хром якорей сентиментов - /// конфига (питает попарные зоны фазы-2, не зависит от labui-констант). - fn compile_sentiment_tint(&self, role: &str, name: &str) -> Result { - // Существование категории проверяется до пофазного резолва, чтобы - // неизвестное имя дало свою ошибку, а не «семья не найдена». - if !self.sentiments.categories.iter().any(|c| c.name == name) { - return Err(ConfigError::UnknownSentiment { - referenced_by: format!("roles.{role}"), - sentiment: name.to_string(), - }); - } - - LadderTint::new([ - self.sentiment_solid_for_mode(role, name, 0)?, - self.sentiment_solid_for_mode(role, name, 1)?, - self.sentiment_solid_for_mode(role, name, 2)?, - self.sentiment_solid_for_mode(role, name, 3)?, - ]) - .map_err(|mode| ConfigError::InvalidHex { - field: format!("roles.{role} (сентимент-тинт, режим {mode})"), - value: "<вне кодированного домена>".to_string(), - }) - } - - /// Солид сентимента `name` в режиме `mode_idx` (0 light / 1 dark / - /// 2 light-ic / 3 dark-ic) под законом категориальных зон (Волна 1). - /// - /// Оркестрация ДВУХФАЗНАЯ (сохранена), но БРЕНД-СВОБОДНАЯ — бренд оттенок - /// больше не смещает; двигать его может только пол и попарные зоны соседей: - /// - /// 1. каждая категория решается на СВОЁМ прототипе с полом (без бренда, - /// `resolve_config_sentiment_solid_among` с пустыми зонами); - /// ПОКОЯЩИЕСЯ (решённый оттенок = оттенок прототипа, ≤ 0.5°) объявляются - /// неподвижными оккупантами — деривационная идентичность якорей клиента - /// не нарушается по построению (для labui-якорей ВСЕ покоятся); - /// 2. СМЕЩЁННЫЕ (пол клампанул оттенок) перерешиваются по порядку конфига с - /// зонами оккупантов: угловой отступ от каждой зоны — инверсия хорды - /// `s_perc_min` при средней хроме пары (тот же закон различимости - /// сентиментов между собой; СОХРАНЁН). Решённый смещённый сам становится - /// оккупантом для следующих. - /// - /// Ахроматичные оккупанты (C < ε) зон не несут — у серого нет оттенка. - /// Ахроматичный ЯКОРЬ обрабатывается внутри солвера (сырой якорь). - 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 { - match mode_idx { - 0 => a.light.clone(), - 1 => a.dark.clone(), - 2 => a.light_ic.clone(), - _ => a.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 { - role: role.to_string(), - sentiment: cat.name.clone(), - 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 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)) - } } /// Словарь эталонного пресета labui — ТОЛЬКО семантика ролей/алиасов (ни одного -/// цветового значения). `#[cfg(test)]`-only (ADR-0001 PR-c): labui-дерево ушло из +/// цветового значения). `#[cfg(test)]`-only (ADR-0001): labui-дерево ушло из /// ОТГРУЖАЕМОГО кода ядра — прод-скан агностичности /// (`tests/agnostic_production_surface.rs`) его не видит. Единственный потребитель — /// тестовая фикстура `fixture`. diff --git a/crates/labcolors-core/src/config/fixture.rs b/crates/labcolors-core/src/config/fixture.rs index adff42b0..837438db 100644 --- a/crates/labcolors-core/src/config/fixture.rs +++ b/crates/labcolors-core/src/config/fixture.rs @@ -1,10 +1,10 @@ //! Каноническая референс-фикстура labui — дерево Даниила ЦЕЛИКОМ (якоря, //! ручки, палитра, замер Figma 2026-07-02): тестовый оракул, `#[cfg(test)]`-only. -//! Прод-ядро агностично (CH-3, ADR-0001 PR-c): брендовые hex не покидают тестов — +//! Прод-ядро агностично (CH-3, ADR-0001): брендовые hex не покидают тестов — //! гейт `tests/agnostic_production_surface.rs`; wasm-граница читает замороженный //! JSON-паспорт (`labcolors-wasm/tests/data/labui.config.json`), не эту фикстуру. //! -//! Словарь ролей/алиасов фикстура тянет из ПРОДОВОГО модуля пресета +//! Словарь ролей/алиасов фикстура тянет из соседнего test-only пресета //! (`config/preset.rs`) — один источник; байт-в-байт гейт эмиссии //! (`crate::agnostic_gates`) замораживает контракт тонкий == полный. //! @@ -20,7 +20,7 @@ use crate::semantic; pub fn labui_reference() -> ThemeConfig { ThemeConfig { brand: Brand { - // Пер-темный бренд labui (reference/labui-accent-primitives.md §2, + // Пер-темный бренд Lab UI (`reference/labui-accent-primitives.md`, «Якоря»; // Figma `Accent/Brand`): light/dark/light-ic/dark-ic — дословно. anchors: anchors("#007AFF", "#4A8FFF", "#0040DD", "#409CFF"), }, @@ -32,7 +32,6 @@ pub fn labui_reference() -> ThemeConfig { }, tint: NeutralTint { // Ручки подтона — из констант semantic.rs (единый источник истины). - ratio: semantic::NEUTRAL_TINT_RATIO, target_mp: semantic::TINT_TARGET_MP, hue_stiffness: semantic::TINT_HUE_STIFFNESS, // Явный измеренный оттенок (SSOT NEUTRAL_HUE_DEG): labui несёт @@ -56,8 +55,8 @@ pub fn labui_reference() -> ThemeConfig { }), }, // Палитра labui — 10 замеренных семейств, ПЕР-ТЕМНО ДОСЛОВНО из - // reference/labui-accent-primitives.md §2 (Figma `Accent/*`, все 4 режима, - // замер 2026-07-02). Светлый якорь совпадает с accent.rs::anchor_hex. + // `reference/labui-accent-primitives.md`, «Якоря» (Figma `Accent/*`, все + // четыре режима, замер 2026-07-02). palette: vec![ fam("red", "#FF3B30", "#FF3A3A", "#D70015", "#FF6161"), fam("orange", "#FFA100", "#FF9008", "#C93400", "#FFA940"), @@ -70,25 +69,6 @@ pub fn labui_reference() -> ThemeConfig { fam("purple", "#AF52DE", "#BF5AF2", "#8944AB", "#DA8FFF"), fam("pink", "#FF2D55", "#FF2D55", "#D30F45", "#FF6482"), ], - 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), - ], - 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![ ("light".to_string(), VcPreset::Srgb), @@ -119,18 +99,3 @@ fn fam(key: &str, light: &str, dark: &str, light_ic: &str, dark_ic: &str) -> Pal anchors: anchors(light, dark, light_ic, dark_ic), } } - -/// Краткий конструктор сентимент-категории для фикстуры. -fn sentiment( - name: &str, - family: &str, - hue_floor_deg: Option, - preferred_side: Option, -) -> SentimentCategory { - SentimentCategory { - name: name.to_string(), - family: family.to_string(), - hue_floor_deg, - preferred_side, - } -} diff --git a/crates/labcolors-core/src/config/preset.rs b/crates/labcolors-core/src/config/preset.rs index b83f519c..c8661367 100644 --- a/crates/labcolors-core/src/config/preset.rs +++ b/crates/labcolors-core/src/config/preset.rs @@ -1,5 +1,5 @@ -//! Словарь эталонного пресета labui — СЕМАНТИКА ролей/алиасов (ADR-0001 PR-c, -//! план BL-007): имена (= `Role::key()` ядра), фракции, позиции лестницы, +//! Словарь эталонного пресета labui — СЕМАНТИКА ролей/алиасов (ADR-0001): +//! имена (= `Role::key()` ядра), фракции, позиции лестницы, //! полы — ни одного цветового значения. Модуль `#[cfg(test)]`-ONLY: labui-дерево //! НЕ входит в ОТГРУЖАЕМЫЙ код ядра (строгая агностичность) — прод-скан //! `tests/agnostic_production_surface.rs` этот модуль ИСКЛЮЧАЕТ. Единственный @@ -19,7 +19,7 @@ use crate::solve::Floor; /// Общий источник для фикстуры `labui_reference` (`#[cfg(test)]`, потому не /// линк): полный эталон несёт ТОТ ЖЕ словарь — один источник, ноль расхождения. /// Словарь несёт семантику (имена = `Role::key()` ядра, рецепты 1:1 из -/// `RoleTable::default` — тоже `#[cfg(test)]`-only оракул (ADR-0001 PR-c), потому +/// `RoleTable::default` — тоже `#[cfg(test)]`-only оракул (ADR-0001), потому /// не линк) — ни одного цветового значения: якоря/ручки остаются в конфиге клиента. pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // Фракции и полы — 1:1 из RoleTable::default (semantic.rs), включая border-strong @@ -44,8 +44,8 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { position, floor: None, }; - let sent_pos = |name: &str, position| RoleRecipe::Ladder { - source: LadderSource::Sentiment(name.to_string()), + let family_pos = |key: &str, position| RoleRecipe::Ladder { + source: LadderSource::Family(key.to_string()), position, floor: None, }; @@ -154,10 +154,10 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { ("none".to_string(), RoleRecipe::Zero), ]; - // ── Акцентная/сентимент/FX/альфа-лестница (поглощает GAP #59) ───────────── + // ── Семейная/FX/альфа-лестница (поглощает GAP #59) ─────────────────────── // Имена = consumedRoles labui (roles.json) без префикса `--lab-`, минус // удаляемые по коллапсу (static-*/inverted-*/on-*/material-*, роли-от-фона). - // Каждая семья (brand + 4 сентимента) несёт label×4 · fill×4 · border(strong/ + // Каждая семья (brand + 4 client-named status families) несёт label×4 · fill×4 · border(strong/ // base/soft). FX focus-ring/glow — солид/@52. `-tinted` — альфа-аналог солида // соответствующего fill-*-primary. Все альфы — из меню LadderPosition (Figma). // Цветной лейбл (ратификация ch5c, M1): доля/пол КАЖДОГО уровня = нейтральный @@ -166,7 +166,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // одноуровневость поперёк характеров ПО ПОСТРОЕНИЮ; оттенок = источник семьи // (чистый цвет, светлота выводится контрактом на кривой семьи). Заменяет // прежнюю α-рампу @72/@52/@32 поверх тинта (40/40 нарушений одноуровневости, - // нелегальность light-темы) — см. scratchpad/ch5c-ratification.md §2. + // нелегальность light-темы) — executable proof в `one_levelness_tests`. let hued_label = |prefix: &str, level: &str, fraction: f64, floor: Floor, source: &LadderSource| { ( @@ -208,22 +208,23 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // Brand-семья: источник = бренд. roles.extend(ladder_family("brand", LadderSource::Brand, &brand_pos)); - // Сентимент-семьи: источник = сентимент-категория (разводится с брендом). - for (prefix, sname) in [ - ("danger", "danger"), - ("warning", "warning"), - ("success", "success"), - ("info", "info"), + // Имена ролей принадлежат fixture-клиенту; Core видит только непрозрачные + // ссылки на обычные цветовые семейства и не меняет их якоря. + for (prefix, family_key) in [ + ("danger", "red"), + ("warning", "orange"), + ("success", "green"), + ("info", "blue"), ] { - let mk = move |pos| sent_pos(sname, pos); + let mk = move |pos| family_pos(family_key, pos); roles.extend(ladder_family( prefix, - LadderSource::Sentiment(sname.to_string()), + LadderSource::Family(family_key.to_string()), &mk, )); } - // FX focus-ring (солид) и glow (@52). Сентимент/бренд-источники — акцентные; + // FX focus-ring (солид) и glow (@52). Семейные/бренд-источники — акцентные; // `*-neutral`/`inverted` — НЕЙТРАЛЬНЫЕ (стаб: rgb(255 255 255 / .522) и т.п., // НЕ бренд). roles.push(( @@ -232,11 +233,11 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { )); roles.push(( "fx-focus-ring-danger".to_string(), - sent_pos("danger", LadderPosition::FocusRing), + family_pos("red", LadderPosition::FocusRing), )); roles.push(( "fx-focus-ring-warning".to_string(), - sent_pos("warning", LadderPosition::FocusRing), + family_pos("orange", LadderPosition::FocusRing), )); // Нейтральный фокус: тёмный край нейтрали, солид (стаб light rgb(16 16 18) = // Контур нейтрали ПЕР-ТЕМНЫЙ (стаб: light #101012 / dark #F6F8FA) — едет @@ -266,11 +267,11 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { roles.push(("fx-glow-brand".to_string(), glow(LadderSource::Brand))); roles.push(( "fx-glow-danger".to_string(), - glow(LadderSource::Sentiment("danger".to_string())), + glow(LadderSource::Family("red".to_string())), )); roles.push(( "fx-glow-warning".to_string(), - glow(LadderSource::Sentiment("warning".to_string())), + glow(LadderSource::Family("orange".to_string())), )); roles.push(( "fx-glow-neutral".to_string(), @@ -292,7 +293,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { neutral_pos(NeutralPick::Mid, LadderPosition::SkeletonHighlight), )); - // Компонентные роли. accent = бренд, danger = danger-сентимент, neutral — + // Компонентные роли. accent = бренд, danger = client role over red family, neutral — // НЕЙТРАЛЬНЫЙ (стаб: fill-neutral солид-литерал; fill-neutral-tinted и // border-neutral алиасят нейтральные core-роли fill-primary/border-base). // @@ -316,7 +317,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // border-neutral = var(border-base) → алиас (см. aliases ниже). roles.push(( "fill-danger-tinted".to_string(), - sent_pos("danger", LadderPosition::FillPrimary), + family_pos("red", LadderPosition::FillPrimary), )); roles.push(( "label-accent".to_string(), @@ -324,7 +325,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { )); roles.push(( "label-danger".to_string(), - sent_pos("danger", LadderPosition::LabelPrimary), + family_pos("red", LadderPosition::LabelPrimary), )); roles.push(( "border-accent".to_string(), @@ -333,7 +334,7 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { // border-neutral = var(border-base): алиас на нейтральную dJ' границу core. roles.push(( "border-danger".to_string(), - sent_pos("danger", LadderPosition::BorderBase), + family_pos("red", LadderPosition::BorderBase), )); roles.push(( "border-focus".to_string(), @@ -341,15 +342,20 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> { )); // Пары «заливка × лейбл» бейджа (crate::pair): якорь источника, минимально - // сдвинутый до победы перцептивной стороны лейбла в штатной полярности; + // сдвинутый до выбранной ветви переходной pair-эвристики; // лейбл на такой заливке — обычный nested resolve потребителя. Статики // покрываются тем же законом (белый/чёрный якоря нейтрали). let pair = |source| RoleRecipe::PairFill { source }; roles.push(("badge-fill-brand".to_string(), pair(LadderSource::Brand))); - for sname in ["danger", "warning", "success", "info"] { + for (client_name, family_key) in [ + ("danger", "red"), + ("warning", "orange"), + ("success", "green"), + ("info", "blue"), + ] { roles.push(( - format!("badge-fill-{sname}"), - pair(LadderSource::Sentiment(sname.to_string())), + format!("badge-fill-{client_name}"), + pair(LadderSource::Family(family_key.to_string())), )); } roles.push(( diff --git a/crates/labcolors-core/src/config/tests.rs b/crates/labcolors-core/src/config/tests.rs index b2732c18..6bde84de 100644 --- a/crates/labcolors-core/src/config/tests.rs +++ b/crates/labcolors-core/src/config/tests.rs @@ -5,8 +5,8 @@ //! 3. Валидатор: за-предельное значение КАЖДОЙ ручки даёт `ConfigError` + //! RED-proof мутацией предела (валидный vs невалидный на границе). //! 4. Лестница/альфа: Ladder/AlphaAnalog компилируются в полупрозрачные специи; -//! diff=пусто против consumedRoles; S_PERC_MIN-идентичность; значенческая -//! сверка со стабом labui (light+dark) + RED-proof мутаций. +//! семейные источники точно сохраняют клиентские якоря во всех контекстах; +//! значенческая сверка со стабом labui держит представителей остальных групп. use super::fixture::labui_reference; use super::test_support::resolved_repr as repr; @@ -49,7 +49,7 @@ fn labui_named_set_is_byte_identical_to_default_role_table() { .compile_named_role_table() .expect("эталонная фикстура labui обязана компилироваться"); - // Фикстура несёт 20 core-ролей ПЛЮС акцентную/сентимент/FX/альфа + // Фикстура несёт 20 core-ролей ПЛЮС семейные/FX/альфа // лестницу. Байт-в-байт гарантия — на СОЛВЕР-ролях (имена = Role::key()): // именно их пинит golden-грид. Нейтральные заливки/границы НАМЕРЕННО // расходятся с дефолт-таблицей: они эмитятся лестницей rgba(mid, α) — @@ -340,36 +340,6 @@ fn decorative_lc_requires_the_core_physical_floor() { assert_eq!(boundary.validate(), Ok(())); } -#[test] -fn tint_ratio_out_of_bounds_is_rejected() { - let mut cfg = labui_reference(); - cfg.neutral.tint.ratio = 1.5; - assert!(matches!( - cfg.validate(), - Err(ConfigError::OutOfBounds { handle, .. }) if handle == "neutral.tint.ratio" - )); - let mut neg = labui_reference(); - neg.neutral.tint.ratio = -0.01; - assert!(neg.validate().is_err()); -} - -#[test] -fn tint_ratio_bound_red_proof_at_edges() { - // [0, 1] замкнут: 0.0 и 1.0 валидны, вне — нет. - for r in [0.0, 1.0] { - let mut cfg = labui_reference(); - cfg.neutral.tint.ratio = r; - assert_eq!( - cfg.validate(), - Ok(()), - "ratio={r} на границе должен быть валиден" - ); - } - let mut over = labui_reference(); - over.neutral.tint.ratio = 1.0 + 1e-9; - assert!(over.validate().is_err()); -} - #[test] fn target_mp_non_positive_is_rejected() { let mut cfg = labui_reference(); @@ -391,129 +361,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 / имена / ссылки. // ───────────────────────────────────────────────────────────────────────────── @@ -552,16 +399,6 @@ fn invalid_role_name_is_rejected() { )); } -#[test] -fn sentiment_referencing_missing_family_is_rejected() { - let mut cfg = labui_reference(); - cfg.sentiments.categories[0].family = "nonexistent".to_string(); - assert!(matches!( - cfg.validate(), - Err(ConfigError::UnknownFamily { family, .. }) if family == "nonexistent" - )); -} - #[test] fn alias_to_missing_role_is_rejected() { let mut cfg = labui_reference(); @@ -710,17 +547,10 @@ fn config_error_display_is_russian_and_informative() { // diff=пусто против consumedRoles labui. // ───────────────────────────────────────────────────────────────────────────── -/// Полный контракт `--lab-*` labui из `packages/colors-stub/roles.json` -/// (снят 2026-07-02, источник в шапке файла: генерируется из -/// `reference/labui-tokens-snapshot.dtcg.json`). Захардкожен здесь как SSOT для -/// diff-теста — при регенерации roles.json обновить этот список синхронно. -/// -/// Имена без префикса `--lab-`. IC-режимы зарезервированы (в roles.json не -/// перечислены), поэтому и здесь их нет. -/// -/// Компромисс: это ЗЕРКАЛО roles.json, не живой файл. Класс дрейфа зеркала -/// закрывается гардами поезда labui (consumed-contract против живой эмиссии) — -/// там diff проверяется против фактического потребления, не против копии. +/// Замороженный snapshot ожидаемых `--lab-*` имён референс-фикстуры. +/// Это test oracle, не SSOT публичного клиента: production Core не читает его, +/// а актуальность доказывает только diff с текущей fixture-эмиссией. +/// Имена без префикса `--lab-`; IC-режимы не добавляют отдельные имена. const LABUI_CONSUMED_ROLES: &[&str] = &[ // Backgrounds — ВХОДЫ (набор фонов = конфиг потребителя), не роли эмиссии. // Labels (core neutral). @@ -728,7 +558,7 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[ "label-secondary", "label-tertiary", "label-quaternary", - // Labels — brand/сентименты. + // Labels — бренд и клиентские семейства. "label-brand-primary", "label-brand-secondary", "label-brand-tertiary", @@ -755,7 +585,7 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[ "fill-tertiary", "fill-quaternary", "fill-none", - // Fills — brand/сентименты. + // Fills — бренд и клиентские семейства. "fill-brand-primary", "fill-brand-secondary", "fill-brand-tertiary", @@ -783,7 +613,7 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[ "border-soft", "border-ghost", "border-none", - // Border — brand/сентименты. + // Borders — бренд и клиентские семейства. "border-brand-strong", "border-brand-base", "border-brand-soft", @@ -982,173 +812,109 @@ 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("фикстура валидна"); +fn family_sources_preserve_authored_anchors_in_every_context() { 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(); - } + for family in &cfg.palette { + cfg.roles.push(( + format!("probe-family-{}", family.key), + RoleRecipe::Ladder { + source: LadderSource::Family(family.key.clone()), + position: LadderPosition::FillPrimary, + floor: None, + }, + )); } - let mutated = cfg - .sentiment_s_perc_min() - .expect("мутация якоря сохраняет валидность"); - assert!( - (base - mutated).abs() > 1e-3, - "RED-proof провален: подмена якоря НЕ сдвинула S_PERC_MIN ({base} vs {mutated})" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Сентимент — деривационная идентичность (тинт == сырой якорь при -// labui-бренде). -// ───────────────────────────────────────────────────────────────────────────── - -/// Деривационная идентичность: при бренде 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`]). -#[test] -fn sentiment_tint_is_raw_family_anchor_under_categorical_zones() { - let cfg = labui_reference(); - let table = cfg.compile_named_role_table().unwrap(); + let table = cfg.compile_named_role_table().expect("фикстура валидна"); - // Под законом зон бренд оттенок НЕ смещает — ВСЕ сентименты (включая Info) - // отдыхают на сыром якоре семейства. Проверяем на светлой теме (канонический - // кейс, бренд labui = светлый `#007AFF`); пер-темные режимы покрыты - // `labui_info_sentiment_rests_on_blue_focus` и байт-гейтом эмиссии. - let cases: &[(&str, &str)] = &[ - ("fill-danger-primary", "red"), - ("fill-success-primary", "green"), - ("fill-warning-primary", "orange"), - ("fill-info-primary", "blue"), - ]; - let vc = ViewingConditions::srgb(); // светлая тема, brand = #007AFF - - for (role, fam_key) in cases { - let fam = cfg.palette.iter().find(|f| &f.key == fam_key).unwrap(); - let (_, spec) = table.entries().iter().find(|(n, _)| n == role).unwrap(); + for family in &cfg.palette { + let role = format!("probe-family-{}", family.key); + let anchors = &family.anchors; + let (_, spec) = table + .entries() + .iter() + .find(|(name, _)| name == &role) + .expect("роль есть в фикстуре"); let RoleSpec::Ladder { tint, .. } = spec else { panic!("{role}: ожидался Ladder-спек, получено {spec:?}"); }; - let got_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); - let want_hex = crate::spaces::srgb::hex_from_srgb_encoded( - crate::spaces::srgb::srgb_encoded_from_hex(&fam.anchors.light).unwrap(), - ); - assert_eq!( - got_hex, want_hex, - "ДЕРИВАЦИОННАЯ ИДЕНТИЧНОСТЬ НЕ СОШЛАСЬ (светлая тема): `{role}`: \ - сентимент-тинт {got_hex} != сырой якорь {fam_key} {want_hex}. \ - Сентимент-солвер сместил оттенок при labui-бренде — осмыслить, не прятать." - ); + let modes = [ + ("light", ViewingConditions::srgb(), &anchors.light), + ("dark", ViewingConditions::dim_surround(), &anchors.dark), + ( + "light-ic", + ViewingConditions::srgb_high_contrast(), + &anchors.light_ic, + ), + ( + "dark-ic", + ViewingConditions::dim_surround_high_contrast(), + &anchors.dark_ic, + ), + ]; + + for (mode, vc, authored) in modes { + let got = crate::spaces::srgb::hex_from_srgb_encoded(tint.for_vc(&vc)); + assert_eq!( + got, *authored, + "{role}/{mode}: family source moved authored anchor {authored}" + ); + } } } -/// ФИКС, ВИДИМЫЙ ВЛАДЕЛЬЦУ (РЕФРЕЙМ Волны 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`), как заявил владелец. +/// A family key is an opaque client ID: consistently renaming the declaration +/// and every reference must compile to the identical physical graph. #[test] -fn labui_info_sentiment_rests_on_blue_focus() { - let cfg = labui_reference(); - let table = cfg.compile_named_role_table().unwrap(); - let (_, spec) = table - .entries() - .iter() - .find(|(n, _)| n == "fill-info-primary") - .unwrap(); - let RoleSpec::Ladder { tint, .. } = spec else { - panic!("fill-info-primary: ожидался Ladder"); - }; - let blue = cfg.palette.iter().find(|f| f.key == "blue").unwrap(); - let modes = [ - ( - "light", - ViewingConditions::srgb(), - blue.anchors.light.as_str(), - ), - ( - "dark", - ViewingConditions::dim_surround(), - blue.anchors.dark.as_str(), - ), - ( - "light-ic", - ViewingConditions::srgb_high_contrast(), - blue.anchors.light_ic.as_str(), - ), - ( - "dark-ic", - ViewingConditions::dim_surround_high_contrast(), - blue.anchors.dark_ic.as_str(), - ), - ]; - 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) - ); +fn renaming_family_id_and_references_does_not_change_the_compiled_graph() { + fn rename_source(source: &mut LadderSource, from: &str, to: &str) { + if let LadderSource::Family(key) = source { + if key == from { + *key = to.to_string(); + } + } } - // 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})" + + let original = labui_reference(); + let mut renamed = original.clone(); + renamed + .palette + .iter_mut() + .find(|family| family.key == "red") + .expect("red fixture family") + .key = "client-family-42".to_string(); + + for (_, recipe) in &mut renamed.roles { + match recipe { + RoleRecipe::TextAnchor { hue, .. } => { + if let Some(source) = hue { + rename_source(source, "red", "client-family-42"); + } + } + RoleRecipe::Ladder { source, .. } + | RoleRecipe::Glow { source, .. } + | RoleRecipe::PairFill { source } + | RoleRecipe::PairLabel { source, .. } + | RoleRecipe::Material { source, .. } => { + rename_source(source, "red", "client-family-42"); + } + RoleRecipe::AlphaAnalog { of, .. } => { + rename_source(of, "red", "client-family-42"); + } + RoleRecipe::DjAnchor { .. } | RoleRecipe::DecorativeLc { .. } | RoleRecipe::Zero => {} + } + } + + assert_eq!( + renamed.compile_named_role_table().expect("renamed config"), + original + .compile_named_role_table() + .expect("original config"), ); } @@ -1401,11 +1167,8 @@ fn assert_matches_stub(role: &str, theme: &str, got: &Resolved, want: &str) { /// Закрывает класс «имя есть, значение врёт»: skeleton = нейтраль #787880 с /// пер-темной альфой, glow-neutral = белый @52, акценты = пер-темный якорь. /// -/// Исключены НАМЕРЕННО расходящиеся роли (с комментарием-ссылкой): -/// - `border-info-*`/`label-info-*`/`fill-info-*` — под законом Волны 1 info -/// отдыхает на синем фокусе (тест `labui_info_sentiment_rests_on_blue_focus`); их -/// значенческую сверку со стабом (до-Wave-1 значения) обрабатывает владелец; -/// - `fx-focus-ring-neutral` (dark), `fx-glow-inverted`, `fill-neutral` — +/// Исключены намеренно непредставительные `fx-focus-ring-neutral` (dark), +/// `fx-glow-inverted` и `fill-neutral`: их пер-темный нейтральный край, inverted-якоря и /// задокументированные gap-и (пер-темный нейтральный край / inverted-якоря / /// PROVISIONAL-литерал не выводятся из тройки neutral.anchors). #[test] @@ -1420,9 +1183,9 @@ fn representative_roles_match_stub_values_light_and_dark() { // (тинт семьи под альфой — 40/40 нарушений одноуровневости), а цветной // TextAnchor — держит Lc-контракт уровня в чистом оттенке семьи и // резолвится в СОЛИД (`Resolved::Color`), не в Translucent. Его эмиссия и - // одноуровневость проверяются гейтом `tests/one_levelness.rs`. Здесь + // одноуровневость проверяются модулем `src/one_levelness_tests.rs`. Здесь // остаётся представитель полупрозрачной СЕМЕЙНОЙ заливки (тот же - // сентимент-тинт под альфой рампы — класс «имя есть, значение тинта врёт»). + // семейный тинт под альфой рампы — класс «имя есть, значение тинта врёт»). ( "fill-danger-secondary", "rgb(255 59 48 / 0.078)", @@ -1562,7 +1325,7 @@ fn value_test_bites_on_alpha_mutation() { /// Сторона пары — идентичность семьи НА РЕЗОЛВ-УРОВНЕ. Носитель класса — /// БРЕНД под dark-IC: источник Brand несёт сырые якоря, и его dark-ic -/// (#409CFF, Y = 0.321) пересекает кроссовер 0.30. Сентименты (включая +/// (#409CFF, Y = 0.321) пересекает кроссовер 0.30. Семейные якоря (включая /// info) разведены солвером и порог не straddle-ят — на них мутация /// «сторона от vc» поведенчески неразличима (выживший мутант M3 /// верификатора). Мутация semantic.rs srgb→vc обязана уронить ЭТОТ тест. @@ -1847,22 +1610,6 @@ fn emitted_namespace_allows_non_colliding_near_misses() { assert_eq!(cfg.validate(), Ok(())); } -/// 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() { @@ -1884,21 +1631,21 @@ fn validator_rejects_non_finite_handles() { } } -/// Ошибки ссылок различимы по виду: сентимент/роль/семейство — разные варианты. +/// Ошибки ссылок различимы по виду: роль и семейство — разные варианты. #[test] fn validator_reference_errors_are_distinguishable() { let mut c = labui_reference(); c.roles.push(( - "probe-bad-sentiment".to_string(), + "probe-bad-family".to_string(), RoleRecipe::Ladder { - source: LadderSource::Sentiment("nonexistent".to_string()), + source: LadderSource::Family("nonexistent".to_string()), position: LadderPosition::LabelPrimary, floor: None, }, )); assert!(matches!( c.validate(), - Err(ConfigError::UnknownSentiment { .. }) + Err(ConfigError::UnknownFamily { .. }) )); let mut c = labui_reference(); @@ -2047,11 +1794,43 @@ fn validate_is_a_complete_preflight() { assert!(got.contains(want), "ждали {want}, получено {got}"); }; - // Ахроматичная нейтраль без override — деривационная ошибка подтона. + // Точный серый без override законен, пока все material-источники ахроматичны; + // validate и compile обязаны одинаково принять такой контракт. let mut c = labui_reference(); c.neutral.tint.hue_override_deg = None; c.neutral.anchors.dark = "#101010".to_string(); - assert_parity(&c, "AchromaticHueSource"); + assert!(c.validate().is_ok()); + assert!(c.compile_named_role_table().is_ok()); + + // Цветной источник нельзя отложить внутрь neutral-policy: preflight обязан + // отвергнуть конфликт до первого runtime-resolve. + for (name, recipe) in &mut c.roles { + if name == "fill-brand-secondary" { + *recipe = RoleRecipe::Material { + source: LadderSource::Brand, + tone_light: 12.0, + tone_dark: 12.0, + floor: Floor::AaUi, + }; + } + } + assert!(matches!( + c.validate(), + Err(ConfigError::IncompatibleRolePolicy { ref role, .. }) + if role == "fill-brand-secondary" + )); + assert!(matches!( + c.compile_named_role_table(), + Err(ConfigError::IncompatibleRolePolicy { ref role, .. }) + if role == "fill-brand-secondary" + )); + assert_eq!( + c.validate().unwrap_err().to_string(), + format!( + "material `fill-brand-secondary`: {}", + RoleSpec::INCOMPATIBLE_CHROMA_REASON + ) + ); // Edge-роль без четвёрки edge — деривационная ошибка края нейтрали. let mut c = labui_reference(); @@ -2112,33 +1891,43 @@ 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:?}"), - } +fn assert_achromatic_hex(hex: &str, context: &str) { + let [red, green, blue] = crate::srgb8::hex_bytes(hex).expect("canonical emitted hex"); + assert_eq!( + red, green, + "{context}: invented red/green direction in {hex}" + ); + assert_eq!( + green, blue, + "{context}: invented green/blue direction in {hex}" + ); } -/// Ахроматичные источники оттенка: серая нейтраль без override — ошибка; -/// серый бренд — сентимент честно равен сырому якорю (разведение отключено). +fn assert_chromatic_hex(hex: &str, context: &str) { + let [red, green, blue] = crate::srgb8::hex_bytes(hex).expect("canonical emitted hex"); + assert!( + red != green || green != blue, + "{context}: one-byte chromatic direction was discarded in {hex}" + ); +} + +/// Exact-gray sources carry neutral identity without an override; the nearest +/// off-axis byte still carries its authored direction. #[test] fn achromatic_hue_sources_are_handled_honestly() { let mut c = labui_reference(); c.neutral.tint.hue_override_deg = None; - c.neutral.anchors.dark = "#101010".to_string(); // чистый серый: хрома ≈ 0 - assert!(matches!( - c.compile_named_role_table(), - Err(ConfigError::AchromaticHueSource { .. }) - )); + c.neutral.anchors.dark = "#101010".to_string(); + let neutral_table = c + .compile_named_role_table() + .expect("exact-gray neutral source compiles without an override"); + assert_eq!(neutral_table.chroma(), RoleChroma::Neutral); + + c.neutral.anchors.dark = "#101011".to_string(); + let chromatic_table = c + .compile_named_role_table() + .expect("nearest chromatic neutral source retains its direction"); + assert!(matches!(chromatic_table.chroma(), RoleChroma::Curve { .. })); let mut c = labui_reference(); // Серый бренд: все четыре режима ахроматичны. @@ -2155,9 +1944,18 @@ fn achromatic_hue_sources_are_handled_honestly() { &crate::spaces::vc::ViewingConditions::srgb(), ) .expect("валидная ахроматическая brand-фикстура обязана резолвиться"); - // `fill-danger-primary` остаётся Ladder (сентимент-тинт под альфой) — на нём - // и проверяем «серый бренд → сырой якорь семейства». `label-danger-primary` - // после ратификации ch5c (M1) — цветной TextAnchor (Color), не Translucent. + + let (_, label) = set + .iter() + .find(|(name, _)| name == "label-brand-primary") + .expect("цветная brand-роль есть"); + let Resolved::Color { solved, .. } = label else { + panic!("ожидался Color"); + }; + assert_achromatic_hex(solved.hex(), "TextAnchor from achromatic Brand"); + + // Exact-gray Brand must not affect an unrelated client-owned family source. + // The role name below is opaque fixture data and carries no Core semantics. let (_, r) = set .iter() .find(|(n, _)| n == "fill-danger-primary") @@ -2168,10 +1966,311 @@ fn achromatic_hue_sources_are_handled_honestly() { assert_eq!( r.tint_hex(), "#FF3B30", - "при сером бренде сентимент = сырой якорь семейства (разведение отключено)" + "независимый family-источник обязан сохранить клиентский якорь" ); } +/// `NeutralPick` — данные, а не запрос на общий подтон таблицы: выбранный точный +/// серый якорь остаётся ахроматическим при цветной policy других нейтралей. +#[test] +fn material_uses_the_selected_neutral_source_before_chroma_classification() { + let mut config = labui_reference(); + config.neutral.tint.hue_override_deg = None; + assert!(matches!( + config.compile_named_role_table().unwrap().chroma(), + RoleChroma::Curve { .. } + )); + for (name, recipe) in &mut config.roles { + if name == "fill-brand-secondary" { + *recipe = RoleRecipe::Material { + source: LadderSource::Neutral(NeutralPick::Light), + tone_light: 12.0, + tone_dark: 12.0, + floor: Floor::AaUi, + }; + } + } + + let table = config.compile_named_role_table().unwrap(); + let set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .expect("exact-gray material source must resolve under a chromatic table policy"); + let (_, Resolved::Material(material)) = set + .iter() + .find(|(name, _)| name == "fill-brand-secondary") + .expect("material role exists") + else { + panic!("fill-brand-secondary must resolve to Material"); + }; + assert_achromatic_hex(material.tint_hex(), "selected Neutral(Light) Material"); + assert_achromatic_hex(material.base_hex(), "selected Neutral(Light) Material"); +} + +/// Transitional characterization only: these paths are removed with the closed +/// recipe menu. The durable law lives in `Srgb8`, `SourceHuePlan` and the generic +/// curve tests: exact emitted gray has no hue; the nearest off-axis byte does. +#[test] +fn every_hue_consuming_path_preserves_achromatic_source_identity() { + let viewing_conditions = [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ]; + + for vc in viewing_conditions { + let mut config = labui_reference(); + config.brand.anchors = crate::ladder::ThemeAnchors { + light: "#808080".to_string(), + dark: "#808080".to_string(), + light_ic: "#808080".to_string(), + dark_ic: "#808080".to_string(), + }; + for (name, recipe) in &mut config.roles { + match name.as_str() { + "label-brand-secondary" => { + *recipe = RoleRecipe::PairLabel { + source: LadderSource::Brand, + fraction: 0.5, + floor: Floor::AaUi, + }; + } + "fill-brand-secondary" => { + *recipe = RoleRecipe::Material { + source: LadderSource::Brand, + tone_light: 12.0, + tone_dark: 12.0, + floor: Floor::AaUi, + }; + } + _ => {} + } + } + + let table = config + .compile_named_role_table() + .expect("achromatic Brand recipes compile"); + let background = if vc.is_dark_theme() { + BgInput::solid("#101010").unwrap() + } else { + BgInput::solid("#FFFFFF").unwrap() + }; + let set = resolve_named_set(&background, &table, &vc) + .expect("achromatic source-derived recipes resolve"); + + for role in ["label-brand-primary", "label-brand-secondary"] { + let (_, Resolved::Color { solved, .. }) = set + .iter() + .find(|(name, _)| name == role) + .unwrap_or_else(|| panic!("missing {role}")) + else { + panic!("{role} must resolve to Color"); + }; + assert_achromatic_hex(solved.hex(), role); + } + + let (_, Resolved::Material(material)) = set + .iter() + .find(|(name, _)| name == "fill-brand-secondary") + .expect("material role exists") + else { + panic!("fill-brand-secondary must resolve to Material"); + }; + assert_achromatic_hex(material.tint_hex(), "Material tint"); + assert_achromatic_hex(material.base_hex(), "Material base"); + } +} + +#[test] +fn nearest_chromatic_source_survives_every_current_source_consuming_path() { + let mut config = labui_reference(); + config.brand.anchors = crate::ladder::ThemeAnchors { + light: "#808081".to_string(), + dark: "#808081".to_string(), + light_ic: "#808081".to_string(), + dark_ic: "#808081".to_string(), + }; + for (name, recipe) in &mut config.roles { + match name.as_str() { + "label-brand-secondary" => { + *recipe = RoleRecipe::PairLabel { + source: LadderSource::Brand, + fraction: 0.5, + floor: Floor::AaUi, + }; + } + "fill-brand-secondary" => { + *recipe = RoleRecipe::Material { + source: LadderSource::Brand, + tone_light: 12.0, + tone_dark: 12.0, + floor: Floor::AaUi, + }; + } + _ => {} + } + } + + let table = config.compile_named_role_table().unwrap(); + let set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .unwrap(); + + for role in ["label-brand-primary", "label-brand-secondary"] { + let (_, Resolved::Color { solved, .. }) = set + .iter() + .find(|(name, _)| name == role) + .unwrap_or_else(|| panic!("missing {role}")) + else { + panic!("{role} must resolve to Color"); + }; + assert_chromatic_hex(solved.hex(), role); + } + + let (_, Resolved::Material(material)) = set + .iter() + .find(|(name, _)| name == "fill-brand-secondary") + .expect("material role exists") + else { + panic!("fill-brand-secondary must resolve to Material"); + }; + assert_chromatic_hex(material.base_hex(), "Material"); + + let (_, Resolved::Translucent(pair_fill)) = set + .iter() + .find(|(name, _)| name == "badge-fill-brand") + .expect("pair fill exists") + else { + panic!("badge-fill-brand must resolve to Translucent"); + }; + assert_chromatic_hex(pair_fill.tint_hex(), "PairFill"); +} + +#[test] +fn achromatic_solid_floor_never_invents_hue() { + let mut floor_config = labui_reference(); + floor_config.brand.anchors = crate::ladder::ThemeAnchors { + light: "#E0E0E0".to_string(), + dark: "#E0E0E0".to_string(), + light_ic: "#E0E0E0".to_string(), + dark_ic: "#E0E0E0".to_string(), + }; + let floor_table = floor_config.compile_named_role_table().unwrap(); + let floor_set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &floor_table, + &ViewingConditions::srgb(), + ) + .unwrap(); + let (_, Resolved::Translucent(border)) = floor_set + .iter() + .find(|(name, _)| name == "border-brand-strong") + .expect("solid border exists") + else { + panic!("border-brand-strong must resolve to Translucent"); + }; + assert!( + border.floor_coerced(), + "fixture must execute the floor-shift branch" + ); + assert_achromatic_hex(border.tint_hex(), "solid floor shift"); +} + +#[test] +fn adjacent_foreign_source_cannot_change_an_independent_solve() { + use crate::ladder::LadderTint; + use crate::semantic::{NamedRoleTable, RoleChroma, RoleSpec, TextAnchor}; + + let tint = |hex: &str| { + let encoded = crate::spaces::srgb::srgb_encoded_from_hex(hex).unwrap(); + LadderTint::new([encoded; 4]).unwrap() + }; + let senior_anchor = TextAnchor::new(0.9, Floor::AaText) + .unwrap() + .with_hue(tint("#FF3B30")); + let junior_anchor = TextAnchor::new(0.8, Floor::AaText) + .unwrap() + .with_hue(tint("#808080")); + let table = NamedRoleTable::new( + vec![ + ("senior".to_string(), RoleSpec::Anchor(senior_anchor)), + ("junior".to_string(), RoleSpec::Anchor(junior_anchor)), + ], + Vec::new(), + RoleChroma::Neutral, + ) + .expect("two source identities are a valid table"); + + let background = BgInput::solid("#6F6F6F").unwrap(); + let vc = ViewingConditions::srgb(); + let set = resolve_named_set(&background, &table, &vc).unwrap(); + let (_, junior) = set + .iter() + .find(|(name, _)| name == "junior") + .expect("junior exists"); + let Resolved::Color { solved, .. } = junior else { + panic!("junior must resolve to Color"); + }; + assert_achromatic_hex(solved.hex(), "independent achromatic source"); + + let isolated = NamedRoleTable::new( + vec![("junior".to_string(), RoleSpec::Anchor(junior_anchor))], + Vec::new(), + RoleChroma::Neutral, + ) + .unwrap(); + let isolated_set = resolve_named_set(&background, &isolated, &vc).unwrap(); + let Resolved::Color { + solved: isolated_solved, + .. + } = &isolated_set[0].1 + else { + panic!("isolated junior must resolve to Color"); + }; + assert_eq!(solved.hex(), isolated_solved.hex()); + assert_eq!( + junior, &isolated_set[0].1, + "an unrelated adjacent source must not change value or provenance" + ); +} + +#[test] +fn all_achromatic_material_is_lawful_under_a_neutral_table_policy() { + use crate::ladder::LadderTint; + use crate::semantic::{DjMagnitude, NamedRoleTable, RoleChroma, RoleSpec}; + + let gray = crate::spaces::srgb::srgb_encoded_from_hex("#808080").unwrap(); + let table = NamedRoleTable::new( + vec![( + "material".to_string(), + RoleSpec::Material { + hue: Some(LadderTint::new([gray; 4]).unwrap()), + tone: DjMagnitude::new(12.0, 12.0), + floor: Floor::AaUi, + }, + )], + Vec::new(), + RoleChroma::Neutral, + ) + .expect("all-achromatic source needs no chromatic table policy"); + let set = resolve_named_set( + &BgInput::solid("#FFFFFF").unwrap(), + &table, + &ViewingConditions::srgb(), + ) + .unwrap(); + let Resolved::Material(material) = &set[0].1 else { + panic!("material must resolve"); + }; + assert_achromatic_hex(material.tint_hex(), "neutral-policy Material"); +} + /// КОМПОЗИЦИОННЫЙ контракт FX-стека теней. /// /// Прежний контракт держал только пер-токенный порядок (|Lc| каждой ступени @@ -2211,7 +2310,7 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() { "fx-shadow-penumbra", "fx-shadow-major", ]; - let bg_jp = LcsColor::from_hex_with_vc(bg_hex, &vc).unwrap().jp; + let bg_jp = LcsColor::from_hex_with_vc(bg_hex, &vc).unwrap().jp(); let mut state = srgb_encoded_from_hex(bg_hex).unwrap(); let mut prev_delta = 0.0_f64; for name in stack { @@ -2233,7 +2332,7 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() { "{name}: наслоение слоя не изменило композит ({state_hex}) — вырожденная ступень стека" ); // (2) суммарная различимость стека от фона строго растёт. - let jp = LcsColor::from_hex_with_vc(&state_hex, &vc).unwrap().jp; + let jp = LcsColor::from_hex_with_vc(&state_hex, &vc).unwrap().jp(); let delta = (jp - bg_jp).abs(); assert!( delta > prev_delta, @@ -2249,8 +2348,8 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() { /// Ladder@52-строк): (а) на тёмной базе паспорта свечение решается БЕЗ /// деградации, halo = пер-темный якорь источника, α ∈ (0, 1], фактический шаг /// в допуске квантования от контрактной ступени Base; (б) на белом фоне -/// белое нейтральное свечение деградирует ЧЕСТНО (на белом screen — point-no-op -/// reference-профиля) — флаг degraded, не молчание и не ошибка. +/// белое нейтральное свечение возвращает явный typed target status (на белом +/// screen — point-no-op reference-профиля), не молчание и не ошибка. #[test] fn glow_roles_resolve_screen_layers() { let cfg = labui_reference(); @@ -2271,21 +2370,26 @@ fn glow_roles_resolve_screen_layers() { Resolved::Glow(g) => g, other => panic!("fx-glow-brand должен быть Resolved::Glow, получено {other:?}"), }; - assert!( - !g.degraded(), - "бренд-свечение на тёмной базе не деградирует" + assert_eq!( + g.target_status(), + crate::glow::GlowTargetStatus::LegacyReached, + "бренд-свечение на тёмной базе достигает target" ); assert_eq!(g.halo_hex(), "#4A8FFF", "halo = пер-темный якорь бренда"); assert!(g.alpha() > 0.0 && g.alpha() <= 1.0); let target = crate::glow::GlowStep::Base.target_dj(); assert!( - g.achieved_dj() >= target - 1e-9 && g.achieved_dj() - target < 0.5, + g.halo_achieved_dj() >= target - 1e-9 && g.halo_achieved_dj() - target < 0.5, "шаг ступени Base: достигнуто {:.4} (ожидалось [цель, цель+0.5))", - g.achieved_dj() + g.halo_achieved_dj() ); // Анатомия: core светлее halo (пересвет). let vc = &vc_dark; - let jp = |hex: &str| crate::lcs::LcsColor::from_hex_with_vc(hex, vc).unwrap().jp; + let jp = |hex: &str| { + crate::lcs::LcsColor::from_hex_with_vc(hex, vc) + .unwrap() + .jp() + }; assert!(jp(g.core_hex()) > jp(g.halo_hex()), "core светлее halo"); // (б) белое свечение на белом — честная деградация. @@ -2298,100 +2402,20 @@ fn glow_roles_resolve_screen_layers() { .expect("fx-glow-neutral в наборе"); match res { Resolved::Glow(g) => { + assert_eq!( + g.target_status(), + crate::glow::GlowTargetStatus::LegacyUnreachable, + "белое свечение на белом обязано сообщить недостижимость" + ); assert!( - g.degraded(), - "белое свечение на белом обязано деградировать честно" + g.halo_achieved_dj() < 0.5, + "screen над белым гаснет физически" ); - assert!(g.achieved_dj() < 0.5, "screen над белым гаснет физически"); } other => panic!("fx-glow-neutral должен быть Resolved::Glow, получено {other:?}"), } } -/// Попарная различимость сентиментов между СОБОЙ (аудит 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 -/// ложится в янтарную дугу у пола вместо зелени. Тест держит закон: -/// ВСЕ пары ≥ порога во всех режимах, без исключений. -#[test] -fn labui_sentiment_solids_keep_pairwise_ab_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(), - crate::spaces::vc::ViewingConditions::srgb_high_contrast(), - 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(); - for cat in &cfg.sentiments.categories { - 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)); - } - 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(); - assert!( - d_ab >= s_perc_min, - "режим {mode_idx}: сентименты `{na}` и `{nb}` перцептивно слиплись: \ - ab-дистанция {d_ab:.4} < порога {s_perc_min:.4}" - ); - } - } - } -} - -/// Warning light-ic лежит в янтарной дуге, а не в зелени. -/// -/// РЕФРЕЙМ Волны 1 (закон категориальных зон). Прежде это был RED-proof лечения -/// S-02: брендоцентричный однотельный закон зеркалил IC-Warning далёким брендом в -/// зелень (~127°), а многотельный лечил в янтарь. Под новым законом -/// brand-displacement УБРАН целиком — Warning всегда ОТДЫХАЕТ на своём оранжевом -/// прототипе (для light-ic якорь orange `#C93400`), поэтому зелени неоткуда взяться -/// и однотельного контр-примера больше не существует. Инвариант «warning light-ic -/// в янтаре» ОСТАЁТСЯ валидным следствием закона и сохранён как страж; мёртвый -/// RED-proof (однотельный→зелень) удалён. -#[test] -fn warning_light_ic_rests_in_amber_arc() { - 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})" - ); -} // ───────────────────────────────────────────────────────────────────────────── // 5. Пустой контракт отклоняется на загрузке. // Агностичность: ядро не знает ролей — конфиг несёт СВОЙ словарь; голый @@ -2425,7 +2449,7 @@ fn empty_contract_is_rejected_at_load() { } // ───────────────────────────────────────────────────────────────────────────── -// Материал (whitepaper §3.7): двухслойный контракт «тинт 01 (α) + база 02» с ВЫВЕДЕННОЙ α. +// Материал (whitepaper, «Точечные композиции»): двухслойный контракт «тинт 01 (α) + база 02» с ВЫВЕДЕННОЙ α. // ───────────────────────────────────────────────────────────────────────────── /// Заменить роль на произвольный рецепт и вернуть её резолв на `bg_hex`/`vc`. @@ -2491,7 +2515,11 @@ fn material_two_layer_solid_canon_byte_exact_and_guaranteed() { "α вне (0,1]: {}", m.alpha() ); - assert!(m.guaranteed(), "AA-гарантия обязана держаться"); + assert_eq!( + m.alpha_status(), + crate::material::MaterialAlphaStatusV1::Satisfied, + "AA-floor обязан иметь typed satisfied status" + ); assert!(m.worst_contrast() >= m.floor() - 1e-9, "worst < floor"); assert!((m.floor() - 4.5).abs() < 1e-12, "AA-text пол = 4.5"); } @@ -2671,9 +2699,10 @@ fn material_dark_theme_white_pole_guaranteed() { matches!(m.pole(), crate::material::Pole::White), "тёмная поверхность обязана коммитить белый полюс" ); - assert!( - m.guaranteed(), - "AA-гарантия обязана держаться и на тёмной теме" + assert_eq!( + m.alpha_status(), + crate::material::MaterialAlphaStatusV1::Satisfied, + "AA-floor обязан иметь typed satisfied status и на тёмной теме" ); } diff --git a/crates/labcolors-core/src/curve.rs b/crates/labcolors-core/src/curve.rs index eb0af853..85e33cdb 100644 --- a/crates/labcolors-core/src/curve.rs +++ b/crates/labcolors-core/src/curve.rs @@ -1,40 +1,128 @@ +use crate::Srgb8; use crate::lcs::LcsColor; use crate::spaces::vc::ViewingConditions; -/// A parametric colour curve sampled over `t ∈ [0, 1]`. +/// Конечная нормированная позиция на цветовой кривой. /// -/// Implemented by [`NeutralCurve`](crate::neutral::NeutralCurve) and -/// [`AccentCurve`](crate::scale::AccentCurve) so that downstream consumers -/// (e.g. semantic resolution) can accept either generically. +/// Создание служит публичной границей входа. Готовое значение исключает `NaN`, +/// бесконечность и скаляр вне домена, поэтому вычислению кривой не нужны clamp +/// или правдоподобный цвет по умолчанию. +/// +/// ```compile_fail +/// use labcolors_core::neutral::NeutralCurve; +/// let curve = NeutralCurve::new("#FFFFFF", "#808080", "#000000").unwrap(); +/// curve.at(f64::NAN); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub struct CurvePosition(f64); + +/// Причина, по которой скаляр нельзя преобразовать в [`CurvePosition`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurvePositionError { + NonFinite, + OutsideUnitInterval, +} + +impl CurvePosition { + pub const START: Self = Self(0.0); + pub const MIDPOINT: Self = Self(0.5); + pub const END: Self = Self(1.0); + + pub fn new(value: f64) -> Result { + if !value.is_finite() { + return Err(CurvePositionError::NonFinite); + } + if !(0.0..=1.0).contains(&value) { + return Err(CurvePositionError::OutsideUnitInterval); + } + // Каноническая запись отождествляет `-0.0` и `0.0`, не включая + // несущественный знаковый бит IEEE в идентичность значения. + Ok(Self(if value == 0.0 { 0.0 } else { value })) + } + + pub const fn get(self) -> f64 { + self.0 + } + + fn from_sample(index: usize, last: usize) -> Self { + debug_assert!(last > 0 && index <= last); + Self(index as f64 / last as f64) + } +} + +impl TryFrom for CurvePosition { + type Error = CurvePositionError; + + fn try_from(value: f64) -> Result { + Self::new(value) + } +} + +impl std::fmt::Display for CurvePositionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NonFinite => f.write_str("curve position must be finite"), + Self::OutsideUnitInterval => { + f.write_str("curve position must be within the closed interval [0, 1]") + } + } + } +} + +impl std::error::Error for CurvePositionError {} + +/// Параметрическая цветовая кривая на `t ∈ [0, 1]`. +/// +/// Реализуется [`NeutralCurve`](crate::neutral::NeutralCurve) и +/// [`AccentCurve`](crate::scale::AccentCurve), чтобы потребители принимали обе +/// кривые через один контракт. pub trait ColorCurve { - /// Colour at normalised position `t`, clamped to `[0, 1]`. - fn at(&self, t: f64) -> LcsColor; + /// Цвет в проверенной нормированной позиции. + fn at(&self, position: CurvePosition) -> LcsColor; - /// The viewing conditions this curve was built with. + /// Условия просмотра, в которых построена кривая. /// - /// Hex conversion MUST go through these conditions — converting a - /// colour with mismatched VC silently drifts (see the - /// `wrong_vc_roundtrip_drifts` test in `lcs`). + /// Hex-преобразование обязано использовать эти условия: несовпадающие VC + /// вызывают тихий дрейф (см. тест `wrong_vc_roundtrip_drifts` в `lcs`). fn vc(&self) -> &ViewingConditions; - /// `n` evenly-spaced samples along the curve. + /// Квантует непрерывную точку на типизированной границе эмиссии. + /// + /// Реализация может сохранить точный инвариант представления, например + /// заданную нейтральную ось. [`Self::at`] остаётся непрерывным и не + /// привязывается к выходной решётке. + fn render_srgb8(&self, position: CurvePosition) -> Srgb8 { + self.at(position).to_srgb8_with_vc(self.vc()) + } + + /// `n` равноудалённых образцов вдоль кривой. /// - /// Default implementation delegates to [`at`](ColorCurve::at). + /// Реализация по умолчанию делегирует [`at`](ColorCurve::at). fn sample(&self, n: usize) -> Vec { if n == 0 { return Vec::new(); } if n == 1 { - return vec![self.at(0.5)]; + return vec![self.at(CurvePosition::MIDPOINT)]; } - (0..n).map(|i| self.at(i as f64 / (n - 1) as f64)).collect() + (0..n) + .map(|i| self.at(CurvePosition::from_sample(i, n - 1))) + .collect() } - /// `n` hex strings sampled through this curve's own viewing conditions. + /// `n` hex-строк, вычисленных в собственных условиях просмотра кривой. fn sample_hex(&self, n: usize) -> Vec { - self.sample(n) - .iter() - .map(|c| c.to_hex_with_vc(self.vc())) + if n == 0 { + return Vec::new(); + } + if n == 1 { + return vec![self.render_srgb8(CurvePosition::MIDPOINT).to_hex()]; + } + (0..n) + .map(|i| { + self.render_srgb8(CurvePosition::from_sample(i, n - 1)) + .to_hex() + }) .collect() } } @@ -42,51 +130,67 @@ pub trait ColorCurve { #[cfg(test)] mod tests { use super::*; - use crate::neutral::{CurveParams, NeutralCurve}; + use crate::neutral::NeutralCurve; use crate::scale::AccentCurve; - use crate::sentiment::{Sentiment, SentimentCurve}; - - /// Dedup guard (issue #6): all three curves must reach `sample()` through the - /// ONE trait default, not through per-struct inherent copies. A `&dyn - /// ColorCurve` can only call the trait method, so if any struct stops - /// implementing `ColorCurve` — the exact regression that left this issue - /// half-done for `SentimentCurve` — this test fails to compile. The value - /// assertions pin that the shared default reproduces each curve's own ramp, - /// so removing the inherent bodies changed no output. + + #[test] + fn curve_position_rejects_nonfinite_and_out_of_domain_values() { + for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] { + assert_eq!( + CurvePosition::try_from(value), + Err(CurvePositionError::NonFinite) + ); + } + for value in [ + -f64::from_bits(1), + f64::from_bits(1.0_f64.to_bits() + 1), + -f64::MAX, + f64::MAX, + ] { + assert_eq!( + CurvePosition::try_from(value), + Err(CurvePositionError::OutsideUnitInterval) + ); + } + assert_eq!(CurvePosition::try_from(-0.0).unwrap().get().to_bits(), 0); + assert_eq!(CurvePosition::try_from(1.0).unwrap().get(), 1.0); + } + + /// Страж дедупликации (issue #6): обе кривые обязаны вызывать единственную + /// реализацию `sample()` из trait, а не собственные копии. Через + /// `&dyn ColorCurve` доступен только метод trait, поэтому потеря реализации + /// `ColorCurve` ломает + /// компиляцию. Проверки значений подтверждают, что общий метод воспроизводит + /// исходную растяжку каждой кривой. #[test] fn every_curve_samples_through_the_shared_trait_default() { let neutral = NeutralCurve::new("#FFFFFF", "#787880", "#101012") .expect("canonical anchors are valid"); let accent = AccentCurve::new("#007AFF", &neutral).expect("#007AFF is a valid accent seed"); - let sentiment = SentimentCurve::from_sentiment(Sentiment::Info, 33.5, "#3E87FF", &neutral) - .expect("Info sentiment resolves on the canonical neutral"); - - for curve in [ - &neutral as &dyn ColorCurve, - &accent as &dyn ColorCurve, - &sentiment as &dyn ColorCurve, - ] { - // n == 0 / n == 1 / n > 1 branches of the single default body. + for curve in [&neutral as &dyn ColorCurve, &accent as &dyn ColorCurve] { + // Все ветви единственной реализации: n == 0, n == 1 и n > 1. assert!(curve.sample(0).is_empty(), "n=0 must be empty"); - assert_eq!(curve.sample(1), vec![curve.at(0.5)], "n=1 is the midpoint"); + assert_eq!( + curve.sample(1), + vec![curve.at(CurvePosition::MIDPOINT)], + "n=1 is the midpoint" + ); let five = curve.sample(5); assert_eq!(five.len(), 5, "n>1 returns n samples"); - assert_eq!(five[0], curve.at(0.0), "first sample is t=0"); - assert_eq!(five[4], curve.at(1.0), "last sample is t=1"); + assert_eq!( + five[0], + curve.at(CurvePosition::START), + "first sample is t=0" + ); + assert_eq!(five[4], curve.at(CurvePosition::END), "last sample is t=1"); } } #[test] fn dyn_curve_renders_through_own_vc() { let vc = ViewingConditions::dim_surround(); - let curve = NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &vc, - ) - .expect("NeutralCurve::with_vc should succeed for valid dim-surround anchors"); + let curve = NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &vc) + .expect("NeutralCurve::with_vc should succeed for valid dim-surround anchors"); let curve: &dyn ColorCurve = &curve; let hexes = curve.sample_hex(13); diff --git a/crates/labcolors-core/src/dim_tinted_tests.rs b/crates/labcolors-core/src/dim_tinted_tests.rs index 3ed6e381..4e59524e 100644 --- a/crates/labcolors-core/src/dim_tinted_tests.rs +++ b/crates/labcolors-core/src/dim_tinted_tests.rs @@ -7,8 +7,8 @@ //! the DEFAULT tinted table — across a grid of backgrounds, asserting all four //! invariants at once: //! -//! 1. **Perceptual target accuracy.** Each text/UI role lands within ±1 Lc of -//! the target its anchor implies (fraction × the background's max contrast), +//! 1. **Candidate-score target accuracy.** Each text/UI role lands within ±1 Lc of +//! the target its anchor implies (fraction × the background's max Ys score), //! re-measured independently — *except* where the WCAG floor legitimately //! overrode it (`floor_override`), where the contrast is only pushed *up*. //! 2. **WCAG floors.** primary/secondary clear 4.5:1, muted/icon clear 3:1, on @@ -152,21 +152,21 @@ fn dim_tinted_carries_the_cool_neutral_undertone() { // tint because max_chroma collapses at the lightness extremes, so // the hue can be noisier there; assert the hue band only where the // tint is actually present (s above a small floor). - if c.s > 0.02 { - let dist = hue_distance(c.h_ok, TINT_HUE_CENTER); + if c.s() > 0.02 { + let dist = hue_distance(c.h_ok(), TINT_HUE_CENTER); assert!( dist <= TINT_HUE_BAND, "dim {bg_hex} {}: undertone hue {:.1}° is {dist:.1}° off the cool \ neutral {TINT_HUE_CENTER}° (hex {hex}) — tint drifted or flipped warm", role.key(), - c.h_ok, + c.h_ok(), ); } assert!( - c.s > 1e-4, + c.s() > 1e-4, "dim {bg_hex} {}: saturation {} ~ 0 — role came out sterile grey, not tinted", role.key(), - c.s, + c.s(), ); // Cool lean: the blue byte is at least the red byte (blue-violet). @@ -204,13 +204,13 @@ fn dim_tinted_perceptual_target_accuracy_where_floor_does_not_override() { let set = resolve_set(&bg, &table, &vc); for role in roles { if let Some((solved, _)) = role_solved(&set, role) { - // Ось читаемости меряется в Ys (ADR-0003 глава #64): `solved.lc()` + // Ys candidate score вычисляется в Ys (ADR-0003): `solved.lc()` // — Ys-замер, независимый пересчёт обязан читать тот же домен. let fg_disp = crate::spaces::srgb::srgb_encoded_from_hex(solved.hex()).expect("valid hex"); let bg_disp = crate::spaces::srgb::srgb_encoded_from_hex(bg_hex).expect("valid hex"); - let measured = crate::lpc::lpc_readability_ys(fg_disp, bg_disp); + let measured = crate::lpc::ys_candidate_score_for_test(fg_disp, bg_disp); assert!( (solved.lc() - measured).abs() <= 1.0, "dim {bg_hex} {}: reported lc {} disagrees with re-measured {measured} \ @@ -223,20 +223,3 @@ fn dim_tinted_perceptual_target_accuracy_where_floor_does_not_override() { } } } - -#[test] -fn tmp_probe_ys_anchors() { - let white = crate::spaces::srgb::srgb_encoded_from_hex("#FFFFFF").expect("bg"); - let max = crate::lpc::lpc_readability_ys( - crate::spaces::srgb::srgb_encoded_from_hex("#000000").expect("fg"), - white, - ); - println!("PROBE max(black-on-white) = {max}"); - for h in [ - "#141414", "#767676", "#949494", "#C2C2C2", "#17171C", "#EDEDED", - ] { - let fg = crate::spaces::srgb::srgb_encoded_from_hex(h).expect("fg"); - let ys = crate::lpc::lpc_readability_ys(fg, white); - println!("PROBE {h}: ys_lc={ys} fraction={}", ys / max); - } -} diff --git a/crates/labcolors-core/src/exposure_support.rs b/crates/labcolors-core/src/exposure_support.rs index 3f9537ba..8a5e7e74 100644 --- a/crates/labcolors-core/src/exposure_support.rs +++ b/crates/labcolors-core/src/exposure_support.rs @@ -19,7 +19,7 @@ use crate::spaces::srgb::{srgb_encoded_from_hex, srgb_gamma_inv}; -/// 49 реальных якорей labui (роли/сентименты/акценты/нейтраль) из паспорта. +/// 49 реальных якорей Lab UI (бренд, семейства и нейтрали) из паспорта. /// Дедуп-набор всех hex-литералов паспорта — «реальные входы» экспозиции. pub(crate) const LABUI_ANCHORS: &[&str] = &[ "#0040DD", "#0050CF", "#0071A4", "#007AFF", "#00C7BE", "#0C817B", "#101012", "#248A3D", diff --git a/crates/labcolors-core/src/glow.rs b/crates/labcolors-core/src/glow.rs index 78dc547c..66855515 100644 --- a/crates/labcolors-core/src/glow.rs +++ b/crates/labcolors-core/src/glow.rs @@ -39,8 +39,8 @@ //! — исходный цвет. Радиусы и размытие здесь отсутствуют: названия обозначают //! назначение слоёв у потребителя, а не измеренную геометрию. //! -//! Для хроматического источника core строится существующей политикой -//! [`crate::accent_balance`]: midpoint J′ задаёт начальную Oklab-светлоту, chroma +//! Для хроматического источника core строится внутренней max-chroma-at-lightness +//! политикой: midpoint J′ задаёт начальную Oklab-светлоту, chroma //! берётся на sRGB-границе данного hue. Точная sRGB8-нейтраль остаётся //! нейтральной: у неё нет hue, который можно было бы честно «усилить». //! После хроматического преобразования и sRGB8-квантования итоговый J′ не @@ -57,7 +57,8 @@ use crate::numerics::{ }; use crate::spaces::oklab::oklab_to_srgb_linear; use crate::spaces::srgb::{ - decode_8bit, hex_from_srgb, hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_to_xyz, + hex_from_srgb, hex_from_srgb_encoded, srgb_encoded_from_hex, srgb_linear_from_srgb8, + srgb_to_xyz, }; use crate::spaces::vc::ViewingConditions; @@ -83,7 +84,8 @@ pub const GLOW_COMPOSITE_PROFILE: &str = "encoded-srgb8-screen-v1"; /// решения и не усиливает сертификат композита. pub const GLOW_DIAGNOSTIC_PROFILE: &str = "cam16-ucs-jprime-li2017-v1"; -/// Версия recipe, строящего core-слой из начального CAM16-UCS J′ и Oklab cusp. +/// Версия recipe, строящего core-слой из начального CAM16-UCS J′: exact-gray +/// источник остаётся на neutral ray, chromatic источник использует Oklab cusp. /// /// Профиль идентифицирует только алгоритм построения слоя. Он не является /// гарантией выбора, appearance-сертификатом или утверждением о пространстве @@ -145,9 +147,9 @@ impl GlowDiagnosticProfileV1 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum GlowLayerRecipeProfileV1 { - /// Начальный midpoint CAM16-UCS J′, переведённый в Oklab lightness; - /// хроматический core берёт ограниченную cusp chroma текущего Oklab hue, - /// halo равен источнику. + /// Начальный midpoint CAM16-UCS J′, переведённый в Oklab lightness. При + /// равных sRGB8-каналах core идёт по neutral ray; при неравных берёт + /// ограниченную cusp chroma текущего Oklab hue. Halo равен источнику. Cam16JPrimeOklabCuspV1, } @@ -424,9 +426,9 @@ fn validate_viewing_numerics(vc: &ViewingConditions) -> Result<(), String> { fn validate_lcs_numerics(label: &str, color: &LcsColor) -> Result<(), String> { for (name, value) in [ - ("jp", color.jp), - ("h_ok", color.h_ok), - ("s", color.s), + ("jp", color.jp()), + ("h_ok", color.h_ok()), + ("s", color.s()), ("h_cam", color.h_cam()), ] { if !value.is_finite() { @@ -440,7 +442,7 @@ fn validate_lcs_numerics(label: &str, color: &LcsColor) -> Result<(), String> { /// участвуют в целевой функции, поэтому полный `LcsColor` был бы лишней работой /// на каждом из сотен состояний sRGB8. Формула J′ остаётся тем же SSOT из `cam16`. fn jp_from_srgb8(bytes: [u8; 3], vc: &ViewingConditions) -> Result { - let rgb = bytes.map(decode_8bit); + let rgb = srgb_linear_from_srgb8(crate::Srgb8::new(bytes)); let (j, _, _) = crate::spaces::cam16::forward(srgb_to_xyz(rgb), vc); let jp = crate::spaces::cam16::ucs_j(j); if jp.is_finite() { @@ -650,7 +652,7 @@ fn encoded_bytes(rgb: [f64; 3]) -> [u8; 3] { } fn composite_hex(bytes: [u8; 3]) -> String { - format!("#{:02X}{:02X}{:02X}", bytes[0], bytes[1], bytes[2]) + crate::Srgb8::new(bytes).to_hex() } /// Поток всех состояний, достижимых representable binary64-alpha в объявленном @@ -908,15 +910,6 @@ impl GlowSolve { self.status } - /// Геттер совместимости прежнего булева контракта: цель недостижима как при - /// точном no-op, так и при явном legacy-исходе с максимумом. - pub fn degraded(&self) -> bool { - matches!( - self.status, - GlowTargetStatus::ExactNoopUnreachable | GlowTargetStatus::LegacyUnreachable - ) - } - /// Слой, по которому решалась цель. pub fn constraint_layer(&self) -> GlowConstraintLayer { GlowConstraintLayer::Halo @@ -1177,8 +1170,8 @@ fn solve_screen_alpha_for_dj_legacy( /// /// В recipe v1 halo буквально равен источнику. Для core арифметическая середина /// J′ источника и 100 задаёт начальную Oklab-светлоту. У хроматического источника -/// chroma — sRGB-граница его Oklab hue через -/// [`crate::accent_balance::accent_balanced`]; у точного sRGB8-нейтраля chroma +/// chroma — sRGB-граница его Oklab hue через внутреннюю +/// max-chroma-at-lightness операцию; у точного sRGB8-нейтраля chroma /// остаётся нулевой, потому его численный hue не несёт цветового смысла. /// Фактический J′ эмитированного core измеряется вызывающим кодом: он не обязан /// совпасть с начальным значением после смены координат и sRGB8-квантования. @@ -1193,7 +1186,7 @@ pub fn glow_layers_from_source( let canonical_source_hex = hex_from_srgb_encoded(source_encoded); let src = LcsColor::from_hex_with_vc(&canonical_source_hex, vc)?; validate_lcs_numerics("source", &src)?; - let jp_core = (src.jp + 100.0) * 0.5; + let jp_core = (src.jp() + 100.0) * 0.5; if !jp_core.is_finite() { return Err(format!("core J′ seed не конечен: {jp_core}")); } @@ -1202,12 +1195,12 @@ pub fn glow_layers_from_source( return Err(format!("core Oklab L вне конечного [0,1]: {l_core}")); } - let core_rgb = if source_bytes[0] == source_bytes[1] && source_bytes[1] == source_bytes[2] { + let core_rgb = if crate::Srgb8::new(source_bytes).is_achromatic() { // У точного sRGB8-нейтраля оттенка нет. Число atan2 от матричного шума // нельзя превращать в насыщенный core: hue-отсутствие сохраняется. - oklab_to_srgb_linear([l_core, 0.0, 0.0]) + crate::spaces::oklab::neutral_srgb_linear(l_core) } else { - let balanced = crate::accent_balance::accent_balanced(l_core, src.h_ok, vc); + let balanced = crate::accent_balance::accent_balanced(l_core, src.h_ok(), vc); validate_lcs_numerics("core", &balanced.color)?; for (name, value) in [ ("l_ok", balanced.l_ok), @@ -1449,7 +1442,7 @@ mod tests { boundaries.sort_unstable(); boundaries.dedup(); - let bg_jp = LcsColor::from_hex_with_vc(bg_hex, vc).unwrap().jp; + let bg_jp = LcsColor::from_hex_with_vc(bg_hex, vc).unwrap().jp(); let mut states: Vec<(f64, String, f64)> = Vec::new(); for boundary in boundaries { let alpha = f64::from_bits(boundary); @@ -1460,7 +1453,7 @@ mod tests { { continue; } - let jp = LcsColor::from_hex_with_vc(&hex, vc).unwrap().jp; + let jp = LcsColor::from_hex_with_vc(&hex, vc).unwrap().jp(); states.push((alpha, hex, (jp - bg_jp).abs())); } states @@ -1517,12 +1510,12 @@ mod tests { for byte in 0_u16..=255 { let hex = format!("#{byte:02X}{byte:02X}{byte:02X}"); let lean = jp_from_hex(&hex, &vc).unwrap(); - let full = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp; + let full = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp(); assert_eq!(lean.to_bits(), full.to_bits(), "{hex}"); } for hex in ["#007AFF", "#FF3B30", "#34C759", "#FFD000", "#3E87FF"] { let lean = jp_from_hex(hex, &vc).unwrap(); - let full = LcsColor::from_hex_with_vc(hex, &vc).unwrap().jp; + let full = LcsColor::from_hex_with_vc(hex, &vc).unwrap().jp(); assert_eq!(lean.to_bits(), full.to_bits(), "{hex}"); } } @@ -1779,7 +1772,6 @@ mod tests { let vc = ViewingConditions::dim_surround(); for target in [GLOW_SUBTLE_DJ, GLOW_BASE_DJ, GLOW_BLOOM_DJ] { let g = solve_legacy("#3E87FF", "#101012", target, &vc).unwrap(); - assert!(!g.degraded(), "цель {target} недостижима на #101012"); assert_eq!(g.status(), GlowTargetStatus::LegacyReached); assert_eq!( g.selection_diagnostic_profile(), @@ -1803,10 +1795,10 @@ mod tests { let vc = ViewingConditions::dim_surround(); let tint = encoded_bytes(srgb_encoded_from_hex("#4A8FFF").unwrap()); let bg = encoded_bytes(srgb_encoded_from_hex("#101012").unwrap()); - let bg_jp = LcsColor::from_hex_with_vc("#101012", &vc).unwrap().jp; + let bg_jp = LcsColor::from_hex_with_vc("#101012", &vc).unwrap().jp(); let measured_at = |alpha: f64| { let hex = composite_hex(screen_layer_over_srgb8(tint, alpha, bg).unwrap()); - let jp = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp; + let jp = LcsColor::from_hex_with_vc(&hex, &vc).unwrap().jp(); ((jp - bg_jp).abs(), hex) }; @@ -1857,9 +1849,9 @@ mod tests { for target in [0.01, 0.32, 1.0, 10.0, global_best.2 + 1.0] { let solved = solve_legacy(tint_hex, bg_hex, target, &vc).unwrap(); let expected = oracle.iter().find(|state| state.2 >= target); - let (expected_state, degraded) = match expected { - Some(state) => (state, false), - None => (global_best, true), + let (expected_state, expected_status) = match expected { + Some(state) => (state, GlowTargetStatus::LegacyReached), + None => (global_best, GlowTargetStatus::LegacyUnreachable), }; assert_eq!( solved.composite_hex(), @@ -1867,7 +1859,7 @@ mod tests { "tint={tint_hex}, bg={bg_hex}, target={target}" ); assert_eq!(solved.achieved_dj().to_bits(), expected_state.2.to_bits()); - assert_eq!(solved.degraded(), degraded); + assert_eq!(solved.status(), expected_status); let emitted_alpha = solved.alpha_css().parse::().unwrap(); assert_eq!( @@ -1954,7 +1946,6 @@ mod tests { fn glow_on_white_degrades_honestly() { let vc = ViewingConditions::srgb(); let g = solve_legacy("#3E87FF", "#FFFFFF", GLOW_BASE_DJ, &vc).unwrap(); - assert!(g.degraded(), "над белым screen обязан быть point-no-op"); assert_eq!(g.status(), GlowTargetStatus::LegacyUnreachable); assert!(g.achieved_dj() < GLOW_BASE_DJ); assert_eq!(g.composite_hex(), "#FFFFFF", "screen над белым — тождество"); @@ -1975,8 +1966,8 @@ mod tests { 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!(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}°"); // Хрома баланса = стена гамута ⇒ эмиссия в гамуте, без тихого клипа @@ -2014,6 +2005,24 @@ mod tests { } } + #[test] + fn nearest_chromatic_source_is_not_classified_as_gray() { + for vc in [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ] { + let (core, halo) = glow_layers_from_source("#808081", &vc).unwrap(); + let [red, green, blue] = encoded_bytes(srgb_encoded_from_hex(&core).unwrap()); + assert_eq!(halo, "#808081"); + assert!( + red != green || green != blue, + "one-byte chromatic source collapsed to gray core {core}" + ); + } + } + /// Публичные поля VC позволяют внешнему коду создать несогласованное /// производное состояние. Такое состояние раньше протаскивало NaN через /// CAM16 и маскировало его квантизацией в правдоподобный `#000000`. diff --git a/crates/labcolors-core/src/ladder.rs b/crates/labcolors-core/src/ladder.rs index d85e0d4e..30fa44b9 100644 --- a/crates/labcolors-core/src/ladder.rs +++ b/crates/labcolors-core/src/ladder.rs @@ -1,4 +1,4 @@ -//! Лестница акцента/сентимента/бренда/нейтрали как ДАННЫЕ: закрытое меню позиций +//! Лестница семейства/бренда/нейтрали как ДАННЫЕ: закрытое меню позиций //! (каждая несёт свою альфу Figma-рампы) + физика тинта источника по теме. //! //! # Закон лестницы @@ -14,7 +14,7 @@ //! [`crate::alpha::composite_over_encoded`]) — для честного замера dJ'/WCAG //! контраст-корректности на подложке (фаза 1 AA: контраст меряется на композите). //! Заземление: Figma «🧪Lab UI (v.1)» (переменные `Accent/Derivable/*`, обход -//! через figma-console, 2026-07-02), `reference/labui-accent-primitives.md` §2 +//! через figma-console, 2026-07-02), `reference/labui-accent-primitives.md`, «Якоря» //! (пер-темные якоря), стаб из labui-репо `packages/colors-stub/contract.css` //! (не путь в ЭТОМ репо — файла здесь нет; @NN-рампа, снапшот-значения запинены //! тестами конфига). @@ -28,11 +28,11 @@ //! имена = [`LadderPosition::key`], контракт разбора конфига). //! Единый паттерн проверен на brand/danger/info/success (см. «Закон лестницы» //! выше). Это данные позиций, а не POLICY-константы перцептивных модулей, -//! поэтому провенанс держится этой doc-строкой + тестом лестницы, а не строкой -//! реестра (как якорные hex палитры, `accent.rs`). +//! поэтому провенанс держится этой doc-строкой и тестом лестницы, а не закрытым +//! каталогом клиентских семейств в Core. -use crate::spaces::oklab::srgb_linear_to_oklab; -use crate::spaces::srgb::{srgb_encoded_from_hex, srgb_gamma_inv}; +use crate::Srgb8; +use crate::spaces::srgb::srgb_encoded_from_hex; use crate::spaces::vc::ViewingConditions; /// Пер-темная четвёрка якорных hex (`light` / `dark` / `light-ic` / `dark-ic`). @@ -51,7 +51,7 @@ fn vc_slot(vc: &ViewingConditions) -> usize { /// Источник лестницы (семейство палитры или бренд) несёт свой якорь отдельно для /// каждого режима — тёмная тема и режим повышенного контраста (IC) не выводятся -/// из светлого якоря, а замеряются (`reference/labui-accent-primitives.md` §2: +/// из светлого якоря, а замеряются (`reference/labui-accent-primitives.md`, «Якоря»: /// Red light `#FF3B30` / dark `#FF3A3A` / light-ic `#D70015` / dark-ic `#FF6161`). /// Выбор режима — по условиям просмотра резолва ([`ThemeAnchors::for_vc`]). #[derive(Debug, Clone, PartialEq)] @@ -135,25 +135,30 @@ impl LadderTint { 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() + /// Exact emitted sRGB8 source selected for these viewing conditions. + pub(crate) fn srgb8_for_vc(&self, vc: &ViewingConditions) -> Srgb8 { + let bytes = self + .for_vc(vc) + .map(|channel| (channel * 255.0).round() as u8); + Srgb8::new(bytes) + } + + /// Whether every declared context lies exactly on the encoded grey axis. + /// + /// Used by executable-table validation: an all-achromatic source is lawful + /// under a neutral policy, while a mixed/chromatic source still requires a + /// policy capable of carrying its authored direction. + pub(crate) fn all_modes_achromatic(&self) -> bool { + self.quad.iter().all(|encoded| { + let bytes = encoded.map(|channel| (channel * 255.0).round() as u8); + Srgb8::new(bytes).is_achromatic() + }) } } /// Закрытое меню позиций лестницы: каждая позиция несёт свою альфу Figma-рампы. /// -/// Перечень зафиксирован приложением A к ADR-0001 (заземление 2026-07-02). +/// Исполняемый перечень — [`LadderPosition::ALL`], пары — [`Self::alpha_pair`]. /// Солидные позиции (`α = 1.0`) — `LabelPrimary`, `BorderStrong`, `FocusRing`; /// остальные несут альфу `@NN` из рампы (см. провенанс в документации модуля). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -305,8 +310,8 @@ impl LadderPosition { if vc.is_dark_theme() { dark } else { light } } - /// Стабильный kebab-ключ позиции — для разбора рецепта из JSON-конфига - /// и для приложения A к ADR. Часть контракта имён; опечатка ловится тестом. + /// Стабильный kebab-ключ позиции для разбора рецепта из JSON-конфига. + /// Часть контракта имён; полнота относительно [`Self::ALL`] ловится тестом. pub fn key(self) -> &'static str { match self { LadderPosition::LabelPrimary => "label-primary", @@ -517,4 +522,49 @@ mod tests { assert_eq!(tint.for_vc(&vc), want, "тинт для vc разошёлся с якорем"); } } + + #[test] + fn achromatic_classification_covers_every_context_and_emitted_rounding() { + let gray = srgb_encoded_from_hex("#808080").unwrap(); + let chromatic = srgb_encoded_from_hex("#808081").unwrap(); + let contexts = [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ]; + for (slot, selected) in contexts.iter().enumerate() { + let mut quad = [gray; 4]; + quad[slot] = chromatic; + let mixed = LadderTint::new(quad).unwrap(); + assert!( + !mixed.all_modes_achromatic(), + "one chromatic slot {slot} must make the source mixed" + ); + for (context_slot, context) in contexts.iter().enumerate() { + assert_eq!( + mixed.srgb8_for_vc(context).is_achromatic(), + context_slot != slot, + "context slot {context_slot} selected the wrong source" + ); + } + assert!( + !mixed.srgb8_for_vc(selected).is_achromatic(), + "selected slot {slot} must retain its one-byte chromatic direction" + ); + } + + let off_grid_gray = [128.1 / 255.0, 127.9 / 255.0, 128.49 / 255.0]; + let rounded = LadderTint::new([off_grid_gray; 4]).unwrap(); + assert!( + rounded.all_modes_achromatic(), + "classification is about emitted bytes, not unequal raw f64 values" + ); + assert_eq!( + rounded + .srgb8_for_vc(&ViewingConditions::dim_surround()) + .bytes(), + [128; 3] + ); + } } diff --git a/crates/labcolors-core/src/lcs.rs b/crates/labcolors-core/src/lcs.rs index 5a9e3121..74a34f4e 100644 --- a/crates/labcolors-core/src/lcs.rs +++ b/crates/labcolors-core/src/lcs.rs @@ -3,25 +3,63 @@ //! CAM16-UCS/Oklab views are implementation inputs, not independent editable //! definitions of LCS and not a claim of uniform perceptual attributes. -use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex, srgb_to_xyz, xyz_to_srgb}; +use crate::Srgb8; +use crate::spaces::srgb::{srgb_linear_from_srgb8, 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. +/// Физическое подмногообразие, сохраняемое преобразованиями до эмиссии. +/// +/// Тип закрыт: клиент описывает стимул, а не флаги реализации. Locus не даёт +/// матричному округлению изобрести хроматическое направление там, где его нет в +/// точном encoded-источнике. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PhysicalLocus { + General, + SrgbGrayAxis, +} + +/// Все поля оттенка (`h_ok`, `h_cam`) хранятся в **градусах** `[0, 360)`. +/// Радианы используются только непосредственно в тригонометрических вызовах. +/// +/// Координаты доступны извне только для чтения: `locus` фиксирует физический +/// инвариант представления. Независимое изменение одной координаты заставило бы +/// преобразование и измерение видеть разные цвета. +/// +/// ```compile_fail +/// use labcolors_core::LcsColor; +/// let mut color = LcsColor::from_hex("#808080").unwrap(); +/// color.s = 1.0; +/// ``` #[derive(Debug, Clone, Copy, PartialEq)] pub struct LcsColor { - pub jp: f64, - pub h_ok: f64, + jp: f64, + h_ok: f64, /// Internal reparameterisation of CAM16-UCS colourfulness `M′`: /// `s = M′ / (J′ + 1)`. The `+ 1` is a regulariser against division by zero /// as `J′ → 0`; `LcsColor::mp` applies the analytical inverse /// `s · (J′ + 1)`, subject to ordinary binary64 round-off. This is NOT the /// CAM16 saturation correlate. - pub s: f64, + s: f64, h_cam: f64, + locus: PhysicalLocus, } impl LcsColor { + /// Координата CAM16-UCS `J′` в условиях просмотра построения. + pub fn jp(&self) -> f64 { + self.jp + } + + /// Угол оттенка Oklab в градусах; для точных серых sRGB каноничен ноль. + pub fn h_ok(&self) -> f64 { + self.h_ok + } + + /// Внутренняя перепараметризация хромы, а не клиентская ось намерения. + pub fn s(&self) -> f64 { + self.s + } + /// Parse from hex using standard sRGB viewing conditions (average surround). pub fn from_hex(hex: &str) -> Result { Self::from_hex_with_vc(hex, &ViewingConditions::srgb()) @@ -33,10 +71,28 @@ impl LcsColor { /// VC (e.g. [`ViewingConditions::dim_surround`] for dark themes). They are /// implementation inputs, not universal perceptual-attribute scales. pub fn from_hex_with_vc(hex: &str, vc: &ViewingConditions) -> Result { - let rgb = srgb_from_hex(hex)?; + let encoded = Srgb8::new(crate::srgb8::hex_bytes(hex)?); + Ok(Self::from_srgb8_with_vc(encoded, vc)) + } + + /// Строит appearance-представление точного эмитированного стимула sRGB8. + /// + /// Парсинг и результат решателя проходят через эту границу, поэтому точная + /// серая ось не расходится между представлениями одних байтов. + pub(crate) fn from_srgb8_with_vc(encoded: Srgb8, vc: &ViewingConditions) -> Self { + let rgb = srgb_linear_from_srgb8(encoded); let xyz = srgb_to_xyz(rgb); let h_ok = oklab::oklab_hue(rgb); - Ok(Self::from_xyz_with_hok(xyz, h_ok, vc)) + let mut color = Self::from_xyz_with_hok(xyz, h_ok, vc); + if encoded.is_achromatic() { + // На точной encoded-серой оси оттенок не определён. Матричное + // округление Oklab иначе превращает его отсутствие в разрывный + // числовой угол у заданных байтовых якорей, хотя интерполированные + // серые точки несут канонический ноль. + color.h_ok = 0.0; + color.locus = PhysicalLocus::SrgbGrayAxis; + } + color } /// Convert to hex using standard sRGB viewing conditions. @@ -49,15 +105,45 @@ impl LcsColor { /// Must use the same VC that was used to construct this colour, otherwise /// the round-trip will introduce drift. 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) + self.to_srgb8_with_vc(vc).to_hex() } - /// Raw constructor from already-valid coordinates (curves, solver). - /// No validation here: inputs come from our own maths, not user input. + /// Квантует через общую с кривыми типизированную границу эмиссии. + pub(crate) fn to_srgb8_with_vc(self, vc: &ViewingConditions) -> crate::Srgb8 { + crate::spaces::srgb::srgb8_from_linear(self.to_linear_srgb_with_vc(vc)) + } + + /// Строит значение из уже корректных координат кривой или решателя. + /// Неконечный внутренний результат нарушает инвариант и не преобразуется в + /// правдоподобный display-байт. pub(crate) fn new(jp: f64, h_ok: f64, s: f64, h_cam: f64) -> Self { - Self { jp, h_ok, s, h_cam } + assert!( + [jp, h_ok, s, h_cam].into_iter().all(f64::is_finite), + "internal LCS coordinates must be finite" + ); + Self { + jp, + h_ok, + s, + h_cam, + locus: PhysicalLocus::General, + } + } + + /// Строит непрерывную физическую точку серой оси sRGB со светлотой + /// CAM16-UCS `jp` в условиях `vc`. + /// + /// Точка не привязывается к байту. Locus ограничивает только финальное + /// преобразование одним общим каналом перед обычным округлением sRGB. + pub(crate) fn from_srgb_gray_axis_jp(jp: f64, vc: &ViewingConditions) -> Self { + let y = srgb_gray_linear_at_jp(jp, vc); + let mut color = Self::from_xyz_with_hok(srgb_to_xyz([y; 3]), 0.0, vc); + // `y` — аналитическая инверсия запрошенной координаты. Сохраняем саму + // координату точно, не возвращая погрешность Newton/прямого хода в + // непрерывный каркас кривой. + color.jp = jp; + color.locus = PhysicalLocus::SrgbGrayAxis; + color } /// CAM16-UCS colourfulness correlate `M'`, recovered through the analytical @@ -98,19 +184,26 @@ impl LcsColor { /// caller that already ran [`cam16::forward`] (e.g. [`crate::solve`]'s /// `finish`) reuses that result instead of recomputing it. 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). This is - // an analytically invertible coordinate transform used for - // colour-difference work; binary64 round-off is covered by the shared - // tolerance tests. No individual J'/M' value is assigned a universal - // attribute meaning here. Inverse in `to_xyz` uses the same helpers. + // Перешкалирование CAM16-UCS (Li et al. 2017, + // DOI 10.1002/col.22131) — аналитически обратимое преобразование для + // цветовых различий. Тесты с общим допуском покрывают округление + // binary64; отдельным J′/M′ здесь не приписывается универсальный смысл. + // Обратный путь использует те же функции. let jp = cam16::ucs_j(j); let mp = cam16::ucs_m(m); let s = mp / (jp + 1.0); - Self { jp, h_ok, s, h_cam } + Self::new(jp, h_ok, s, h_cam) } - pub(crate) fn to_xyz(self, vc: &ViewingConditions) -> [f64; 3] { + fn to_linear_srgb_with_vc(self, vc: &ViewingConditions) -> [f64; 3] { + match self.locus { + PhysicalLocus::General => xyz_to_srgb(self.to_xyz_general(vc)), + PhysicalLocus::SrgbGrayAxis => [srgb_gray_linear_at_jp(self.jp, vc); 3], + } + } + + fn to_xyz_general(self, vc: &ViewingConditions) -> [f64; 3] { // Inverse CAM16-UCS rescaling (Li et al. 2017, DOI 10.1002/col.22131), // single source of truth in `cam16`. let j = cam16::ucs_j_inv(self.jp); @@ -151,6 +244,26 @@ impl LcsColor { } } +/// Инвертирует светлоту CAM16-UCS на ахроматическом луче D65. +/// +/// Инверсия определяется только CAM16 `J` на сером луче D65 и не вызывает +/// отдельную H-K-диагностику appearance-яркости. CAM16 может сохранять малый +/// остаток `M` у численно ахроматического стимула, поэтому нулевая хроматическая +/// коррелята не заявляется. Этот скаляр — SSOT для XYZ, непрерывного линейного +/// sRGB и последующей серой эмиссии sRGB8. +fn srgb_gray_linear_at_jp(jp: f64, vc: &ViewingConditions) -> f64 { + assert!(jp.is_finite(), "internal gray-axis J′ must be finite"); + if jp <= 0.0 { + return 0.0; + } + let j = cam16::ucs_j_inv(jp); + assert!( + j.is_finite() && j > 0.0, + "internal CAM16 gray-axis J must be finite and positive" + ); + cam16::gray_y(j, vc) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/labcolors-core/src/lib.rs b/crates/labcolors-core/src/lib.rs index 8fbd7d98..2022d3bc 100644 --- a/crates/labcolors-core/src/lib.rs +++ b/crates/labcolors-core/src/lib.rs @@ -11,9 +11,7 @@ pub(crate) mod spaces; pub use srgb8::Srgb8; -pub(crate) mod accent; -pub mod accent_balance; -pub mod accent_surface; +pub(crate) mod accent_balance; pub mod alpha; pub(crate) mod appearance; pub mod config; @@ -21,14 +19,13 @@ pub mod glow; pub mod hash; pub mod ladder; pub mod lcs; -pub mod lpc; +pub(crate) mod lpc; pub mod material; pub mod neutral; pub mod numerical_plan; -pub mod pair; +pub(crate) mod pair; pub mod scale; pub mod semantic; -pub mod sentiment; pub mod solve; pub(crate) mod wcag; @@ -58,7 +55,7 @@ mod one_levelness_tests; #[cfg(test)] mod lcs_hue_dimensionality_tests; -// Built-in-showcase behaviour tests, relocated in-crate (ADR-0001 PR-c): the +// Built-in-showcase behaviour tests, relocated in-crate (ADR-0001): the // built-in `Role`/`RoleTable`/`resolve_set` cluster is now `#[cfg(test)]`-only, // so these tests — which exercise it as the byte-identity oracle — must live // inside the crate to see it (integration tests only see the public API). @@ -82,23 +79,17 @@ mod r3_byte_identity_tests; #[cfg(test)] mod reference_vectors_deep; -// AccentCurve/SentimentCurve golden snapshots, relocated in-crate (ADR-0001 -// PR-c): the `Sentiment` enum is now `#[cfg(test)]`-only, and the golden uses -// the crate-private `SentimentCurve::from_sentiment` helper, so this test must -// live inside the crate to see them. +// AccentCurve golden snapshots are in-crate because their built-in showcase +// anchors are `#[cfg(test)]`-only. #[cfg(test)] mod accent_golden_tests; -pub use accent_surface::{ - AccentSurface, SurfaceMaterial, derive_accent_surface_ramp, render_surface, -}; pub use alpha::composite_over_encoded; pub use config::{ Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, - PaletteFamily, RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig, - VcPreset, + PaletteFamily, RoleRecipe, ThemeConfig, ThemesConfig, VcPreset, }; -pub use curve::ColorCurve; +pub use curve::{ColorCurve, CurvePosition, CurvePositionError}; pub use glow::{ GLOW_BASE_DJ, GLOW_BLOOM_DJ, GLOW_COMPOSITE_PROFILE, GLOW_DIAGNOSTIC_PROFILE, GLOW_LAYER_RECIPE_PROFILE, GLOW_SUBTLE_DJ, GlowCompositeCertificateV1, @@ -138,7 +129,7 @@ pub use semantic::{ }; pub use wcag22_evidence::CanonicalFiniteBoundedEvidenceV1; // The built-in v1 showcase (`Role`/`RoleTable`/`resolve`/`resolve_set`) is no -// longer part of the production API (ADR-0001 PR-c): the agnostic engine ships +// longer part of the production API (ADR-0001): the agnostic engine ships // only the string-keyed `resolve_named_set` path. It survives ONLY as the // `#[cfg(test)]` byte-identity oracle for the named path, re-exported crate-wide // so the in-crate showcase tests keep their `crate::…` spellings. @@ -146,11 +137,11 @@ pub use wcag22_evidence::CanonicalFiniteBoundedEvidenceV1; pub(crate) use semantic::{Role, RoleTable, resolve_set}; pub use solve::{ BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveFailure, SolveFailureBoundary, - SolveFailureCategory, SolveJob, Solved, TypographicContext, solve, solve_many, + SolveFailureCategory, SolveJob, Solved, solve, solve_many, }; pub use spaces::oklch::{css_alpha_value, 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::srgb::srgb_encoded_from_hex; pub use spaces::vc::ViewingConditions; /// Компилирует rust-блоки package-local README как doctest-ы: опубликованный @@ -159,3 +150,135 @@ pub use spaces::vc::ViewingConditions; #[cfg(doctest)] #[doc = include_str!("../README.md")] pub struct ReadmeDoctests; + +/// Временные акцентные рецепты — детали реализации, а не контракты клиента. +/// Публичный API источников, целей и ограничений принадлежит occurrence-графу. +/// +/// ```compile_fail +/// use labcolors_core::accent_balance::accent_balanced; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::accent_surface::derive_accent_surface_ramp; +/// ``` +#[cfg(doctest)] +pub struct InternalAccentRecipes; + +/// Точные выходные байты не доказывают перцептивную видимость оттенка. +/// Удалённый вердикт нельзя восстанавливать из какой-либо формы результата. +/// +/// ```compile_fail +/// use labcolors_core::Resolved; +/// +/// fn inferred_verdict(result: &Resolved) -> bool { +/// match result { +/// Resolved::Color { hue_vanished, .. } => *hue_vanished, +/// _ => false, +/// } +/// } +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::Resolved; +/// +/// fn inferred_verdict(result: &Resolved) -> bool { +/// match result { +/// Resolved::Material(material) => material.hue_vanished(), +/// _ => false, +/// } +/// } +/// ``` +#[cfg(doctest)] +pub struct NoHueVisibilityVerdict; + +/// Устаревшие compatibility-алиасы не входят в breaking-релиз до клиентов. +/// Единственный SSOT — типизированные статусы и явно названные измерения. +/// +/// ```compile_fail +/// use labcolors_core::Resolved; +/// +/// fn old_measurement_alias(result: &Resolved) -> Option { +/// match result { +/// Resolved::Glow(glow) => Some(glow.achieved_dj()), +/// _ => None, +/// } +/// } +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::Resolved; +/// +/// fn old_boolean_alias(result: &Resolved) -> Option { +/// match result { +/// Resolved::Glow(glow) => Some(glow.degraded()), +/// _ => None, +/// } +/// } +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::Resolved; +/// +/// fn old_boolean_alias(result: &Resolved) -> Option { +/// match result { +/// Resolved::Material(material) => Some(material.guaranteed()), +/// _ => None, +/// } +/// } +/// ``` +#[cfg(doctest)] +pub struct NoCompatibilityAliases; + +/// Метрика поверхности не смешивает координаты несовместимых appearance- +/// пространств и не публикует результат как LPC. Будущему примитиву расстояния +/// нужны собственные допущенные модель, имя, домен и независимый oracle. +/// +/// ```compile_fail +/// use labcolors_core::lpc::lpc_surface; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::lpc::lpc_surface_with_vc; +/// ``` +#[cfg(doctest)] +pub struct NoHybridLpcSurfaceMetric; + +/// Неполные скалярные функции не образуют публичный контракт LPC. Поле `lc` +/// переходного resolver — только зафиксированная candidate-координата. LPC +/// становится публичным утверждением лишь через версионированный реестр +/// evaluators с идентичностями стимула, контекста, применимости и evidence. +/// +/// ```compile_fail +/// use labcolors_core::lpc::lpc; +/// ``` +#[cfg(doctest)] +pub struct NoPrematureScalarLpcApi; + +/// Полярность/fill пары — legacy-механика рецептов, а не extension point. +/// Общий occurrence-граф заменяет её единым совместным solve. +/// +/// ```compile_fail +/// use labcolors_core::pair::pair_side; +/// ``` +#[cfg(doctest)] +pub struct NoPublicPairRecipeApi; + +/// Сырые `f64` не являются валидированным цветовым значением: публичная +/// сериализация идёт через [`Srgb8::to_hex`], где невалидное состояние уже +/// непредставимо. Внутренние formatter-ы с generated-finite precondition не +/// образуют public API: внешний `NaN` не может попасть в них через эту границу. +/// +/// ```compile_fail +/// use labcolors_core::hex_from_srgb_encoded; +/// ``` +/// +/// ```compile_fail +/// use labcolors_core::hex_from_srgb; +/// ``` +/// +/// ``` +/// use labcolors_core::Srgb8; +/// assert_eq!(Srgb8::new([0x1A, 0x2B, 0x3C]).to_hex(), "#1A2B3C"); +/// ``` +#[cfg(doctest)] +pub struct NoRawFloatSrgbSerializer; diff --git a/crates/labcolors-core/src/lpc.rs b/crates/labcolors-core/src/lpc.rs index 63c041d3..32034d5c 100644 --- a/crates/labcolors-core/src/lpc.rs +++ b/crates/labcolors-core/src/lpc.rs @@ -5,8 +5,14 @@ //! outperforms APCA. Readability admission is owned by a versioned evaluator //! profile with declared typography, observer and context applicability. -use crate::spaces::srgb::{D65_WHITE, srgb_from_hex, srgb_to_xyz}; -use crate::spaces::{cam16, cat16, vc::ViewingConditions}; +use crate::spaces::{cam16, vc::ViewingConditions}; + +#[cfg(test)] +use crate::Srgb8; +#[cfg(test)] +use crate::spaces::srgb::{srgb_from_hex, srgb_linear_from_srgb8, srgb_to_xyz}; +#[cfg(test)] +use crate::srgb8::hex_bytes; /// CIECAM16 correlates `(J, M, h)` for an XYZ stimulus. /// @@ -39,146 +45,24 @@ pub(crate) fn hk_coeff(h_cam_deg: f64) -> f64 { + 0.792 } -/// CAM16 lightness `J` of the achromatic stimulus at luminance `y`. -/// -/// This is the exact inverse of [`y_hk`]: `y_hk` finds the grey luminance whose -/// `J` equals a target `J_HK`, so given that luminance back, `grey_j` returns -/// the same `J_HK`. The inverse solver uses it to turn a target H-K-corrected -/// luminance into the `J_HK` a foreground colour must reproduce. -pub(crate) fn grey_j(y: f64, vc: &ViewingConditions) -> f64 { - let xyz = [y * D65_WHITE[0], y, y * D65_WHITE[2]]; - cam16_jch_from_xyz(xyz, vc).0 -} - -/// Grey luminance whose CAM16 lightness equals `j_hk` (inverse of [`grey_j`]). +/// Gray luminance whose CAM16 lightness equals `j_hk`. /// -/// `J` is monotonic in luminance for the achromatic axis, so this inverts the -/// forward chain analytically in closed form, then polishes with two Newton -/// steps to full `f64` precision (see [`y_hk_analytic`]). The reference -/// fixed-iteration bisection [`y_hk_bisect`] is retained for tests. +/// LPC supplies an H-K-adjusted `J` target; the physical inversion itself is +/// shared appearance geometry in [`cam16::gray_y`]. pub(crate) fn y_hk(j_hk: f64, vc: &ViewingConditions) -> f64 { - y_hk_analytic(j_hk, vc) + cam16::gray_y(j_hk, vc) } -/// Closed-form inverse of [`grey_j`] with Newton polish. -/// -/// # Derivation (CIECAM16 — Li et al. 2017, DOI 10.1002/col.22131; CIE 248:2022) -/// -/// For an achromatic D65 stimulus of luminance `y`, the forward path -/// [`grey_j`] reduces to a chain of monotonic, individually invertible links. -/// Each cone response after chromatic adaptation is **linear in `y`**: -/// `lms_a[i] = k_i · y`, where the per-channel scale -/// `k_i = d·100 + (1 − d)·rgb_w[i]` follows from `rgb_d[i] = d·100/rgb_w[i] + 1 − d` -/// (`cam16` discounting, see [`ViewingConditions::build`]) and the D65 grey -/// `xyz = [y·Xw, y, y·Zw]·100` (the CAT16 white-point cone response cancels the -/// `100/rgb_w[i]` term). The achromatic signal is then -/// `A = nbb · Σ wᵢ·adapt(kᵢ·y)` with weights `w = [2, 1, 1/20]`, and -/// `J = 100·(A/A_w)^(c·z)`. -/// -/// Inverting link by link: -/// 1. `J → A`: `A = A_w · (J/100)^(1/(c·z))` — exact (power). -/// 2. `A → target sum`: `Σ wᵢ·adapt(kᵢ·y) = A/nbb` — exact (linear). -/// 3. seed `y`: collapse the three channels onto one effective scale -/// `k_eff = Σ wᵢkᵢ / Σ wᵢ` and invert the single compression -/// `adapt(k_eff·y) = S` via `(F_L·k_eff·y/100)^0.42 = 27.13·S/(400 − S)` -/// (the inverse of [`cam16::adapt`], Li et al. 2017 / CIE 248:2022), then take the -/// `1/0.42` power. -/// -/// The three channels do **not** share one scale (`k_i` spread ≈ 1 % from -/// incomplete chromatic adaptation), so step 3 is an approximation, not an -/// exact inverse — there is no algebraic inverse for a sum of three distinct -/// fractional-power terms. The seed lands within ~5·10⁻⁶ of the true `y`; two -/// Newton steps on the exact 3-term residual `f(y) − target` (closed-form -/// derivative) drive it to machine epsilon, matching [`y_hk_bisect`] to -/// < 2·10⁻¹² over the full `J_HK` grid. -/// -/// `Y` is clamped to `[0, 1]`, reproducing the bisection's search interval: -/// `J_HK` can exceed `grey_j(1.0) = 100` for near-white chromatic colours -/// (the H-K term lifts `J`), and there the bisection saturates at `Y = 1`. +/// Benchmark wrapper for the production CAM16 gray-axis inverse. +#[cfg(test)] fn y_hk_analytic(j_hk: f64, vc: &ViewingConditions) -> f64 { - if j_hk <= 0.0 { - return 0.0; - } - - // Per-channel cone-response scale: lms_a[i] = k[i] · y (linear in y). - // rgb_d[i] = d·100/rgb_w[i] + (1−d), and the D65 grey cone response is - // rgb_w[i]·y, so lms_a[i] = (d·100 + (1−d)·rgb_w[i])·y. Recover rgb_w[i] - // (and hence the (1−d) part) from rgb_d[i] without re-deriving d: - // (1−d)·rgb_w[i] = rgb_w[i] − d·100, with rgb_w[i] from CAT16 of the white. - let rgb_w = cat16::xyz_to_cone([ - D65_WHITE[0] * 100.0, - D65_WHITE[1] * 100.0, - D65_WHITE[2] * 100.0, - ]); - // rgb_d[i] = d·(100/rgb_w[i]) + (1−d) ⇒ d = (rgb_d[i] − 1)/(100/rgb_w[i] − 1). - // Solve for d from channel 0, then k[i] = d·100 + (1−d)·rgb_w[i]. - let d = (vc.rgb_d[0] - 1.0) / (100.0 / rgb_w[0] - 1.0); - let k = [ - d * 100.0 + (1.0 - d) * rgb_w[0], - d * 100.0 + (1.0 - d) * rgb_w[1], - d * 100.0 + (1.0 - d) * rgb_w[2], - ]; - const W: [f64; 3] = [2.0, 1.0, 1.0 / 20.0]; - let w_sum = W[0] + W[1] + W[2]; - - // Step 1+2: J → target value of Σ wᵢ·adapt(kᵢ·y). - let target = vc.aw * (j_hk / 100.0).powf(1.0 / (vc.c * vc.z)) / vc.nbb; - - // Step 3: analytic seed via the weighted-effective channel. - let s = target / w_sum; - if s <= 0.0 { - return 0.0; - } - if s >= 400.0 { - // adapt saturates at 400; J_HK is beyond what grey luminance ≤ 1 yields. - return 1.0; - } - let k_eff = (W[0] * k[0] + W[1] * k[1] + W[2] * k[2]) / w_sum; - // Inverse of adapt: (F_L·k_eff·y/100)^0.42 = 27.13·S/(400 − S). - let p = 27.13 * s / (400.0 - s); - let mut y = p.powf(1.0 / 0.42) * 100.0 / (vc.fl * k_eff); - - // Newton polish on the exact 3-term residual. adapt'(c) w.r.t. c, with - // P = (F_L·c/100)^0.42: dadapt/dc = 400·27.13·(0.42·P/c)/(P + 27.13)². - let residual_slope = |y: f64| -> (f64, f64) { - let mut f = 0.0; - let mut df = 0.0; - for i in 0..3 { - let c = k[i] * y; - let x = vc.fl * c / 100.0; - let pp = x.powf(0.42); - let denom = pp + 27.13; - f += W[i] * 400.0 * pp / denom; - let dp = 0.42 * pp / c; - df += W[i] * k[i] * 400.0 * 27.13 * dp / (denom * denom); - } - (f - target, df) - }; - for _ in 0..2 { - let (err, slope) = residual_slope(y); - y -= err / slope; - } - - y.clamp(0.0, 1.0) + cam16::gray_y_analytic(j_hk, vc) } -/// Reference inverse of [`grey_j`] by fixed-iteration bisection. -/// -/// `J` is monotonic in luminance on the achromatic axis, so 64 bisection -/// iterations on `[0, 1]` converge to full `f64` precision. Retained as the -/// ground truth that [`y_hk_analytic`] is property-tested against. +/// Benchmark wrapper for the fixed-iteration CAM16 oracle. +#[cfg(test)] fn y_hk_bisect(j_hk: f64, vc: &ViewingConditions) -> f64 { - let mut lo = 0.0_f64; - let mut hi = 1.0_f64; - for _ in 0..64 { - let mid = (lo + hi) * 0.5; - if grey_j(mid, vc) < j_hk { - lo = mid; - } else { - hi = mid; - } - } - (lo + hi) * 0.5 + cam16::gray_y_bisect(j_hk, vc) } // Константы candidate-кривой транскрибированы из опубликованного набора @@ -369,139 +253,75 @@ 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]); +#[cfg(test)] +fn srgb8_to_y_hk(rgb: Srgb8, vc: &ViewingConditions) -> f64 { + let rgb = srgb_linear_from_srgb8(rgb); let xyz = srgb_to_xyz(rgb); y_hk(j_hk_from_xyz(xyz, vc).max(0.0), vc) } -/// Perceptual-contrast (LPC) between a foreground and background hex colour -/// under standard sRGB viewing conditions (light-theme average surround). -/// -/// Shortcut for [`lpc_with_vc`] with [`ViewingConditions::srgb`]. Use this for -/// light themes; for dark themes call [`lpc_with_vc`] with -/// [`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()) +/// Test-only characterization of the retired scalar apparent-contrast +/// candidate. Exact bytes make malformed transport unrepresentable; the +/// function is deliberately absent from the production/public LPC surface. +#[cfg(test)] +pub(crate) fn apparent_contrast_candidate_srgb8(fg: Srgb8, bg: Srgb8) -> f64 { + apparent_contrast_candidate_srgb8_with_vc(fg, bg, &ViewingConditions::srgb()) } -/// Perceptual-contrast (LPC) between a foreground and background hex colour -/// under the given viewing conditions. -/// -/// Contrast is resolved in the perceptual space of `vc`, so the same hex pair -/// yields different `Lc` under light and dark themes — that divergence is the -/// point: a dark theme must reach the same contrast *contract* in a -/// dim-surround space (Bartleson–Breneman compensation), not reuse the light -/// numbers. Pick the VC for the theme: [`ViewingConditions::srgb`] for light -/// themes (average surround, F=1.0 c=0.69 Nc=1.0), -/// [`ViewingConditions::dim_surround`] for dark themes (dim surround, -/// F=0.9 c=0.59 Nc=0.9). The surround triplets are CIE 159:2004 Table 1 -/// (carried unchanged into CIE 248:2022); Bartleson & Breneman (1967) is the -/// psychophysical basis for a dim surround lowering apparent contrast. (This is -/// 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); +/// Viewing-condition-specific form of +/// [`apparent_contrast_candidate_srgb8`]. This is characterization machinery, +/// not an admitted readability evaluator or a complete LPC definition. +#[cfg(test)] +pub(crate) fn apparent_contrast_candidate_srgb8_with_vc( + fg: Srgb8, + bg: Srgb8, + vc: &ViewingConditions, +) -> f64 { + let y_fg = srgb8_to_y_hk(fg, vc); + let y_bg = srgb8_to_y_hk(bg, vc); contrast_core(y_fg, y_bg) } -/// LPC surface distance between two hex colours under standard sRGB viewing -/// conditions (light-theme average surround). -/// -/// 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()) -} - -/// LPC surface distance between two hex colours under the given viewing -/// conditions. -/// -/// Both colours are decoded under `vc`, so dark-theme surfaces are compared in -/// 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() -} - -/// LPC contrast between two [`crate::lcs::LcsColor`] values under standard sRGB -/// viewing conditions (light-theme average surround). -/// -/// Shortcut for [`lpc_lcs_with_vc`] with [`ViewingConditions::srgb`]. Uses the -/// pre-computed CAM16 J' and Oklab hue stored in each colour, avoiding -/// re-parsing hex strings. Delegates to the same perceptual-contrast core -/// curve as [`lpc`]. -pub fn lpc_lcs(fg: &crate::lcs::LcsColor, bg: &crate::lcs::LcsColor) -> f64 { - lpc_lcs_with_vc(fg, bg, &ViewingConditions::srgb()) +#[cfg(test)] +pub(crate) fn apparent_contrast_candidate_hex_for_test( + fg_hex: &str, + bg_hex: &str, +) -> Result { + Ok(apparent_contrast_candidate_srgb8( + Srgb8::new(hex_bytes(fg_hex)?), + Srgb8::new(hex_bytes(bg_hex)?), + )) } -/// LPC contrast between two [`crate::lcs::LcsColor`] values under the given -/// viewing conditions. -/// -/// `vc` must be the same viewing conditions the colours were constructed under -/// (e.g. via [`crate::lcs::LcsColor::from_hex_with_vc`]): the H-K chroma term -/// and the luminance resolution both read it. Use [`ViewingConditions::srgb`] -/// for light themes and [`ViewingConditions::dim_surround`] for dark themes. -pub fn lpc_lcs_with_vc( - fg: &crate::lcs::LcsColor, - bg: &crate::lcs::LcsColor, +#[cfg(test)] +pub(crate) fn apparent_contrast_candidate_hex_with_vc_for_test( + fg_hex: &str, + bg_hex: &str, vc: &ViewingConditions, -) -> f64 { - let y_fg = y_hk_from_lcs(fg, vc); - let y_bg = y_hk_from_lcs(bg, vc); - contrast_core(y_fg, y_bg) -} - -/// Derive hk-adjusted luminance from an existing [`LcsColor`]. -/// -/// Decompresses the stored UCS correlates back to raw CAM16 (J' → J, -/// M' → M → C) so the result is bit-identical to the hex path -/// ([`lpc`]); previously this used J'/M' directly, so `lpc` and -/// `lpc_lcs` disagreed for chromatic colours. -fn y_hk_from_lcs(c: &crate::lcs::LcsColor, vc: &ViewingConditions) -> f64 { - let j = cam16::ucs_j_inv(c.jp); - let m = cam16::ucs_m_inv(c.mp()); - // `vc.fl_pow_025` == инлайновый `vc.fl.powf(0.25)`; байт-идентичная `chroma`. - let chroma = m / vc.fl_pow_025; - let j_hk = j + hk_coeff(c.h_cam()) * chroma.powf(HK_CHROMA_EXPONENT); - y_hk(j_hk.max(0.0), vc) +) -> Result { + Ok(apparent_contrast_candidate_srgb8_with_vc( + Srgb8::new(hex_bytes(fg_hex)?), + Srgb8::new(hex_bytes(bg_hex)?), + vc, + )) } -/// Perceptual contrast (LPC) of a foreground over a background computed in the -/// **readability luminance domain** `Ys` (WCAG relative luminance) rather than -/// the Helmholtz–Kohlrausch brightness domain `Y_hk` that the apparent-contrast -/// metric [`lpc`] uses. С главы #64 (активация ADR-0003) это домен, в котором -/// считает ось читаемости движка. +/// Test-reference score of a foreground over a background computed in the +/// display-luminance domain `Ys` (WCAG relative luminance) rather than +/// the Helmholtz–Kohlrausch brightness domain `Y_hk`. С главы #64 (активация +/// ADR-0003) это домен, в котором считается Ys candidate score движка. /// /// # Why a second domain exists /// /// [`contrast_core`] carries the published SAPC-8 (`0.0.98G-4g`) constants, and /// those constants were **calibrated with screen luminance `Ys` as their input**. -/// [`lpc`] instead feeds the curve `Y_hk` — a *brightness* estimate that lifts a -/// saturated hue's effective luminance above its photometric value (`#007AFF`: -/// `Y 0.211 → Y_hk 0.346`). Applied to the readability axis this inverts the -/// label polarity: the saturated background reads as a mid grey and the curve -/// prefers a black label where a white one is more legible. The CH-4 science -/// étude measured a monotonic 15:0 white→black flip across the saturated-hue -/// sweep, driven entirely by that out-of-domain `Y_hk` substitution. Reading the -/// **calibrated** domain removes it. Full rationale and blast radius: +/// The retired scalar candidate instead fed the curve `Y_hk` — a *brightness* +/// estimate that lifts a saturated hue's effective luminance above its photometric value (`#007AFF`: +/// `Y 0.211 → Y_hk 0.346`). Applied to the frozen curve this changes its +/// polarity branch because it substitutes an out-of-domain input. The CH-4 +/// characterization observed the resulting branch flips; it did not establish +/// a legibility preference. Restoring the formula's declared domain removes them. +/// Full rationale and blast radius: /// `docs/decisions/0003-hk-scope.md` (variant A — accepted 2026-07-06). /// /// # Argument domain @@ -513,49 +333,37 @@ fn y_hk_from_lcs(c: &crate::lcs::LcsColor, vc: &ViewingConditions) -> f64 { /// input. `Ys` is display-referred, so this /// contrast is viewing-condition invariant by construction: the dark-theme /// surround compensation `Y_hk` carries is a brightness concern, kept off the -/// readability axis (flagged as an open question in the ADR). +/// Ys candidate-score path. /// -/// АКТИВИРОВАНО главой #64 (вариант A ADR-0003 в проде): ось читаемости движка +/// ADR-0003: Ys candidate score движка /// считает именно в этом домене — `solve::finish`/`meets_floor`, интервал фона, /// recheck-примитивы (`semantic::measure_contrast`, `recheck_against*`) и белая /// сторона кроссовера пары (`pair::pair_side`). Сам движок зовёт /// [`contrast_core`] + [`crate::wcag::relative_luminance`] напрямую на уже -/// готовых скалярах; эта функция — референс-вход той же формулы (те же -/// функции, ноль новых констант), удобный для внешних сверок. `Y_hk` остался -/// только на яркостной оси: метрика [`lpc`] (apparent contrast), чернильная -/// сторона пары, свечение, сентимент, нейтральная лестница. -#[doc(hidden)] -pub fn lpc_readability_ys(fg_display: [f64; 3], bg_display: [f64; 3]) -> f64 { +/// готовых скалярах; эта функция — только test-reference той же формулы (те же +/// функции, ноль новых констант). `Y_hk` остаётся отдельной appearance- +/// координатой, но не публичным scalar-LPC API. +#[cfg(test)] +pub(crate) fn ys_candidate_score_for_test(fg_display: [f64; 3], bg_display: [f64; 3]) -> f64 { contrast_core( crate::wcag::relative_luminance(fg_display), crate::wcag::relative_luminance(bg_display), ) } -/// Benchmark-only access to the two grey-axis inverse implementations. -/// -/// These wrap the crate-private `y_hk_analytic` and `y_hk_bisect` so the -/// `benches/y_hk.rs` Criterion harness can compare them head-to-head. Hidden -/// from the rendered docs and not part of the supported public surface — the -/// only supported entry point is `y_hk`, whose signature is unchanged. -#[doc(hidden)] -pub mod bench_support { - use super::ViewingConditions; +#[cfg(test)] +mod tests { + use super::*; - /// Analytic closed-form + Newton inverse (the production path). - pub fn y_hk_analytic(j_hk: f64, vc: &ViewingConditions) -> f64 { - super::y_hk_analytic(j_hk, vc) + fn lpc(fg_hex: &str, bg_hex: &str) -> f64 { + apparent_contrast_candidate_hex_for_test(fg_hex, bg_hex) + .expect("test fixture must contain valid sRGB8 hex") } - /// Bisection reference inverse (64 iterations). - pub fn y_hk_bisect(j_hk: f64, vc: &ViewingConditions) -> f64 { - super::y_hk_bisect(j_hk, vc) + fn lpc_with_vc(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> f64 { + apparent_contrast_candidate_hex_with_vc_for_test(fg_hex, bg_hex, vc) + .expect("test fixture must contain valid sRGB8 hex") } -} - -#[cfg(test)] -mod tests { - use super::*; #[test] fn black_on_white_matches_reference() { @@ -591,12 +399,6 @@ mod tests { assert!((lc1 + lc2).abs() < 3.0, "polarity swap: {} vs {}", lc1, lc2); } - #[test] - fn surface_white_vs_near_white() { - let de = lpc_surface("#ffffff", "#f6f7fa"); - assert!(de > 1.0 && de < 10.0, "surface delta: {}", de); - } - #[test] fn neutral_hk_boost_is_small() { // A near-neutral grey carries a tiny residual CAM16 colourfulness @@ -622,8 +424,8 @@ mod tests { for bg_hex in ["#007AFF", "#0082FF", "#FF0000", "#00B087"] { let bg = enc(bg_hex); // Readability (Ys) domain: white wins — matches platform convention. - let white_ys = lpc_readability_ys(white, bg).abs(); - let black_ys = lpc_readability_ys(black, bg).abs(); + let white_ys = ys_candidate_score_for_test(white, bg).abs(); + let black_ys = ys_candidate_score_for_test(black, bg).abs(); assert!( white_ys > black_ys, "{bg_hex}: Ys domain must prefer white (|white|={white_ys} !> |black|={black_ys})" @@ -646,7 +448,7 @@ mod tests { // black-on-white number (≈106.04) is preserved, so the WCAG legal floor // and the endpoint anchors the golden grid locks are untouched. let enc = |hex: &str| crate::spaces::srgb::srgb_encoded_from_hex(hex).expect("valid hex"); - let bw_ys = lpc_readability_ys(enc("#000000"), enc("#FFFFFF")); + let bw_ys = ys_candidate_score_for_test(enc("#000000"), enc("#FFFFFF")); let bw_hk = lpc("#000000", "#FFFFFF"); assert!( (bw_ys - bw_hk).abs() < 1e-9, @@ -667,7 +469,7 @@ mod tests { // reads as materially higher contrast there than in the H-K domain. let enc = |hex: &str| crate::spaces::srgb::srgb_encoded_from_hex(hex).expect("valid hex"); let bg = "#007AFF"; - let white_ys = lpc_readability_ys(enc("#FFFFFF"), enc(bg)).abs(); + let white_ys = ys_candidate_score_for_test(enc("#FFFFFF"), enc(bg)).abs(); let white_hk = lpc("#FFFFFF", bg).abs(); assert!( white_ys > white_hk + 5.0, @@ -732,63 +534,15 @@ mod tests { } } - #[test] - fn lpc_and_lpc_lcs_agree() { - // Both entry points must compute the identical metric: the LcsColor - // path decompresses J'/M' back to raw J/M before the H-K term. - for (fg, bg) in [ - ("#0000FF", "#FFFFFF"), - ("#FF0000", "#101012"), - ("#34C759", "#FFFFFF"), - ] { - let via_hex = lpc(fg, bg); - let f = crate::lcs::LcsColor::from_hex(fg).expect("valid fg hex"); - let b = crate::lcs::LcsColor::from_hex(bg).expect("valid bg hex"); - let via_lcs = lpc_lcs(&f, &b); - assert!( - (via_hex - via_lcs).abs() < 1e-6, - "{fg}/{bg}: lpc={via_hex} lpc_lcs={via_lcs}" - ); - } - } - - #[test] - fn surface_same_color_is_zero() { - let de = lpc_surface("#3478F6", "#3478F6"); - assert!(de.abs() < 1e-9, "self-distance must be zero: {}", de); - } - - #[test] - fn surface_hue_distance_ordering() { - // Opposite hues must be further apart than near-identical reds. - // Guards the degrees-as-radians regression in the chroma vector. - let far = lpc_surface("#FF0000", "#00B050"); - let near = lpc_surface("#FF0000", "#F51111"); - assert!( - far > near, - "red↔green ({}) should exceed red↔red' ({})", - far, - near - ); - } - #[test] fn shortcuts_match_srgb_with_vc() { - // The srgb shortcuts must be bit-identical to the explicit srgb VC - // path — this is what keeps every legacy value (e.g. black-on-white - // 106.0407) unchanged after the refactor. + // The test-only sRGB characterization shortcut must be bit-identical + // to the same typed bytes evaluated with an explicit standard VC. let srgb = ViewingConditions::srgb(); assert_eq!( lpc("#0000FF", "#FFFFFF"), lpc_with_vc("#0000FF", "#FFFFFF", &srgb) ); - assert_eq!( - lpc_surface("#FFFFFF", "#f6f7fa"), - lpc_surface_with_vc("#FFFFFF", "#f6f7fa", &srgb) - ); - let f = crate::lcs::LcsColor::from_hex("#0000FF").expect("valid fg hex"); - let b = crate::lcs::LcsColor::from_hex("#FFFFFF").expect("valid bg hex"); - assert_eq!(lpc_lcs(&f, &b), lpc_lcs_with_vc(&f, &b, &srgb)); } #[test] @@ -854,7 +608,7 @@ mod tests { let mut max_dy = 0.0_f64; // Sweep J_HK across the full reachable range, including the // above-100 band (near-white chromatic colours, where the H-K term - // lifts J past grey_j(1.0) = 100 and both paths must saturate Y=1). + // lifts J past gray_j(1.0) = 100 and both paths must saturate Y=1). for n in 0..=4000 { let j_hk = n as f64 / 4000.0 * 104.0; let analytic = y_hk_analytic(j_hk, &vc); @@ -873,14 +627,14 @@ mod tests { for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { // J_HK = 0 → black (Y = 0). assert_eq!(y_hk_analytic(0.0, &vc), 0.0); - // J_HK = grey_j(1.0) = 100 → white (Y = 1), within round-off. - assert!((y_hk_analytic(grey_j(1.0, &vc), &vc) - 1.0).abs() < 1e-9); + // J_HK = gray_j(1.0) = 100 → white (Y = 1), within round-off. + assert!((y_hk_analytic(cam16::gray_j(1.0, &vc), &vc) - 1.0).abs() < 1e-9); // J_HK above 100 (reachable for near-white chromatic colours) must // clamp to Y = 1, matching the bisection's [0,1] search interval. assert_eq!(y_hk_analytic(130.0, &vc), 1.0); - // Round-trip: grey_j(y) → y_hk_analytic recovers y. + // Round-trip: gray_j(y) → y_hk_analytic recovers y. for &y in &[0.01_f64, 0.18, 0.5, 0.9] { - let recovered = y_hk_analytic(grey_j(y, &vc), &vc); + let recovered = y_hk_analytic(cam16::gray_j(y, &vc), &vc); assert!( (recovered - y).abs() < 1e-12, "round-trip y={y}: recovered {recovered}, |d|={}", @@ -890,24 +644,6 @@ mod tests { } } - #[test] - fn lpc_and_lpc_lcs_agree_under_dim() { - // The hex and LcsColor entry points must compute the identical metric - // under dim surround too — provided the colour is constructed under the - // same VC that is passed to lpc_lcs_with_vc. - let dim = ViewingConditions::dim_surround(); - for (fg, bg) in [("#0000FF", "#FFFFFF"), ("#34C759", "#101012")] { - let via_hex = lpc_with_vc(fg, bg, &dim); - let f = crate::lcs::LcsColor::from_hex_with_vc(fg, &dim).expect("valid fg hex"); - let b = crate::lcs::LcsColor::from_hex_with_vc(bg, &dim).expect("valid bg hex"); - let via_lcs = lpc_lcs_with_vc(&f, &b, &dim); - assert!( - (via_hex - via_lcs).abs() < 1e-6, - "{fg}/{bg} under dim: lpc={via_hex} lpc_lcs={via_lcs}" - ); - } - } - #[test] fn soft_clamp_inv_is_a_left_inverse_of_soft_clamp() { // BUG CLASS: silent inverse drift. `soft_clamp_inv` is the analytic diff --git a/crates/labcolors-core/src/material.rs b/crates/labcolors-core/src/material.rs index 221c8110..13936e11 100644 --- a/crates/labcolors-core/src/material.rs +++ b/crates/labcolors-core/src/material.rs @@ -83,7 +83,7 @@ const MATERIAL_COMPOSITE_RANGE_MARGIN: f64 = 8.0 * f64::EPSILON; /// Ахроматический полюс коммит-лейбла поверхности: цвет максимального контраста /// на тоне (белый на тёмном тоне, чёрный на светлом). Механизм полярности -/// [`crate::pair`]/`commitLabel` в чистом, параметр-свободном виде. +/// внутреннего `pair`/`commitLabel` в чистом, параметр-свободном виде. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Pole { /// Чёрный лейбл (`L = 0`) — полюс максимального контраста на СВЕТЛОМ тоне. diff --git a/crates/labcolors-core/src/neutral.rs b/crates/labcolors-core/src/neutral.rs index bc998a32..afcccb2f 100644 --- a/crates/labcolors-core/src/neutral.rs +++ b/crates/labcolors-core/src/neutral.rs @@ -1,3 +1,4 @@ +use crate::curve::CurvePosition; use crate::lcs::LcsColor; use crate::spaces::vc::ViewingConditions; @@ -61,21 +62,18 @@ const HUE_PURITY_MP_REF_RATIO: f64 = 1.5; // SSOT-TRACKED — показатель кривой чистоты, терминал (e) design-choice (форма мотивирована Abney; (0,1]), см. docs/empirical-inventory.md. const HUE_PURITY_EXPONENT: f64 = 0.6; -/// Унаследованные параметры формы нейтральной кривой. +/// Внутренний профиль формы нейтральной кривой. /// -/// Значения сохраняют текущую эмиссию до замены политики построения, но не являются -/// универсальной психофизикой или пользовательскими осями намерения. Равномерные -/// шаги Oklab/sRGB/CAM16-J′ и прямое прочтение метрики контрастного усиления Уиттла -/// уже опровергнуты по магнитуде; тёмная ветвь дополнительно вырождается около -/// чёрного. Поэтому gamma — терминальная compatibility policy для прежних байтов, -/// а не human law. Задачи #219/#261 могут исследовать отдельную человеко- -/// ориентированную замену; generic fact-only механизмы не должны зависеть от -/// этих чисел или ждать такого исследования. +/// Это задекларированная калибровка движка, а не универсальная психофизика и не +/// пользовательские оси намерения. Равномерные шаги Oklab/sRGB/CAM16-J′ и +/// прямое прочтение метрики контрастного усиления Уиттла уже опровергнуты по +/// магнитуде; тёмная ветвь дополнительно вырождается около чёрного. Поэтому +/// параметры закрыты от публичного API и не выдаются за закон восприятия. #[derive(Debug, Clone, Copy, PartialEq)] -pub struct CurveParams { - pub gamma_light: f64, - pub gamma_dark: f64, - pub chroma_peak_t: f64, +struct CurveParams { + gamma_light: f64, + gamma_dark: f64, + chroma_peak_t: f64, } impl Default for CurveParams { @@ -83,13 +81,11 @@ impl Default for CurveParams { Self { // Показатель степени, которым гамма-кривая отображает t на светлотный // интервал [светлый якорь, базовый], при t <= 0.5. - // SSOT-TRACKED — (e) compatibility policy прежней эмиссии; - // научная замена отдельно остаётся OPEN в #219/#261. + // SSOT-TRACKED — (e) задекларированная калибровка движка, не закон восприятия. gamma_light: 1.75, // Показатель степени для тёмной ветви (t > 0.5): интервал [базовый, // тёмный якорь]. - // SSOT-TRACKED — (e) compatibility policy прежней эмиссии; - // научная замена отдельно остаётся OPEN в #219/#261. + // SSOT-TRACKED — (e) задекларированная калибровка движка, не закон восприятия. gamma_dark: 1.5, // Позиция вдоль параметра кривой t, где огибающая хромы достигает пика. // SSOT-TRACKED — зафиксированный скаляр прежней эмиссии; исследование @@ -102,14 +98,21 @@ impl Default for CurveParams { /// Нейтральная (серо-осевая) кривая по трём якорям: светлый → базовый → тёмный. /// /// Светлота ведётся гамма-ветвями (`CurveParams`), хрома — C1-огибающей -/// (`chroma_envelope`), оттенок — покоем у базы с purity-коррекцией шумных -/// концов (`hue_purity`). `at()` — примитив каждого шага построения лестницы, -/// поэтому все t-инвариантные величины предвычислены в конструкторе. +/// (`chroma_envelope`), оттенок — покоем у базы с коррекцией чистоты шумных +/// концов (`hue_purity`). Когда все три источника лежат точно на sRGB8-серой +/// оси, путь оттенка и хромы не применяется: `at()` остаётся непрерывным на +/// физической серой оси, а общий канал квантуется только на границе эмиссии. +/// `at()` — примитив каждого шага лестницы, поэтому все t-инвариантные +/// величины предвычислены в конструкторе. #[derive(Debug, Clone)] pub struct NeutralCurve { a_light: LcsColor, a_base: LcsColor, a_dark: LcsColor, + /// Все три заданных якоря точно лежат на эмитированной серой оси sRGB8. + /// Этот факт представления обходит интерполяцию оттенка в [`Self::at`]: + /// числовой оттенок серой точки CAM/Oklab не определяет её идентичность. + locus: NeutralLocus, h_ok_base: f64, h_cam_base: f64, // Purity-скорректированные оттенки концов кривой. Они не зависят от t, @@ -124,54 +127,65 @@ pub struct NeutralCurve { vc: ViewingConditions, } +/// Физический домен всей заданной нейтральной кривой. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NeutralLocus { + General, + SrgbGrayAxis, +} + impl NeutralCurve { /// Build a neutral curve using standard sRGB viewing conditions (average surround). 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, &ViewingConditions::srgb()) } - /// Как [`NeutralCurve::new`], но с нестандартными форм-параметрами - /// (например, из `labui.config`), viewing conditions — стандартные sRGB. - pub fn with_params( + /// Строит нейтральную кривую для заданных условий просмотра. + /// + /// Калибровкой формы владеет движок; она версионируется с реализацией. + /// Клиент задаёт физические якоря и контекст, а не числовые gamma-ручки. + pub fn with_vc( light: &str, base: &str, dark: &str, - params: CurveParams, + vc: &ViewingConditions, ) -> Result { - Self::with_vc(light, base, dark, ¶ms, &ViewingConditions::srgb()) + Self::build(light, base, dark, CurveParams::default(), vc) } - /// 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. - pub fn with_vc( + fn build( light: &str, base: &str, dark: &str, - params: &CurveParams, + params: CurveParams, vc: &ViewingConditions, ) -> Result { 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 { + if a_light.jp() <= a_base.jp() { return Err("light anchor must be lighter than base".into()); } - if a_base.jp <= a_dark.jp { + if a_base.jp() <= a_dark.jp() { return Err("base anchor must be lighter than dark".into()); } - let h_ok_base = a_base.h_ok; + let exact_gray_sources = [ + crate::Srgb8::new(crate::srgb8::hex_bytes(light)?), + crate::Srgb8::new(crate::srgb8::hex_bytes(base)?), + crate::Srgb8::new(crate::srgb8::hex_bytes(dark)?), + ]; + let locus = if exact_gray_sources + .into_iter() + .all(crate::Srgb8::is_achromatic) + { + NeutralLocus::SrgbGrayAxis + } else { + NeutralLocus::General + }; + + let h_ok_base = a_base.h_ok(); let h_cam_base = a_base.h_cam(); // Achromatic anchors have unreliable h_ok (atan2 of ~0 values). @@ -179,13 +193,17 @@ impl NeutralCurve { // 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()) + let a_light = if locus == NeutralLocus::SrgbGrayAxis { + a_light + } else 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()) + let a_dark = if locus == NeutralLocus::SrgbGrayAxis { + a_dark + } else 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 }; @@ -198,8 +216,8 @@ impl NeutralCurve { 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_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); @@ -207,51 +225,41 @@ impl NeutralCurve { a_light, a_base, a_dark, + locus, h_ok_base, h_cam_base, h_ok_light_eff, h_ok_dark_eff, h_cam_light_eff, h_cam_dark_eff, - params: *params, + params, vc: *vc, }) } /// Точка кривой при `t ∈ [0, 1]`: 0 — светлый якорь, 0.5 — базовый, /// 1 — тёмный. - pub fn at(&self, t: f64) -> LcsColor { - let t = t.clamp(0.0, 1.0); - - // Снап к якорям в пределах 1e-12: гарантирует байт-точное - // воспроизведение входных hex-якорей на концах и в базе — иначе - // накопленная FP-погрешность интерполяции могла бы сдвинуть - // квантованный выход на единицу канала. - if (t - 0.0).abs() < 1e-12 { + pub fn at(&self, position: CurvePosition) -> LcsColor { + let t = position.get(); + + // Только точные доменные узлы воспроизводят полное состояние якоря. + // Tolerance-снап создавал скрытые полки возле узлов и рвал производную; + // Тип CurvePosition не допускает внешних значений вне диапазона. + if t == 0.0 { return self.a_light; } - if (t - 0.5).abs() < 1e-12 { + if t == 0.5 { 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 jp = self.jp_at(position); + + if self.locus == NeutralLocus::SrgbGrayAxis { + return LcsColor::from_srgb_gray_axis_jp(jp, &self.vc); + } let mp = chroma_envelope( t, @@ -268,6 +276,35 @@ impl NeutralCurve { LcsColor::new(jp, h_ok, s, h_cam) } + /// Непрерывный каркас светлоты, общий для нейтральной и акцентной кривых. + /// + /// Функция не создаёт представление цвета и не квантует выход, поэтому + /// потребитель не наследует байтовую ступень sRGB8 из нейтральной эмиссии. + pub(crate) fn jp_at(&self, position: CurvePosition) -> f64 { + let t = position.get(); + if t == 0.0 { + return self.a_light.jp(); + } + if t == 0.5 { + return self.a_base.jp(); + } + if t == 1.0 { + return self.a_dark.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) + } + } + /// The viewing conditions used to build this curve. pub fn vc(&self) -> &ViewingConditions { &self.vc @@ -305,19 +342,19 @@ impl NeutralCurve { } } -/// C1-continuous chroma envelope through the chromas of all three anchors. +/// C1-непрерывная огибающая хромы через хрому трёх якорей. /// -/// 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` полукосинус поднимает M′ светлого якоря до M′ базы, затем +/// сохраняет уровень базы до `t = 0.5` и полукосинусом опускает его до M′ +/// тёмного якоря. Все якоря воспроизводятся точно, а наклон в стыках равен нулю, +/// поэтому M′ имеет класс C1 на `[0, 1]`. Предшественник `sine_env` привязывал +/// оба конца к хроме тёмного якоря и создавал скачки при `t = 0` и `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. +/// `t_peak` принадлежит движку и лежит в `(0, 0.5]`; при `0.5` плато пусто. +/// Инвариант проверяется assert, а не тихо исправляется: публичный вход сюда не +/// попадает. 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); + debug_assert!(t_peak > 0.0 && t_peak <= 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) @@ -361,8 +398,8 @@ fn hue_purity(mp: f64, mp_ref: f64) -> f64 { } impl crate::curve::ColorCurve for NeutralCurve { - fn at(&self, t: f64) -> LcsColor { - self.at(t) + fn at(&self, position: CurvePosition) -> LcsColor { + self.at(position) } fn vc(&self) -> &ViewingConditions { @@ -375,27 +412,230 @@ mod tests { use super::*; use crate::curve::ColorCurve; + fn position(value: f64) -> CurvePosition { + CurvePosition::new(value).expect("test position is inside [0, 1]") + } + fn default_curve() -> NeutralCurve { NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() } + fn gray_hex(byte: u8) -> String { + format!("#{byte:02X}{byte:02X}{byte:02X}") + } + + fn assert_exact_gray(hex: &str, context: &str) { + let [red, green, blue] = crate::srgb8::hex_bytes(hex).expect("curve emitted canonical hex"); + assert_eq!( + red, green, + "{context}: red/green direction invented in {hex}" + ); + assert_eq!( + green, blue, + "{context}: green/blue direction invented in {hex}" + ); + } + + /// Исчерпывает соседние допустимые тройки конечной серой оси sRGB8. Каждый + /// байт и каждая ветвь интерполяции участвуют; матричный шум не должен + /// превращаться в физический хроматический байт. + #[test] + fn every_exact_gray_neighbour_triple_stays_on_the_gray_axis() { + for vc in [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ] { + for dark in 0_u8..=253 { + let base = dark + 1; + let light = dark + 2; + let curve = + NeutralCurve::with_vc(&gray_hex(light), &gray_hex(base), &gray_hex(dark), &vc) + .unwrap(); + + for step in 0..=32 { + let t = f64::from(step) / 32.0; + assert_exact_gray( + &curve.at(position(t)).to_hex_with_vc(&vc), + &format!("anchors={light}/{base}/{dark}, t={t}, vc={vc:?}"), + ); + } + } + } + } + + /// Закон — точное равенство, а не допуск в один байт. Один канал вне оси в + /// любом якоре сохраняет хроматический путь кривой. + #[test] + fn nearest_off_axis_anchor_is_not_collapsed_to_gray() { + for (anchors, t) in [ + (("#FFFFFE", "#808080", "#101010"), 0.25), + (("#FFFFFF", "#808081", "#101010"), 0.25), + (("#FFFFFF", "#808080", "#101011"), 0.625), + ] { + let curve = NeutralCurve::new(anchors.0, anchors.1, anchors.2).unwrap(); + let hex = curve.at(position(t)).to_hex(); + let [red, green, blue] = crate::srgb8::hex_bytes(&hex).unwrap(); + assert!( + red != green || green != blue, + "nearest off-axis anchor {anchors:?} was collapsed at t={t}: {hex}" + ); + } + } + + /// Дифференциальный oracle: непрерывный серый скаляр единожды кодируется и + /// округляется нормативной функцией sRGB. Эмиссия не добавляет вторую + /// политику выбора ближайшего J′. + #[test] + fn exact_gray_emission_matches_one_channel_srgb_quantisation() { + let params = CurveParams::default(); + let triples = [ + (255_u8, 128_u8, 0_u8), + (224, 128, 16), + (10, 9, 8), + (2, 1, 0), + (255, 254, 253), + ]; + + for vc in [ + ViewingConditions::srgb(), + ViewingConditions::dim_surround(), + ViewingConditions::srgb_high_contrast(), + ViewingConditions::dim_surround_high_contrast(), + ] { + let gray_jp: [f64; 256] = std::array::from_fn(|byte| { + LcsColor::from_hex_with_vc(&gray_hex(byte as u8), &vc) + .unwrap() + .jp() + }); + + for (light, base, dark) in triples { + let curve = NeutralCurve::build( + &gray_hex(light), + &gray_hex(base), + &gray_hex(dark), + params, + &vc, + ) + .unwrap(); + + for step in 0..=256 { + let t = f64::from(step) / 256.0; + let target_jp = if t <= 0.5 { + let u = t / 0.5; + gray_jp[usize::from(light)] + + (gray_jp[usize::from(base)] - gray_jp[usize::from(light)]) + * u.powf(params.gamma_light) + } else { + let u = (t - 0.5) / 0.5; + gray_jp[usize::from(base)] + + (gray_jp[usize::from(dark)] - gray_jp[usize::from(base)]) + * u.powf(params.gamma_dark) + }; + let j = crate::spaces::cam16::ucs_j_inv(target_jp); + let y = if target_jp <= 0.0 { + 0.0 + } else if !j.is_finite() || j <= 0.0 { + 1.0 + } else { + crate::spaces::cam16::gray_y(j, &vc) + }; + let oracle = (crate::spaces::srgb::srgb_gamma(y) * 255.0).round() as u8; + + let actual_hex = curve.at(position(t)).to_hex_with_vc(&vc); + let [actual, green, blue] = crate::srgb8::hex_bytes(&actual_hex).unwrap(); + assert_eq!(actual, green, "oracle fixture left gray axis: {actual_hex}"); + assert_eq!(green, blue, "oracle fixture left gray axis: {actual_hex}"); + assert_eq!( + actual, oracle, + "anchors={light}/{base}/{dark}, t={t}, vc={vc:?}, target J'={target_jp}" + ); + } + } + } + } + + /// RED: квантизация конечного выхода принадлежит эмиссии, а не непрерывной + /// кривой. Соседние binary64-позиции по сторонам байтовой границы sRGB8 не + /// создают скачок в заданной траектории J′ или в использующей её акцентной + /// кривой. + #[test] + fn emitted_gray_byte_wall_does_not_quantise_continuous_curve_state() { + let left_t = 0.019_000_259_522_785_33_f64; + let right_t = f64::from_bits(left_t.to_bits() + 1); + assert_eq!( + right_t.to_bits(), + left_t.to_bits() + 1, + "hostile inputs must be adjacent" + ); + + let neutral = NeutralCurve::new("#FFFFFF", "#808080", "#000000").unwrap(); + let neutral_jump = + (neutral.at(position(left_t)).jp() - neutral.at(position(right_t)).jp()).abs(); + assert!( + neutral_jump < 1e-9, + "emission byte wall leaked into NeutralCurve::at: ΔJ'={neutral_jump}" + ); + + let accent = crate::scale::AccentCurve::new("#007AFF", &neutral).unwrap(); + let accent_jump = + (accent.at(position(left_t)).jp() - accent.at(position(right_t)).jp()).abs(); + assert!( + accent_jump < 1e-9, + "neutral emission byte wall leaked into AccentCurve::at: ΔJ'={accent_jump}" + ); + } + + /// Anti-vacuum: непрерывное состояние обязано меняться, даже когда конечный + /// выход повторяется. Это ловит раннюю привязку к байтам и пустой тест + /// непрерывности с допуском в целый код серого. + #[test] + fn exact_gray_curve_keeps_continuous_state_until_emission() { + let curve = NeutralCurve::new("#FFFFFF", "#808080", "#000000").unwrap(); + let mut previous = curve.at(CurvePosition::START); + let mut previous_byte = crate::srgb8::hex_bytes(&previous.to_hex()).unwrap()[0]; + let mut observed_repeated_output = false; + + for step in 1..=4096 { + let point = curve.at(position(f64::from(step) / 4096.0)); + assert!( + point.jp() < previous.jp(), + "continuous J' plateaued or reversed at step {step}: {} -> {}", + previous.jp(), + point.jp() + ); + let bytes = crate::srgb8::hex_bytes(&point.to_hex()).unwrap(); + assert_eq!(bytes[0], bytes[1], "gray point emitted red/green drift"); + assert_eq!(bytes[1], bytes[2], "gray point emitted green/blue drift"); + observed_repeated_output |= bytes[0] == previous_byte; + previous = point; + previous_byte = bytes[0]; + } + + assert!( + observed_repeated_output, + "finite sRGB8 output unexpectedly behaved as a continuous domain" + ); + } + #[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); + let c0 = curve.at(CurvePosition::START); + let cm = curve.at(CurvePosition::MIDPOINT); + let c1 = curve.at(CurvePosition::END); assert!( - (c0.jp - curve.light_anchor().jp).abs() < 1e-9, + (c0.jp() - curve.light_anchor().jp()).abs() < 1e-9, "t=0 jp mismatch" ); assert!( - (cm.jp - curve.base_anchor().jp).abs() < 1e-9, + (cm.jp() - curve.base_anchor().jp()).abs() < 1e-9, "t=0.5 jp mismatch" ); assert!( - (c1.jp - curve.dark_anchor().jp).abs() < 1e-9, + (c1.jp() - curve.dark_anchor().jp()).abs() < 1e-9, "t=1.0 jp mismatch" ); } @@ -406,10 +646,10 @@ mod tests { let steps = curve.sample(100); for w in steps.windows(2) { assert!( - w[0].jp >= w[1].jp - 1e-9, + w[0].jp() >= w[1].jp() - 1e-9, "jp increased: {} -> {}", - w[0].jp, - w[1].jp + w[0].jp(), + w[1].jp() ); } } @@ -417,10 +657,10 @@ mod tests { #[test] fn hue_drift_under_30_degrees() { let curve = default_curve(); - let base_hue = curve.base_anchor().h_ok; + 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; + let c = curve.at(position(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={})", @@ -454,15 +694,15 @@ mod tests { #[test] fn jp_within_anchor_bounds() { let curve = default_curve(); - let j_max = curve.light_anchor().jp; - let j_min = curve.dark_anchor().jp; + 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); + let c = curve.at(position(i as f64 / 100.0)); assert!( - c.jp <= j_max + 1e-9 && c.jp >= j_min - 1e-9, + c.jp() <= j_max + 1e-9 && c.jp() >= j_min - 1e-9, "t={}: jp={} out of [{}, {}]", i as f64 / 100.0, - c.jp, + c.jp(), j_min, j_max ); @@ -488,12 +728,12 @@ mod tests { fn s_non_negative_everywhere() { let curve = default_curve(); for i in 0..=100 { - let c = curve.at(i as f64 / 100.0); + let c = curve.at(position(i as f64 / 100.0)); assert!( - c.s >= -1e-9, + c.s() >= -1e-9, "negative s at t={}: {}", i as f64 / 100.0, - c.s + c.s() ); } } @@ -502,14 +742,7 @@ mod tests { fn dim_curve() -> NeutralCurve { let vc = ViewingConditions::dim_surround(); - NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &vc, - ) - .unwrap() + NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &vc).unwrap() } #[test] @@ -522,10 +755,10 @@ mod tests { let avg = default_curve(); let dim = dim_curve(); assert!( - dim.base_anchor().jp > avg.base_anchor().jp, + 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, + dim.base_anchor().jp(), + avg.base_anchor().jp(), ); } @@ -535,10 +768,10 @@ mod tests { let steps = curve.sample(100); for w in steps.windows(2) { assert!( - w[0].jp >= w[1].jp - 1e-9, + w[0].jp() >= w[1].jp() - 1e-9, "dim jp increased: {} -> {}", - w[0].jp, - w[1].jp, + w[0].jp(), + w[1].jp(), ); } } @@ -580,13 +813,13 @@ mod tests { // не имеют скачков ни в якорях, ни между ними. for curve in [default_curve(), dim_curve()] { let n = 2000; - let mut prev = curve.at(0.0); + let mut prev = curve.at(CurvePosition::START); for i in 1..=n { let t = i as f64 / n as f64; - let c = curve.at(t); + let c = curve.at(position(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(); + 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); @@ -595,6 +828,26 @@ mod tests { } } + #[test] + fn exact_gray_anchor_has_no_numeric_hue_spike_between_adjacent_f64_positions() { + let curve = NeutralCurve::new("#FFFFFF", "#808080", "#000000").unwrap(); + let midpoint = 0.5_f64; + let positions = [ + f64::from_bits(midpoint.to_bits() - 1), + midpoint, + f64::from_bits(midpoint.to_bits() + 1), + ]; + let hues = positions.map(|t| curve.at(position(t)).h_ok()); + + for pair in hues.windows(2) { + let delta = ((pair[1] - pair[0] + 180.0).rem_euclid(360.0) - 180.0).abs(); + assert!( + delta <= f64::EPSILON, + "exact gray midpoint introduced a numeric hue spike: {hues:?}" + ); + } + } + #[test] fn envelope_passes_through_all_anchor_chromas() { let curve = default_curve(); @@ -605,7 +858,7 @@ mod tests { (0.5 + eps, curve.base_anchor(), "base (fall start)"), (1.0 - eps, curve.dark_anchor(), "dark"), ] { - let got = curve.at(t).mp(); + let got = curve.at(position(t)).mp(); let want = anchor.mp(); assert!( (got - want).abs() < 0.01, @@ -794,6 +1047,7 @@ mod exposure_locks { #[cfg(test)] mod wave2_e_locks { use super::{CurveParams, NeutralCurve}; + use crate::ViewingConditions; use crate::curve::ColorCurve; use crate::spaces::oklab::srgb_linear_to_oklab; use crate::spaces::srgb::srgb_from_hex; @@ -805,9 +1059,15 @@ mod wave2_e_locks { } fn ladder(params: CurveParams) -> Vec { - NeutralCurve::with_params("#FFFFFF", "#787880", "#101012", params) - .unwrap() - .sample_hex(13) + NeutralCurve::build( + "#FFFFFF", + "#787880", + "#101012", + params, + &ViewingConditions::srgb(), + ) + .unwrap() + .sample_hex(13) } fn max_de_vs_default(vary: impl Fn(&mut CurveParams)) -> f64 { @@ -893,9 +1153,15 @@ mod whittle_crispening_metric { 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) + NeutralCurve::build( + "#FFFFFF", + "#787880", + "#101012", + p, + &crate::spaces::vc::ViewingConditions::srgb(), + ) + .unwrap() + .sample_hex(13) } /// Обобщённый контраст Уиттла (Whittle 1986/1992): `W = (L − Lb) / min(L, Lb)`. diff --git a/crates/labcolors-core/src/one_levelness_tests.rs b/crates/labcolors-core/src/one_levelness_tests.rs index cecc7884..62811b83 100644 --- a/crates/labcolors-core/src/one_levelness_tests.rs +++ b/crates/labcolors-core/src/one_levelness_tests.rs @@ -4,12 +4,12 @@ //! НЕ ТОТ контракт читаемости, что нейтральный лейбл того же уровня. До //! ратификации это была α-рампа @72/@52/@32 поверх тинта семьи — 40/40 //! нарушений одноуровневости (danger-S light 38.0 Lc против neutral-S 66.4; -//! light-тема нелегальна до 1.69:1). См. scratchpad/ch5c-ratification.md §2. +//! light-тема нелегальна до 1.69:1). Этот модуль является executable proof. //! //! ИНВАРИАНТ (мандат владельца 2026-07-03 «одноуровневое одноуровнево»): //! для каждой темы × уровня × семьи //! | |Lc(label--L)| − |Lc(label-neutral-L)| | ≤ TOL -//! ЛИБО роль явно несёт флаг нестрогого исхода (`compressed` / `hue_vanished`) — +//! ЛИБО роль явно несёт доказанный флаг нестрогого исхода (`compressed`) — //! тогда точная цель не заявляется выполненной. Плюс юр. полы уровня //! (AA text 4.5 / 4.5 / AA UI 3.0 / —) держатся у цветного лейбла как у нейтрали //! (полы НЕТОРГУЕМЫ). @@ -100,13 +100,13 @@ fn one_levelness_violations(table: &NamedRoleTable) -> Vec { let fam = role(&set, &fam_role_name); let fam_lc = abs_lc(&set, &fam_role_name); let delta = (fam_lc - neutral_lc).abs(); - let honest = fam.compressed() || fam.hue_vanished(); + let honest = fam.compressed(); // 1. Одноуровневость: |ΔLc| ≤ TOL ИЛИ честный флаг деградации. if delta > TOL && !honest { out.push(format!( "{}/{fam_role_name}: |Lc| {fam_lc:.1} против нейтрали {neutral_lc:.1} \ - (Δ {delta:.1} > {TOL}) без флага compressed/hue-vanished", + (Δ {delta:.1} > {TOL}) без флага compressed", theme.name )); } @@ -150,9 +150,9 @@ fn one_levelness_holds_on_labui_reference() { /// семьи) роняет гейт с конкретными числами. Доказывает, что гейт кусается — /// зелёный-с-рождения был бы багом (тест-театр). /// -/// `label-danger-secondary` → `Ladder(sent danger, LabelSecondary)`: на светлой +/// `label-danger-secondary` → `Ladder(family red, LabelSecondary)`: на светлой /// теме композит @72 даёт |Lc| ~38 против нейтрали ~66 (Δ ~28 ≫ TOL), и роль -/// становится Translucent — у неё НЕТ флага compressed/hue-vanished, то есть это +/// становится Translucent — у неё НЕТ флага compressed, то есть это /// молчаливая деградация, ровно тот класс, что гейт обязан ловить. #[test] fn red_proof_ladder_recipe_breaks_one_levelness() { @@ -172,7 +172,7 @@ fn red_proof_ladder_recipe_breaks_one_levelness() { for (name, recipe) in &mut cfg.roles { if name == "label-danger-secondary" { *recipe = RoleRecipe::Ladder { - source: LadderSource::Sentiment("danger".to_string()), + source: LadderSource::Family("red".to_string()), position: LadderPosition::LabelSecondary, floor: None, }; @@ -191,69 +191,3 @@ fn red_proof_ladder_recipe_breaks_one_levelness() { violations.join("\n") ); } - -/// 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 по построению), -/// поэтому истинное расхождение либо строго ноль, либо макроскопично (доля стены); -/// «шума в младших битах» тут физически нет — обе величины из единой геометрии. -#[test] -fn accent_surface_derivation_rides_gamut_wall_closed_form() { - use crate::accent_balance::accent_balanced; - use crate::derive_accent_surface_ramp; - use crate::neutral::{CurveParams, NeutralCurve}; - use crate::scale::{jp_to_oklab_l, max_chroma}; - - 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]; - - for theme in themes() { - let curve = NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &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); - 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)", - theme.name - ); - } - } - } -} diff --git a/crates/labcolors-core/src/pair.rs b/crates/labcolors-core/src/pair.rs index 7755bcbf..d91c5027 100644 --- a/crates/labcolors-core/src/pair.rs +++ b/crates/labcolors-core/src/pair.rs @@ -13,12 +13,13 @@ //! буква WCAG требовала чернил, глаз и Figma — белого. //! //! Закон пары: сторона лейбла выбирается ПЕРЦЕПТИВНЫМ кроссовером по якорю, -//! а заливка минимально двигается по светлоте (оттенок и хрома идентичности -//! сохраняются в Oklab), пока выбранная сторона не начнёт выигрывать штатный +//! а непрерывный кандидат заливки минимально двигается по светлоте вдоль +//! фиксированных Oklab `a,b`, пока выбранная сторона не начнёт выигрывать штатный //! `choose_polarity`. Дальше лейбл решается ОБЫЧНЫМ nested resolve на //! выведенной заливке — пара не изобретает второй текстовый закон, она //! готовит поверхность, на которой существующий закон даёт перцептивно -//! правильную сторону с легальным полом. +//! правильную сторону с легальным полом. Гамут и sRGB8-квантование могут изменить +//! координаты; рецепт не выдаёт это за гарантию сохранения идентичности. //! //! # Кроссовер (level-3) //! @@ -38,46 +39,43 @@ //! выведена из самой формулы WCAG, не подобрана. Сдвиг для фирменных якорей //! мал: #007AFF (0.211 → 0.179) — едва заметное утемнение при том же оттенке. -use crate::spaces::oklab::{oklab_to_srgb_linear, srgb_linear_to_oklab}; +use crate::spaces::oklab::{neutral_srgb_linear, oklab_to_srgb_linear, srgb_linear_to_oklab}; use crate::spaces::srgb::srgb_gamma_inv; use crate::spaces::vc::ViewingConditions; /// Y-порог кроссовера стороны пары «заливка × лейбл» — решается ОДИН РАЗ по /// каноническому светлому якорю семьи. /// -/// Терминал **(a) DERIVED** — не выбран, а ИЗМЕРЕН из опубликованной модели. +/// Терминал **(a) DERIVED** внутри замороженной численной эвристики. /// /// # Что этот порог решает /// Ахроматический терминал решения стороны. С главы #64 (level-3) [`pair_side`] -/// не сравнивает люминанс с порогом напрямую — он меряет достижимый |Lc| обоих +/// не сравнивает люминанс с порогом напрямую — он сравнивает |Lc| обоих /// архетипов лейбла той же кривой; на серой оси H-K-член ≈ 0 и правило /// редуцируется ровно к этому порогу (лок /// `achromatic_reduction_matches_derived_crossover`). Порог обязан стоять там, -/// где обе стороны РАВНО читаемы, — в чисто-люминансном домене самой кривой. +/// где обе ветви frozen Ys candidate-кривой равны по модулю. /// /// # Почему именно это число (вывод, не выбор) -/// Перцептивная читаемость меряется контраст-ядром APCA -/// ([`crate::lpc::contrast_core`], опубликованный набор SAPC-8 `0.0.98G-4g`). +/// Используется внутреннее `contrast_core` с замороженным набором SAPC-8 +/// `0.0.98G-4g`. /// Кроссовер = фон, на котором |Lc| ЧЁРНОГО лейбла догоняет |Lc| БЕЛОГО. Он /// найден бисекцией (корень единственный на [0.2, 0.6], проверено сменой знака): -/// **Y = 0.341955**. Это не подгонка — это математическое свойство опубликованных -/// экспонент; дрейф любой из них ломает лок +/// **Y = 0.341955**. Это математическое свойство текущих экспонент, но не +/// LPC/readability optimum; дрейф любой из них ломает лок /// `pair_crossover_equals_measured_core_polarity_flip` (пиннинг деривации). /// /// # Почему не соседние кандидаты -/// - НЕ `0.179` (`WHITE_WINS_Y`) — то WCAG-tie, легальный ПОЛ гарантии контраста, -/// а не перцептивный оптимум читаемости. -/// - НЕ `0.325` (полная метрика [`crate::lpc::lpc`], байт 155 серой оси) — та -/// внутри несёт CAM16/Y_hk-реконструкцию ЯРКОСТНОГО домена, чужую для -/// чисто-люминансного решения `pair_side` (ось читаемости — люминанс, ADR-0003). +/// - НЕ `0.179` (`WHITE_WINS_Y`) — это отдельная граница двойной законности WCAG. +/// - НЕ `0.325` (test-only H-K appearance-candidate, байт 155 серой оси) — тот +/// использует другой входной домен, чужой для Ys-решения `pair_side`. /// /// # Согласованность и почему это ещё и ЛУЧШЕ прежнего 0.30 /// 0.341955 лежит ВНУТРИ интервала 10 Figma-якорей labui (0.246, 0.423) — вывод /// не спорит с палитрой, а совпадает с ней (экспозиция 21.69%, /// `exposure_pair_crossover`). Прежнее 0.30 было дизайн-тюнингом НИЖЕ модели: при -/// Y ∈ [0.30, 0.342) оно ставило ЧЕРНИЛЬНЫЙ лейбл там, где по модели БЕЛЫЙ -/// читается лучше. Вывод не только объективен — он исправляет эту недодачу -/// светлых лейблов. +/// Y ∈ [0.30, 0.342) оно выбирало ink-ветвь там, где frozen curve даёт больший +/// модуль white-ветви. Это согласование одной эвристики, не claim читаемости. // С главы #64 (level-3) `pair_side` не потребляет этот порог в РАНТАЙМЕ — он // выводит кроссовер из достижимого |Lc| обоих архетипов лейбла той же кривой. // Порог остаётся АХРОМАТИЧЕСКИМ ТЕРМИНАЛОМ правила: на серой оси H-K-член ≈ 0 и @@ -125,7 +123,7 @@ fn wcag_y_encoded(rgb: [f64; 3]) -> f64 { /// Сторона лейбла пары по перцептивному кроссоверу якоря. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PairSide { +pub(crate) enum PairSide { /// Якорь перцептивно тёмный — лейбл светлый, заливка двигается до /// строгой победы белого в штатной полярности. Light, @@ -163,7 +161,7 @@ pub enum PairSide { /// /// Тай (равенство скоров) отдан свету: полярностная асимметрия чтения — белый /// предпочтителен, пока чернила строго не выиграли. -pub fn pair_side(anchor_encoded: [f64; 3]) -> PairSide { +pub(crate) fn pair_side(anchor_encoded: [f64; 3]) -> PairSide { let ys = wcag_y_encoded(anchor_encoded); let lin = [ srgb_gamma_inv(anchor_encoded[0]), @@ -181,15 +179,17 @@ pub fn pair_side(anchor_encoded: [f64; 3]) -> PairSide { } /// Заливка пары: пер-режимный якорь, минимально сдвинутый по L Oklab до -/// строгой победы СТОРОНЫ СЕМЬИ (a, b — оттенок/хрома идентичности — не -/// трогаются в Oklab-координатах; у края куба каналы честно клампятся). +/// строгой победы СТОРОНЫ СЕМЬИ. Непрерывный хроматический кандидат держит +/// исходные Oklab `a,b`; ахроматический идёт по точной neutral ray, чтобы шум +/// матрицы не изобрёл hue. Затем каналы клампятся и квантуются, поэтому +/// финальные байты не несут отдельной гарантии сохранения оттенка. /// /// Сторона приходит от канонического светлого якоря семьи ([`pair_side`]) и /// одна на все режимы; движение пер-режимное: светлая сторона — утемнение до /// `Y < WHITE_WINS_Y`, чернильная — осветление до `Y > BLACK_WINS_Y` /// (IC-якоря проваливаются под границу). Якорь, уже дающий победу, не /// двигается вовсе. -pub fn pair_fill(anchor_encoded: [f64; 3], side: PairSide) -> [f64; 3] { +pub(crate) fn pair_fill(anchor_encoded: [f64; 3], side: PairSide) -> [f64; 3] { let y = wcag_y_encoded(anchor_encoded); let (needs_move, target_dark) = match side { PairSide::Light => (y >= WHITE_WINS_Y, true), @@ -204,8 +204,18 @@ pub fn pair_fill(anchor_encoded: [f64; 3], side: PairSide) -> [f64; 3] { srgb_gamma_inv(anchor_encoded[2]), ]; let lab = srgb_linear_to_oklab(lin); + let source = crate::Srgb8::new( + anchor_encoded.map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8), + ); + let linear_at = |l: f64| { + if source.is_achromatic() { + neutral_srgb_linear(l) + } else { + oklab_to_srgb_linear([l, lab[1], lab[2]]) + } + }; let wins = |l: f64| { - let cand = encode_clamped(oklab_to_srgb_linear([l, lab[1], lab[2]])); + let cand = encode_clamped(linear_at(l)); if target_dark { wcag_y_encoded(cand) < WHITE_WINS_Y } else { @@ -227,7 +237,7 @@ pub fn pair_fill(anchor_encoded: [f64; 3], side: PairSide) -> [f64; 3] { hi = mid; } } - encode_clamped(oklab_to_srgb_linear([lo, lab[1], lab[2]])) + encode_clamped(linear_at(lo)) } /// Линейный sRGB → кодированный, КВАНТОВАННЫЙ в 8-битную решётку с клампом @@ -282,9 +292,9 @@ mod tests { } /// Минимальность сдвига: заливка тёмной стороны кроссовера двигается до - /// строгой победы белого и ни шагом дальше; оттенок/хрома целы. + /// строгой победы белого и ни шагом дальше. #[test] - fn light_side_nudges_minimally_preserving_identity() { + fn light_side_nudges_minimally() { for hex in ["#007AFF", "#FF3B30", "#3E87FF"] { let anchor = enc(hex); let fill = pair_fill(anchor, PairSide::Light); @@ -297,23 +307,6 @@ mod tests { y > WHITE_WINS_Y - 0.004, "{hex}: сдвиг минимален (Y={y:.4}, граница {WHITE_WINS_Y})" ); - // Оттенок и хрома идентичности: a, b Oklab якоря сохранены. - let lab_a = srgb_linear_to_oklab([ - srgb_gamma_inv(anchor[0]), - srgb_gamma_inv(anchor[1]), - srgb_gamma_inv(anchor[2]), - ]); - let lab_f = srgb_linear_to_oklab([ - srgb_gamma_inv(fill[0]), - srgb_gamma_inv(fill[1]), - srgb_gamma_inv(fill[2]), - ]); - assert!( - (lab_a[1] - lab_f[1]).abs() < 0.02 && (lab_a[2] - lab_f[2]).abs() < 0.02, - "{hex}: оттенок/хрома сохранены (Δa={:.4}, Δb={:.4})", - (lab_a[1] - lab_f[1]).abs(), - (lab_a[2] - lab_f[2]).abs() - ); } } @@ -336,6 +329,29 @@ mod tests { ); } + #[test] + fn every_exact_gray_pair_fill_stays_on_the_encoded_gray_axis() { + for byte in 0_u8..=255 { + let channel = f64::from(byte) / 255.0; + let anchor = [channel; 3]; + let fill = pair_fill(anchor, pair_side(anchor)); + let [red, green, blue] = crate::srgb8::hex_bytes(&hex_from_srgb_encoded(fill)).unwrap(); + assert_eq!(red, green, "gray {byte}: red/green direction invented"); + assert_eq!(green, blue, "gray {byte}: green/blue direction invented"); + } + } + + #[test] + fn nearest_chromatic_pair_fill_keeps_a_chromatic_direction() { + let anchor = enc("#808081"); + let fill = pair_fill(anchor, pair_side(anchor)); + let [red, green, blue] = crate::srgb8::hex_bytes(&hex_from_srgb_encoded(fill)).unwrap(); + assert!( + red != green || green != blue, + "one-byte chromatic source was collapsed onto the gray axis" + ); + } + /// Сторона — идентичность семьи: канонический светлый якорь решает один /// раз, тёмные/IC-варианты семьи НЕ флипают её (info dark #5696FF Y=0.31 /// перелезает кроссовер — контрпример оси A). @@ -655,18 +671,18 @@ mod exposure_locks { /// бисекция. Это домен ахроматического терминала, к которому level-3 /// `pair_side` редуцируется на серой оси, — потому его перелом = якорь /// `PAIR_CROSSOVER_Y`. - /// 2. Полная метрика ([`crate::lpc::lpc`], серая ось 8-бит, внутри Y_hk с - /// CAM16-реконструкцией): вторичный замер яркостного домена (byte 155, + /// 2. Test-only H-K appearance-candidate на серой оси sRGB8 с + /// CAM16-реконструкцией: вторичная численная характеризация (byte 155, /// Y≈0.325) — печатается для сравнения, но решению pair.rs чужд, не якорь. /// - /// Числа печатаются и пиннятся снапшотом: дрейф любой опубликованной константы + /// Числа печатаются и пиннятся снапшотом: дрейф любой замороженной константы /// ядра сдвинет перелом и СЛОМАЕТ лок — значит `PAIR_CROSSOVER_Y` не может молча /// разойтись с деривацией (это и есть пиннинг (a) DERIVED). Перелом лежит внутри /// интервала 10 якорей (0.246, 0.423) — вывод согласован с палитрой. Провенанс — /// docs/empirical-inventory.md (строка 52). #[test] fn pair_crossover_equals_measured_core_polarity_flip() { - use crate::lpc::{contrast_core, lpc}; + use crate::lpc::{apparent_contrast_candidate_hex_for_test, contrast_core}; use crate::spaces::srgb::srgb_gamma_inv; // 1. Ядро: f(Y) = |Lc белого| − |Lc чёрного| меняет знак на [0.2, 0.6]. let f = |y: f64| contrast_core(1.0, y).abs() - contrast_core(0.0, y).abs(); @@ -702,11 +718,18 @@ mod exposure_locks { } } let core_x = 0.5 * (lo + hi); - // 2. Полная метрика: первый серый байт, где чёрный лейбл обгоняет белый. + // 2. Test-only H-K appearance-candidate: первый серый байт, где + // black-ветвь обгоняет white-ветвь. let mut flip_byte = None; for v in 1..=255u32 { let hex = format!("#{v:02X}{v:02X}{v:02X}"); - if lpc("#000000", &hex).abs() >= lpc("#FFFFFF", &hex).abs() { + if apparent_contrast_candidate_hex_for_test("#000000", &hex) + .expect("generated grey is valid") + .abs() + >= apparent_contrast_candidate_hex_for_test("#FFFFFF", &hex) + .expect("generated grey is valid") + .abs() + { flip_byte = Some(v); break; } @@ -715,7 +738,7 @@ mod exposure_locks { let y_at = |b: u32| srgb_gamma_inv(f64::from(b) / 255.0); let (y_below, y_flip) = (y_at(v - 1), y_at(v)); eprintln!( - "MODEL CROSSOVER: core Y={core_x:.6}; full-metric grey axis: byte {v} (Y in ({y_below:.4}, {y_flip:.4}])" + "MODEL CROSSOVER: core Y={core_x:.6}; H-K candidate grey axis: byte {v} (Y in ({y_below:.4}, {y_flip:.4}])" ); // Снапшоты замера — регрессионный якорь (a)-деривации (прежний // rustdoc-клейм «перелом ≈ 0.36» опровергнут этим замером 2026-07-07). @@ -725,10 +748,10 @@ mod exposure_locks { ); assert_eq!( v, 155, - "полная метрика: перелом серой оси ушёл от снапшота (byte {v}, Y={y_flip:.4})" + "H-K candidate: перелом серой оси ушёл от снапшота (byte {v}, Y={y_flip:.4})" ); // (a) DERIVED: константа РАВНА измеренному перелому ядра — якорь = ядро - // `contrast_core`, а не полная метрика (byte 155, Y≈0.325 несёт + // `contrast_core`, а не H-K candidate (byte 155, Y≈0.325 несёт // CAM16/Y_hk-реконструкцию; она остаётся вторичным замером, не якорем). assert!( (PAIR_CROSSOVER_Y - core_x).abs() < 1e-5, diff --git a/crates/labcolors-core/src/pair_label_tests.rs b/crates/labcolors-core/src/pair_label_tests.rs index 515073d2..2772df72 100644 --- a/crates/labcolors-core/src/pair_label_tests.rs +++ b/crates/labcolors-core/src/pair_label_tests.rs @@ -15,7 +15,7 @@ //! `fill--primary`) держит UI-пол на тинте во всех темах (гвардит то, //! что уже отгружено, — near-black лейбл сейчас даёт 13–18:1). //! 2. `pair_label_*` — новая роль держит пол против тинт-поверхности во всех -//! сентимент-категориях × light/dark (± IC). +//! client-defined families × light/dark (± IC). //! 3. `pair_label_beats_page_resolved_label` — дифференциальный RED-proof: //! ТОТ ЖЕ контракт (доля 0.4757, `AaUi`), решённый против страницы //! (`label--tertiary`), проваливает 3:1 на тинте у warning/success, @@ -43,10 +43,10 @@ use crate::{ fn families() -> [(&'static str, LadderSource); 5] { [ ("brand", LadderSource::Brand), - ("danger", LadderSource::Sentiment("danger".to_string())), - ("warning", LadderSource::Sentiment("warning".to_string())), - ("success", LadderSource::Sentiment("success".to_string())), - ("info", LadderSource::Sentiment("info".to_string())), + ("danger", LadderSource::Family("red".to_string())), + ("warning", LadderSource::Family("orange".to_string())), + ("success", LadderSource::Family("green".to_string())), + ("info", LadderSource::Family("blue".to_string())), ] } @@ -171,10 +171,9 @@ fn pair_label_clears_ui_floor_against_tinted_surface_all_families_and_themes() { } } -/// Лейбл тинт-бейджа остаётся ЦВЕТНЫМ (оттенок не испарился) — иначе гарантия -/// контраста была бы куплена ценой near-black, а не выведенного цвета. +/// Лейбл тинт-бейджа эмитит другой байтовый цвет, чем основной label семьи. #[test] -fn pair_label_stays_hued_not_near_black() { +fn pair_label_bytes_differ_from_primary_label() { let table = labui_with_badge_labels(); let bg = BgInput::solid("#FFFFFF").unwrap(); let set = resolve_named_set(&bg, &table, &ViewingConditions::srgb()) @@ -182,25 +181,16 @@ fn pair_label_stays_hued_not_near_black() { for (fam, _) in families() { let role = format!("badge-label-{fam}"); let res = &set.iter().find(|(n, _)| n == &role).unwrap().1; - let Resolved::Color { - solved, - hue_vanished, - .. - } = res - else { + let Resolved::Color { solved, .. } = res else { panic!("`{role}` обязан решиться цветом"); }; - assert!( - !hue_vanished, - "`{role}`: оттенок семьи обязан выжить (не near-black кламп)" - ); - // И отличается от отгруженного near-black `label--primary` (17:1): - // цветной лейбл у пола — это НЕ чёрный. + // Здесь проверяется только различие представления; перцептивный порог из + // одного `assert_ne!` не выводится. let primary = solid_hex(&set, &format!("label-{fam}-primary")); assert_ne!( solved.hex(), primary, - "`{role}` не должен схлопнуться в near-black `label-{fam}-primary`" + "`{role}` не должен совпасть байт-в-байт с `label-{fam}-primary`" ); } } diff --git a/crates/labcolors-core/src/r3_byte_identity_tests.rs b/crates/labcolors-core/src/r3_byte_identity_tests.rs index 246b799d..3419e896 100644 --- a/crates/labcolors-core/src/r3_byte_identity_tests.rs +++ b/crates/labcolors-core/src/r3_byte_identity_tests.rs @@ -2,7 +2,7 @@ //! //! 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 +//! marker commits) must be value-preserving — no emitted accent 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 @@ -28,8 +28,7 @@ //! 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. +//! • `accent_golden.rs` already pins the same 13-stop ladder. //! 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. @@ -39,24 +38,19 @@ //! 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 emitted accent hex values change. //! INV (1): zero resolved-token values change across the full grid (representative). use crate::{ - BgInput, Resolved, Role, RoleTable, ViewingConditions, - curve::ColorCurve, - neutral::NeutralCurve, - resolve_set, - scale::AccentCurve, - sentiment::{Sentiment, SentimentCurve}, + BgInput, Resolved, Role, RoleTable, ViewingConditions, curve::ColorCurve, + neutral::NeutralCurve, resolve_set, scale::AccentCurve, }; // ───────────────────────────────────────────────────────────────────────────── -// R3-A: sample_hex(13) golden ladder for two representative curves. +// R3-A: sample_hex(13) golden ladder for a representative curve. // // 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 +// It is 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. // @@ -70,15 +64,6 @@ const R3_ACCENT_007AFF_GOLDEN: [&str; 13] = [ "#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") @@ -106,26 +91,6 @@ fn r3_sample_hex_13_accent_007aff_byte_identity() { ); } -/// 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!` падает. -#[test] -fn r3_sample_hex_13_sentiment_info_byte_identity() { - 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." - ); -} - // ───────────────────────────────────────────────────────────────────────────── // R3-B: resolve_set 240-cell byte-identity (representative subset). // @@ -147,7 +112,7 @@ fn r3_sample_hex_13_sentiment_info_byte_identity() { /// contrast text role and the most sensitive canary for a lightness-shift. const R3_RESOLVE_SET_SPOTS: [(&str, &str, &str, &str); 12] = [ // sRGB viewing conditions — regenerated for the readability→`Ys` activation - // (глава #64, ADR-0003): ось читаемости перешла в люминансный домен `Ys`, и + // (ADR-0003): candidate-score путь использует люминансный домен `Ys`, и // текстовая лестница пересобрана на принятые владельцем hex'ы (#141414 и // тонированный дефолт). Owner sign-off = ADR-0003 (Принято, делегация // владельца). Значения совпадают с 240-cell GOLDEN в semantic.rs (тот же diff --git a/crates/labcolors-core/src/scale.rs b/crates/labcolors-core/src/scale.rs index f50920f2..741cc4fa 100644 --- a/crates/labcolors-core/src/scale.rs +++ b/crates/labcolors-core/src/scale.rs @@ -1,3 +1,4 @@ +use crate::curve::CurvePosition; use crate::lcs::LcsColor; use crate::neutral::NeutralCurve; use crate::spaces::cam16; @@ -56,7 +57,7 @@ impl AccentCurve { /// за гамут (абсолютная хрома у краёв физически недостижима). pub fn new(canonical_hex: &str, neutral: &NeutralCurve) -> Result { let color = LcsColor::from_hex(canonical_hex)?; - let h_canonical = color.h_ok; + let h_canonical = color.h_ok(); let rgb = srgb_from_hex(canonical_hex)?; let lab = srgb_linear_to_oklab(rgb); @@ -84,10 +85,8 @@ impl AccentCurve { /// оттенок — поиск максимума хромы со штрафом дрейфа от канонического /// (см. `find_optimal_hue`), хрома — `sat_ratio ×` стена гамута на этой /// светлоте. - pub fn at(&self, t: f64) -> LcsColor { - let t = t.clamp(0.0, 1.0); - let neutral_color = self.neutral.at(t); - let jp = neutral_color.jp; + pub fn at(&self, position: CurvePosition) -> LcsColor { + let jp = self.neutral.jp_at(position); let l_ok = jp_to_oklab_l(jp, &self.vc); @@ -328,14 +327,14 @@ pub(crate) fn coarse_to_fine_argmax( /// Oklab L of the grey whose CAM16-UCS lightness J' equals `jp`, in closed form. /// -/// # Derivation (mirror of `lpc::y_hk_analytic`) +/// # Derivation (shared CAM16 gray-axis inverse) /// /// `AccentCurve::at` calls this once per stretch point to anchor the accent's /// lightness on the same grey axis the neutral curve defines. The forward map it /// inverts is the achromatic chain /// /// ```text -/// y ──grey_j──▶ J ──UCS──▶ J' = 1.7·J / (1 + 0.007·J) +/// y ──gray_j──▶ J ──UCS──▶ J' = 1.7·J / (1 + 0.007·J) /// ``` /// /// followed by `L_ok = srgb_linear_to_oklab([y, y, y])[0]`. Every link is a @@ -344,10 +343,10 @@ pub(crate) fn coarse_to_fine_argmax( /// 1. **J' → J** — the CAM16-UCS lightness rescale (Li et al. 2017, /// DOI 10.1002/col.22131) inverts in closed form: /// `jp·(1 + 0.007·J) = 1.7·J ⇒ J = jp / (1.7 − 0.007·jp)`. This is the same -/// inverse `lpc::y_hk_from_lcs` already uses for the LcsColor contrast path. +/// inverse the contrast path also uses for `LcsColor`. /// 2. **J → y** — on the achromatic D65 ray, chroma is zero, so the Hellwig H-K /// term vanishes and `J_HK ≡ J`. Recovering the grey luminance from `J` is -/// therefore *exactly* `lpc::y_hk(J, vc)` — the analytic CAM16 grey-axis +/// therefore *exactly* `cam16::gray_y(J, vc)` — the analytic CAM16 gray-axis /// inverse (closed-form seed + two Newton steps) that replaced an identical /// 64-step bisection in PR #51. Reused verbatim here, no second copy of the /// cone-response algebra. @@ -383,7 +382,7 @@ pub(crate) fn jp_to_oklab_l(jp: f64, vc: &ViewingConditions) -> f64 { // Step 2: invert the achromatic CAM16 chain J → y. On the grey axis chroma // is zero, so J_HK ≡ J and the H-K-corrected grey inverse is the plain one. - let y = crate::lpc::y_hk(j, vc); + let y = cam16::gray_y(j, vc); // Step 3: grey Oklab L through the identical forward function the bisection // used — keeps the emitted lightness bit-for-bit, so the accent golden holds. @@ -829,8 +828,8 @@ pub(crate) fn max_chroma_bisect(l_ok: f64, h_ok_deg: f64) -> f64 { } impl crate::curve::ColorCurve for AccentCurve { - fn at(&self, t: f64) -> LcsColor { - self.at(t) + fn at(&self, position: CurvePosition) -> LcsColor { + self.at(position) } fn vc(&self) -> &ViewingConditions { @@ -843,6 +842,10 @@ mod tests { use super::*; use crate::curve::ColorCurve; + fn position(value: f64) -> CurvePosition { + CurvePosition::new(value).expect("test position is inside [0, 1]") + } + fn default_neutral() -> NeutralCurve { NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() } @@ -1129,10 +1132,10 @@ mod tests { let steps = curve.sample(50); for w in steps.windows(2) { assert!( - w[0].jp >= w[1].jp - 0.5, + w[0].jp() >= w[1].jp() - 0.5, "jp increased: {} -> {}", - w[0].jp, - w[1].jp + w[0].jp(), + w[1].jp() ); } } @@ -1142,8 +1145,13 @@ mod tests { let neutral = default_neutral(); let curve = AccentCurve::new("#007AFF", &neutral).unwrap(); for i in 0..=50 { - let c = curve.at(i as f64 / 50.0); - assert!(c.s >= -1e-6, "negative s at t={}: {}", i as f64 / 50.0, c.s); + let c = curve.at(position(i as f64 / 50.0)); + assert!( + c.s() >= -1e-6, + "negative s at t={}: {}", + i as f64 / 50.0, + c.s() + ); } } @@ -1152,7 +1160,7 @@ mod tests { let neutral = default_neutral(); let curve = AccentCurve::new("#007AFF", &neutral).unwrap(); for i in 0..=50 { - let color = curve.at(i as f64 / 50.0); + let color = curve.at(position(i as f64 / 50.0)); let hex = color.to_hex(); let rgb = srgb_from_hex(&hex).unwrap(); assert!( @@ -1265,7 +1273,7 @@ mod tests { // 64-step bisection oracle to better than the bisection's own resolution. // Both paths feed the identical `srgb_linear_to_oklab([y,y,y])`, so the // only divergence is in the recovered grey luminance `y`; that inherits - // the < 1e-12 bound `lpc::y_hk` is held to (see y_hk_analytic tests), and + // the < 1e-12 bound `cam16::gray_y` is held to (see inverse tests), and // the cube root only contracts it. We assert max|dL| < 1e-12 and report // the measured worst case. // @@ -1282,7 +1290,7 @@ mod tests { for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { let mut max_dl = 0.0_f64; let mut worst_jp = 0.0_f64; - // grey_j(1.0) ≈ 100; sweep (0, 104] to a hair past white, mirroring + // gray_j(1.0) ≈ 100; sweep (0, 104] to a hair past white, mirroring // the y_hk grid test's reachable-range coverage. Start at n = 1 so the // exact-black J' = 0 endpoint (pinned elsewhere) is excluded. for n in 1..=6000 { @@ -1323,7 +1331,7 @@ mod tests { // Round-trip: the J' produced by a known grey luminance recovers an L // that equals the forward grey L for that same luminance. for &y in &[0.02_f64, 0.18, 0.5, 0.9, 1.0] { - let j = crate::lpc::grey_j(y, &vc); + let j = cam16::gray_j(y, &vc); // J' through the shared helper — the same J'-generation production // uses; the equivalence is still anchored by the independent // `srgb_linear_to_oklab` reference below, not by `ucs_j`. @@ -1381,17 +1389,9 @@ mod tests { // ── Dark-theme (dim-surround) accent tests ──────────────── fn dim_neutral() -> NeutralCurve { - use crate::neutral::CurveParams; use crate::spaces::vc::ViewingConditions; let vc = ViewingConditions::dim_surround(); - NeutralCurve::with_vc( - "#FFFFFF", - "#787880", - "#101012", - &CurveParams::default(), - &vc, - ) - .unwrap() + NeutralCurve::with_vc("#FFFFFF", "#787880", "#101012", &vc).unwrap() } #[test] @@ -1401,10 +1401,10 @@ mod tests { let steps = curve.sample(50); for w in steps.windows(2) { assert!( - w[0].jp >= w[1].jp - 0.5, + w[0].jp() >= w[1].jp() - 0.5, "dim accent jp increased: {} -> {}", - w[0].jp, - w[1].jp, + w[0].jp(), + w[1].jp(), ); } } @@ -1414,7 +1414,7 @@ mod tests { let neutral = dim_neutral(); let curve = AccentCurve::new("#007AFF", &neutral).unwrap(); for i in 0..=50 { - let color = curve.at(i as f64 / 50.0); + let color = curve.at(position(i as f64 / 50.0)); let hex = color.to_hex_with_vc(&curve.vc); let rgb = srgb_from_hex(&hex).unwrap(); assert!( diff --git a/crates/labcolors-core/src/semantic.rs b/crates/labcolors-core/src/semantic.rs index 1b62a624..18df8b2d 100644 --- a/crates/labcolors-core/src/semantic.rs +++ b/crates/labcolors-core/src/semantic.rs @@ -1,158 +1,44 @@ //! Контекстный резолвер скомпилированных клиентских цветовых контрактов. //! -//! Where [`solve`](crate::solve()) answers "what colour meets *this* signed -//! contrast against *this* background", this module answers the product-level -//! question one layer up: "give me the whole set of named colours a UI needs -//! against this background". [`NamedRoleTable`] carries opaque client IDs plus -//! core-owned recipes; [`resolve_named_set`] resolves them in declaration order. -//! Допуск атомарен: в успешном наборе живут только локальные -//! `unreachable | unresolved`, а rejected/unsupported/internal провенанс -//! возвращает [`ResolveSetError`] без частичного вектора. Сериализация -//! результата — ответственность биндинга. +//! # Текущий контракт //! -//! # Polarity is read from the background, never from the role +//! [`NamedRoleTable`] — переходное скомпилированное представление набора. +//! Клиентские идентификаторы непрозрачны: Core не выводит из строк семантику, +//! иерархию или физику. [`resolve_named_set`] решает весь набор относительно +//! текущего фона и viewing conditions; сериализация принадлежит биндингу. //! -//! [`solve`] takes a *signed* `Lc` (positive = dark-on-light, negative = -//! light-on-dark). A role stores only the *magnitude* of the contrast it wants; -//! this module picks the sign from the background, so the same role table -//! resolves correctly on a light or a dark background without the caller -//! choosing a theme. That is what "resolved from any background" means. +//! Граница набора атомарна. Доказанная недостижимость и незавершённый bounded +//! search остаются типизированными исходами отдельных ролей; rejected, +//! unsupported и internal закрывают вызов через [`ResolveSetError`] без +//! частичного успешного вектора. Нулевое значение представлено явно через +//! [`Resolved::None`]. //! -//! The sign is chosen in two stages, and — crucially — from the *WCAG* gate the -//! text roles actually have to clear, not from the perceptual maximum: +//! Клиентский источник задаётся неизменяемыми sRGB8-байтами. Равные каналы +//! точно ахроматичны; неравные доказывают лишь хроматичность представления. +//! Финальные байты сами по себе не доказывают сохранение или различимость +//! authored hue — такой вывод требует явного relation-constraint и сертификата. //! -//! 1. **WCAG reachability first.** A text role floors at the legal AA ratio -//! (4.5:1 for text). Which polarity can reach that floor is a property of the -//! background alone — `contrast_ratio(black, bg)` vs `contrast_ratio(white, -//! bg)` — and is independent of the viewing conditions, because the WCAG -//! formula is. So the polarity that clears the strict 4.5:1 floor wins. This -//! is what stops a light-grey background (`#808080`, `#999999`) from reporting -//! every text role unreachable while *black* text on it passes AA with room to -//! spare: the old "pick the larger LPC maximum" rule flipped polarity near -//! `#999999`, far from the WCAG flip near `#747474`, and chose the side the -//! legal floor could not reach. -//! 2. **Tie-break to the perceptual winner (white).** When both polarities clear -//! the strict floor they do so only on a narrow band: black clears 4.5:1 at -//! `Y ≥ 0.175`, white at `Y ≤ 0.1833`, so both are legal only on -//! `Y ∈ [0.175, 0.1833]` (`#757575`, `#767676` and same-luminance chromatics -//! such as `#0078D4`). Across that whole band the perceptual layer prefers -//! *light-on-dark* with a wide margin — the luminance-domain LPC core -//! (`crate::lpc::contrast_core`) has its black-overtakes-white crossover far -//! higher, near `Y ≈ 0.342` (measured, locked by -//! `pair::exposure_locks::pair_crossover_equals_measured_core_polarity_flip`) -//! — so the tie resolves to white (`break_tie`). This -//! replaces the former "larger WCAG margin wins" rule, whose symmetric margin -//! crossed over *inside* the band (`Y ≈ 0.1791`) and chose dark-on-light on the -//! upper half — the perceptually weaker side there, and the one that made -//! `#0078D4` emit black against the Fluent convention of white. When *neither* -//! polarity can clear the floor (a true mid-grey with no readable side), the -//! side that comes *closest* is chosen, so an admitted [`RoleFailure`] retains -//! честное best-case-свидетельство `max_ratio`, а не худшее. +//! # Целевая граница //! -//! Because the criterion is VC-independent, a role's polarity never flips between -//! the light and dim viewing conditions for the same background — no per-theme -//! coin-flip on a near-tie like `#3478F6`. +//! Публичная модель сходится к общему графу `Color`, `Paint`, `Surface`, +//! `Occurrence` и `Constraint`: значение или представление наблюдается в +//! конкретном контексте и удовлетворяет объявленным физическим ограничениям. +//! Клиент владеет именами и смыслом; Core — композитингом, контрастом, +//! адаптацией, конечной эмиссией и сертификатами. //! -//! # Sanity over arithmetic: the anchor principle -//! -//! Text contrast magnitudes are **not fixed deltas**. A fixed delta is how -//! `label-primary` once came out grey: a mid contrast number satisfies the -//! contract arithmetically but violates the design intent that primary text on -//! white reads as *black*. Instead, a text role anchors its target to a -//! **fraction of the maximum contrast the background can supply** -//! ([`TextAnchor`]). Primary asks for ~97 % of that maximum — almost the -//! strongest the background allows — so on white it lands near-black and on -//! black near-white, by construction, on *any* background. The fractions are -//! calibrated against Daniel's Figma anchors (see `RoleTable::default`) and -//! stay marked "calibrates" until his eye signs off. -//! -//! Because every text role is a fraction of the *same* per-background maximum, -//! the hierarchy primary > secondary > muted > disabled is **strict wherever the -//! background physically allows it** — symmetric by construction across both -//! polarities. This is the deliberate fix for the asymmetry baked into the -//! literal Figma tokens, where equal opacity steps produced a dark-theme -//! hierarchy ~40 % weaker than the light one (see the module tests). -//! -//! # Hierarchy compression is flagged, never silent -//! -//! On a background whose readable window is *narrower than the hierarchy's own -//! steps* — a near-AA mid-grey such as `#747474`, where the only readable -//! polarity has barely any room above 4.5:1 — two adjacent text roles can be -//! forced by the legal floor onto the same point. The old code let primary and -//! secondary collapse to an identical hex silently, falsifying the "strict -//! hierarchy by construction" claim. This module instead degrades *honestly*: -//! a subordinate role is nudged to the smallest distinguishable quantisation -//! step below its senior **only while it still clears its own floor**. If order -//! and the junior's hard floor conflict, the floor wins: the legal junior colour -//! is retained and [`Resolved::compressed`] marks the non-exact hierarchy -//! outcome. A consumer never receives a floor-violating colour disguised as a -//! successful hierarchy. -//! -//! # The zero token -//! -//! "Empty" is a value, not a missing entry. A role that means "no colour here" -//! (`Role::None`) is part of the table and resolves to an explicit -//! [`Resolved::None`] — an honest zero (transparent / no contrast), never a -//! skipped key. Swapping a literal for a token later is then a change of value, -//! not the insertion of a token where a hole used to be. -//! -//! # Out of scope for v1 (extension seams, not implementations) -//! -//! - **Decorative contracts split by physics.** The `border-base` / `border-soft` -//! ladder and the `fill-primary..quaternary` ladder are *dJ'* roles -//! ([`RoleSpec::DecorativeDj`]): each holds the owner's literal perceived- -//! lightness step (a `J'` offset) against its surface, per theme, solved -//! analytically with no readability floor. The `shadow-minor..major` stack and -//! `separator` stay legacy Lc [`RoleSpec::Decorative`] placeholders (the shadow -//! owner anchors are alpha opacities, not dJ' steps); only their relative order -//! is a contract, defined and covered in the `surface-jnd` chapter. -//! `border-strong` carries the `label-primary` FRACTION but a 3:1 non-text -//! floor (WCAG 1.4.11): a border must be distinguishable, not readable. -//! - **Brand / sentiment roles are not here.** v1 carries one *neutral* -//! undertone for the whole table (the cool tint of Daniel's neutral ladder, -//! see [`RoleChroma`]); per-role brand/accent hues are a later chapter. The -//! chroma seam (`RoleTable::with_chroma`) is left open so that chapter can -//! swap the policy over the existing sentiment machinery without reshaping -//! this table. -//! -//! # The neutral undertone: identity, not sterile grey -//! -//! Daniel's neutral is tinted — `#101012` carries a cool blue-violet undertone, -//! not a pure grey. A role table resolved with zero chroma threw that identity -//! away: `label-primary` on white came out the sterile `#141414`. So every -//! resolved role carries the neutral's undertone and lands as a *relative* of the -//! neutral family — `label-primary` on white as a cool near-black in the `#101012` -//! family. The undertone is small enough that the WCAG floors, the strict -//! hierarchy, and the near-black/near-white primary all hold exactly as before -//! (the solver re-solves lightness to the same target with the tint applied). -//! -//! The default undertone policy is [`RoleChroma::Curve`] (v2), derived from three -//! computable mechanisms rather than a flat ratio of the gamut: -//! -//! 1. **Constant CAM16-UCS coordinate** — the chroma at each role's resolved -//! lightness is solved to a constant `M'` (`TINT_TARGET_MP`), not a fixed -//! fraction of the gamut maximum. This is a characterized design policy that -//! holds chroma in the lights and moderates it in the middle; it does not turn -//! `M'` into a universal perceptual-colorfulness scale. -//! 2. **Cusp-attracted hue** — the hue at each lightness is pulled toward the -//! local chroma cusp of the sRGB gamut, penalised for leaving the canonical -//! 286° (`cusp_attracted_hue`). The drift emerges from geometry; it is *not* -//! a set of hard-coded hue nodes. (Honest limit: the gamut's cusp near 286° -//! does not drift to the reference's light-end azure — see that function.) -//! 3. **Perceptibility floor** — where the gamut cannot host the target -//! colorfulness, the curve takes the gamut maximum and is allowed to fall -//! toward `TINT_PERCEPTIBLE_MP_FLOOR` rather than fake chroma it cannot reach. -//! -//! A caller who wants the v1 flat-ratio undertone opts back into it with -//! [`RoleChroma::flat_neutral_tint`]; pure grey with [`RoleChroma::Neutral`]; -//! either via `RoleTable::with_chroma`. +//! [`RoleSpec`], [`RoleChroma`] и остальные recipe-варианты — переходный +//! compiled transport существующего пути, а не публичная точка расширения. +//! Новые возможности должны выражаться общими узлами, рёбрами и constraints, +//! без добавления семантических recipe-кейсов в Core. +use crate::Srgb8; use crate::ladder::LadderTint; use crate::scale; use crate::solve::{ self, BgInput, ChromaPolicy, Contract, Floor, Hue, SolveFailure, SolveFailureBoundary, SolveFailureCategory, Solved, }; +use crate::spaces::oklab::{HUE_DEG_MAX_EXCLUSIVE, HUE_DEG_MIN_INCLUSIVE}; use crate::spaces::srgb::srgb_gamma; use crate::spaces::vc::ViewingConditions; use crate::wcag; @@ -228,18 +114,12 @@ const IC_DECORATIVE_FLOOR_MIN: f64 = 15.0; // Лестницы fill и border несут буквальные dJ'-якоря — перцептивную разницу // светлоты, которую каждый декоративный шаг держит относительно своей // поверхности, вычисленные из растяжек LabUI Figma ("Вычисленные якоря", -// reference/labui-figma-structure.md). Это правильная единица измерения (шаг J', -// а не контраст Lc) и правильный тип ([`RoleSpec::DecorativeDj`], решается без -// порога читаемости и без обрезки низкого контраста — это различимость, а не -// разборчивость текста). -// -// Числа, единица измерения и источник — реальные измерения из Figma. +// reference/labui-figma-structure.md). Это смещения координаты J', а не Lc; +// они не являются измеренным JND или моделью разборчивости. // -// По темам: якоря измерены отдельно под каждой темой, потому что восприятие -// светлоты зависит от окружения (surround). Тёмные якоря примерно в 2.2 раза -// больше светлых — измеренная компенсация для тёмного окружения — поэтому -// они НЕ выведены из светлого набора, а являются отдельными измерениями под -// тёмную тему, которые выбираются при резолве по теме viewing conditions. +// Числа — авторская per-theme дизайн-калибровка, вычисленная из reference- +// эмиссий под объявленными VC. Dark-значения не выводятся из light множителем: +// обе стороны выбираются непосредственно по VC. /// dJ'-якоря лестницы fill (`fill-primary` … `fill-quaternary`), строго убывающие /// по видимости. Буквальные измеренные значения; отдельно light/dark по теме. @@ -285,7 +165,8 @@ pub(crate) const BORDER_SOFT_DJ: DjMagnitude = DjMagnitude::new(3.15, 5.83); /// терминологическим подлогом. Значения провизорны; единственный контракт — /// строгий возрастающий порядок ступеней (тест /// `shadow_constant_stack_is_strictly_ascending_with_gaps`). Финальная -/// перцептивная калибровка теневого стека — за владельцем. +/// Перцептивное повышение статуса требует опубликованных данных либо явной +/// клиентской policy; собственная owner-сессия не является evidence. #[cfg(test)] // SSOT-TRACKED — величина Lc стека теней (минимальная ступень). pub(crate) const SHADOW_MINOR_LC: f64 = 8.0; @@ -311,7 +192,7 @@ pub(crate) const SHADOW_MAJOR_LC: f64 = 14.0; // #9C9C9C → Ys 50.446. Вывод задокументирован в rustdoc `Default for // RoleTable` ниже. Это «якорный принцип»: роль держит почти максимум, что // позволяет фон, а не фиксированную дельту; финальная перцептивная -// калибровка долей — за владельцем. +// Их изменение требует опубликованного evidence либо явной client policy. /// Доля максимального Lc для `LabelPrimary` (и `BorderStrong`): /// Ys(#141414)/Ys(max) = 103.2157/106.0407. @@ -336,7 +217,8 @@ const LABEL_QUATERNARY_FRACTION: f64 = 0.29335999; /// Lc-величина декоративного разделителя (`Separator`). Единственная оставшаяся /// провизорная декоративная величина: держится выше [`DECORATIVE_FLOOR_MIN`] -/// (7.5); финальная JND-калибровка — за владельцем. +/// (7.5). Перцептивная калибровка требует опубликованного evidence либо явной +/// client policy; литерал не является JND-утверждением. #[cfg(test)] // SSOT-TRACKED — провизорная декоративная величина Separator (Lc), см. docs/empirical-inventory.md. const SEPARATOR_DECORATIVE_LC: f64 = 8.0; @@ -511,32 +393,28 @@ impl Role { } } -/// How a text/UI role expresses its target contrast against a background. +/// Переходное представление цели text/UI-роли по Ys candidate-score. /// -/// A fraction of the background's maximum achievable contrast — *not* a fixed -/// `Lc` delta. See the module docs on the anchor principle for why. `fraction` -/// is in `(0, 1]`: `1.0` names the physical contrast endpoint exactly. +/// Доля максимальной величины candidate-score данного фона, а не фиксированная +/// дельта `Lc`. `fraction` лежит в `(0, 1]`; `1.0` означает конец замороженной +/// SAPC-shaped кривой. Координата не является LPC/readability evidence. #[derive(Debug, Clone, Copy, PartialEq)] pub struct TextAnchor { fraction: f64, conformance: Floor, - /// Опциональный источник ОТТЕНКА семьи (ратификация ch5c, M1). `None` — - /// нейтральный лейбл (подтон из [`RoleChroma`] таблицы, прежний путь - /// байт-в-байт). `Some(tint)` — ЦВЕТНОЙ лейбл: держит ТОТ ЖЕ Lc-контракт - /// уровня, что нейтральный (доля·max, тот же WCAG-пол — одноуровневость по - /// построению), но решённый в чистом оттенке семьи. Тинт — пер-темный - /// `Copy`-якорь идентичности (Figma-оттенок сохранён); светлота выводится - /// контрактом на LCS-кривой семьи, хрома = стена гамута на решённой - /// светлоте. Резолв — [`resolve_hued_anchor`]. + /// Опциональный источник физической цветовой идентичности. `None` берёт + /// [`RoleChroma`] таблицы. `Some(tint)` держит тот же candidate-score/floor-контракт, а + /// точные эмитируемые sRGB8-байты выбирают план: равные каналы остаются + /// нейтральными; неравные задают Oklab-направление и максимальную доступную + /// хрому на решённой светлоте. Резолв — [`resolve_hued_anchor`]. hue: Option, } impl TextAnchor { - /// A text anchor at `fraction` of the background's maximum contrast, with the - /// given WCAG conformance floor. `fraction` must be finite and inside - /// `(0, 1]`; invalid input is rejected rather than silently rewritten. - /// Neutral undertone (no family hue); attach one with - /// [`with_hue`](Self::with_hue). + /// Якорь на доле `fraction` от максимального Ys candidate-score фона с + /// заданным WCAG-полом. `fraction` обязан быть конечным и лежать в `(0, 1]`; + /// невалидный ввод отклоняется без тихой коррекции. По умолчанию семейный + /// оттенок отсутствует; его добавляет [`with_hue`](Self::with_hue). pub fn new(fraction: f64, conformance: Floor) -> Result { if !fraction.is_finite() || fraction <= 0.0 || fraction > 1.0 { return Err(SolveFailure::InvalidInput(format!( @@ -550,42 +428,36 @@ impl TextAnchor { }) } - /// Тот же якорь, но решаемый в чистом оттенке `hue`-семьи (M1 ch5c). Контракт - /// уровня (`fraction`/`conformance`) НЕ меняется — меняется лишь физика цвета: - /// одноуровневость держится, оттенок = идентичность семьи. + /// Тот же якорь с явным источником цветовой идентичности. Контракт уровня + /// (`fraction`/`conformance`) не меняется. Равные sRGB8-каналы источника + /// остаются нейтральными; неравные несут направление hue. pub fn with_hue(mut self, hue: crate::ladder::LadderTint) -> Self { self.hue = Some(hue); self } - /// The fraction of maximum contrast this anchor targets, in `(0, 1]`. + /// Доля максимальной величины Ys candidate-score в `(0, 1]`. pub fn fraction(self) -> f64 { self.fraction } - /// The WCAG conformance floor applied after the perceptual target. + /// WCAG-пол, применяемый после candidate-score цели. pub fn conformance(self) -> Floor { self.conformance } - /// Источник оттенка семьи, если это цветной лейбл (M1). `None` — нейтральный. + /// Явный источник физической цветовой идентичности; `None` берёт policy таблицы. pub fn hue(self) -> Option { self.hue } } -/// A decorative perceived-lightness-difference (dJ') magnitude, with the owner's -/// per-theme calibration. +/// Пара авторских per-theme смещений координаты CAM16-UCS `J'`. /// -/// The owner measured the perceived-lightness step a decorative element should -/// hold against its surface separately for each theme — perception of a lightness -/// difference is not theme-invariant, and the owner's dark anchors run ~2.2× the -/// light ones (a measured over-compensation for dark surrounds). So the magnitude -/// is a `(light, dark)` pair, and the solver picks the side that matches the -/// viewing conditions it resolves under via [`for_vc`](DjMagnitude::for_vc). This -/// keeps the role table a single VC-agnostic instance (it serves both themes) and -/// localises the per-theme choice to the one place that knows the VC — the resolve -/// step — rather than forcing two tables or a theme parameter through every caller. +/// Значения выведены из указанных Figma-эмиссий под объявленными viewing +/// conditions. Это дизайн-калибровка, а не измеренный JND или универсальный +/// закон компенсации окружения. Нужную сторону выбирает +/// [`for_vc`](DjMagnitude::for_vc). #[derive(Debug, Clone, Copy, PartialEq)] pub struct DjMagnitude { light: f64, @@ -593,13 +465,12 @@ pub struct DjMagnitude { } impl DjMagnitude { - /// A dJ' magnitude from its per-theme anchors (light surround, dark surround). + /// Авторские `J'`-смещения для светлого и тёмного профилей VC. pub const fn new(light: f64, dark: f64) -> Self { Self { light, dark } } - /// The anchor for these viewing conditions: the dark value under a dimmed - /// surround (dark theme), the light value otherwise. + /// Якорь для данных VC: dark-значение при dimmed surround, иначе light. pub fn for_vc(self, vc: &ViewingConditions) -> f64 { if vc.is_dark_theme() { self.dark @@ -608,55 +479,46 @@ impl DjMagnitude { } } - /// The light-surround anchor. + /// Якорь светлого окружения. pub fn light(self) -> f64 { self.light } - /// The dark-surround anchor. + /// Якорь тёмного окружения. pub fn dark(self) -> f64 { self.dark } } -/// The contrast recipe behind a role — the shape this module solves. +/// Переходный численный рецепт роли — форма, исполняемая этим модулем. /// -/// Text/UI roles ([`Anchor`](RoleSpec::Anchor)) target a fraction of the -/// background's maximum; dJ' decorative roles -/// ([`DecorativeDj`](RoleSpec::DecorativeDj)) target a perceived-lightness step on -/// the CAM16-UCS J' axis with no readability floor; legacy Lc decorative roles -/// ([`Decorative`](RoleSpec::Decorative)) target an `Lc` magnitude held only for -/// the stack's relative ordering (the shadow anchors are alpha opacities, not -/// dJ' steps — see the shadow-stack note above); the -/// zero token ([`Zero`](RoleSpec::Zero)) resolves to nothing. Construct these -/// through `RoleTable`; they are exposed so a caller can read or override a recipe. +/// Text/UI-якоря задают долю максимума замороженной Ys-кривой; декоративные dJ' +/// роли — шаг координаты CAM16-UCS `J'` без WCAG-пола; legacy-декоративные Lc +/// роли — величину Ys candidate-score только для относительного порядка стека. +/// Нулевой токен разрешается в отсутствие значения. Эти варианты описывают +/// текущий transport и не являются точкой расширения Core. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] pub enum RoleSpec { - /// Anchored text/UI contrast: a fraction of the background's maximum. + /// Ys candidate-score text/UI-якоря: доля максимума данного фона. Anchor(TextAnchor), - /// Decorative perceived-lightness difference (dJ'): the solved colour sits - /// `magnitude_dj` away from the background on the CAM16-UCS lightness (`J'`) - /// axis, toward the larger headroom (the set polarity). No readability floor - /// and no low-contrast clip — this is distinguishability of a decorative - /// element (a fill tint, a hairline border), a different physics from the - /// legibility the [`Anchor`](RoleSpec::Anchor) / [`Decorative`](RoleSpec::Decorative) roles solve. + /// Декоративное смещение координаты CAM16-UCS `J'`: решённый цвет находится + /// на `magnitude_dj` от фона по оси `J'` в сторону большего доступного + /// диапазона. Нет WCAG-пола и low-contrast clip; координатный контракт сам + /// по себе не заявляет JND или читаемость глифов. /// - /// The magnitude carries the owner's literal Figma-computed anchors per theme - /// (see [`DjMagnitude`]); the solve is analytic (J' offset → grey-axis Oklab L - /// → undertone build → quantise → honest dJ' measurement on the emitted hex). - /// The unit, type, and source of the anchor are the owner's — not a - /// substitute. + /// Величина несёт авторские Figma-якоря по темам (см. [`DjMagnitude`]); solve + /// аналитически переводит J'-смещение в Oklab L, строит подтон, квантует и + /// повторно измеряет dJ' на эмитированном hex. DecorativeDj { magnitude_dj: DjMagnitude }, - /// Decorative just-noticeable-difference contrast: an `Lc` magnitude, held - /// above `DECORATIVE_FLOOR_MIN`, with [`Floor::None`]. + /// Decorative Ys candidate-score magnitude `Lc`, held above + /// `DECORATIVE_FLOOR_MIN`, with [`Floor::None`]. It is not a JND claim. /// - /// Retained for the shadow stack, whose owner anchors are alpha opacities, - /// not dJ' steps — converting them to dJ' would invent numbers with no owner - /// source. The relative order between the shadow steps is the contract this - /// variant carries; `surface-jnd` derives shadow contracts from the alphas. + /// Сохранено для стека теней: его исходные якоря — alpha opacity, не dJ'. + /// Перевод в dJ' выдумал бы числа без источника; этот вариант несёт только + /// относительный порядок ступеней. Decorative { magnitude: f64 }, - /// Ступень лестницы акцента/сентимента/бренда/нейтрали: тинт-якорь источника + /// Ступень лестницы семейства/бренда/нейтрали: тинт-якорь источника /// (по теме) при альфе позиции. Эмитит пару (тинт, α) НАПРЯМУЮ (закон /// лестницы labui — композитит браузер, а не солид-эквивалент, см. /// [`crate::ladder`]). Резолв — [`Resolved::Translucent`]: несёт тинт (то, что @@ -684,13 +546,13 @@ pub enum RoleSpec { /// migration adapter на границе, не core-семантика. mode: crate::numerical_plan::NumericalExecutionModeV1, }, - /// Заливка пары ([`crate::pair`]): якорь источника, сдвинутый до победы - /// перцептивной стороны лейбла в штатной полярности; солид-эмиссия. + /// Заливка пары (внутренний модуль `pair`): якорь источника, сдвинутый до победы + /// выбранной ветви переходной pair-эвристики; солид-эмиссия. PairFill { /// Пер-темный кодированный якорь источника. tint: LadderTint, }, - /// Лейбл ТИНТ-бейджа ([`crate::pair`], лейбл-сторона). Семейно-оттеночный + /// Лейбл ТИНТ-бейджа (внутренний модуль `pair`, лейбл-сторона). Семейно-оттеночный /// лейбл, чей WCAG-пол энфорсится ПРОТИВ объявленной тинт-поверхности /// (exact source-over композит declared `tint` при compatibility-альфе /// позиции `fill-*-primary` над фоном резолва), а НЕ против фона страницы @@ -724,12 +586,11 @@ pub enum RoleSpec { alpha_dark: f64, /// Опциональный юр. пол UI (M2 ch5c): солидная семейная граница /// (`border--strong`, α=1) ОБЯЗАНА держать 3:1 (WCAG 1.4.11). - /// `None` — прежний путь (тинт эмитится как есть). `Some(floor)` — - /// если композит чист (≥ пола), эмитится точный семейный солид (Figma - /// цел); иначе МИНИМАЛЬНЫЙ ЛЕГАЛЬНЫЙ СДВИГ по кривой семьи до легальности, - /// объявленный флагом [`TranslucentResolved::floor_coerced`]. Применим - /// только к солиду (α=1); у полупрозрачных позиций игнорируется (контраст - /// полупрозрачной роли — свойство композита, не тинта). + /// `None` — тинт эмитится как есть. `Some(floor)` допустим только для + /// solid-позиции (`α=1`): если она уже проходит пол, сохраняются точные + /// байты; иначе выполняется минимальный легальный сдвиг с флагом + /// [`TranslucentResolved::floor_coerced`]. Для полупрозрачной позиции + /// поле должно отсутствовать; `Some(Floor::None)` тоже невалиден. floor: Option, }, /// Альфа-аналог солида источника через композит-инверсию ([`crate::alpha`], @@ -754,17 +615,16 @@ pub enum RoleSpec { }, /// Двухслойный материал (стекло/акрил): опаковая тон-база `02` на целевом /// |ΔJ'| тира + полупрозрачный тинт `01` (тот же тон) с ВЫВЕДЕННОЙ альфой. - /// Резолв — [`Resolved::Material`] ([`crate::material`]; канон — `docs/whitepaper.md` §3.7). + /// Резолв — [`Resolved::Material`] ([`crate::material`]; см. `docs/whitepaper.md`, «Точечные композиции»). /// /// Тон строится dj-anchor-солвером на светлоте `tone` от фона резолва в /// оттенке семьи; α выбирается охарактеризованным для платформы поиском /// с фиксированным числом шагов и повторно проверяется как проходящее /// состояние для `floor`. Material { - /// Оттенок семьи тона. `None` — нейтральный материал (подтон таблицы, то - /// же 286°, что и остальные нейтральные эмиссии). `Some(tint)` — - /// семейно-оттеночный (акцент/сентимент): оттенок подставляется в кривую - /// подтона таблицы, красочность (`target_mp`/`hue_stiffness`) — от неё же. + /// Источник цветовой идентичности тона. `None` берёт neutral policy + /// таблицы. Для `Some(tint)` равные sRGB8-каналы остаются нейтральными, + /// неравные подставляют направление источника в chroma-policy таблицы. hue: Option, /// Целевой |ΔJ'| тона-базы от фона резолва (пер-темная пара): тир /// материала (base = крупный/заметный, subtle = малый/тонкий). @@ -773,7 +633,7 @@ pub enum RoleSpec { /// `Floor::None` невалиден — материал обязан нести пол (валидатор ловит). floor: Floor, }, - /// The zero token: resolves to [`Resolved::None`]. + /// Нулевой токен: разрешается в [`Resolved::None`]. Zero, } @@ -800,38 +660,10 @@ pub enum RoleSpec { /// измеренный якорь с доказанной локальной робастностью. Протокол калибровки: /// пере-замерить Oklab-оттенок собственного семейства нейтралей потребителя /// (`atan2(b,a)` средней ступени) — измерение, а не эксперимент с наблюдателями. -// SSOT-TRACKED — измеренный Oklab-оттенок нейтральной шкалы, терминал (e) design-choice (измеренный якорь, байт-инвариант по разбросу [285.78,286.01]), см. docs/empirical-inventory.md. +// Test-only characterization anchor; not a production policy constant. +#[cfg(test)] pub(crate) const NEUTRAL_HUE_DEG: f64 = 286.0; -/// Доля от максимальной хромы в гамуте, которую несёт тонированная роль. -/// -/// Намеренно небольшая: подтон должен *ощущаться*, но никогда не *считываться* -/// как цвет. Решатель применяет абсолютную хрому `ratio · max_chroma(L)` -/// ([`build_color`](crate::solve)), а `max_chroma` достигает пика на средней -/// светлоте и падает почти до нуля на обоих краях (тёмном и светлом). Поэтому -/// один плоский коэффициент бесплатно воспроизводит дух огибающей нейтральной -/// кривой: самый сильный подтон приходится на роли средней силы, самый слабый — -/// на почти-чёрный/почти-белый края текстовой шкалы — "меньше у тёмных/светлых -/// краёв, больше к середине". -/// `0.10`: на белом `label-primary` резолвится в холодный почти-чёрный -/// семейства `#101012`, а не в чистый серый. -/// -/// Терминал **(e) DESIGN-CHOICE** — генуинная свободная ручка «силы подтона». -/// Blast radius: используется ТОЛЬКО опциональной v1-политикой -/// [`RoleChroma::flat_neutral_tint`] (не дефолтная — дефолт `Curve` держит хрому -/// через `TINT_TARGET_MP`), т.е. в проде «спит», пока потребитель не вернётся к -/// плоскому тинту. Легальный диапазон конфига **[0, 1]** (валидатор `TINT_RATIO` -/// в `config.rs`). Sensitivity (Волна 2, лок -/// `neutral_tint_ratio_sensitivity_is_bounded`): свип легальной полосы -/// [0, 0.20] по светлотам шкалы даёт max ΔE_ok ≈ **0.0288** (>1 JND) — -/// НЕПРЕРЫВНЫЙ материальный дрейф (ratio прямо масштабирует хрому -/// `ratio · max_chroma(L)`), значит честный (e), не (c). Протокол калибровки: -/// поднимать ratio, пока подтон не начнёт «считываться как цвет» (перцептивный -/// потолок, замер по [`TINT_PERCEPTIBLE_MP_FLOOR`]) — тогда 0.10 = чуть ниже -/// этого потолка на серединных ролях. -// SSOT-TRACKED — коэффициент хромы нейтрального подтона, терминал (e) design-choice (opt-in v1-политика; max ΔE_ok 0.0288 по [0,0.2]), см. docs/empirical-inventory.md. -pub(crate) const NEUTRAL_TINT_RATIO: f64 = 0.10; - /// Целевая перцептивная красочность (CAM16-UCS `M'`) по умолчанию, которую /// v2-кривая подтона держит по всей шкале светлоты — параметр "сила". /// @@ -862,10 +694,8 @@ pub(crate) const NEUTRAL_TINT_RATIO: f64 = 0.10; /// `6.1` — единственный скаляр силы, применённый одинаково по всей шкале /// (см. тест `curve_fits_reference_plateau_colorfulness` для количественного /// сравнения с референсом). -// `#[cfg(test)]` снова: с уходом фикстуры в `config::fixture` (тест-оракул) -// прод-потребителей не осталось — словарь пресета (BL-007) несёт только -// семантику ролей, а прод-репро `tint_target_sweep_repro` принимает цель -// параметром. +// `#[cfg(test)]`: значение принадлежит только characterization fixture и не +// задаёт политику agnostic Core. // SSOT-TRACKED — целевой M' в CAM16-UCS. #[cfg(test)] pub(crate) const TINT_TARGET_MP: f64 = 6.1; @@ -896,31 +726,13 @@ pub(crate) const TINT_TARGET_MP: f64 = 6.1; /// потребителя, но ДЕФОЛТ 9.0 к этому режиму не относится. Протокол «выхода из /// (c)»: если потребитель осознанно ставит стиффнес < 0.36 (хочет касп-дрейф), /// он ре-открывает материальность и обязан объявить своё значение (e)-ручкой. -// SSOT-TRACKED — жёсткость прижатия оттенка к каспу, терминал (c) interval-insensitive (выход байт-инвариантен выше порога пиннинга ≈0.36, дефолт 9.0 = 25× порога), см. docs/empirical-inventory.md. +// Test-only characterization scalar; not a production policy constant. +#[cfg(test)] pub(crate) const TINT_HUE_STIFFNESS: f64 = 9.0; -/// Порог воспринимаемости (механизм 3) в единицах CAM16-UCS `M'`. Ниже -/// примерно этой красочности подтон попадает в "мёртвую серую зону" — -/// заметно неразличимую как цвет. Там, где гамут не может обеспечить -/// `TINT_TARGET_MP`, кривая не гонится за ним через стену гамута: она -/// берёт максимум, который даёт гамут, и честно допускает падение к этому -/// порогу на самых краях (почти-чёрный / почти-белый), где даже собственный -/// `M'` референса падает до ~2.3–3.0. -/// -/// Терминал **(c) INTERVAL-INSENSITIVE**: порог сидит вплотную ПОД измеренным -/// потолком ахроматического `M'`-шума серых (максимум ≈1.53 у белого, -/// `tint_floor_tracks_achromatic_mp_noise_ceiling`), а доля sRGB-гаммы, где -/// точное значение решает «ощущаемый тон / мёртвая серость», — **0.07%** -/// (`exposure_tint_perceptible_mp_floor`). Ниже порога классификация выхода -/// провизорно неизменна по всему практическому интервалу — сильнее «выбор -/// дизайна», ре-аудит `science/reclassify-e-buckets` 2026-07-07, реестр -/// docs/empirical-inventory.md. -// SSOT-TRACKED — порог воспринимаемости в CAM16-UCS M', терминал (c) interval-insensitive (exposure 0.07%), см. docs/empirical-inventory.md. -pub(crate) const TINT_PERCEPTIBLE_MP_FLOOR: f64 = 1.5; - -/// Half-width (degrees) of the hue window the cusp search explores around the -/// canonical hue. The undertone may drift inside a blue-violet band; it may not -/// wander into unrelated quadrants (red, cyan), so the search is bounded. +/// Полуширина окна оттенка в градусах для cusp-поиска вокруг канонического hue. +/// Подтон может смещаться внутри ограниченного диапазона, но не уходить в +/// несвязанные квадранты. /// /// Терминал **(e) DESIGN-CHOICE** (НЕ (c), несмотря на внешнее сходство с /// [`crate::scale::HUE_SEARCH_HALF_WINDOW`]): замер @@ -946,54 +758,39 @@ pub(crate) const TINT_PERCEPTIBLE_MP_FLOOR: f64 = 1.5; // SSOT-TRACKED — hue search half-window (degrees), терминал (e) design-choice (намеренный кап (0,~42.5°], не interval-insensitive), см. docs/empirical-inventory.md. const CUSP_HALF_WINDOW_DEG: f64 = 40.0; -/// The chroma policy a role table carries. +/// Chroma-policy, которую несёт таблица ролей. /// -/// The v1 default was [`Tinted`](RoleChroma::Tinted) (a flat ratio of the gamut -/// maximum); the v2 default is [`Curve`](RoleChroma::Curve), the science-derived -/// undertone (constant perceptual colorfulness + cusp-attracted hue + a -/// perceptibility floor). [`Neutral`](RoleChroma::Neutral) is the achromatic -/// override. A caller replaces the table's chroma wholesale via -/// `RoleTable::with_chroma`; the enum is the seam later chapters extend for -/// brand/sentiment-tinted roles without reshaping this type. +/// [`Tinted`](RoleChroma::Tinted) — общий fixed-ratio примитив с явными +/// клиентскими hue/ratio; [`Curve`](RoleChroma::Curve) — параметризованное +/// построение подтона; [`Neutral`](RoleChroma::Neutral) — ахроматическая policy. +/// [`NamedRoleTable`] хранит policy без вывода из client ID. #[derive(Debug, Clone, Copy, PartialEq)] #[non_exhaustive] pub enum RoleChroma { - /// Achromatic (grey): zero chroma, hue ignored. The explicit override that - /// reproduces the pre-tint behaviour. + /// Ахроматический режим: нулевая chroma, hue не влияет. Neutral, - /// A small undertone at a fixed Oklab `hue_deg`, carried as `ratio` of the - /// in-gamut maximum chroma at each role's resolved lightness. The flat-ratio - /// v1 policy: kept as an opt-in because its envelope follows `max_chroma(L)`, - /// which over-saturates the middle and starves the light end relative to the - /// reference. Prefer [`Curve`](RoleChroma::Curve). + /// Цвет в Oklab-направлении `hue_deg` при `ratio` от максимальной in-gamut + /// chroma на каждой решённой светлоте. Оба значения — явные входы policy; + /// Core не приписывает им клиентский смысл. Tinted { hue_deg: f64, ratio: f64 }, - /// v2-подтон, выведенный из трёх вычислимых механизмов, а не из - /// захардкоженных узлов рампы: + /// Параметризованное построение цветности на решённой Oklab-светлоте. /// - /// 1. **Постоянная перцептивная красочность** — хрома на резолвленной - /// светлоте каждой роли решается так, чтобы цвет нёс `target_mp` - /// CAM16-UCS `M'` (а не фиксированную долю гамута). Именно - /// равномерность UCS позволяет одной константе держать хрому в светлых - /// и умерять её в середине. См. `TINT_TARGET_MP`. - /// 2. **Оттенок, притянутый к каспу** — оттенок на каждой светлоте - /// притягивается к локальному каспу хромы гамута sRGB (вычисляется из - /// `max_chroma(L, h)`), со штрафом `hue_stiffness` за отклонение от - /// `canonical_hue_deg`. См. `cusp_attracted_hue`. - /// 3. **Порог воспринимаемости** — там, где гамут не может обеспечить - /// `target_mp`, кривая берёт максимум гамута и на краях честно - /// допускает падение к `TINT_PERCEPTIBLE_MP_FLOOR`, а не подделывает - /// хрому. + /// 1. На каждой кандидатной светлоте решатель ищет gamut-valid цвет с + /// заданной координатой CAM16-UCS `target_mp`. Это численная цель + /// конкретной модели, а не утверждение об универсальной перцептивной + /// равномерности. + /// 2. Поиск оттенка стартует от переданного `canonical_hue_deg` и применяет + /// `hue_stiffness` при сравнении с локальной геометрией максимальной + /// sRGB-хромы. Оттенок принадлежит caller/compiled policy; Core не + /// приписывает ему роль, происхождение или фиксированное значение. + /// 3. Если `target_mp` недостижим, построение ограничивается доступной + /// границей гамута. Любая последующая интерпретация результата остаётся + /// model-scoped. /// - /// `target_mp` ("сила") и `hue_stiffness` ("удержание оттенка") — два - /// **выбранных** скаляра, единственные свободные ручки политики. - /// Остальное в кривой опирается на три **измеренные / геометрические** - /// константы, а не на свободные параметры: оттенок тёмного якоря - /// `canonical_hue_deg` (286°, измерен по нейтральной шкале), порог - /// воспринимаемости (`TINT_PERCEPTIBLE_MP_FLOOR`, 1.5 `M'`) и окно - /// поиска каспа по оттенку (`CUSP_HALF_WINDOW_DEG`, ±40°). То есть - /// политика — это "два выбранных скаляра + три измеренные/геометрические - /// константы", а не "два скаляра" — всё за пределами двух выбранных ручек - /// является фиксированной геометрией. + /// Все три поля — входы политики с проверяемым численным доменом. + /// `CUSP_HALF_WINDOW_DEG` ограничивает внутренний поиск как versioned + /// design choice с документированной чувствительностью; это не следствие + /// одной лишь геометрии и не научный default для клиентов. Curve { canonical_hue_deg: f64, target_mp: f64, @@ -1010,9 +807,11 @@ impl RoleChroma { match self { RoleChroma::Neutral => Ok(()), RoleChroma::Tinted { hue_deg, ratio } => { - if !hue_deg.is_finite() { + if !hue_deg.is_finite() + || !(HUE_DEG_MIN_INCLUSIVE..HUE_DEG_MAX_EXCLUSIVE).contains(&hue_deg) + { return Err(SolveFailure::InvalidInput(format!( - "undertone hue must be finite, got {hue_deg}" + "undertone hue must be finite and inside [0, 360), got {hue_deg}" ))); } if !ratio.is_finite() || !(0.0..=1.0).contains(&ratio) { @@ -1027,9 +826,11 @@ impl RoleChroma { target_mp, hue_stiffness, } => { - if !canonical_hue_deg.is_finite() { + if !canonical_hue_deg.is_finite() + || !(HUE_DEG_MIN_INCLUSIVE..HUE_DEG_MAX_EXCLUSIVE).contains(&canonical_hue_deg) + { return Err(SolveFailure::InvalidInput(format!( - "curve canonical hue must be finite, got {canonical_hue_deg}" + "curve canonical hue must be finite and inside [0, 360), got {canonical_hue_deg}" ))); } if !target_mp.is_finite() || target_mp <= 0.0 { @@ -1047,23 +848,8 @@ impl RoleChroma { } } - /// The v1 flat-ratio neutral undertone, kept as an explicit opt-in. - /// - /// The default table moved to [`Curve`](RoleChroma::Curve); a caller who - /// prefers the older flat-ratio behaviour (the neutral's cool hue at a fixed - /// fraction of the gamut maximum, `NEUTRAL_TINT_RATIO`) opts back into it - /// with `RoleTable::default().with_chroma(RoleChroma::flat_neutral_tint())`. - /// This is the additive seam the task requires: the v1 policy stays a - /// first-class, named choice even though it is no longer the default. - pub fn flat_neutral_tint() -> Self { - RoleChroma::Tinted { - hue_deg: NEUTRAL_HUE_DEG, - ratio: NEUTRAL_TINT_RATIO, - } - } - - /// The v2 default: the science-derived undertone curve at its calibrated - /// scalars. + /// Замороженная кривая test-only фикстуры: oracle миграционного паритета, + /// не научный default для клиентских данных. #[cfg(test)] fn neutral_curve() -> Self { RoleChroma::Curve { @@ -1073,16 +859,13 @@ impl RoleChroma { } } - /// Plan the solver's `(hue, chroma)` inputs for a role whose contrast-solved - /// Oklab lightness is `l_ok`. + /// Строит `(hue, chroma)` для роли с уже решённой Oklab-светлотой `l_ok`. /// - /// For the lightness-independent policies ([`Neutral`](RoleChroma::Neutral), - /// [`Tinted`](RoleChroma::Tinted)) the plan ignores `l_ok` and reproduces the - /// v1 behaviour exactly. For [`Curve`](RoleChroma::Curve) the hue is the - /// cusp-attracted hue at `l_ok` and the chroma ratio is the one that lands the - /// colour on the target perceptual colorfulness at that lightness and hue — - /// the per-lightness derivation that makes the undertone a curve, not a - /// constant. + /// [`Neutral`](RoleChroma::Neutral) и [`Tinted`](RoleChroma::Tinted) не + /// зависят от `l_ok`. Для [`Curve`](RoleChroma::Curve) hue притягивается к + /// cusp при `l_ok`, а ratio решается к объявленной численной цели `target_mp` + /// на этой светлоте; это model-scoped построение кривой, не общий закон + /// восприятия. fn plan_for_lightness(self, l_ok: f64, vc: &ViewingConditions) -> (Hue, ChromaPolicy) { match self { RoleChroma::Neutral => (Hue::deg(0.0), ChromaPolicy::Neutral), @@ -1094,38 +877,28 @@ impl RoleChroma { target_mp, hue_stiffness, } => { - // The Curve plan is a pure function of `(l_ok, policy scalars, vc)`: - // an 81-step cusp-hue scan plus a CAM16 ratio bisection. Within one - // resolve sweep the same lightness recurs across roles and across a - // role's fixed-point refinements, so a sweep-scoped exact-key memo - // returns the byte-identical `(hue, ratio)` without redoing either - // scan. See [`curve_plan_cached`]. + // Curve-план — чистая функция `(l_ok, policy scalars, vc)`: + // 81-точечный hue-поиск и CAM16-бисекция ratio. Exact-key memo + // возвращает те же биты при повторе светлоты внутри sweep, не + // повторяя оба поиска; см. [`curve_plan_cached`]. curve_plan_cached(l_ok, canonical_hue_deg, target_mp, hue_stiffness, vc) } } } - /// A lightness-independent plan for the achromatic probe pass (pass A), used - /// only to discover a role's contrast-solved lightness before the real - /// per-lightness plan is built. Always achromatic so the probe is fast and - /// the discovered lightness is the role's true contrast lightness. + /// Независимый от светлоты ахроматический probe-план: узнаёт contrast-solved + /// светлоту роли до построения основного per-lightness плана. fn probe_plan() -> (Hue, ChromaPolicy) { (Hue::deg(0.0), ChromaPolicy::Neutral) } } thread_local! { - /// Process-lived memo for the [`RoleChroma::Curve`] plan, keyed on the bit - /// patterns of `(l_ok, canonical, target_mp, stiffness, vc)` so a hit returns - /// the byte-identical `(hue, ratio)` the 81-step cusp scan + CAM16 ratio - /// bisection would. The plan is a deterministic function of the key, so the - /// cache is always correct — a repeat of any of these means re-resolving the - /// same theme (the common case: a tool re-resolving as a background is tweaked, - /// or the same neutral resolved against many surfaces), where the cusp scan is - /// pure recomputation. Bounded by [`CURVE_PLAN_CACHE_CAP`]: the bisected `l_ok` - /// is effectively arbitrary across unrelated backgrounds, so without a cap the - /// map could grow without bound — at the cap it is cleared wholesale (a cold - /// rebuild, never incorrectness). + /// Process-lived memo Curve-плана по битам + /// `(l_ok, canonical, target_mp, stiffness, vc)`. Попадание возвращает тот же + /// `(hue, ratio)`, что 81-точечный cusp-поиск и CAM16-бисекция. Размер ограничен + /// [`CURVE_PLAN_CACHE_CAP`]; при достижении cap карта очищается, что вызывает + /// только cold rebuild, но не меняет результат. static CURVE_PLAN_CACHE: std::cell::RefCell< std::collections::HashMap<[u64; 5], (f64, f64)>, > = std::cell::RefCell::new(std::collections::HashMap::new()); @@ -1235,15 +1008,15 @@ fn cusp_attracted_hue(l_ok: f64, canonical_deg: f64, stiffness: f64) -> f64 { /// The chroma ratio (for [`ChromaPolicy::Relative`]) that lands a colour of Oklab /// lightness `l_ok` and hue `hue_deg` on perceptual colorfulness `target_mp` -/// (CAM16-UCS `M'`) — mechanism 1, with the mechanism-3 floor at the gamut wall. +/// (CAM16-UCS `M'`), bounded by the gamut wall. /// /// `M'` rises monotonically with chroma at fixed lightness and hue, so the ratio /// is found by bisection: build the colour at a trial ratio, measure its `M'` /// through the same CAM16-UCS path the engine uses ([`LcsColor::mp`]), and /// narrow. If even `ratio = 1` (the gamut maximum) cannot reach `target_mp`, the /// gamut is the limit — return `1.0` and let the colourfulness sit at the most -/// the gamut allows (honestly below target, toward -/// `TINT_PERCEPTIBLE_MP_FLOOR` at the pinched extremes) rather than fake it. +/// the gamut allows (honestly below target at pinched extremes) rather than fake +/// it. This solver does not classify human perceptibility. fn ratio_for_target_mp(l_ok: f64, hue_deg: f64, target_mp: f64, vc: &ViewingConditions) -> f64 { let target = target_mp; // The in-gamut max chroma depends only on `(l_ok, hue_deg)`, both fixed across @@ -1320,92 +1093,6 @@ fn build_curve_color_with_cmax(l_ok: f64, hue_deg: f64, ratio: f64, c_max: f64) ] } -/// Plateau reference node for the tint sweep: `(oklab_l, reference_mp)`. -type PlateauNode = (f64, f64); -/// One tint-sweep row: `(candidate_target, rms_residual, max_residual)`. -type SweepRow = (f64, f64, f64); - -/// Reproduction hook for `examples/tint_target_sweep.rs` — **not** stable public -/// API (`#[doc(hidden)]`). Exposes the real-engine tint identity-curve `M'` sweep -/// behind `TINT_TARGET_MP` so its provenance is reproducible from outside -/// `#[cfg(test)]` without duplicating (and drifting from) the engine. The realised -/// curve `M'` is computed by the exact path the `#[cfg(test)]` -/// `curve_fits_reference_plateau_colorfulness` metric uses (`cusp_attracted_hue` → -/// `ratio_for_target_mp` → gamut-clamped build → CAM16-UCS `M'`), so a caller's -/// printed numbers cannot drift from the test. -/// -/// Returns `(plateau_nodes, sweep)`: -/// * `plateau_nodes`: `(oklab_l, reference_mp)` for the reference-ramp nodes whose -/// Oklab lightness lies in `[l_min, l_max]` (the colourfulness plateau). -/// * `sweep`: `(candidate_target, rms_residual, max_residual)` — over the plateau -/// of `|realised_curve_mp(l, target) − reference_mp|` (the `M'` of the -/// **gamut-clamped** curve built to `target`, not the raw target): `rms` is the -/// RMS (the metric `TINT_TARGET_MP` minimises), `max` is the largest per-node -/// residual (the quality figure the in-code test bounds at ≤ 1.0). -/// -/// Value-preserving: reads the engine, changes nothing. -#[doc(hidden)] -pub fn tint_target_sweep_repro( - targets: &[f64], - l_min: f64, - l_max: f64, -) -> (Vec, Vec) { - use crate::spaces::oklab::srgb_linear_to_oklab; - use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex}; - // Mirrors the `#[cfg(test)]` REFERENCE_NODES (owner reference ramp; pure - // #FFFFFF dropped as achromatic). Bound by the shared metric, not by name. - let nodes: [&str; 12] = [ - "#101012", "#151518", "#212125", "#303136", "#44444B", "#5B5C64", "#787881", "#9698A2", - "#B3B5BF", "#CDD0D9", "#E4E7ED", "#F6F8FA", - ]; - let vc = ViewingConditions::srgb(); - let mut plateau: Vec = Vec::new(); - for hex in nodes { - let Ok(rgb) = srgb_from_hex(hex) else { - continue; - }; - let l = srgb_linear_to_oklab(rgb)[0]; - if l < l_min || l > l_max { - continue; - } - let Ok(node) = crate::lcs::LcsColor::from_hex_with_vc(hex, &vc) else { - continue; - }; - plateau.push((l, node.mp())); - } - let curve_mp = |l: f64, target: f64| -> Option { - let h = cusp_attracted_hue(l, NEUTRAL_HUE_DEG, TINT_HUE_STIFFNESS); - let r = ratio_for_target_mp(l, h, target, &vc); - let rgb = build_curve_color_with_cmax(l, h, r, crate::scale::max_chroma(l, h)); - crate::lcs::LcsColor::from_hex_with_vc(&hex_from_srgb(rgb), &vc) - .ok() - .map(|c| c.mp()) - }; - let mut sweep: Vec = Vec::with_capacity(targets.len()); - for &t in targets { - let mut sumsq = 0.0_f64; - let mut maxabs = 0.0_f64; - let mut n = 0_usize; - for &(l, ref_mp) in &plateau { - if let Some(cm) = curve_mp(l, t) { - let d = (cm - ref_mp).abs(); - sumsq += d * d; - if d > maxabs { - maxabs = d; - } - n += 1; - } - } - let rms = if n > 0 { - (sumsq / n as f64).sqrt() - } else { - f64::NAN - }; - sweep.push((t, rms, maxabs)); - } - (plateau, sweep) -} - /// The default, overridable recipe set mapping every `Role` to a [`RoleSpec`]. /// /// [`default`](RoleTable::default) is the calibrated v1 table; override any @@ -1441,10 +1128,10 @@ impl RoleTable { /// any. /// /// This is the *legal floor* the solver can never drop below for `role` — - /// independent of the perceptual target and of the background. Anchored + /// independent of the Ys candidate-score target and of the background. Anchored /// (text / UI) roles carry their [`TextAnchor`]'s WCAG conformance /// ([`Floor::AaText`] → 4.5, [`Floor::AaUi`] → 3.0); every decorative / - /// JND / zero role has no legal floor and returns `None`. + /// decorative / zero role has no legal floor and returns `None`. /// /// A runtime that eases between resolved themes uses this to *hold the /// floor every frame* during the transition: an intermediate (interpolated) @@ -1469,8 +1156,8 @@ impl RoleTable { /// The default table carries the v2 undertone curve ([`RoleChroma::Curve`]); /// this is the seam that overrides it completely — pass /// [`RoleChroma::Neutral`] for the achromatic pure-grey behaviour, - /// [`RoleChroma::flat_neutral_tint`] for the v1 flat-ratio undertone, or a - /// custom [`RoleChroma::Tinted`] / [`RoleChroma::Curve`] for another policy. + /// an explicit [`RoleChroma::Tinted`] or [`RoleChroma::Curve`] for another + /// test policy. /// The override is total: it replaces the policy for *every* role, including /// dropping the tint to zero. pub fn with_chroma(mut self, chroma: RoleChroma) -> Self { @@ -1773,24 +1460,17 @@ pub enum Resolved { /// when the legal floor squeezed this role's target against its senior's so /// the exact hierarchy target could not hold. The role is either demoted to /// the smallest still-legal step, copied from a still-legal senior, or kept at - /// its own legal colour when hierarchy and floor conflict. No readability - /// floor is traded for ordering. See the module docs. + /// its own legal colour when hierarchy and floor conflict. No normative + /// WCAG floor is traded for ordering. See the module docs. /// /// `achieved_dj` — честный замер |ΔJ'| на отданном hex для dJ'-ролей - /// (симметрия честности с [`GlowResolved::achieved_dj`]); `None` у - /// контраст-ролей (их метрика — Lc, он в [`Solved::lc`]). + /// (как явно названные [`GlowResolved::halo_achieved_dj`] и + /// [`GlowResolved::core_achieved_dj`] для изолированных Glow-слоёв); `None` у + /// score-ролей (их переходная Ys candidate-координата — [`Solved::lc`]). Color { solved: Solved, compressed: bool, achieved_dj: Option, - /// Цветной лейбл (M1 ch5c) фактически ПОТЕРЯЛ цвет: на решённой - /// уровнем-контрактом светлоте красочность `M'` цвета упала ниже порога - /// воспринимаемости тинта (`TINT_PERCEPTIBLE_MP_FLOOR`) — у краёв - /// LCS-кривой семьи (почти-белый / почти-чёрный) хрома физически → 0. - /// Честный флаг, НЕ молчаливая деградация к серому/белому: потребитель - /// читает флаг и знает, что оттенок семьи здесь неразличим. `false` у - /// нейтральных лейблов и у цветных, сохранивших различимый цвет. - hue_vanished: bool, }, /// Полупрозрачная роль лестницы/альфа-аналога: `rgba(tint, α)`, которую /// потребитель красит НАПРЯМУЮ (закон лестницы labui — композитит браузер). @@ -1868,7 +1548,7 @@ pub struct TranslucentResolved { alpha: f64, /// Солид-композит `rgba(tint, α)` над фоном резолва, `#RRGGBB`. composite_hex: String, - /// Знаковый перцептивный контраст `Lc` композита против фона резолва. + /// Знаковая кандидатная оценка `Lc` по `Ys` для композита и фона резолва. composite_lc: f64, /// WCAG 2.1 контраст-отношение композита против фона резолва (1–21). composite_wcag: f64, @@ -1895,9 +1575,9 @@ pub struct TranslucentResolved { /// Солидная семейная граница (`border--strong`, M2 ch5c) была /// притемнена по кривой семьи до юр. пола UI (3:1), потому что тинт-якорь /// семьи не держал 3:1 на этом фоне. Честный флаг минимального легального - /// сдвига — оттенок/насыщенность семьи сохранены, изменилась лишь светлота. - /// `false` у прямой лестницы и когда семейный солид уже легален (Figma-тинт - /// эмитирован без сдвига). + /// сдвига по объявленной кривой семьи. Флаг не утверждает перцептивную + /// сохранность hue/chroma: истиной представления остаются финальные байты. + /// `false` у прямой лестницы и когда семейный солид уже легален. floor_coerced: bool, } @@ -1917,7 +1597,7 @@ impl TranslucentResolved { &self.composite_hex } - /// Знаковый `Lc` композита против фона резолва (метрика фазы 1 AA). + /// Знаковая кандидатная оценка `Lc` по `Ys` композита против фона резолва. pub fn composite_lc(&self) -> f64 { self.composite_lc } @@ -2067,18 +1747,6 @@ impl GlowResolved { pub fn core_composite_certificate(&self) -> &crate::glow::GlowCompositeCertificateV1 { &self.core_composite_certificate } - /// Алиас совместимости: прежнее поле измеряло именно halo. - pub fn achieved_dj(&self) -> f64 { - self.halo_achieved_dj - } - /// Алиас совместимости: `true` для точного и legacy-исходов недостижимости. - pub fn degraded(&self) -> bool { - matches!( - self.target_status, - crate::glow::GlowTargetStatus::ExactNoopUnreachable - | crate::glow::GlowTargetStatus::LegacyUnreachable - ) - } } /// Стабильный терминальный исход Glow при отсутствии sound-границы target/max. @@ -2118,8 +1786,8 @@ impl GlowIndeterminateResolved { } } -/// Резолв двухслойного материала: тон `T` (семейно-оттеночный опаковый цвет на -/// целевой светлоте тира) + ВЫВЕДЕННАЯ альфа тинта. +/// Резолв двухслойного материала: тон `T` на целевой светлоте тира + +/// ВЫВЕДЕННАЯ альфа тинта. /// /// Потребитель красит `--lab-bg-material--01: oklch( / α)` /// (полупрозрачный слой стекла) и `-02: ` (опаковая база под солид-каноном). @@ -2132,7 +1800,7 @@ impl GlowIndeterminateResolved { /// повторно проверенный верхний кандидат, при котором коммит-полюс поверхности /// ([`pole`](Self::pole)) держит [`floor`](Self::floor) по всему коридору /// `[чёрный, белый]` ([`crate::material`]). [`worst_contrast`](Self::worst_contrast) -/// и [`guaranteed`](Self::guaranteed) пересчитываемы потребителем из эмитированных +/// и [`alpha_status`](Self::alpha_status) пересчитываемы потребителем из эмитированных /// `01`/`02`: ядро и официальный `packages/colors/effective-bg.js::compositeOver` /// используют один byte-scale affine order `B + α·(T−B)`. #[derive(Debug, Clone, PartialEq)] @@ -2146,7 +1814,6 @@ pub struct MaterialResolved { pole: crate::material::Pole, achieved_dj: f64, tone_compressed: bool, - hue_vanished: bool, distinct: bool, } @@ -2197,11 +1864,6 @@ impl MaterialResolved { self.floor } - /// Предикат совместимости поверх [`Self::alpha_status`]. - pub fn guaranteed(&self) -> bool { - self.alpha_status == crate::material::MaterialAlphaStatusV1::Satisfied - } - /// Коммит-полюс поверхности: полюс максимального контраста на тоне (белый на /// тёмном, чёрный на светлом). pub fn pole(&self) -> crate::material::Pole { @@ -2221,13 +1883,6 @@ impl MaterialResolved { self.tone_compressed } - /// Оттенок семьи физически выродился у края гамута (near-white/near-black): - /// красочность тона ниже порога воспринимаемости. Честный флаг — не - /// молчаливая деградация к серому. `false` у нейтрали и различимых оттенков. - pub fn hue_vanished(&self) -> bool { - self.hue_vanished - } - /// Солид-канон (тон) отличим от фона резолва на 8-битной сетке дисплея. `false` /// — тон ≈ фон, поверхность является пиксельным no-op на этом фоне. pub fn distinct(&self) -> bool { @@ -2243,7 +1898,6 @@ impl Resolved { solved, compressed: false, achieved_dj: Option::None, - hue_vanished: false, } } @@ -2255,20 +1909,6 @@ impl Resolved { } } - /// Цветной лейбл потерял различимый цвет на решённой светлоте (M1 ch5c): - /// `M'` цвета ниже `TINT_PERCEPTIBLE_MP_FLOOR`. `false` для нейтральных и - /// сохранивших цвет ролей, для zero и unreachable. Честный сигнал вырождения - /// оттенка — не молчаливая деградация. - pub fn hue_vanished(&self) -> bool { - matches!( - self, - Resolved::Color { - hue_vanished: true, - .. - } - ) - } - /// Whether this role produced an explicitly non-exact outcome: /// /// - contrast roles: the legal floor prevented the exact hierarchy target; @@ -2289,8 +1929,8 @@ impl Resolved { ) } - /// The signed perceptual contrast `Lc` of a resolved colour, if any. The - /// zero token reports `0.0`; an unreachable role reports `None`; a + /// The signed Ys candidate score `Lc` of a resolved colour, if any. This is not + /// an LPC/readability verdict. The zero token reports `0.0`; an unreachable role reports `None`; a /// [`Translucent`](Resolved::Translucent) role reports its **composite's** `Lc` (a /// semi-transparent role's contrast is that of its composite, not its tint). pub fn lc(&self) -> Option { @@ -2496,9 +2136,9 @@ fn resolve_spec_in( let contract = match *spec { RoleSpec::Zero => return Ok(Resolved::None), RoleSpec::Anchor(anchor) => { - // Цветной лейбл (M1 ch5c): тот же контракт уровня, решённый в чистом - // оттенке семьи. Нейтральный (`hue == None`) идёт прежним путём - // байт-в-байт. + // Явный source-slot держит тот же контракт уровня; exact-gray остаётся + // нейтральным, остальные байты несут направление. `None` берёт policy + // таблицы. if let Some(hue_tint) = anchor.hue() { return resolve_hued_anchor(bg, anchor, hue_tint, vc, ctx); } @@ -2516,7 +2156,6 @@ fn resolve_spec_in( solved: d.solved, compressed: d.degraded, achieved_dj: Some(d.achieved_dj), - hue_vanished: false, } }); } @@ -2681,52 +2320,43 @@ fn resolve_spec_in( return resolve_rgba_inverted(of.for_vc(vc), alpha, bg, vc); } RoleSpec::Material { hue, tone, floor } => { - // Материал (whitepaper §3.7): тон-база — семейно-оттеночная опаковая поверхность на - // целевом |ΔJ'| тира (dj-anchor-солвером), тинт — тот же тон с - // ВЫВЕДЕННОЙ альфой. Нейтральный материал (`hue == None`) держит подтон - // ТАБЛИЦЫ (тот же 286°, что остальные нейтральные эмиссии); семейный - // подставляет оттенок якоря в кривую подтона. + // Материал (whitepaper, «Точечные композиции»): тон-база — опаковая + // поверхность на целевом |ΔJ'| тира, тинт — тот же тон с выведенной + // альфой. `None` берёт policy таблицы; явный source классифицируется + // по точным sRGB8-байтам до вывода chroma-плана. let tone_chroma = match hue { None => chroma, Some(hue_tint) => { - let hue_deg = crate::accent::oklab_hue_of( - &crate::spaces::srgb::hex_from_srgb_encoded(hue_tint.for_vc(vc)), - ); - // Оттенок семьи подставляется в НЕСУЩИЙ ОТТЕНОК подтон таблицы, - // красочность (`target_mp`/`hue_stiffness` / `ratio`) — от неё же. - match chroma { - RoleChroma::Curve { - target_mp, - hue_stiffness, - .. - } => RoleChroma::Curve { - canonical_hue_deg: hue_deg, - target_mp, - hue_stiffness, - }, - RoleChroma::Tinted { ratio, .. } => RoleChroma::Tinted { hue_deg, ratio }, - // Ахроматичный (или будущий) подтон не несёт оттенка — - // семейный материал на нём невыразим. Честный отказ, НЕ тихая - // подмена нейтральным тоном при флаге `family_hued=true`. - RoleChroma::Neutral => { - return Err(SolveFailure::InvalidInput( - "семейный материал требует хроматического подтона \ - таблицы (curve/tinted), у таблицы — ахроматический" - .to_string(), - )); + match crate::spaces::oklab::hue_of_srgb8(hue_tint.srgb8_for_vc(vc)) { + crate::spaces::oklab::OklabHue::Achromatic => RoleChroma::Neutral, + crate::spaces::oklab::OklabHue::Chromatic { degrees: hue_deg } => { + // Оттенок источника подставляется в несущий оттенок + // подтона таблицы; его colorfulness-policy остаётся + // общей для всех клиентских ID. + match chroma { + RoleChroma::Curve { + target_mp, + hue_stiffness, + .. + } => RoleChroma::Curve { + canonical_hue_deg: hue_deg, + target_mp, + hue_stiffness, + }, + RoleChroma::Tinted { ratio, .. } => { + RoleChroma::Tinted { hue_deg, ratio } + } + RoleChroma::Neutral => { + return Err(SolveFailure::InvalidInput( + RoleSpec::INCOMPATIBLE_CHROMA_REASON.to_owned(), + )); + } + } } } } }; - return resolve_material( - bg, - tone.for_vc(vc), - floor, - ctx.polarity, - tone_chroma, - hue.is_some(), - vc, - ); + return resolve_material(bg, tone.for_vc(vc), floor, ctx.polarity, tone_chroma, vc); } }; @@ -2755,6 +2385,27 @@ fn encoded_rgb_valid(encoded: [f64; 3]) -> bool { .all(|channel| channel.is_finite() && (0.0..=1.0).contains(channel)) } +#[derive(Debug, Clone, Copy)] +struct SourceHuePlan { + hue: Hue, + chroma: ChromaPolicy, +} + +/// Build the maximal-chroma plan only after exact sRGB8 hue classification. +/// Equal channel bytes remain neutral; Oklab matrix noise is never amplified. +fn source_hue_plan(source: Srgb8) -> SourceHuePlan { + match crate::spaces::oklab::hue_of_srgb8(source) { + crate::spaces::oklab::OklabHue::Achromatic => SourceHuePlan { + hue: Hue::deg(0.0), + chroma: ChromaPolicy::Neutral, + }, + crate::spaces::oklab::OklabHue::Chromatic { degrees } => SourceHuePlan { + hue: Hue::deg(degrees), + chroma: ChromaPolicy::Relative(1.0), + }, + } +} + fn role_alpha_valid(alpha: f64) -> bool { alpha.is_finite() && alpha > 0.0 && alpha <= 1.0 } @@ -2808,28 +2459,23 @@ fn resolve_rgba_direct( finish_rgba(tint_q, alpha, bg_encoded, vc, false, false) } -/// Резолв ЦВЕТНОГО текст/UI-лейбла (ратификация ch5c, M1). +/// Резолв text/UI-якоря с явным источником цветовой идентичности. /// -/// Цветной лейбл держит ТОТ ЖЕ Lc-контракт уровня, что нейтральный +/// Якорь держит тот же Lc-контракт уровня, что путь без явного source /// ([`ResolveContext::anchored_contract`] — доля·max ахроматической полярности + /// тот же WCAG-пол): одноуровневость поперёк характеров ПО ПОСТРОЕНИЮ, потому что /// цель — абсолютный Lc нейтральной ступени, а НЕ доля от максимума-в-оттенке /// (последнее снова оказалось бы слабее нейтрали). Отличие от нейтрального пути — -/// физика цвета: +/// физика источника: /// -/// * оттенок = ИДЕНТИЧНОСТЬ семьи (Oklab-угол пер-темного тинта-якоря; Figma- -/// оттенок сохранён, светлота выводится); -/// * лексикографический порядок «красоты» — (1) контракт читаемости фиксирует -/// светлоту на LCS-кривой семьи, (2) на ней берётся МАКСИМУМ чистого цвета -/// (стена гамута, [`ChromaPolicy::Relative`]`(1.0)`): каждая точка кривой -/// красива, хрома выведена, не оптимизируется отдельной метрикой. +/// * равные эмитируемые sRGB8-каналы не несут hue и используют neutral plan; +/// * неравные каналы задают Oklab-угол, а [`ChromaPolicy::Relative`]`(1.0)` +/// выбирает физический максимум доступной хромы на решённой светлоте. /// -/// Честные исходы (не молчаливая деградация): -/// * `compressed` — юр. пол уровня перекрыл перцептивную цель -/// ([`Solved::floor_override`]): контракт занят ближайшим легальным, не точным; -/// * `hue_vanished` — на решённой светлоте красочность `M'` цвета упала ниже -/// `TINT_PERCEPTIBLE_MP_FLOOR`: у краёв кривой (почти-белый/чёрный) хрома -/// физически → 0, лейбл фактически потерял цвет — объявлено флагом. +/// `compressed` сообщает, что юр. пол уровня перекрыл перцептивную цель +/// ([`Solved::floor_override`]): контракт занят ближайшим легальным, не точным. +/// Сохранность воспринимаемой hue-идентичности из одних выходных байтов не +/// выводится; финальный hex остаётся единственной истиной представления. fn resolve_hued_anchor( bg: &BgInput, anchor: TextAnchor, @@ -2837,10 +2483,10 @@ fn resolve_hued_anchor( vc: &ViewingConditions, ctx: &ResolveContext, ) -> PendingResolution { - resolve_hued_anchor_from_encoded_source(bg, anchor, hue_tint.for_vc(vc), vc, ctx) + resolve_hued_anchor_from_srgb8(bg, anchor, hue_tint.srgb8_for_vc(vc), vc, ctx) } -/// Тот же цветной резолв, но источник оттенка — уже выбранный (по теме) +/// Тот же резолв, но источник идентичности уже выбран по теме как /// кодированный стимул, а не [`LadderTint`]-пейлоад. /// /// Отдельный вход нужен appearance-графу (#307): foreground occurrence несёт @@ -2850,34 +2496,24 @@ fn resolve_hued_anchor( /// Для квантованного источника оба пути дают один hex по построению /// ([`crate::spaces::srgb::hex_from_srgb_encoded`] округляет так же, как /// квантизация эмиссии), что закреплено differential-тестами миграции. -fn resolve_hued_anchor_from_encoded_source( +fn resolve_hued_anchor_from_srgb8( bg: &BgInput, anchor: TextAnchor, - source_encoded: [f64; 3], + source: Srgb8, vc: &ViewingConditions, ctx: &ResolveContext, ) -> PendingResolution { let contract = ctx.anchored_contract(anchor)?; let interval = *ctx.interval.as_ref().map_err(Clone::clone)?; - let hue_deg = - crate::accent::oklab_hue_of(&crate::spaces::srgb::hex_from_srgb_encoded(source_encoded)); - match solve::solve_in( - bg, - contract, - Hue::deg(hue_deg), - ChromaPolicy::Relative(1.0), - vc, - interval, - ) { + let plan = source_hue_plan(source); + match solve::solve_in(bg, contract, plan.hue, plan.chroma, vc, interval) { Ok(solved) => { - let hue_vanished = solved.color().mp() < TINT_PERCEPTIBLE_MP_FLOOR; // Тот же смысл, что у нейтрали: пол перекрыл перцептивную цель. let compressed = solved.floor_override(); Ok(Resolved::Color { solved, compressed, achieved_dj: Option::None, - hue_vanished, }) } Err(reason) => Err(reason), @@ -2888,11 +2524,11 @@ fn resolve_hued_anchor_from_encoded_source( /// (ратификация ch5c, M2). /// /// Солид семьи (α=1) обязан держать 3:1 (WCAG 1.4.11 для границ контролов). Если -/// композит тинта уже чист (≥ пола) — эмитится ТОЧНЫЙ семейный солид (Figma- -/// идентичность цела, диффа эмиссии нет). Иначе — МИНИМАЛЬНЫЙ ЛЕГАЛЬНЫЙ СДВИГ по -/// кривой семьи: контракт целит естественный Lc тинта, а юр. пол притемняет цвет -/// РОВНО до легальности; оттенок и насыщенность семьи сохранены (доля хромы -/// якоря на решённой светлоте). Сдвиг объявлен флагом +/// композит тинта уже держит пол — эмитится точный исходный солид. Иначе — +/// минимальный легальный сдвиг по объявленной кривой семьи: контракт целит +/// естественный Lc тинта, а пол меняет lightness-кандидат ровно до легальности. +/// Гамут и квантование относятся к финальным байтам; перцептивная идентичность +/// отсюда не выводится. Сдвиг объявлен флагом /// [`TranslucentResolved::floor_coerced`] — не молчаливая деградация. Эмиссия /// остаётся полупрозрачной формой (`rgba`, α=1), как у любой семейной границы, /// чтобы форма роли не расходилась между легальными и притемнёнными характерами. @@ -2925,8 +2561,6 @@ fn resolve_solid_with_ui_floor( } // Нелегально: минимальный сдвиг по кривой семьи до пола. let interval = *ctx.interval.as_ref().map_err(Clone::clone)?; - let tint_hex = crate::spaces::srgb::hex_from_srgb_encoded(tint_q); - let hue_deg = crate::accent::oklab_hue_of(&tint_hex); // Естественный знаковый Lc якоря и доля его хромы на собственной светлоте — // чтобы сдвиг сохранил насыщенность семьи, а не выехал на стену гамута. let tint_linear = [ @@ -2940,25 +2574,31 @@ fn resolve_solid_with_ui_floor( crate::spaces::srgb::srgb_gamma_inv(bg_encoded[2]), ]; let (anchor_lc, _) = measure_contrast(bg_linear, tint_linear, vc); - let lab = crate::spaces::oklab::srgb_linear_to_oklab(tint_linear); - let anchor_chroma = (lab[1] * lab[1] + lab[2] * lab[2]).sqrt(); - let c_max = scale::max_chroma(lab[0], hue_deg); - let chroma_ratio = if c_max > f64::EPSILON { - (anchor_chroma / c_max).clamp(0.0, 1.0) - } else { - 0.0 + let source = crate::alpha::encoded_to_srgb8(tint_q, "solid tint") + .map(Srgb8::new) + .map_err(|reason| { + SolveFailure::InternalInvariant(format!( + "validated solid hue source could not be quantised: {reason}" + )) + })?; + let (hue, chroma_policy) = match crate::spaces::oklab::hue_of_srgb8(source) { + crate::spaces::oklab::OklabHue::Achromatic => (Hue::deg(0.0), ChromaPolicy::Neutral), + crate::spaces::oklab::OklabHue::Chromatic { degrees } => { + let lab = crate::spaces::oklab::srgb_linear_to_oklab(tint_linear); + let anchor_chroma = lab[1].hypot(lab[2]); + let c_max = scale::max_chroma(lab[0], degrees); + let chroma_ratio = if c_max > f64::EPSILON { + (anchor_chroma / c_max).clamp(0.0, 1.0) + } else { + 0.0 + }; + (Hue::deg(degrees), ChromaPolicy::Relative(chroma_ratio)) + } }; // Контракт целит естественный Lc; пол уровня (`floor`) притемняет ровно до // легальности — минимальный сдвиг по построению solve. let contract = solve::Contract::text(anchor_lc).with_conformance(floor); - match solve::solve_in( - bg, - contract, - Hue::deg(hue_deg), - ChromaPolicy::Relative(chroma_ratio), - vc, - interval, - ) { + match solve::solve_in(bg, contract, hue, chroma_policy, vc, interval) { Ok(solved) => match crate::spaces::srgb::srgb_encoded_from_hex(solved.hex()) { Ok(shifted) => finish_rgba(shifted, 1.0, bg_encoded, vc, false, true), Err(reason) => Err(SolveFailure::InternalInvariant(format!( @@ -3051,7 +2691,7 @@ fn pair_label_opacity_input_error(error: &str) -> PendingResolution { /// отдельно сдвинутая солид-эмиссия; никакого ребра `PairFill → PairLabel` нет. /// /// Оттеночный foreground решается текущим -/// [`resolve_hued_anchor_from_encoded_source`] НА ЭТОЙ ПОВЕРХНОСТИ. Appearance- +/// [`resolve_hued_anchor_from_srgb8`] НА ЭТОЙ ПОВЕРХНОСТИ. Appearance- /// граф не присваивает этому последующему решению доказательный статус: он /// возвращает только source/against/backdrop. Дифференциальный тест закрепляет /// подключение и результаты миграции на проверяемом домене, но оба пути @@ -3154,8 +2794,13 @@ pub(crate) fn resolve_pair_label( // Identity-ребро occurrence: foreground решается из ВОЗВРАЩЁННОГО // источника (byte → byte/255 точно), а не повторного чтения `tint` — // иначе объявленное ребро идентичности было бы декоративным. - let source_encoded = occurrence.source.map(|channel| f64::from(channel) / 255.0); - resolve_hued_anchor_from_encoded_source(&surface_bg, anchor, source_encoded, vc, &surface_ctx) + resolve_hued_anchor_from_srgb8( + &surface_bg, + anchor, + Srgb8::new(occurrence.source), + vc, + &surface_ctx, + ) } /// Замороженная ручная реализация `resolve_pair_label` ДО миграции #307 — @@ -3234,7 +2879,7 @@ fn resolve_rgba_inverted( } }; let (tint_srgb8, actual_alpha) = analog; - let tint_q = tint_srgb8.map(|channel| f64::from(channel) / 255.0); + let tint_q = Srgb8::new(tint_srgb8).encoded(); // Резолвер возвращает тот же binary64 либо строго больший точный пол. let alpha_coerced = actual_alpha > requested_alpha; finish_rgba(tint_q, actual_alpha, bg_encoded, vc, alpha_coerced, false) @@ -3243,8 +2888,8 @@ fn resolve_rgba_inverted( /// Собрать [`Resolved::Translucent`] из эмитируемых тинта и альфы: вывести их /// encoded-sRGB8 reference-композит и замерить его против фона резолва. /// -/// Контраст меряется в тех же метриках, что и у солид-роли: перцептивный `Lc` -/// на линейном свете ([`measure_contrast`]) и WCAG на кодированном дисплее — так +/// Числа вычисляются в тех же координатах, что и у solid-роли: Ys candidate +/// score ([`measure_contrast`]) и WCAG на кодированном дисплее — так /// полупрозрачная роль сопоставима с solved-ролью на фазе 1 AA. fn finish_rgba( tint_encoded: [f64; 3], @@ -3275,7 +2920,7 @@ fn finish_rgba( ))); } }; - // Линейный свет из кодированного (per-channel gamma-декод) для перцептивного Lc. + // Линейный свет из кодированного (per-channel gamma-декод) для Ys candidate score. let decode = |e: [f64; 3]| { [ srgb_gamma_inv(e[0]), @@ -3303,22 +2948,20 @@ fn finish_rgba( })) } -/// Резолв двухслойного материала (whitepaper §3.7): тон-база `02` на целевом |ΔJ'| в оттенке -/// семьи + тинт `01` (тот же тон) с ВЫВЕДЕННОЙ альфой. +/// Резолв двухслойного материала (whitepaper, «Точечные композиции»): тон-база +/// `02` на целевом |ΔJ'| + тинт `01` (тот же тон) с ВЫВЕДЕННОЙ альфой. /// /// Тон строится тем же dj-anchor-солвером, что декоративные |ΔJ'|-роли /// ([`resolve_dj`]), поэтому различимость поверхности от фона наследуется его /// физикой. Альфа тинта выбирается [`crate::material::solve_material_alpha_hex`] /// как проходящий верхний кандидат, при котором композит тона над худшим фоном -/// коридора `[чёрный, белый]` держит пол. `family_hued` — оттенок -/// семьи присутствует (флаг вырождения оттенка применим); у нейтрали `false`. +/// коридора `[чёрный, белый]` держит пол. fn resolve_material( bg: &BgInput, tone_dj: f64, floor: Floor, polarity: Polarity, chroma: RoleChroma, - family_hued: bool, vc: &ViewingConditions, ) -> PendingResolution { use crate::spaces::srgb::hex_from_srgb_encoded; @@ -3332,12 +2975,9 @@ fn resolve_material( )); } }; - // Тон-база 02: семейно-оттеночная опаковая поверхность на целевом |ΔJ'|. + // Тон-база 02: опаковая поверхность на целевом |ΔJ'|. let dj = resolve_dj(bg, tone_dj, polarity, chroma, vc)?; let tone_hex = dj.solved.hex().to_string(); - // Вырождение оттенка семьи у края гамута — только у семейных материалов; - // нейтраль ахроматична намеренно, не «выродилась». - let hue_vanished = family_hued && dj.solved.color().mp() < TINT_PERCEPTIBLE_MP_FLOOR; // α: повторно проверенный проходящий верхний кандидат над коридором // [чёрный, белый]. let m = match crate::material::solve_material_alpha_hex(&tone_hex, floor_ratio) { @@ -3362,7 +3002,6 @@ fn resolve_material( pole: m.pole(), achieved_dj: dj.achieved_dj, tone_compressed: dj.degraded, - hue_vanished, distinct, })) } @@ -3500,7 +3139,7 @@ pub fn resolve_set( vc: &ViewingConditions, ) -> Vec<(Role, Resolved)> { // The former O(1) grey (`greyfast`) and chromatic-memo (`chromafast`) fast - // paths were deleted with ADR-0001 PR-c: they only ever accelerated this + // paths were deleted under ADR-0001: they only ever accelerated this // built-in `resolve_set`, which is no longer on any production path (the // agnostic engine ships only the string-keyed `resolve_named_set`). A cold // named grey resolve was measured at ~1.7 ms (resolve-only) / ~3.1 ms @@ -3546,16 +3185,8 @@ pub(crate) fn resolve_set_live( /// each entry is the same [`RoleSpec`] the built-in path solves, and the same /// [`RoleChroma`] undertone applies to the whole table. /// -/// Text ladders are compressed honestly by `enforce_named_text_hierarchy`, the -/// string-keyed analogue of `enforce_text_hierarchy`: a ladder is read off the -/// config (a declaration-order run of strictly-descending [`Anchor`](RoleSpec::Anchor) -/// roles), not off role names, so an arbitrary consumer table degrades a squeezed -/// mid-grey exactly as the built-in table does instead of silently collapsing two -/// labels onto one colour. A junior floor always remains hard; when it conflicts -/// with the requested order, the legal junior survives with `compressed = true`. -/// The pass is a no-op wherever every rung is individually reachable — which is -/// why the labui fixture stays byte-identical on the golden grid (see the -/// byte-identity test). +/// Entries are independent opaque nodes. Declaration order never implies a +/// hierarchy or dependency; relations must arrive as explicit typed graph edges. #[derive(Debug, Clone, PartialEq)] pub struct NamedRoleTable { entries: Vec<(String, RoleSpec)>, @@ -3642,22 +3273,40 @@ impl RoleSpec { } } - /// Проверить меж-полевой контракт рецепта и общетабличной политики хромы. - /// Family-hued материал требует политику, способную нести hue: принять его - /// под ахроматической политикой значило бы отложить невалидный вход до - /// runtime-резолва. - fn validate_with_chroma(self, chroma: RoleChroma) -> Result<(), String> { + /// Каноническая причина единственного конфликта рецепта с chroma-policy. + pub(crate) const INCOMPATIBLE_CHROMA_REASON: &'static str = + "chromatic source needs curve/tinted policy"; + + /// Проверить и собственный домен рецепта, и его связь с chroma-policy. + /// + /// Публичный конструктор таблицы принимает сырые `RoleSpec`, поэтому обязан + /// выполнить оба слоя проверки до появления исполняемого состояния. + pub(crate) fn validate_with_chroma(self, chroma: RoleChroma) -> Result<(), String> { self.validate_domain()?; - if matches!(self, RoleSpec::Material { hue: Some(_), .. }) - && matches!(chroma, RoleChroma::Neutral) - { - return Err( - "family-hued material requires a chromatic table policy (curve/tinted)".into(), - ); + if !self.is_chroma_compatible(chroma) { + return Err(Self::INCOMPATIBLE_CHROMA_REASON.to_owned()); } Ok(()) } + /// Проверить только меж-полевой контракт после отдельной domain-validation. + /// + /// Хроматический material-источник требует политики, способной сохранить + /// направление; ахроматический источник допустим и при neutral-policy. + /// Две validation-границы используют один предикат. Явный inline удерживает + /// его без отдельного тела в канонической WASM-сборке; backend-дрифт ловит + /// исполняемый size-ратчет, а не это пояснение. + #[inline(always)] + pub(crate) fn is_chroma_compatible(&self, chroma: RoleChroma) -> bool { + let material_has_chromatic_source = match self { + RoleSpec::Material { + hue: Some(source), .. + } => !source.all_modes_achromatic(), + _ => false, + }; + !matches!(chroma, RoleChroma::Neutral) || !material_has_chromatic_source + } + /// WCAG-пол этой спеки — свойство контракта, не резолва: текст/UI-якорь /// несёт пол своего [`TextAnchor`] (AaText → 4.5, AaUi → 3.0), все /// остальные формы (декоративные, dJ', лестница, альфа-аналог, zero) — @@ -3779,9 +3428,8 @@ impl NamedRoleTable { /// `Ok` сохраняет порядок объявления и может нести только допущенные /// локальные `unreachable | unresolved`. Rejected-вход, неподдержанная capability /// or internal drift returns [`ResolveSetError`] for the entire call; no partial -/// вектор не наблюдаем. Объявленные текстовые лестницы сжимаются на месте -/// только после того, как их индивидуальные результаты прошли ту же границу -/// допуска. +/// вектор не наблюдаем. В отсутствие объявленных graph edges роли не влияют на +/// физический результат соседних деклараций. pub fn resolve_named_set( bg: &BgInput, table: &NamedRoleTable, @@ -3797,16 +3445,11 @@ pub fn resolve_named_set( let resolved = admit_resolution(resolve_spec_in(bg, spec, table.chroma, vc, &ctx))?; set.push((name.clone(), resolved)); } - // Keep every declared text ladder non-strict-but-honest, the string-keyed - // analogue of the built-in path's hierarchy pass (see - // [`enforce_named_text_hierarchy`]). A no-op wherever each ladder is already - // individually reachable (the labui fixture on the golden grid). - enforce_named_text_hierarchy(&mut set, table, bg, vc, &ctx)?; Ok(set) } -/// Measure the perceptual contrast (`Lc`) and WCAG 2.1 ratio a foreground colour -/// achieves against a background — the cheap **recheck** primitive. +/// Measure the frozen candidate `Lc` score and WCAG 2.1 ratio a foreground +/// colour achieves against a background — the cheap **recheck** primitive. /// /// Both colours are **linear** sRGB; the result is `(lc, wcag_ratio)`. С /// активации ADR-0003 (глава #64) замер полностью display-доменный — ни @@ -3825,8 +3468,8 @@ pub fn measure_contrast( fg_linear: [f64; 3], _vc: &ViewingConditions, ) -> (f64, f64) { - // Обе метрики — перцептивный `Lc` и легальный WCAG — читают ОДНУ люминансу - // квантованного display-цвета (ось читаемости в `Ys`, ADR-0003), exactly as + // Candidate `Lc` и легальный WCAG читают ОДНУ люминансу + // квантованного display-цвета (candidate score в `Ys`, ADR-0003), exactly as // the solver measures it (`finish` → `quantised_display`), so the recheck // reproduces the solver's reported `lc`/`wcag_ratio` bit-for-bit. let fg_disp = crate::solve::quantised_display(fg_linear); @@ -3854,7 +3497,7 @@ pub fn measure_contrast( /// a freshly-resolved set re-checks to its own reported contrasts. Returns `Err` /// if any hex is invalid (only `#RRGGBB` or bare `RRGGBB` is accepted). /// One colour's recheck ingredient from its hex: the WCAG relative luminance -/// `rl` of its display bytes — с активации ADR-0003 перцептивный `Lc` и +/// `rl` of its display bytes — с активации ADR-0003 candidate `Lc` и /// легальный WCAG читают ОДНУ и ту же люминансу, бывшая пара `(y_hk, rl)` /// схлопнулась в один скаляр, а recheck стал VC-независимым (display-домен). /// @@ -3896,7 +3539,7 @@ pub fn recheck_against( /// rechecks the SAME foreground set against N backdrop samples (a gradient / /// image); each foreground's `rl_fg` is computed ONCE and reused for every /// sample — с активации ADR-0003 форвард подешевел до одной -/// `relative_luminance` display-байтов (CAM16 ушёл с оси читаемости), но +/// `relative_luminance` display-байтов (CAM16 не входит в score), но /// хойстинг сохранён: он несёт контракт byte-identity двух входов, не только /// экономию. /// @@ -3935,35 +3578,33 @@ pub fn recheck_against_multi( /// Copying the senior is valid only when that exact emitted colour also clears /// the junior's floor. Otherwise the junior's already-legal solve is retained and /// merely marked non-exact: hierarchy is a soft relation, readability is not. -fn hierarchy_fallback( - senior: Option<(Solved, bool)>, - junior: &Resolved, - junior_floor: Floor, -) -> Resolved { - let Some((senior_solved, senior_hue_vanished)) = senior else { - return junior.clone(); - }; - let senior_is_legal = junior_floor - .min_ratio() - .is_none_or(|minimum| senior_solved.wcag_ratio() >= minimum); - - match junior { +#[cfg(test)] +fn hierarchy_fallback(senior: Option, junior: &Resolved, junior_floor: Floor) -> Resolved { + let retain_junior = || match junior { Resolved::Color { solved, achieved_dj, - hue_vanished, .. - } if !senior_is_legal => Resolved::Color { + } => Resolved::Color { solved: solved.clone(), compressed: true, achieved_dj: *achieved_dj, - hue_vanished: *hue_vanished, }, + other => other.clone(), + }; + let Some(senior_solved) = senior else { + return retain_junior(); + }; + let senior_is_legal = junior_floor + .min_ratio() + .is_none_or(|minimum| senior_solved.wcag_ratio() >= minimum); + + match junior { + Resolved::Color { .. } if !senior_is_legal => retain_junior(), Resolved::Color { .. } => Resolved::Color { solved: senior_solved, compressed: true, achieved_dj: Option::None, - hue_vanished: senior_hue_vanished, }, other => other.clone(), } @@ -4028,157 +3669,19 @@ fn enforce_text_hierarchy( solved, compressed: true, achieved_dj: Option::None, - hue_vanished: false, }, // No ordered step: copy a legal senior or preserve the legal junior. - (Ok(None), senior, junior) => { - hierarchy_fallback(senior.map(|solved| (solved, false)), junior, floor) - } + (Ok(None), senior, junior) => hierarchy_fallback(senior, junior, floor), (Err(reason), _, _) => admit_resolution(Err(reason))?, }; } Ok(()) } -/// The string-keyed analogue of [`enforce_text_hierarchy`]: keep every declared -/// text ladder in an arbitrary [`NamedRoleTable`] non-strict-but-honest, so the -/// agnostic path degrades a squeezed hierarchy exactly as the built-in one does -/// (V1 found the named path had *no* such pass — a general config on a near-AA -/// mid-grey could silently collapse two labels onto one colour). -/// -/// **Which roles form a ladder is read off the config, not off role names.** A -/// ladder is a maximal run of *consecutive* [`Anchor`](RoleSpec::Anchor) roles, in -/// declaration order, whose fractions strictly descend — the shape a text -/// hierarchy has by construction (`primary > secondary > …`). A non-anchor role or -/// a fraction that does not descend ends the run, so `icon` (a lone anchor whose -/// fraction sits above the label below it) and `border-strong` are singleton runs -/// the pass never touches — matching the built-in `TEXT_HIERARCHY` exactly for the -/// labui fixture. Coloured (hued) ladders demote in their family hue -/// ([`demote_below_hued`]); neutral ladders in the undertone ([`demote_below`]). -/// -/// Помимо demote/copy/retain у прохода есть четвёртый честный исход: если сам -/// демоушен-солв юниора завершился категорией Unresolved (bounded search не -/// взял бюджет), юниор становится [`Resolved::Failure`] — громкий локальный -/// отказ вместо тихого выбора одного из спорных цветов. Unreachable демоушена -/// означает лишь «шаг вниз недоступен» и деградирует в copy/retain, а -/// Rejected/Unsupported от валидированного core-запроса — контрактный дрейф -/// ([`SolveFailure::InternalInvariant`]), закрывающий весь набор. -fn enforce_named_text_hierarchy( - set: &mut [(String, Resolved)], - table: &NamedRoleTable, - bg: &BgInput, - vc: &ViewingConditions, - ctx: &ResolveContext, -) -> Result<(), ResolveSetError> { - let entries = table.entries(); - let chroma = table.chroma(); - - // Group declaration-order anchors into strictly-descending runs (the hierarchy - // shape). Runs of length < 2 have no senior/junior pair and are dropped. - let mut runs: Vec> = Vec::new(); - let mut cur: Vec = Vec::new(); - let mut prev_fraction = f64::INFINITY; - let flush = |cur: &mut Vec, runs: &mut Vec>| { - if cur.len() >= 2 { - runs.push(std::mem::take(cur)); - } else { - cur.clear(); - } - }; - for (i, (_, spec)) in entries.iter().enumerate() { - match spec { - RoleSpec::Anchor(a) if a.fraction() < prev_fraction => { - cur.push(i); - prev_fraction = a.fraction(); - } - RoleSpec::Anchor(a) => { - // An anchor that does not descend starts a fresh ladder at itself. - flush(&mut cur, &mut runs); - cur.push(i); - prev_fraction = a.fraction(); - } - _ => { - flush(&mut cur, &mut runs); - prev_fraction = f64::INFINITY; - } - } - } - flush(&mut cur, &mut runs); - - for run in &runs { - for pair in run.windows(2) { - let (senior_idx, junior_idx) = (pair[0], pair[1]); - let Some(senior_mag) = set[senior_idx].1.solved().map(|s| s.lc().abs()) else { - continue; // senior unreachable — nothing to compress against - }; - let Some(junior_mag) = set[junior_idx].1.solved().map(|s| s.lc().abs()) else { - continue; // junior unreachable — surfaced honestly already - }; - if junior_mag + STRICT_STEP <= senior_mag { - continue; // strictly weaker already — hierarchy holds here - } - - // The junior's own conformance governs how far down it may still be legal. - let RoleSpec::Anchor(anchor) = entries[junior_idx].1 else { - continue; - }; - let floor = anchor.conformance(); - let demoted = match anchor.hue() { - Some(hue_tint) => demote_below_hued(senior_mag, hue_tint, floor, bg, vc, ctx), - None => demote_below(senior_mag, ctx, chroma, floor, bg, vc), - }; - // A hued junior that keeps colour reports `hue_vanished` the same way - // `resolve_hued_anchor` does; a neutral junior never vanishes a hue. - let vanished = |solved: &Solved| { - anchor.hue().is_some() && solved.color().mp() < TINT_PERCEPTIBLE_MP_FLOOR - }; - let senior_solved = set[senior_idx].1.solved().cloned().map(|solved| { - let hue_vanished = vanished(&solved); - (solved, hue_vanished) - }); - set[junior_idx].1 = match (demoted, senior_solved, &set[junior_idx].1) { - // A distinguishable, still-legal step below the senior. - (Ok(Some(solved)), _, _) => { - let hue_vanished = vanished(&solved); - Resolved::Color { - solved, - compressed: true, - achieved_dj: Option::None, - hue_vanished, - } - } - // No ordered step: copy a legal senior or preserve the legal junior. - (Ok(None), senior, junior) => hierarchy_fallback(senior, junior, floor), - (Err(reason), _, _) => admit_resolution(Err(reason))?, - }; - } - } - Ok(()) -} - -/// The smallest separation in `|Lc|` that counts as "strictly weaker". Note: -/// near the extremes a single quantisation step can be worth only ~0.2–0.3 Lc, -/// so a demotion may need several grid steps to clear it — and when even the -/// laxest legal target cannot, the junior is set equal to its senior instead. -/// The 0.5 threshold separates real visual distinction from float noise. -/// -/// Терминал **(e) DESIGN-CHOICE** (НЕ (c)): `STRICT_STEP` — прямое слагаемое -/// цели демоции (`target = senior_mag − STRICT_STEP`), поэтому его точное -/// значение НЕПРЕРЫВНО и напрямую сдвигает эмитируемый цвет junior-роли — -/// доказательства интервал-нечувствительности нет. Лок -/// `strict_step_sits_just_above_typical_grid_step` лишь характеризует, что -/// 0.5 сидит чуть выше типичного (медианного ≈0.44) Lc-шага 8-бит серой -/// сетки — обоснование ГРАНИЦЫ квантования, не доказательство immaterial-сти. -/// -/// Легальный диапазон (Волна 2): `[~0.44, ~7.85)` — снизу типичный (медианный) -/// Lc-шаг кванта серых (ниже него «строго слабее» = float-шум, лок), сверху -/// обрыв loClip мягкого клампа APCA (жёсткий разрыв — единственный шаг такого -/// размера). 0.5 сидит у нижней границы: минимальный шаг, надёжно превышающий -/// шум сетки. Протокол «объективизации»: замерить перцептивный порог -/// различимости соседних Lc-ступеней (JND по контрасту на серой рампе) — тогда -/// `STRICT_STEP = max(этот JND, медианный шаг сетки)`; измерение стало бы -/// кандидатом-выводом (замер → сравнение → решение), не обязательным экспериментом. -// SSOT-TRACKED — minimum Lc separation for visual distinction vs float noise, терминал (e) design-choice (не interval-insensitive — прямо параметризует выход; диапазон [~0.44,~7.85)), см. docs/empirical-inventory.md. +/// Frozen threshold of the test-only built-in `RoleTable` oracle. Production +/// named roles do not infer hierarchy from declaration order; this value carries +/// no product or perceptual claim and disappears with that legacy oracle. +#[cfg(test)] const STRICT_STEP: f64 = 0.5; /// Try to solve a junior text role at the strongest target that is still @@ -4186,6 +3689,7 @@ const STRICT_STEP: f64 = 0.5; /// clears `floor`. Returns the demoted colour, or `None` if even the laxest /// distinguishable target cannot stay legal — in which case the caller keeps the /// floored colour and only flags the compression. +#[cfg(test)] fn demote_below( senior_mag: f64, ctx: &ResolveContext, @@ -4210,40 +3714,9 @@ fn demote_below( ) } -/// Hue-preserving sibling of [`demote_below`] for a **coloured** label (M1): the -/// junior is re-solved just under its senior *in the family hue*, not in the -/// neutral undertone — a neutral demote would strip the family colour the whole -/// point of a hued label is to carry. Mirrors [`resolve_hued_anchor`]'s solve -/// (`Hue::deg(family)`, `ChromaPolicy::Relative(1.0)`) but at the reduced target. -fn demote_below_hued( - senior_mag: f64, - hue_tint: crate::ladder::LadderTint, - floor: Floor, - bg: &BgInput, - vc: &ViewingConditions, - ctx: &ResolveContext, -) -> Result, SolveFailure> { - let target = ctx.polarity.sign() * (senior_mag - STRICT_STEP).max(0.0); - let contract = Contract::text(target).with_conformance(floor); - let interval = *ctx.interval.as_ref().map_err(Clone::clone)?; - let hue_deg = crate::accent::oklab_hue_of(&crate::spaces::srgb::hex_from_srgb_encoded( - hue_tint.for_vc(vc), - )); - demotion_outcome( - solve::solve_in( - bg, - contract, - Hue::deg(hue_deg), - ChromaPolicy::Relative(1.0), - vc, - interval, - ), - senior_mag, - ) -} - /// Interpret one hierarchy-demotion solve without conflating proof, bounded /// uncertainty, invalid generated state, and internal drift. +#[cfg(test)] fn demotion_outcome( outcome: Result, senior_mag: f64, @@ -4617,56 +4090,6 @@ mod tests { assert_eq!(contract.floor(), 0.0, "zero is a valid physical ceiling"); } - #[test] - fn hierarchy_demotion_never_turns_an_internal_failure_into_a_colour() { - let bg = BgInput::solid("#FFFFFF").unwrap(); - let vc = ViewingConditions::srgb(); - let solved = crate::solve::solve( - bg.clone(), - Contract::text(60.0).with_conformance(Floor::None), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &vc, - crate::solve::Gamut::Srgb, - ) - .unwrap(); - let table = NamedRoleTable::new( - vec![ - ( - "senior".into(), - RoleSpec::Anchor(TextAnchor::new(1.0, Floor::AaText).unwrap()), - ), - ( - "junior".into(), - RoleSpec::Anchor(TextAnchor::new(0.5, Floor::AaText).unwrap()), - ), - ], - Vec::new(), - RoleChroma::Neutral, - ) - .unwrap(); - let mut set = vec![ - ("senior".into(), Resolved::color(solved.clone())), - ("junior".into(), Resolved::color(solved)), - ]; - let failure = SolveFailure::InternalInvariant("injected interval drift".into()); - let ctx = ResolveContext { - polarity: Polarity::DarkOnLight, - max_contrast: Ok(100.0), - interval: Err(failure.clone()), - high_contrast: false, - }; - - let before = set.clone(); - let error = enforce_named_text_hierarchy(&mut set, &table, &bg, &vc, &ctx) - .expect_err("internal hierarchy drift must close the whole set"); - - assert_eq!(error.kind(), ResolveSetErrorKind::Internal); - assert_eq!(error.code(), None); - assert_eq!(error.reason(), &failure); - assert_eq!(set, before, "failed hierarchy admission must be atomic"); - } - #[test] fn hierarchy_demotion_distinguishes_proof_from_uncertainty_and_core_drift() { for failure in [ @@ -5502,6 +4925,14 @@ mod tests { hue_deg: f64::NAN, ratio: 0.5, }, + RoleChroma::Tinted { + hue_deg: -f64::EPSILON, + ratio: 0.5, + }, + RoleChroma::Tinted { + hue_deg: 360.0, + ratio: 0.5, + }, RoleChroma::Tinted { hue_deg: 0.0, ratio: -f64::EPSILON, @@ -5515,6 +4946,21 @@ mod tests { target_mp: 1.0, hue_stiffness: 0.0, }, + RoleChroma::Curve { + canonical_hue_deg: -f64::EPSILON, + target_mp: 1.0, + hue_stiffness: 0.0, + }, + RoleChroma::Curve { + canonical_hue_deg: 360.0, + target_mp: 1.0, + hue_stiffness: 0.0, + }, + RoleChroma::Curve { + canonical_hue_deg: 1.0e308, + target_mp: 1.0, + hue_stiffness: 0.0, + }, RoleChroma::Curve { canonical_hue_deg: 0.0, target_mp: 0.0, @@ -5556,11 +5002,20 @@ mod tests { hue_deg: 359.999, ratio: 1.0, }, + RoleChroma::Tinted { + hue_deg: f64::from_bits(360.0_f64.to_bits() - 1), + ratio: 1.0, + }, RoleChroma::Curve { canonical_hue_deg: 0.0, target_mp: f64::MIN_POSITIVE, hue_stiffness: 0.0, }, + RoleChroma::Curve { + canonical_hue_deg: f64::from_bits(360.0_f64.to_bits() - 1), + target_mp: 1.0, + hue_stiffness: 0.0, + }, ] { assert!( resolve_valid(chroma) @@ -5586,10 +5041,8 @@ mod tests { // // TINT_TARGET_MP = 6.1 sits (essentially) at the constant that minimises the // root-mean-square residual of curve M' against the reference's plateau nodes. - // The reproducible sweep (`examples/tint_target_sweep.rs`, real engine) measures - // RMS-argmin t* = 6.01 and, at 6.1, RMS 0.358 / max per-node 0.649 M' on the - // current engine — a broad, flat minimum across 6.0-6.2. (An earlier note read - // "residual ≈ 0.90 M'"; that figure is superseded by this measurement.) + // The in-crate test deliberately owns both fixture and engine path: no + // public reproduction hook may leak these client nodes into Core. let vc = ViewingConditions::srgb(); let mut max_resid = 0.0_f64; for hex in REFERENCE_NODES { @@ -5704,11 +5157,9 @@ mod tests { } #[test] - fn hierarchy_never_trades_a_junior_readability_floor_for_order() { - // A stronger floor on the junior can make the hierarchy and readability - // constraints incompatible on a particular background. Hierarchy is a - // presentation preference; the declared readability floor is a hard - // constraint and must survive the compression pass. + fn declaration_order_does_not_override_an_independent_floor() { + // Adjacent opaque IDs carry no hierarchy relation. Each declared floor is + // solved independently until an explicit graph edge says otherwise. let table = NamedRoleTable::new( vec![ ( @@ -5738,7 +5189,10 @@ mod tests { panic!("junior must remain a typed colour outcome, got {junior:?}"); }; - assert!(*compressed, "the hierarchy conflict must be explicit"); + assert!( + !compressed, + "declaration order must not invent a hierarchy or compression outcome" + ); assert!( solved.wcag_ratio() >= crate::wcag::AA_TEXT_RATIO, "junior floor was lost: {} at {}", @@ -6407,10 +5861,10 @@ mod tests { }; let jp_fg = crate::lcs::LcsColor::from_hex_with_vc(solved.hex(), vc) .unwrap() - .jp; + .jp(); let jp_bg = crate::lcs::LcsColor::from_hex_with_vc(bg_hex, vc) .unwrap() - .jp; + .jp(); (jp_fg - jp_bg).abs() } @@ -6452,9 +5906,8 @@ mod tests { #[test] fn dj_magnitude_selects_per_theme() { - // The same role under the dark-theme VC must hold a larger perceived - // separation than under the light VC — the per-VC anchor selection is real, - // not a constant. (The owner's dark anchors run ~2.2x the light ones.) + // The same role selects its independently authored dark offset under the + // dark VC, proving this is a per-VC pair rather than one shared constant. let table = RoleTable::default(); let light = ViewingConditions::srgb(); let dark = ViewingConditions::dim_surround(); @@ -6775,7 +6228,7 @@ mod tests { let bg = BgInput::solid(&bg_hex).unwrap(); let jp_bg = crate::lcs::LcsColor::from_hex_with_vc(&bg_hex, &vc) .unwrap() - .jp; + .jp(); let set = resolve_set(&bg, &table, &vc); let no_silent_clip = set.iter().all(|(role, r)| match r { // Свечение не участвует в dJ'-клип-инварианте (не контраст-роль). @@ -6784,7 +6237,7 @@ mod tests { if matches!(table.spec(*role), RoleSpec::DecorativeDj { .. }) { let jp_fg = crate::lcs::LcsColor::from_hex_with_vc(solved.hex(), &vc) .unwrap() - .jp; + .jp(); (jp_fg - jp_bg).abs() >= 1.0 } else { solved.lc().abs() >= 1.0 @@ -6940,10 +6393,10 @@ mod tests { .unwrap_or_else(|| panic!("{} did not resolve to a colour", role.key())); let jp_fg = crate::lcs::LcsColor::from_hex_with_vc(solved.hex(), vc) .unwrap() - .jp; + .jp(); let jp_bg = crate::lcs::LcsColor::from_hex_with_vc(bg_hex, vc) .unwrap() - .jp; + .jp(); (jp_fg - jp_bg).abs() } @@ -7498,19 +6951,19 @@ mod tests { let mp = crate::lcs::LcsColor::from_hex_with_vc(solved.hex(), &vc) .unwrap() .mp(); + let emitted = Srgb8::new( + crate::srgb8::hex_bytes(solved.hex()) + .expect("solver output is canonical sRGB8"), + ); + assert!( + !emitted.is_achromatic(), + "{bg_hex} {}: curve emission collapsed to exact grey", + r.key() + ); (r, l, mp) }) .collect(); - // The tint is genuinely present — never a flat zero. - for (role, _l, mp) in &mps { - assert!( - *mp > TINT_PERCEPTIBLE_MP_FLOOR - 1e-6, - "{bg_hex} {}: M' {mp} fell below the perceptibility floor", - role.key() - ); - } - // Constant colourfulness: every role whose lightness leaves the gamut // room to host the target sits within a tight band of it. Roles pinned // against the white/black wall (L very near 0 or 1) are allowed to fall @@ -7573,14 +7026,14 @@ mod tests { } #[test] - fn v1_flat_tint_remains_a_valid_opt_in_policy() { - // The v1 flat-ratio undertone is a decision the owner can still opt into: - // `RoleChroma::Tinted { hue, ratio }` must keep resolving roles around its - // fixed hue at a flat fraction of the gamut maximum — lightness-independent, - // unchanged by the v2 curve default. This pins the additive-API promise: the - // existing variant stays valid even though the default moved to `Curve`. + fn explicit_client_tinted_policy_remains_supported() { + // These literals are a test client's declared policy, not a Core default. + // The generic variant must keep the supplied fixed hue and gamut ratio. let vc = ViewingConditions::srgb(); - let flat = RoleTable::default().with_chroma(RoleChroma::flat_neutral_tint()); + let flat = RoleTable::default().with_chroma(RoleChroma::Tinted { + hue_deg: 286.0, + ratio: 0.10, + }); let bg = BgInput::solid("#FFFFFF").unwrap(); // Secondary under the flat policy lands cool, around the canonical hue, and // carries a flat ratio of the gamut max — its chroma is `RATIO * max_chroma`. @@ -7593,7 +7046,7 @@ mod tests { let l = crate::spaces::oklab::srgb_linear_to_oklab( crate::spaces::srgb::srgb_from_hex(solved.hex()).unwrap(), )[0]; - let expected = NEUTRAL_TINT_RATIO * scale::max_chroma(l, NEUTRAL_HUE_DEG); + let expected = 0.10 * scale::max_chroma(l, 286.0); assert!( (chroma - expected).abs() <= 2e-3, "flat tint chroma {chroma:.4} should be ratio*max_chroma {expected:.4}" @@ -7966,17 +7419,17 @@ mod tests { Ok(Resolved::Color { solved, .. }) => solved, other => panic!("{other:?}"), }; - // Where perception governs, the tinted and grey roles target the + // Both tinted and grey roles target the // same Lc and must land within the 1-Lc quantisation budget. Where // the WCAG floor drives the result (an AA-floored role), the legal - // gate — not the perceptual target — sets the colour, and the tint + // gate — not the candidate-score target — sets the colour, and the tint // can land on a neighbouring on-grid point that still clears the // floor; there the only honest invariant is that both clear it. let floor_driven = t.floor_override() || g.floor_override(); if !floor_driven { assert!( (t.lc().abs() - g.lc().abs()).abs() <= 1.0, - "{bg_hex} {}: tint moved a perceptual target (tinted {} vs grey {})", + "{bg_hex} {}: tint moved a candidate-score target (tinted {} vs grey {})", role.key(), t.lc(), g.lc() @@ -8002,8 +7455,7 @@ mod tests { // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod derivator_locks { - use super::{CUSP_HALF_WINDOW_DEG, LIGHTNESS_SETTLE, STRICT_STEP, TINT_PERCEPTIBLE_MP_FLOOR}; - use crate::lcs::LcsColor; + use super::{CUSP_HALF_WINDOW_DEG, LIGHTNESS_SETTLE, STRICT_STEP}; fn grey(i: u8) -> String { format!("#{i:02X}{i:02X}{i:02X}") @@ -8042,9 +7494,11 @@ mod derivator_locks { #[test] fn strict_step_sits_just_above_typical_grid_step() { let mut steps: Vec = Vec::new(); - let mut prev = crate::lpc::lpc(&grey(0), "#FFFFFF"); + let mut prev = crate::lpc::apparent_contrast_candidate_hex_for_test(&grey(0), "#FFFFFF") + .expect("generated grey is valid"); for i in 1u8..=255 { - let lc = crate::lpc::lpc(&grey(i), "#FFFFFF"); + let lc = crate::lpc::apparent_contrast_candidate_hex_for_test(&grey(i), "#FFFFFF") + .expect("generated grey is valid"); steps.push((lc - prev).abs()); prev = lc; } @@ -8068,31 +7522,6 @@ mod derivator_locks { ); } - /// Потолок ахроматического M'-шума CAM16 (серые #000..#FFF, дефолтный VC) - /// отслеживается порогом TINT_PERCEPTIBLE_MP_FLOOR (1.5). ЗАМЕР: максимум — - /// у белого, M'≈1.53; порог 1.5 стоит вплотную ПОД ним. Полоса характеризации - /// широкая, брекетит замер, без подгонки под 1.5. - #[test] - fn tint_floor_tracks_achromatic_mp_noise_ceiling() { - let mut max_mp = 0.0f64; - for i in 0u8..=255 { - let mp = LcsColor::from_hex(&grey(i)).expect("valid grey hex").mp(); - max_mp = max_mp.max(mp); - } - assert!( - (1.4..1.7).contains(&max_mp), - "потолок M'-шума серых {max_mp:.4} вне замеренного диапазона [1.4, 1.7)" - ); - // Направленный ассерт (не |Δ|): порог стоит вплотную ПОД потолком шума — - // floor < max_mp И зазор < 0.15. Инверсия направления (floor над потолком) - // ломает тест. - assert!( - TINT_PERCEPTIBLE_MP_FLOOR < max_mp && max_mp - TINT_PERCEPTIBLE_MP_FLOOR < 0.15, - "TINT_PERCEPTIBLE_MP_FLOOR={TINT_PERCEPTIBLE_MP_FLOOR} должен стоять чуть ПОД потолком \ - M'-шума {max_mp:.4} (floor < max_mp и max_mp − floor < 0.15)" - ); - } - /// Дрейф каспа гамута sRGB вблизи канонического 286° по L-шкале задаёт /// CUSP_HALF_WINDOW_DEG (40°). ⚠️ ЗАМЕР: полный дрейф достигает ≈42.5°, т.е. /// окно поиска (40°) клипует чуть ВНУТРИ полного дрейфа — намеренно (движок @@ -8139,11 +7568,7 @@ mod derivator_locks { // решение. Продакшн НЕ трогается. #[cfg(test)] mod exposure_locks { - use super::{ - DECORATIVE_FLOOR_MIN, IC_DECORATIVE_FLOOR_MIN, QUANT_GUARD, STRICT_STEP, - TINT_PERCEPTIBLE_MP_FLOOR, - }; - use crate::exposure_support::{band_exposure, mp_srgb}; + use super::{DECORATIVE_FLOOR_MIN, IC_DECORATIVE_FLOOR_MIN, QUANT_GUARD, STRICT_STEP}; /// DERIVED-лок (issue #44): `DECORATIVE_FLOOR_MIN == MODEL_LC_FLOOR + QUANT_GUARD`, /// и модельный пол строго ниже декоративного (guard положителен). Рантайм-зеркало @@ -8190,29 +7615,18 @@ mod exposure_locks { ); } - /// EXPOSURE TINT_PERCEPTIBLE_MP_FLOOR: доля гаммы с M' в +-50% полосе вокруг - /// порога перцептируемости тинта — цвета, чья классификация «ощущаемый тон vs - /// мёртвая серость» зависит от точного значения. - #[test] - fn exposure_tint_perceptible_mp_floor() { - let (lo, hi) = ( - 0.5 * TINT_PERCEPTIBLE_MP_FLOOR, - 1.5 * TINT_PERCEPTIBLE_MP_FLOOR, - ); - let (grid_pct, labui) = band_exposure(|c| mp_srgb(c, false), lo, hi); - eprintln!( - "EXPOSURE TINT_PERCEPTIBLE_MP_FLOOR band=[{lo:.2},{hi:.2}] grid_flip={grid_pct:.2}% labui_in_zone={} {:?}", - labui.len(), - labui - ); - } - /// EXPOSURE STRICT_STEP: доля соседних Lc-шагов 8-бит серой сетки в +-20% полосе /// вокруг границы квантования — где точный STRICT_STEP решает «на сетке / нет». #[test] fn exposure_strict_step() { let greys: Vec = (0u16..=255) - .map(|i| crate::lpc::lpc(&format!("#{i:02X}{i:02X}{i:02X}", i = i as u8), "#FFFFFF")) + .map(|i| { + crate::lpc::apparent_contrast_candidate_hex_for_test( + &format!("#{i:02X}{i:02X}{i:02X}", i = i as u8), + "#FFFFFF", + ) + .expect("generated grey is valid") + }) .collect(); let (lo, hi) = (0.8 * STRICT_STEP, 1.2 * STRICT_STEP); let (mut hits, mut tot) = (0usize, 0usize); @@ -8263,10 +7677,7 @@ mod exposure_locks { // ───────────────────────────────────────────────────────────────────────────── #[cfg(test)] mod wave2_e_locks { - use super::{ - NEUTRAL_HUE_DEG, NEUTRAL_TINT_RATIO, TINT_HUE_STIFFNESS, build_curve_color, - cusp_attracted_hue, - }; + use super::{NEUTRAL_HUE_DEG, TINT_HUE_STIFFNESS, build_curve_color, cusp_attracted_hue}; use crate::spaces::oklab::srgb_linear_to_oklab; use crate::spaces::srgb::quantise_srgb; @@ -8343,17 +7754,18 @@ mod wave2_e_locks { #[test] fn neutral_hue_emits_byte_invariant_across_measured_family_spread() { assert_eq!(NEUTRAL_HUE_DEG, 286.0, "измеренный якорь нейтрали"); + // Explicit test-client policy used only to make hue sensitivity visible. + let ratio = 0.10; let ls = [0.15_f64, 0.31, 0.48, 0.58, 0.68, 0.78, 0.86]; let mut max_spread = 0.0_f64; let mut max_wide = 0.0_f64; for &l in &ls { - let base = build_curve_color(l, NEUTRAL_HUE_DEG, NEUTRAL_TINT_RATIO); + let base = build_curve_color(l, NEUTRAL_HUE_DEG, ratio); for h in [285.78_f64, 285.90, 286.01] { - max_spread = - max_spread.max(de_ok(base, build_curve_color(l, h, NEUTRAL_TINT_RATIO))); + max_spread = max_spread.max(de_ok(base, build_curve_color(l, h, ratio))); } for h in [266.0_f64, 276.0, 296.0, 306.0] { - max_wide = max_wide.max(de_ok(base, build_curve_color(l, h, NEUTRAL_TINT_RATIO))); + max_wide = max_wide.max(de_ok(base, build_curve_color(l, h, ratio))); } } assert!( @@ -8368,28 +7780,4 @@ mod wave2_e_locks { "WAVE2 NEUTRAL_HUE_DEG (e): ΔE_ok[measured spread]={max_spread:.6} ΔE_ok[±20°]={max_wide:.4}" ); } - - /// (e) DESIGN-CHOICE sensitivity-лок для `NEUTRAL_TINT_RATIO`. Свип легальной - /// полосы [0, 0.20] по светлотам шкалы: непрерывный МАТЕРИАЛЬНЫЙ дрейф (ratio - /// прямо масштабирует хрому), значит (e), не (c). КУСАЕТСЯ: value-пин `== 0.10` - /// падает на любой мутации. - #[test] - fn neutral_tint_ratio_sensitivity_is_bounded() { - assert_eq!(NEUTRAL_TINT_RATIO, 0.10, "коэффициент подтона"); - let ls = [0.15_f64, 0.31, 0.48, 0.58, 0.68, 0.78, 0.86]; - let mut max_de = 0.0_f64; - for &l in &ls { - let base = build_curve_color(l, NEUTRAL_HUE_DEG, NEUTRAL_TINT_RATIO); - for r in [0.0_f64, 0.05, 0.08, 0.12, 0.15, 0.20] { - max_de = max_de.max(de_ok(base, build_curve_color(l, NEUTRAL_HUE_DEG, r))); - } - } - assert!( - (0.015..0.05).contains(&max_de), - "max ΔE_ok по [0,0.2] {max_de:.4} вне замеренного [0.015, 0.05) — ручка материальна (e)" - ); - eprintln!( - "WAVE2 NEUTRAL_TINT_RATIO (e): max ΔE_ok[0,0.2]={max_de:.4} (материальна → (e), не (c))" - ); - } } diff --git a/crates/labcolors-core/src/sentiment.rs b/crates/labcolors-core/src/sentiment.rs deleted file mode 100644 index b8d49c17..00000000 --- a/crates/labcolors-core/src/sentiment.rs +++ /dev/null @@ -1,1331 +0,0 @@ -// oklab_hue_of: единая реализация формулы якорного оттенка живёт в палитре -// акцентов — сентименты потребляют её, не держат вторую копию физики. -#[cfg(test)] -use crate::accent::Accent; -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::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; - -/// 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. -#[derive(Debug, Clone, Copy, PartialEq)] -#[cfg(test)] -pub enum Sentiment { - Danger, - Warning, - Success, - Info, -} - -#[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, - } - } - - /// Якорный цвет сентимента, чей **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, - ]; -} - -// `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`]. -/// -/// Терминал **(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; - -#[derive(Debug, Clone)] -pub struct SentimentCurve { - pub resolved_hue: f64, - pub was_displaced: bool, - pub displacement: f64, - /// The neutral curve this sentiment rides — its lightness ladder and viewing - /// conditions drive the shared perceived-lightness ramp. - neutral: NeutralCurve, -} - -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 - /// - /// `Err`, если `brand_hue` не конечен, `chroma_hex` не парсится как sRGB, либо - /// резолвер оттенка не нашёл легального кандидата (пустая дуга под полом). Цвет - /// рампы — [`LcsColor`], строится лениво в [`at`](Self::at). - 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}")); - } - 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; - - // Рампа строится по запросу из нейтральной кривой и решённого оттенка - // (закон хромы/светлоты в `at`/`hex_at` НЕ изменён — сменился лишь вход-оттенок). - Ok(Self { - resolved_hue, - was_displaced, - displacement, - neutral: neutral.clone(), - }) - } - - /// 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`). - pub fn at(&self, t: f64) -> LcsColor { - 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)) - } - - /// `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(); - } - 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) - } -} - -impl crate::curve::ColorCurve for SentimentCurve { - fn at(&self, t: f64) -> LcsColor { - self.at(t) - } - - fn vc(&self) -> &ViewingConditions { - self.neutral.vc() - } -} - -/// 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, - 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; - } - - 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 { - if 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 { - if !(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-хромы 8-битного цвета ≈ 1.1e-3 -/// (#FEFFFF; #808081 ≈ 1.5e-3), f64-шум конвейера sRGB→Oklab ≲ 1e-12; -/// 1e-7 лежит между ними с запасом ≥4 порядка в обе стороны — не может -/// переклассифицировать ни один представимый цвет. -// SSOT-TRACKED — арифметика представимости (деривация закрыта, внешнего стандарта не существует): границы в docs/empirical-inventory.md. -pub(crate) const ACHROMATIC_CHROMA_EPS: f64 = 1e-7; - -/// Канонический домен угла оттенка (градусы): `[0, 360)` — угол по модулю -/// 360°, где 360° ≡ 0°. Единые границы для `hue_floor`/`hue_override` -/// валидатора конфига и гардов солвера: два независимых литерала одного -/// домена в цветовом коде — класс тихого расхождения пределов. -// SSOT-TRACKED — определение домена: угол по модулю 360°, не перцептивная политика (реестр, строка 39). -pub(crate) const HUE_DOMAIN_MIN_INCLUSIVE: f64 = 0.0; -/// Верхняя граница канонического домена оттенка (исключительно; 360° ≡ 0°). -// SSOT-TRACKED — определение домена: полный оборот окружности, не перцептивная политика (реестр, строка 40). -pub(crate) const HUE_DOMAIN_MAX_EXCLUSIVE: f64 = 360.0; - -/// Config-facing сентимент-солид: якорь семейства, чей оттенок разрешён законом -/// категориальных зон (Волна 1), при СОХРАНЁННЫХ светлоте и хроме якоря (под -/// анти-неоновым потолком `chroma_fraction`). -/// -/// Оттенок семейства ОТДЫХАЕТ на своём прототипе (фокусе категории) — бренд его -/// больше НЕ смещает (см. `resolve_sentiment_hue_among`). Для покоящегося -/// сентимента солид воспроизводит СЫРОЙ якорь семейства (деривационная -/// идентичность). Оттенок уводит с прототипа только пол (`hue_floor`). -/// -/// # Errors -/// -/// `Err`, если якорь невалиден, `hue_floor` вне домена `[0, 360)`, -/// `chroma_fraction` вне `(0, 1]`, либо легальный оттенок под полом геометрически -/// пуст (см. `resolve_sentiment_hue_among`). -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) { - return Err(format!( - "chroma_fraction вне домена (0 < f ≤ 1): {chroma_fraction}" - )); - } - 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)) -} - -/// Анти-неоновый потолок хромы тинта: `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)) -} - -/// Перевести целевую хорду разделения `chord` в угол оттенка (градусы) при -/// хроме `zone_chroma` — инверсия `chord = 2·C·sin(Δh/2)` для произвольной хорды -/// (конфиг-`S_PERC_MIN`); питает попарные зоны соседей (`NeighborZone`). -/// -/// При `chord ≥ 2·zone_chroma` порог недостижим НИ ОДНИМ углом (хорда -/// окружности радиуса C ограничена диаметром 2C): возвращается ровно 180° — -/// маркер сатурации. Смысл: пара так приглушена, что категориальная хорда -/// недостижима при ЛЮБОМ разведении оттенка — пара перцептивно НЕРАЗДЕЛИМА. -/// Единственный потребитель (конфиг-фаза-2 попарных зон, -/// `sentiment_solid_for_mode`) обязан ПРОПУСТИТЬ такую зону: угловой отступ -/// 180° требует точного антипода (мера нуль), скан `legalize_hue` (шаг 0.05°) -/// его не находит — вышел бы ложный «пустая дуга». Раз пара неразделима, -/// отступ бессмыслен, и зона не накладывает ограничения (приглушённые -/// сентименты и так у серой оси — не флип-риск). Случай реален: приглушённый -/// якорь тёмной темы при хромных соседях. -/// -/// # Errors -/// -/// `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) { - return Err(format!( - "инверсия хорды вне домена: chord={chord}, zone_chroma={zone_chroma}" - )); - } - let ratio = chord / (2.0 * zone_chroma); - if ratio >= 1.0 { - return Ok(180.0); - } - Ok(2.0 * ratio.asin().to_degrees()) -} - -/// The in-gamut sRGB hex at Oklab `(L, C, h)`, channels clamped to `[0, 1]`. -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), - ]) -} - -/// 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) -} - -/// 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; - } - } - 0.5 * (lo + hi) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::curve::ColorCurve; - - fn neutral() -> NeutralCurve { - NeutralCurve::new("#FFFFFF", "#787880", "#101012").unwrap() - } - - #[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() - ); - } - } - - /// Якорные цвета заземлены в 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})" - ); - } - } - - /// Пин контракта дедупликации, достижимый на уровне значений: (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() не делегирует в палитру (появилась локальная копия)" - ); - } - } - - #[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}"); - } - } - } - - /// 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) - } - - #[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}" - ); - } - } - } - } - - // УДАЛЕНЫ Волной 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 - ); - } - } - } - - #[test] - fn rejects_non_finite_brand_hue() { - let n = neutral(); - assert!( - SentimentCurve::from_sentiment(Sentiment::Danger, f64::NAN, "#FF2E2E", &n).is_err() - ); - } - - #[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); - 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})" - ); - } - } - } - } - - #[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()); - } - - /// Ахроматичный якорь семейства не несёт оттенка: разведения нет — солид равен - /// сырому якорю. 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} обязан отвергаться и на сером" - ); - } - 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, - ]; - let l = [ - srgb_gamma_inv(e[0]), - srgb_gamma_inv(e[1]), - srgb_gamma_inv(e[2]), - ]; - let lab = srgb_linear_to_oklab(l); - lab[1].hypot(lab[2]) - } - - /// (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" - ); - } - - /// (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() - } - - /// (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), - )); - } - } - 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/solve.rs b/crates/labcolors-core/src/solve.rs index 993899eb..60e7c18b 100644 --- a/crates/labcolors-core/src/solve.rs +++ b/crates/labcolors-core/src/solve.rs @@ -1,8 +1,8 @@ -//! Inverse perceptual-contrast solver: `solve(bg, contract, …) → colour`. +//! Inverse candidate-contrast solver: `solve(bg, contract, …) → colour`. //! //! The forward path maps a colour to the WCAG relative luminance `Ys` of its -//! quantised display value and through [`contrast_core`](crate::lpc) to a -//! contrast value `Lc` — ось читаемости считает в домене `Ys`, в котором +//! quantised display value and through the internal frozen SAPC-shaped curve to +//! a signed Ys candidate score `Lc` — кривая считает в домене `Ys`, в котором //! определены константы SAPC-8 (ADR-0003). This //! module runs that path backwards: given a background and a target contrast //! it recovers the foreground luminance analytically (the contrast core is @@ -28,8 +28,9 @@ //! An unreachable contract returns [`SolveFailure`] with a reason — never a //! silent clip. //! -//! All canonical contrast constants are reused from [`crate::lpc`]; this module -//! declares none of them (формула APCA SAPC-8 версии 0.0.98G-4g; метрика LPC, не APCA). +//! All frozen curve constants are reused from the internal `lpc` module; this +//! module declares none of them. The scalar is a solver coordinate, not an +//! LPC/readability verdict or certificate. use crate::lcs::LcsColor; use crate::lpc::{ @@ -37,8 +38,10 @@ use crate::lpc::{ LO_BOW_OFFSET, LO_CLIP, LO_WOB_OFFSET, }; use crate::scale::max_chroma; -use crate::spaces::oklab::{oklab_hue, oklab_to_srgb_linear}; -use crate::spaces::srgb::{hex_from_srgb, srgb_from_hex, srgb_gamma, srgb_gamma_inv, srgb_to_xyz}; +use crate::spaces::oklab::{neutral_srgb_linear, oklab_to_srgb_linear}; +use crate::spaces::srgb::{ + hex_from_srgb, srgb_from_hex, srgb_gamma, srgb_gamma_inv, srgb_to_xyz, srgb8_from_linear, +}; use crate::spaces::vc::ViewingConditions; use crate::wcag; @@ -82,32 +85,15 @@ pub enum Gamut { DisplayP3, } -/// Reserved typographic context for a future target resolver. -/// -/// A later chapter will map font size/weight to a target `Lc` (large or bold -/// text tolerates lower contrast). v1 does **not** resolve it — callers pass an -/// explicit target via [`Contract::text`]. This type only reserves the seam so -/// that adding the resolver later is not a breaking change. Advisory inputs -/// (glyph shape, line length, tracking) are intentionally not modelled yet. -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct TypographicContext { - /// Font size in CSS pixels. - pub size_px: f64, - /// Font weight (100–900). - pub weight: u16, -} - /// The WCAG 2.1 AA legal contrast floor a contract must clear. /// /// EAA / EN 301 549 mandate WCAG 2.1 level AA: a relative-luminance contrast /// ratio of 4.5:1 for normal text (success criterion 1.4.3) and 3:1 for /// user-interface components and graphical objects (1.4.11). The floor is the -/// legal minimum *beneath* the perceptual LPC target: if the LPC solution does -/// not clear it, [`solve`] pushes the colour until it does and flags the result -/// via [`Solved::floor_override`], so the caller can see where law overrode -/// perception. Decorative / just-noticeable-difference contracts (shadows, -/// separators) carry [`None`](Floor::None) — readability law does not apply to -/// them, and `solve` leaves them on their perceptual target. +/// legal minimum *beneath* the transitional Ys candidate-score target: if the +/// candidate does not clear it, [`solve`] pushes the colour until it does and +/// flags the result via [`Solved::floor_override`]. Decorative contracts carry +/// [`None`](Floor::None): no normative WCAG floor is requested for them. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum Floor { @@ -115,7 +101,7 @@ pub enum Floor { AaText, /// WCAG 2.1 AA UI components / graphical objects — contrast ratio ≥ 3:1. AaUi, - /// No legal floor (decorative / JND contracts). + /// No normative WCAG floor (decorative contracts). None, } @@ -132,22 +118,20 @@ impl Floor { /// A contrast contract: the band of acceptable contrast against the background. /// -/// Expressed as a signed `Lc` range `[floor, ceiling]`, where the sign encodes +/// Expressed as a signed Ys candidate-score range `Lc = [floor, ceiling]`, +/// where the sign encodes /// polarity (positive is dark-on-light, negative is light-on-dark). v1 text -/// contracts use a degenerate range (`floor == ceiling`); the range type is -/// reserved for future just-noticeable-difference contracts (shadows, -/// separators, borders) where a band — "visible enough to be felt, no more" — -/// matters. `solve` targets `floor`. +/// contracts use a degenerate range (`floor == ceiling`). `solve` targets +/// `floor`. This frozen SAPC-shaped coordinate is not an LPC/readability verdict. /// /// Every contract also carries a WCAG 2.1 [`Floor`]: text and UI contracts get -/// the AA legal minimum by default (4.5:1 / 3:1); range (decorative / JND) +/// the AA legal minimum by default (4.5:1 / 3:1); range/decorative /// contracts get [`Floor::None`]. Disable or change it explicitly with /// [`with_conformance`](Contract::with_conformance). #[derive(Debug, Clone, Copy, PartialEq)] pub struct Contract { floor: f64, ceiling: f64, - typography: Option, conformance: Floor, } @@ -160,7 +144,6 @@ impl Contract { Self { floor: target_lc, ceiling: target_lc, - typography: None, conformance: Floor::AaText, } } @@ -174,30 +157,22 @@ impl Contract { Self { floor: target_lc, ceiling: target_lc, - typography: None, conformance: Floor::AaUi, } } /// A range contract `[floor, ceiling]` of signed `Lc`. `solve` targets `floor`. /// - /// Reserved for decorative / just-noticeable-difference contracts, so it - /// carries [`Floor::None`]: no legal readability floor applies. + /// A transitional decorative Ys candidate-score band. It carries + /// [`Floor::None`]: no normative WCAG floor is requested. pub fn range(floor: f64, ceiling: f64) -> Self { Self { floor, ceiling, - typography: None, conformance: Floor::None, } } - /// Attach a reserved [`TypographicContext`]. Not consulted by `solve` in v1. - pub fn with_typography(mut self, ctx: TypographicContext) -> Self { - self.typography = Some(ctx); - self - } - /// Override the WCAG 2.1 conformance [`Floor`]. The default depends on the /// constructor ([`text`](Self::text) → AA text, [`ui`](Self::ui) → AA UI, /// [`range`](Self::range) → none); pass [`Floor::None`] to disable the legal @@ -217,11 +192,6 @@ impl Contract { self.ceiling } - /// The reserved typographic context, if any (unused by `solve` in v1). - pub fn typography(self) -> Option { - self.typography - } - /// The WCAG 2.1 conformance floor this contract enforces. pub fn conformance(self) -> Floor { self.conformance @@ -245,18 +215,17 @@ impl BgInput { Ok(Self { linear_srgb: rgb }) } - /// Reduce the descriptor to its readability luminance interval — `Ys`, - /// WCAG relative luminance of the quantised display colour (ADR-0003: - /// ось читаемости считает в `Ys`; активировано главой #64). + /// Reduce the descriptor to its candidate-score luminance interval — `Ys`, + /// WCAG relative luminance of the quantised display colour (ADR-0003). /// /// Background-dependency invariant: `resolve_set(bg, table, vc)` depends on /// the background **only** through two scalars derived here from `bg` — the /// WCAG 2.1 relative luminance `Y_wcag` of the quantised display colour - /// (readability contract + polarity + the legal floor: один домен, одно + /// (candidate-score contract + polarity + the legal floor: один домен, одно /// число) and the CAM16-UCS lightness `J'_bg` (needed only by the dJ' - /// roles). Бывший третий скаляр — H-K-люминанс `Y_hk` — покинул ось - /// читаемости вместе с ADR-0003 и живёт только на яркостной оси - /// ([`bg_luma`]: сторона пары, свечение, сентимент). Verified by an + /// roles). Бывший третий скаляр — H-K-люминанс `Y_hk` — не входит в + /// candidate-score путь и живёт только на яркостной оси + /// ([`bg_luma`]: сторона пары, свечение, семейные цветовые операции). Verified by an /// exhaustive trace of every `bg` read on the `resolve_set_live` path. /// The representation stays an interval so a future field background can /// supply bounded endpoints without changing the solver contract. @@ -317,11 +286,11 @@ impl LumaInterval { } } -/// A solved foreground colour and the two contrasts it actually achieves. +/// A solved foreground colour, its Ys candidate score, and legal contrast. /// -/// The perceptual [`lc`](Solved::lc) (signed LPC) and the legal -/// [`wcag_ratio`](Solved::wcag_ratio) (symmetric WCAG 2.1) are reported as -/// separate numbers — they measure different things and are never conflated. +/// The signed Ys candidate [`lc`](Solved::lc) score from the frozen SAPC-shaped curve and the +/// legal [`wcag_ratio`](Solved::wcag_ratio) are reported separately. `lc` is a +/// solver coordinate, not an admitted LPC/readability certificate. #[derive(Debug, Clone, PartialEq)] pub struct Solved { color: LcsColor, @@ -342,9 +311,10 @@ impl Solved { &self.hex } - /// The signed perceptual contrast `Lc` the resolved colour achieves against - /// the background, measured on the quantised hex — what the caller actually - /// gets. This is the LPC metric, not WCAG; see [`wcag_ratio`](Self::wcag_ratio). + /// The signed Ys candidate score `Lc` the frozen SAPC-shaped curve gives the + /// quantised foreground/background pair. This is neither complete LPC nor + /// a readability verdict; see [`wcag_ratio`](Self::wcag_ratio) for the + /// separately reported legal ratio. pub fn lc(&self) -> f64 { self.lc } @@ -358,10 +328,10 @@ impl Solved { self.wcag_ratio } - /// `true` when the WCAG legal floor overrode the perceptual target: the LPC - /// solution did not clear the floor, so the colour was pushed (darker for + /// `true` when the WCAG legal floor overrode the Ys candidate-score target: the + /// candidate solution did not clear the floor, so the colour was pushed (darker for /// dark-on-light, lighter for light-on-dark) until it did. Lets the caller - /// surface where the law won over perception. + /// report that the normative floor changed the transitional target. pub fn floor_override(&self) -> bool { self.floor_override } @@ -644,14 +614,14 @@ pub(crate) fn solve_in( let target = contract.floor(); let y_gov = interval.governing(target); - // Stage 1 — perceptual target. Invert the LPC core for the Oklab lightness + // Stage 1 — Ys candidate-score target. Invert the frozen curve for the Oklab lightness // that reproduces the contract's target against the governing endpoint. let l_lpc = solve_lpc_lightness(y_gov, target, hue, chroma_policy)?; // Stage 2 — legal floor. Text/UI contracts carry a WCAG 2.1 AA floor; if the - // perceptual solution falls short of it, push the colour until it clears the + // candidate solution falls short of it, push the colour until it clears the // floor and flag the override. Decorative ([`Floor::None`]) contracts skip - // this entirely and keep their perceptual target. The resolved Oklab + // this entirely and keep their candidate-score target. The resolved Oklab // lightness (not just the colour) is returned so the bounded neighbour search // below can step to neighbouring hex grid points from it. let bg_disp = bg.governing_display(target); @@ -661,7 +631,7 @@ pub(crate) fn solve_in( }; // Stage 3 — quantise, measure, verify. Build the colour at the resolved - // lightness, emit its hex, and confirm the dual gate (perceptual floor at + // lightness, emit its hex, and confirm the dual gate (candidate-score floor at // both interval ends, plus the legal WCAG floor for text/UI) still holds on // the *quantised* colour. If it does not, walk a bounded number of neighbouring // hex steps toward larger `|Lc|` before returning an explicitly scoped @@ -670,7 +640,7 @@ pub(crate) fn solve_in( let evaluate = |l_ok: f64| -> Result { let rgb = build_color(l_ok, hue, chroma_policy); let solved = finish(rgb, y_gov, bg_disp, floor_override, vc)?; - // Perceptual floor at every interval endpoint. The governing endpoint's + // Candidate-score floor at every interval endpoint. The governing endpoint's // contrast is exactly `solved.lc()` (it is the `y_bg` `finish` measured // against), so reuse it instead of re-deriving foreground display // luminance. Only a *distinct* endpoint (a future field background) pays @@ -835,7 +805,7 @@ fn solve_lpc_lightness( /// The CAM16-UCS lightness `J'` of a **linear**-sRGB colour under `vc`. fn jp_of_linear(rgb_linear: [f64; 3], vc: &ViewingConditions) -> f64 { - LcsColor::from_xyz_with_hok(srgb_to_xyz(rgb_linear), 0.0, vc).jp + LcsColor::from_xyz_with_hok(srgb_to_xyz(rgb_linear), 0.0, vc).jp() } /// The acceptance budget, in CAM16-UCS `J'` units, for a decorative dJ' solve: a @@ -1143,7 +1113,7 @@ fn max_lc(y_bg: f64, target: f64) -> f64 { lpc::contrast_core(extreme_fg, y_bg) } -/// Analytic inverse of [`contrast_core`](crate::lpc): the clamp-inverted +/// Analytic inverse of the internal frozen contrast curve: the clamp-inverted /// foreground luminance `Ys` that yields `target` against `y_bg`. fn invert_contrast(y_bg: f64, target: f64) -> Result { // Past the offset and clip, the smallest representable |Lc| is this floor. @@ -1237,7 +1207,7 @@ fn physical_endpoint_or_range_failure( /// via [`build_color`]. /// /// The search is **VC-free and CAM16-free**: WCAG luminance is a -/// display-domain quantity (ADR-0003 — ось читаемости в `Ys`), so each probe +/// display-domain quantity (ADR-0003 — candidate score in `Ys`), so each probe /// costs one Oklab→sRGB conversion plus gamma-encode and a dot product. Это /// сняло смысл grey-axis LUT (бывший `crate::lut`, удалён этой главой #64): /// таблица существовала, чтобы не платить ~64 CAM16-форварда за бисекцию @@ -1290,8 +1260,7 @@ fn build_color(l_ok: f64, hue: Hue, chroma_policy: ChromaPolicy) -> [f64; 3] { // эмитил #949595 (148,149,149; ratio 3.0036) вместо документированного // #949494. Один общий float на все три канала закрывает класс целиком: // любой обрыв флипает каналы синхронно. - let v = (l_ok * l_ok * l_ok).clamp(0.0, 1.0); - return [v, v, v]; + return neutral_srgb_linear(l_ok); } let h = hue.degrees(); let hr = h.to_radians(); @@ -1322,16 +1291,10 @@ fn finish( floor_override: bool, vc: &ViewingConditions, ) -> Result { - let hex = hex_from_srgb(rgb_ideal); - let rgb_quantised = srgb_from_hex(&hex).map_err(|reason| { - SolveFailure::InternalInvariant(format!( - "solver formatter emitted an invalid sRGB hex: {reason}" - )) - })?; - let xyz = srgb_to_xyz(rgb_quantised); - let (j, m, h) = crate::spaces::cam16::forward(xyz, vc); - let color = LcsColor::from_cam16(j, m, h, oklab_hue(rgb_quantised)); - let disp = quantised_display(rgb_ideal); + let encoded = srgb8_from_linear(rgb_ideal); + let hex = encoded.to_hex(); + let color = LcsColor::from_srgb8_with_vc(encoded, vc); + let disp = encoded.encoded(); let y_fg = wcag::relative_luminance(disp); let lc = lpc::contrast_core(y_fg, y_bg); let wcag_ratio = wcag::contrast_ratio(disp, bg_disp); @@ -1344,7 +1307,7 @@ fn finish( }) } -/// Whether a measured signed perceptual contrast meets the (signed) floor within +/// Whether a measured signed candidate score meets the (signed) floor within /// the 1-Lc quantisation budget. The single comparison both endpoint checks /// share: the governing endpoint passes its already-measured `solved.lc()` here /// directly (no re-derivation), a distinct endpoint passes the contrast @@ -1380,9 +1343,9 @@ fn meets_floor(solved: &Solved, y_bg: f64, target: f64, _vc: &ViewingConditions) /// H-K-corrected luminance (`Y_hk`) of a linear-sRGB stimulus — воспринимаемая /// ЯРКОСТЬ поверхности (Гельмгольц–Кольрауш, серый эквивалент `J_HK`). /// -/// После активации ADR-0003 (глава #64) ось читаемости это НЕ читает: контракт +/// После ADR-0003 Ys candidate-score путь это НЕ читает: контракт /// контраста, полы и recheck меряются в `Ys`. Потребители — яркостная ось: -/// выбор стороны пары ([`crate::pair::pair_side`] — H-K-сдвиг кроссовера на +/// выбор стороны внутренней pair-эвристики (`pair_side` — H-K-сдвиг кроссовера на /// насыщенных фонах) и яркостные подсистемы. pub(crate) fn bg_luma(rgb: [f64; 3], vc: &ViewingConditions) -> f64 { let j_hk = lpc::j_hk_from_xyz(srgb_to_xyz(rgb), vc).max(0.0); @@ -1427,7 +1390,6 @@ pub(crate) mod probe_log { #[cfg(test)] mod tests { use super::*; - use crate::lpc::lpc_with_vc; #[test] fn internal_invariant_failure_is_not_reported_as_invalid_client_input() { @@ -1593,12 +1555,12 @@ mod tests { // (vc name, bg hex) -> (cold forwards, warm forwards), measured. // // RE-MEASURED for the readability→`Ys` activation (глава #64, ADR-0003). - // Ось читаемости покинула домен `Y_hk`: обратный солвер инвертирует `Ys` + // Candidate-score путь не использует `Y_hk`: обратный солвер инвертирует `Ys` // напрямую (`match_lightness_ys`), и весь CAM16-раундтрип `grey_j ↔ y_hk` // на пути читаемости ОТПАЛ — это ровно предсказанный ADR blast radius // («solve.rs упростится … отпадает CAM16-раундтрип для читаемости»). // Оставшиеся форварды — работа ЯРКОСТНЫХ осей (нейтральная лестница, - // сентимент, свечение), которые H-K сохраняют; потому dim/тёмные фоны + // семейные цвета, свечение), которые H-K сохраняют; потому dim/тёмные фоны // дороже (лестница в dim делает больше H-K-работы). Падение ~10-40× // против прежних пинов — не регрессия покрытия, а снятие лишнего домена. // Re-measured 2026-07-08 (глава #64 merge). @@ -1643,23 +1605,17 @@ mod tests { } } - /// Independent re-measure of an emitted hex's signed perceptual contrast in - /// the READABILITY domain (`Ys`) the solver targets since глава #64. Заменяет - /// Y_hk-мерило `lpc_with_vc` в round-trip проверках ВЕЛИЧИНЫ (сверять надо в - /// домене цели; signum-проверки домен-агностичны и остаются на `lpc_with_vc`). - fn readability_lc(fg_hex: &str, bg_hex: &str) -> f64 { + /// Independent re-measure of an emitted hex's signed Ys candidate score. + fn candidate_lc(fg_hex: &str, bg_hex: &str) -> f64 { let fg = crate::spaces::srgb::srgb_encoded_from_hex(fg_hex).expect("valid emitted hex"); let bg = crate::spaces::srgb::srgb_encoded_from_hex(bg_hex).expect("valid bg hex"); - crate::lpc::lpc_readability_ys(fg, bg) + crate::lpc::ys_candidate_score_for_test(fg, bg) } /// Solve and return both the solved value and the contrast measured - /// independently on the resolved hex — in the SAME readability domain the - /// solver now targets (`Ys`, ADR-0003 глава #64): `lpc_readability_ys` on the - /// display bytes. Меряться через `lpc_with_vc` (домен `Y_hk`, apparent - /// contrast) здесь БОЛЬШЕ НЕЛЬЗЯ: ось читаемости переехала в `Ys`, и - /// round-trip обязан сверяться в домене цели, иначе Ys≈Y_hk-разрыв на серости - /// (CAM16-лайтнесс ≠ WCAG-люминанс даже при C=0) даёт ложный недолёт. Третий + /// independently on the resolved hex in the same `Ys` domain the solver + /// targets. Measuring through an H-K appearance correlate would substitute a + /// different input quantity and create a false round-trip miss. The third /// ассерт вызывающих (`solved.lc() == measured` до 1e-9) фиксирует именно это: /// `solved.lc()` — Ys-замер `finish`, и независимый пересчёт обязан совпасть. fn solve_and_measure( @@ -1668,7 +1624,7 @@ mod tests { vc: &ViewingConditions, ) -> Result<(Solved, f64), SolveFailure> { let bg = BgInput::solid(bg_hex)?; - // Floor::None: these helpers exercise the pure perceptual inversion; + // Floor::None: these helpers exercise the frozen candidate-curve inversion; // the WCAG floor (on by default for text) is tested separately. let solved = solve( bg, @@ -1682,7 +1638,7 @@ mod tests { .expect("solved hex is produced by hex_from_srgb → always parses"); let bg_disp = crate::spaces::srgb::srgb_encoded_from_hex(bg_hex) .expect("test bg hex is a valid literal"); - let measured = crate::lpc::lpc_readability_ys(fg_disp, bg_disp); + let measured = crate::lpc::ys_candidate_score_for_test(fg_disp, bg_disp); Ok((solved, measured)) } @@ -1733,6 +1689,69 @@ mod tests { } } + #[test] + fn solved_color_is_the_emitted_exact_gray_state() { + // `Solved` exposes one physical result in two representations. The + // structured value must therefore be exactly the value obtained by + // decoding the emitted bytes under the solve-time VC; otherwise callers + // observe two colours depending on which accessor they choose. + for (vc, vc_name) in vcs() { + for (bg, polarity) in [("#FFFFFF", 1.0_f64), ("#101012", -1.0_f64)] { + for magnitude in MAGNITUDES { + let target = polarity * magnitude; + let (solved, _) = solve_and_measure(bg, target, &vc) + .expect("neutral fixture must resolve on its matching background"); + let [r, g, b] = crate::srgb8::hex_bytes(solved.hex()) + .expect("solver emits canonical sRGB8"); + assert_eq!( + [r, g], + [g, b], + "{vc_name}/{bg}/{target}: neutral solve emitted {}", + solved.hex() + ); + let decoded = LcsColor::from_hex_with_vc(solved.hex(), &vc) + .expect("solver-emitted hex must decode"); + assert_eq!( + solved.color(), + decoded, + "{vc_name}/{bg}/{target}: structured colour diverged from emitted {}", + solved.hex() + ); + } + } + } + } + + #[test] + fn solved_metrics_are_reproducible_from_the_emitted_bytes() { + let vc = ViewingConditions::srgb(); + let background = [1.0, 1.0, 1.0]; + let y_bg = wcag::relative_luminance(background); + for ideal in [ + [0.0, 0.5, 1.0], + [0.003_130_8, 0.214_041_140_482_232_55, 0.999_999_999], + [0.010_329, 0.215_861, 0.503_111], + ] { + let solved = finish(ideal, y_bg, background, false, &vc) + .expect("finite generated colour must finish"); + let emitted = crate::spaces::srgb::srgb_encoded_from_hex(solved.hex()) + .expect("finish emits canonical sRGB8"); + let emitted_y = wcag::relative_luminance(emitted); + assert_eq!( + solved.lc().to_bits(), + lpc::contrast_core(emitted_y, y_bg).to_bits(), + "lc was not measured from emitted {}", + solved.hex() + ); + assert_eq!( + solved.wcag_ratio().to_bits(), + wcag::contrast_ratio(emitted, background).to_bits(), + "WCAG ratio was not measured from emitted {}", + solved.hex() + ); + } + } + #[test] fn property_grid_neutral_and_chromatic_backgrounds() { // Grid: neutral + chromatic backgrounds × both polarities × both VCs × @@ -1825,7 +1844,7 @@ mod tests { }; reachable += 1; // Independently re-measure the emitted hex's signed Lc. - let measured = lpc_with_vc(solved.hex(), bg_hex, &vc); + let measured = candidate_lc(solved.hex(), bg_hex); // Compare signum, not `> 0.0`. Under the AA text floor every // reachable result clears 4.5:1, so `measured` is never the // dead-zone zero here — this is belt-and-suspenders. f64 @@ -1993,9 +2012,9 @@ mod tests { #[test] fn dj_degradation_reports_honest_achieved_dj() { // Цель за стеной оси J' даёт явно помеченный исход ограниченного выбора. - // `achieved_dj` обязан быть ЗАМЕРОМ на отданном - // hex (та же честность, что glow.degraded): перечитываем hex и - // сверяем |ΔJ'| против фона независимо. + // `achieved_dj` обязан быть ЗАМЕРОМ на отданном hex, как явно названные + // замеры изолированных Glow-слоёв: перечитываем hex и сверяем |ΔJ'| + // против фона независимо. let vc = ViewingConditions::srgb(); let bg = BgInput::solid("#101012").unwrap(); let d = solve_dj(&bg, 300.0, -1.0, Hue::deg(0.0), ChromaPolicy::Neutral, &vc) @@ -2077,40 +2096,11 @@ mod tests { assert_eq!(from_text, from_range); } - #[test] - fn reserved_typography_does_not_change_the_result() { - // SEAM (c): the typographic context is reserved; the v1 solver ignores - // it and the caller's explicit target governs. - let vc = ViewingConditions::srgb(); - let bg = BgInput::solid("#FFFFFF").unwrap(); - let plain = solve( - bg.clone(), - Contract::text(60.0), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &vc, - Gamut::Srgb, - ) - .unwrap(); - let with_ctx = solve( - bg, - Contract::text(60.0).with_typography(TypographicContext { - size_px: 32.0, - weight: 700, - }), - Hue::deg(0.0), - ChromaPolicy::Neutral, - &vc, - Gamut::Srgb, - ) - .unwrap(); - assert_eq!(plain, with_ctx); - } - #[test] fn chromatic_foreground_hits_target_and_carries_chroma() { - // A saturated foreground policy still lands on the contrast target, - // because the H-K boost is compensated by lowering lightness. + // A chromatic policy still lands within the declared Ys candidate-score + // budget: `match_lightness_ys` varies Oklab lightness along the selected + // chroma curve; H-K is not an input to this readability path. let vc = ViewingConditions::srgb(); let bg = BgInput::solid("#FFFFFF").unwrap(); let target = 45.0; @@ -2123,16 +2113,16 @@ mod tests { Gamut::Srgb, ) .unwrap(); - let measured = readability_lc(solved.hex(), "#FFFFFF"); + let measured = candidate_lc(solved.hex(), "#FFFFFF"); assert!( (measured - target).abs() <= TOL, "chromatic target {target}, measured {measured}, hex {}", solved.hex() ); assert!( - solved.color().s > 0.01, + solved.color().s() > 0.01, "chromatic policy should carry chroma, s = {}", - solved.color().s + solved.color().s() ); } @@ -2222,7 +2212,7 @@ mod tests { .unwrap(); assert!(solved.floor_override(), "floor must override Lc 15"); assert!(solved.wcag_ratio() >= 4.5 - 1e-9); - let measured = readability_lc(solved.hex(), "#FFFFFF"); + let measured = candidate_lc(solved.hex(), "#FFFFFF"); assert!( measured > 15.0, "pushed darker means more contrast, got {measured}" @@ -2335,7 +2325,7 @@ mod tests { #[test] fn decorative_contracts_skip_the_floor() { - // JND/decorative: range carries Floor::None — perception governs, no + // Decorative: range carries Floor::None — the candidate-score target governs, no // flag, and the (sub-AA) ratio is still reported for transparency. let vc = ViewingConditions::srgb(); let bg = BgInput::solid("#FFFFFF").unwrap(); @@ -2349,7 +2339,7 @@ mod tests { ) .unwrap(); assert!(!solved.floor_override()); - let measured = readability_lc(solved.hex(), "#FFFFFF"); + let measured = candidate_lc(solved.hex(), "#FFFFFF"); assert!((measured - 15.0).abs() <= TOL); assert!(solved.wcag_ratio() < 4.5); } @@ -2370,7 +2360,7 @@ mod tests { .unwrap(); assert!(!solved.floor_override()); assert!(solved.wcag_ratio() >= 4.5); - let measured = readability_lc(solved.hex(), "#FFFFFF"); + let measured = candidate_lc(solved.hex(), "#FFFFFF"); assert!((measured - 90.0).abs() <= TOL); } @@ -2516,7 +2506,7 @@ mod tests { .unwrap_err(); match err { SolveFailure::ExceedsRange { max_achievable, .. } => { - let black_on_white = crate::lpc::lpc_with_vc("#000000", "#FFFFFF", &vc); + let black_on_white = candidate_lc("#000000", "#FFFFFF"); assert!( (max_achievable - black_on_white).abs() < 0.5, "max_achievable {max_achievable} should match the forward curve extreme {black_on_white}" @@ -2557,7 +2547,7 @@ mod tests { // ≈7.3 and the first valid darker grid step is #EDEDED (Lc ≈ 7.604 в // домене `Ys`), within the ±1 budget. The neighbour walk must find it // instead of returning a (lying) ExceedsRange — механизм issue #44 жив. - // ГЛАВА #64 (ADR-0003): ось читаемости перешла в `Ys`, и мерило `Lc` + // ADR-0003: candidate-score путь использует `Ys`, и мерило `Lc` // hex'а пересчиталось — прежний Y_hk-шаг #E9E9E9 (Lc ≈ 7.85) сменился на // #EDEDED (Lc ≈ 7.604). Между полом 7.3 и #EDEDED валидных шагов нет, // потому цель 7.31 ГАРАНТИРОВАННО падает в мёртвую зону и обслуживается @@ -2732,7 +2722,7 @@ mod tests { /// built colour must be recovered to bisection precision, and out-of-gamut /// targets must short-circuit to the gamut endpoints. /// - /// Это единственное место, где солвер инвертирует ось читаемости; тест + /// Это единственное место, где солвер инвертирует Ys candidate-score кривую; тест /// охраняет строгую монотонность `Ys` вдоль тонированной кривой /// (`build_color` при фиксированных hue/policy), на которой держится /// корректность бисекции: плато или излом кривой развалили бы @@ -3262,19 +3252,24 @@ mod tests { mod exposure_locks { use super::{DJ_BUDGET, QUANT_BUDGET}; use crate::lcs::LcsColor; - use crate::lpc::lpc; fn grey(i: u8) -> String { format!("#{i:02X}{i:02X}{i:02X}") } fn grey_lc() -> Vec { (0u16..=255) - .map(|i| lpc(&grey(i as u8), "#FFFFFF")) + .map(|i| { + let fg = crate::spaces::srgb::srgb_encoded_from_hex(&grey(i as u8)) + .expect("generated grey is valid"); + let bg = crate::spaces::srgb::srgb_encoded_from_hex("#FFFFFF") + .expect("literal fixture is valid"); + crate::lpc::ys_candidate_score_for_test(fg, bg) + }) .collect() } fn grey_jp() -> Vec { (0u16..=255) - .map(|i| LcsColor::from_hex(&grey(i as u8)).unwrap().jp) + .map(|i| LcsColor::from_hex(&grey(i as u8)).unwrap().jp()) .collect() } fn median_step(vals: &[f64]) -> f64 { diff --git a/crates/labcolors-core/src/spaces/cam16.rs b/crates/labcolors-core/src/spaces/cam16.rs index ee5891cd..fbfcc99c 100644 --- a/crates/labcolors-core/src/spaces/cam16.rs +++ b/crates/labcolors-core/src/spaces/cam16.rs @@ -14,8 +14,23 @@ //! dependency on a colour-science crate. use crate::spaces::cat16; +use crate::spaces::srgb::D65_WHITE; use crate::spaces::vc::ViewingConditions; +// Параметры нелинейного post-adaptation response и ахроматической суммы CAM16 +// из Li et al. 2017, DOI 10.1002/col.22131. Это параметры опубликованной модели, +// а не настраиваемые product-пороги. Отдельный divisor сохраняет исходный +// порядок IEEE-операций в forward (`blue / 20`), а массив нужен аналитической +// инверсии, где веса исторически умножаются в цикле. Test-only literal-oracle +// ниже намеренно не использует эти константы: иначе общий дрейф production и +// «эталона» оставил бы bit-identity гейт зелёным. +const RESPONSE_SCALE: f64 = 400.0; +const RESPONSE_OFFSET: f64 = 27.13; +const RESPONSE_EXPONENT: f64 = 0.42; +const ACHROMATIC_RED_WEIGHT: f64 = 2.0; +const ACHROMATIC_BLUE_DIVISOR: f64 = 20.0; +const ACHROMATIC_WEIGHTS: [f64; 3] = [ACHROMATIC_RED_WEIGHT, 1.0, 1.0 / ACHROMATIC_BLUE_DIVISOR]; + #[cfg(test)] thread_local! { /// Test-only per-thread counter of [`forward`] invocations. Powers the @@ -35,7 +50,7 @@ thread_local! { /// is the cone-fundamental standard and does not specify CAM16.) pub(crate) fn adapt(c: f64, fl: f64) -> f64 { let x = fl * c.abs() / 100.0; - let y = x.powf(0.42); + let y = x.powf(RESPONSE_EXPONENT); // Магнитуда `400·y/(y+27.13)` ≥ 0 (y ≥ 0), а знак берётся у входа `c`. Раньше // это делалось умножением на `c.signum()` (∈ {−1, +1} на конечных входах); // `copysign` переносит знаковый бит `c` напрямую — байт-идентично для любого @@ -43,13 +58,13 @@ pub(crate) fn adapt(c: f64, fl: f64) -> f64 { // `copysign`; включая ±0.0), но без ветвящегося `signum` и лишнего умножения. // Вход `c` (линейная смесь конечных декодированных sRGB) на горячем пути и под // гейтом bit-identity (`forward_reference` / reference-векторы) всегда конечен. - (400.0 * y / (y + 27.13)).copysign(c) + (RESPONSE_SCALE * y / (y + RESPONSE_OFFSET)).copysign(c) } /// Inverse nonlinear adaptation. pub(crate) fn unadapt(a: f64, fl: f64) -> f64 { let x = a.abs(); - let y = (27.13 * x / (400.0 - x)).max(0.0); + let y = (RESPONSE_OFFSET * x / (RESPONSE_SCALE - x)).max(0.0); // Магнитуда `100·y^(1/0.42)/fl` ≥ 0: `y ≥ 0` (`.max(0.0)`), `y^(1/0.42) ≥ 0` // (powf неотрицательной базы), `fl > 0` (VC-параметр по построению). Знак // берётся у входа `a` — зеркало прямого `adapt`. Раньше это делалось @@ -60,7 +75,102 @@ pub(crate) fn unadapt(a: f64, fl: f64) -> f64 { // магнитуда 0.0 → copysign(0.0, ±0.0)=±0.0). Свип 0/6.86M расхождений; вход `a` // (инверсия конечного J'/M') на этом пути всегда конечен. Убирает ветвящийся // `signum` и лишнее умножение из пер-цветового обратного хода (`to_xyz`). - (100.0 * y.powf(1.0 / 0.42) / fl).copysign(a) + (100.0 * y.powf(1.0 / RESPONSE_EXPONENT) / fl).copysign(a) +} + +/// CAM16 lightness `J` of a D65-axis stimulus with relative luminance `y`. +#[cfg(test)] +pub(crate) fn gray_j(y: f64, vc: &ViewingConditions) -> f64 { + let xyz = [y * D65_WHITE[0], y, y * D65_WHITE[2]]; + forward(xyz, vc).0 +} + +/// Relative luminance on the D65 gray axis whose CAM16 lightness is `j`. +/// +/// The gray-axis forward is monotonic. Its inverse uses an analytic seed and +/// two Newton steps over the exact three-channel CAM16 response, then clamps to +/// the physical `[0, 1]` interval. This is appearance-model geometry shared by +/// LCS and LPC; it is not part of either product policy. +pub(crate) fn gray_y(j: f64, vc: &ViewingConditions) -> f64 { + gray_y_analytic(j, vc) +} + +/// Closed-form seed plus Newton polish for [`gray_y`]. +/// +/// For the D65 gray stimulus, each adapted cone response is linear in `y`: +/// `lms_a[i] = k_i · y`. CAM16 then computes a weighted sum of three nonlinear +/// responses. Collapsing them to one effective scale provides the seed; Newton +/// polish evaluates the actual three terms and their derivative. +pub(crate) fn gray_y_analytic(j: f64, vc: &ViewingConditions) -> f64 { + if j <= 0.0 { + return 0.0; + } + + let rgb_w = cat16::xyz_to_cone([ + D65_WHITE[0] * 100.0, + D65_WHITE[1] * 100.0, + D65_WHITE[2] * 100.0, + ]); + let k = [ + rgb_w[0] * vc.rgb_d[0], + rgb_w[1] * vc.rgb_d[1], + rgb_w[2] * vc.rgb_d[2], + ]; + let w_sum = ACHROMATIC_WEIGHTS[0] + ACHROMATIC_WEIGHTS[1] + ACHROMATIC_WEIGHTS[2]; + + let target = vc.aw * (j / 100.0).powf(1.0 / (vc.c * vc.z)) / vc.nbb; + let s = target / w_sum; + if s <= 0.0 { + return 0.0; + } + if s >= RESPONSE_SCALE { + return 1.0; + } + + let k_eff = (ACHROMATIC_WEIGHTS[0] * k[0] + + ACHROMATIC_WEIGHTS[1] * k[1] + + ACHROMATIC_WEIGHTS[2] * k[2]) + / w_sum; + let p = RESPONSE_OFFSET * s / (RESPONSE_SCALE - s); + let mut y = p.powf(1.0 / RESPONSE_EXPONENT) * 100.0 / (vc.fl * k_eff); + + let residual_slope = |y: f64| -> (f64, f64) { + let mut f = 0.0; + let mut df = 0.0; + for i in 0..3 { + let c = k[i] * y; + let x = vc.fl * c / 100.0; + let pp = x.powf(RESPONSE_EXPONENT); + let denominator = pp + RESPONSE_OFFSET; + f += ACHROMATIC_WEIGHTS[i] * RESPONSE_SCALE * pp / denominator; + let dp = RESPONSE_EXPONENT * pp / c; + df += ACHROMATIC_WEIGHTS[i] * k[i] * RESPONSE_SCALE * RESPONSE_OFFSET * dp + / (denominator * denominator); + } + (f - target, df) + }; + for _ in 0..2 { + let (error, slope) = residual_slope(y); + y -= error / slope; + } + + y.clamp(0.0, 1.0) +} + +/// Fixed-iteration oracle for [`gray_y_analytic`]. +#[cfg(test)] +pub(crate) fn gray_y_bisect(j: f64, vc: &ViewingConditions) -> f64 { + let mut lo = 0.0_f64; + let mut hi = 1.0_f64; + for _ in 0..64 { + let mid = (lo + hi) * 0.5; + if gray_j(mid, vc) < j { + lo = mid; + } else { + hi = mid; + } + } + (lo + hi) * 0.5 } // Per-`resolve_set` memoization of the `forward` pass, keyed on the input `XYZ` @@ -217,7 +327,9 @@ fn forward_compute(xyz: [f64; 3], vc: &ViewingConditions) -> (f64, f64, f64) { let hr = h.to_radians(); let e_hue = 0.25 * ((hr + 2.0).cos() + 3.8); - let a_achrom = (2.0 * lms_aa[0] + lms_aa[1] + lms_aa[2] / 20.0) * vc.nbb; + let a_achrom = + (ACHROMATIC_RED_WEIGHT * lms_aa[0] + lms_aa[1] + lms_aa[2] / ACHROMATIC_BLUE_DIVISOR) + * vc.nbb; let j = 100.0 * (a_achrom / vc.aw).powf(vc.c * vc.z); let u = (a * a + b * b).sqrt(); @@ -276,6 +388,74 @@ mod tests { use super::*; use crate::spaces::srgb::{srgb_from_hex, srgb_to_xyz}; + // Независимые literal-oracles повторяют опубликованные CAM16-параметры, а + // не production-константы. Это намеренная test-only дубликация: общий SSOT + // для реализации не должен превращать проверку чисел в самоутверждение. + fn adapt_reference(c: f64, fl: f64) -> f64 { + let x = fl * c.abs() / 100.0; + let y = x.powf(0.42); + (400.0 * y / (y + 27.13)).copysign(c) + } + + fn unadapt_reference(a: f64, fl: f64) -> f64 { + let x = a.abs(); + let y = (27.13 * x / (400.0 - x)).max(0.0); + (100.0 * y.powf(1.0 / 0.42) / fl).copysign(a) + } + + fn gray_y_analytic_reference(j: f64, vc: &ViewingConditions) -> f64 { + if j <= 0.0 { + return 0.0; + } + + let rgb_w = cat16::xyz_to_cone([ + D65_WHITE[0] * 100.0, + D65_WHITE[1] * 100.0, + D65_WHITE[2] * 100.0, + ]); + let k = [ + rgb_w[0] * vc.rgb_d[0], + rgb_w[1] * vc.rgb_d[1], + rgb_w[2] * vc.rgb_d[2], + ]; + let weights = [2.0, 1.0, 1.0 / 20.0]; + let w_sum = weights[0] + weights[1] + weights[2]; + + let target = vc.aw * (j / 100.0).powf(1.0 / (vc.c * vc.z)) / vc.nbb; + let s = target / w_sum; + if s <= 0.0 { + return 0.0; + } + if s >= 400.0 { + return 1.0; + } + + let k_eff = (weights[0] * k[0] + weights[1] * k[1] + weights[2] * k[2]) / w_sum; + let p = 27.13 * s / (400.0 - s); + let mut y = p.powf(1.0 / 0.42) * 100.0 / (vc.fl * k_eff); + + let residual_slope = |y: f64| -> (f64, f64) { + let mut f = 0.0; + let mut df = 0.0; + for i in 0..3 { + let c = k[i] * y; + let x = vc.fl * c / 100.0; + let pp = x.powf(0.42); + let denominator = pp + 27.13; + f += weights[i] * 400.0 * pp / denominator; + let dp = 0.42 * pp / c; + df += weights[i] * k[i] * 400.0 * 27.13 * dp / (denominator * denominator); + } + (f - target, df) + }; + for _ in 0..2 { + let (error, slope) = residual_slope(y); + y -= error / slope; + } + + y.clamp(0.0, 1.0) + } + /// Frozen reference: the CIECAM16 forward math exactly as it stood inline in /// `lcs::from_xyz_with_hok` / `lpc::cam16_jch_from_xyz` before issue #19 /// merged the two byte-identical copies into [`forward`]. This is the *old @@ -291,9 +471,9 @@ mod tests { lms[2] * vc.rgb_d[2], ]; let lms_aa = [ - adapt(lms_a[0], vc.fl), - adapt(lms_a[1], vc.fl), - adapt(lms_a[2], vc.fl), + adapt_reference(lms_a[0], vc.fl), + adapt_reference(lms_a[1], vc.fl), + adapt_reference(lms_a[2], vc.fl), ]; let a = lms_aa[0] - 12.0 * lms_aa[1] / 11.0 + lms_aa[2] / 11.0; @@ -340,6 +520,74 @@ mod tests { } } + #[test] + fn response_helpers_are_bit_identical_to_the_literal_model() { + let inputs = [ + -100.0_f64, + -1.0, + -f64::MIN_POSITIVE, + -0.0, + 0.0, + f64::MIN_POSITIVE, + 1.0, + 100.0, + ]; + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for value in inputs { + assert_eq!( + adapt(value, vc.fl).to_bits(), + adapt_reference(value, vc.fl).to_bits(), + "adapt drifted for {value} at fl={}", + vc.fl + ); + assert_eq!( + unadapt(value, vc.fl).to_bits(), + unadapt_reference(value, vc.fl).to_bits(), + "unadapt drifted for {value} at fl={}", + vc.fl + ); + } + } + } + + #[test] + fn gray_inverse_is_bit_identical_to_the_pre_ssot_path() { + for vc in [ViewingConditions::srgb(), ViewingConditions::dim_surround()] { + for j in [0.0_f64, f64::MIN_POSITIVE, 1.0, 25.0, 50.0, 75.0, 100.0] { + assert_eq!( + gray_y_analytic(j, &vc).to_bits(), + gray_y_analytic_reference(j, &vc).to_bits(), + "gray inverse drifted for j={j} at fl={}", + vc.fl + ); + } + } + } + + #[test] + fn gray_inverse_uses_all_public_viewing_condition_discount_channels() { + // `ViewingConditions` is publicly representable field-by-field. A + // gray-axis inverse therefore has to invert the exact three discounting + // factors consumed by `forward`, not reconstruct a shared hidden `d` + // from only the red channel. + let mut vc = ViewingConditions::srgb(); + vc.rgb_d[1] *= 1.05; + + let expected_y = srgb_from_hex("#808080").unwrap()[0]; + let j = gray_j(expected_y, &vc); + let analytic = gray_y_analytic(j, &vc); + let oracle = gray_y_bisect(j, &vc); + + assert!( + (analytic - oracle).abs() <= 2.0e-12, + "analytic gray inverse {analytic:.17} disagrees with the same-VC bisection oracle {oracle:.17}" + ); + assert!( + (analytic - expected_y).abs() <= 2.0e-12, + "same-VC gray round-trip moved Y: expected {expected_y:.17}, got {analytic:.17}" + ); + } + #[test] fn cache_returns_bit_identical_to_uncached_math() { // ISOLATED VERIFICATION of the per-set forward cache: with the cache diff --git a/crates/labcolors-core/src/spaces/oklab.rs b/crates/labcolors-core/src/spaces/oklab.rs index 386b1605..2d02a326 100644 --- a/crates/labcolors-core/src/spaces/oklab.rs +++ b/crates/labcolors-core/src/spaces/oklab.rs @@ -25,6 +25,10 @@ const LMS_TO_OKLAB: [[f64; 3]; 3] = [ [ 0.0259040371, 0.7827717662, -0.8086757660], ]; +/// Canonical degree domain of a hue angle. +pub(crate) const HUE_DEG_MIN_INCLUSIVE: f64 = 0.0; +pub(crate) const HUE_DEG_MAX_EXCLUSIVE: f64 = 360.0; + #[rustfmt::skip] pub(crate) const OKLAB_TO_LMS: [[f64; 3]; 3] = [ [1.0, 0.3963377774, 0.2158037573], @@ -59,6 +63,17 @@ pub(crate) fn oklab_to_srgb_linear(lab: [f64; 3]) -> [f64; 3] { mat_vec_mul(LMS_TO_SRGB, lms) } +/// Exact linear-sRGB grey at an Oklab lightness coordinate. +/// +/// On the neutral Oklab ray `a = b = 0`, the first column of `OKLAB_TO_LMS` is +/// exactly one, so all three cube-root LMS channels equal `L`; cubing gives +/// `L³`. Every row of `LMS_TO_SRGB` sums to one, hence every output channel is +/// the same `L³`. Sharing that scalar directly avoids row-specific round-off. +pub(crate) fn neutral_srgb_linear(l_ok: f64) -> [f64; 3] { + let channel = (l_ok * l_ok * l_ok).clamp(0.0, 1.0); + [channel; 3] +} + pub(crate) fn oklab_hue(rgb: [f64; 3]) -> f64 { // Хью зависит только от Oklab-компонент `a`, `b` (строки 1, 2 матрицы // `LMS_TO_OKLAB`) — светлота `L` (строка 0) не участвует. Считаем ровно эти @@ -83,10 +98,64 @@ pub(crate) fn oklab_hue(rgb: [f64; 3]) -> f64 { if deg < 0.0 { deg + 360.0 } else { deg } } +/// Oklab hue identity carried by one exact encoded-sRGB8 stimulus. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum OklabHue { + /// Equal encoded channel bytes: there is no chromatic direction to amplify. + Achromatic, + /// Unequal encoded channel bytes with their Oklab angular coordinate. + Chromatic { degrees: f64 }, +} + +/// Classify an exact emitted sRGB8 stimulus before deriving its Oklab angle. +/// +/// The grey-axis branch is exact byte geometry. It prevents `atan2` of matrix +/// round-off from becoming an invented saturated colour without introducing a +/// tolerance or a perceptual claim. +pub(crate) fn hue_of_srgb8(source: crate::Srgb8) -> OklabHue { + if source.is_achromatic() { + return OklabHue::Achromatic; + } + let linear = super::srgb::srgb_linear_from_srgb8(source); + OklabHue::Chromatic { + degrees: oklab_hue(linear), + } +} + #[cfg(test)] mod tests { use super::*; - use crate::spaces::srgb::srgb_from_hex; + use crate::spaces::srgb::{srgb_from_hex, srgb_gamma_inv}; + + #[test] + fn srgb8_hue_classification_is_exact_and_returns_chromatic_coordinates() { + for base in 0_i16..=255 { + for red_delta in -1_i16..=1 { + for green_delta in -1_i16..=1 { + for blue_delta in -1_i16..=1 { + let channels = [base + red_delta, base + green_delta, base + blue_delta]; + if channels.iter().any(|channel| !(0..=255).contains(channel)) { + continue; + } + let rgb = channels.map(|channel| channel as u8); + let source = crate::Srgb8::new(rgb); + if source.is_achromatic() { + assert_eq!(hue_of_srgb8(source), OklabHue::Achromatic); + } else { + let OklabHue::Chromatic { degrees } = hue_of_srgb8(source) else { + panic!( + "unequal sRGB8 bytes must retain a chromatic direction: {rgb:?}" + ); + }; + let linear = + rgb.map(|channel| srgb_gamma_inv(f64::from(channel) / 255.0)); + assert_eq!(degrees.to_bits(), oklab_hue(linear).to_bits()); + } + } + } + } + } + } #[test] fn white_gives_l1_a0_b0() { @@ -163,16 +232,4 @@ mod tests { h_b ); } - - #[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"); - } } diff --git a/crates/labcolors-core/src/spaces/oklch.rs b/crates/labcolors-core/src/spaces/oklch.rs index da4c2bed..83874159 100644 --- a/crates/labcolors-core/src/spaces/oklch.rs +++ b/crates/labcolors-core/src/spaces/oklch.rs @@ -3,7 +3,7 @@ //! Закон формы (владелец, 2026-07-02): «почему форма эмиссии везде разная? //! …в идеале бы выводить окончательно oklch». Солид и полупрозрачная роль //! отличаются ФИЗИКОЙ (α), но не синтаксисом: `oklch(L% C H)` и -//! `oklch(L% C H / A)` — один парсер, одна форма, перцептивно читаемые +//! `oklch(L% C H / A)` — один парсер, одна форма, явно именованные //! компоненты, готовность к широкому гамуту (P3 — этап gamut-aware солвера). //! //! Значения остаются решёнными в sRGB-гамуте: oklch здесь — система координат diff --git a/crates/labcolors-core/src/spaces/srgb.rs b/crates/labcolors-core/src/spaces/srgb.rs index 8df5aa8e..79ad447f 100644 --- a/crates/labcolors-core/src/spaces/srgb.rs +++ b/crates/labcolors-core/src/spaces/srgb.rs @@ -9,6 +9,7 @@ //! transitive crates) and guarantees exact reproducibility with other //! CSS-based pipelines. +use crate::Srgb8; use crate::srgb8::hex_bytes; /// D65 white point (normalized to Y = 1.0). @@ -132,10 +133,18 @@ pub(crate) fn decode_8bit(byte: u8) -> f64 { /// Parse `#RRGGBB` → linear sRGB `[r, g, b]` in `[0, 1]`. pub fn srgb_from_hex(hex: &str) -> Result<[f64; 3], String> { - let [r, g, b] = hex_bytes(hex)?; + Ok(srgb_linear_from_srgb8(Srgb8::new(hex_bytes(hex)?))) +} + +/// Decode one exact encoded triplet to continuous linear sRGB. +/// +/// Keeping this conversion typed lets callers that also need representation +/// facts parse the transport once without defining a second decode path. +pub(crate) fn srgb_linear_from_srgb8(rgb: Srgb8) -> [f64; 3] { + let [r, g, b] = rgb.bytes(); // The input is always an 8-bit byte, so the decode is an exact table lookup // (finite domain) — no per-channel powf. - Ok([decode_8bit(r), decode_8bit(g), decode_8bit(b)]) + [decode_8bit(r), decode_8bit(g), decode_8bit(b)] } /// Parse `#RRGGBB` → `(linear, display)` in one pass over the bytes: `linear` @@ -187,9 +196,18 @@ pub(crate) fn quantise_srgb(rgb: [f64; 3]) -> [f64; 3] { /// /// The input is continuous, so the gamma encode stays on the live transfer /// function (see the encode note above for why a table cannot be bit-exact here). -pub fn hex_from_srgb(rgb: [f64; 3]) -> String { +pub(crate) fn hex_from_srgb(rgb: [f64; 3]) -> String { + srgb8_from_linear(rgb).to_hex() +} + +/// Quantise continuous linear sRGB to the one exact emitted byte triplet. +/// +/// This is the typed form of [`hex_from_srgb`]; both share the same live gamma, +/// clamp and round operation, so string transport cannot define a second +/// output boundary. +pub(crate) fn srgb8_from_linear(rgb: [f64; 3]) -> crate::Srgb8 { let q = |c: f64| (srgb_gamma(c).clamp(0.0, 1.0) * 255.0).round() as u8; - format!("#{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2])) + crate::Srgb8::new([q(rgb[0]), q(rgb[1]), q(rgb[2])]) } /// Разбор `#RRGGBB` → ГАММА-КОДИРОВАННЫЙ sRGB `[r, g, b]` в `[0, 1]` @@ -199,19 +217,17 @@ pub fn hex_from_srgb(rgb: [f64; 3]) -> String { /// straight-alpha (см. [`crate::alpha`]); для колориметрии используй /// `srgb_from_hex` (линейный свет). pub fn srgb_encoded_from_hex(hex: &str) -> Result<[f64; 3], String> { - let [r, g, b] = hex_bytes(hex)?; - Ok([ - f64::from(r) / 255.0, - f64::from(g) / 255.0, - f64::from(b) / 255.0, - ]) + Ok(Srgb8::new(hex_bytes(hex)?).encoded()) } -/// Гамма-кодированный sRGB `[0, 1]` → `#RRGGBB` (clamp + round до 8 бит). -/// Обратная сторона [`srgb_encoded_from_hex`] — без гамма-преобразований. -pub fn hex_from_srgb_encoded(rgb: [f64; 3]) -> String { +/// Внутренняя сериализация конечного gamma-encoded sRGB в `#RRGGBB`. +/// +/// Это generated-finite boundary: публичный raw-`f64` formatter намеренно не +/// существует, потому `NaN`/бесконечность не должны превращаться в цвет. На +/// публичной границе exact-представление сериализуется через [`Srgb8::to_hex`]. +pub(crate) fn hex_from_srgb_encoded(rgb: [f64; 3]) -> String { let q = |c: f64| (c.clamp(0.0, 1.0) * 255.0).round() as u8; - format!("#{:02X}{:02X}{:02X}", q(rgb[0]), q(rgb[1]), q(rgb[2])) + Srgb8::new([q(rgb[0]), q(rgb[1]), q(rgb[2])]).to_hex() } /// Linear sRGB → CIE XYZ under D65. diff --git a/crates/labcolors-core/src/spaces/vc.rs b/crates/labcolors-core/src/spaces/vc.rs index 6f98549e..6079c6b5 100644 --- a/crates/labcolors-core/src/spaces/vc.rs +++ b/crates/labcolors-core/src/spaces/vc.rs @@ -17,31 +17,40 @@ use std::sync::OnceLock; use super::{cam16::adapt, cat16::xyz_to_cone}; -/// Viewing conditions for the CIECAM16 colour appearance model. +/// Условия просмотра для модели цветового восприятия CIECAM16. /// -/// Defaults match the sRGB standard (D65, 20 % grey background, -/// average surround, no discounting). +/// Значения по умолчанию соответствуют sRGB: D65, серый фон 20 %, среднее +/// окружение, без discounting. +/// +/// Производные поля образуют единое проверенное состояние и не изменяются +/// клиентом по отдельности: +/// +/// ```compile_fail +/// use labcolors_core::ViewingConditions; +/// let mut vc = ViewingConditions::srgb(); +/// vc.fl = f64::NAN; +/// ``` #[derive(Debug, Clone, Copy)] pub struct ViewingConditions { /// Background luminance factor (Yb / Yw). - pub n: f64, + pub(crate) n: f64, /// Achromatic response to the reference white. - pub aw: f64, + pub(crate) aw: f64, /// Chromatic induction factor. - pub nbb: f64, - pub ncb: f64, + pub(crate) nbb: f64, + pub(crate) ncb: f64, /// Luminance-level adaptation factor. - pub fl: f64, + pub(crate) fl: f64, /// Base exponential nonlinearity. - pub z: f64, + pub(crate) z: f64, /// Degree of chromatic adaptation. - pub c: f64, + pub(crate) c: f64, /// Chromatic induction factor. - pub nc: f64, + pub(crate) nc: f64, /// RGB discounting factors. - pub rgb_d: [f64; 3], + pub(crate) rgb_d: [f64; 3], /// Whether these conditions enforce increased contrast (IC). - pub high_contrast: bool, + pub(crate) high_contrast: bool, /// Предвычисленный `F_L^0.25`. Пер-VC константа, которую прямой ход CIECAM16 /// (множитель колорфулнесс `M`) и H-K-хрома иначе пересчитывали бы на КАЖДЫЙ /// цвет, хотя она зависит только от условий просмотра — фиксированных на все @@ -50,13 +59,13 @@ pub struct ViewingConditions { /// libm-вызов на том же операнде, под гейтом bit-identity оракула /// `cam16::forward`. Производное состояние (чистая функция `fl`), /// синхронизируется только через `build`. - pub fl_pow_025: f64, + pub(crate) fl_pow_025: f64, /// Предвычисленный `(1.64 − 0.29^n)^0.73` — пер-VC префактор колорфулнесс, /// общий для прямого `M` и обратного `t`. Хранится, а не перепечатывается на /// каждый цвет, по той же причине и под тем же гейтом bit-identity, что и /// [`fl_pow_025`](Self::fl_pow_025). Производное состояние (чистая функция /// `n`), синхронизируется только через `build`. - pub t_inner: f64, + pub(crate) t_inner: f64, } impl Default for ViewingConditions { @@ -66,6 +75,51 @@ impl Default for ViewingConditions { } impl ViewingConditions { + /// Коэффициент фоновой яркости CAM16: `n = Y_b / Y_w`. + pub fn n(&self) -> f64 { + self.n + } + + /// Ахроматический отклик эталонного белого. + pub fn aw(&self) -> f64 { + self.aw + } + + /// Коэффициент хроматической индукции `N_bb`. + pub fn nbb(&self) -> f64 { + self.nbb + } + + /// Коэффициент адаптации к уровню яркости `F_L`. + pub fn fl(&self) -> f64 { + self.fl + } + + /// Базовый экспоненциальный член CAM16 `z`. + pub fn z(&self) -> f64 { + self.z + } + + /// Коэффициент хроматической адаптации окружения `c`. + pub fn c(&self) -> f64 { + self.c + } + + /// Коэффициент хроматической индукции окружения `N_c`. + pub fn nc(&self) -> f64 { + self.nc + } + + /// RGB-коэффициенты discounting, выведенные из полных условий просмотра. + pub fn rgb_d(&self) -> [f64; 3] { + self.rgb_d + } + + /// Требует ли пресет контракты повышенного контраста. + pub fn is_high_contrast(&self) -> bool { + self.high_contrast + } + /// Standard sRGB viewing conditions (average surround). /// /// Parameters: D65 illuminant, L_A = 64 cd/m², Y_b = 20 %, @@ -108,17 +162,13 @@ impl ViewingConditions { /// `dark_surround` constructor keeps the F = 0.8 endpoint available for /// comparisons and tests. /// - /// The documented dim triplet is asserted here so a silent regression to the - /// dark parameters — the choice this rationale rejects — fails the doctest: + /// Doctest проверяет классификатор пресета и ловит тихий возврат к среднему + /// окружению: /// /// ``` /// use labcolors_core::ViewingConditions; /// let dim = ViewingConditions::dim_surround(); - /// // Dim surround (CIECAM16 Table 1): c = 0.59, N_c = 0.9. - /// assert_eq!(dim.c, 0.59); - /// assert_eq!(dim.nc, 0.9); - /// // Deliberately NOT the dark endpoint (c = 0.525, N_c = 0.8). - /// assert_ne!(dim.c, 0.525); + /// assert!(dim.is_dark_theme()); /// ``` pub fn dim_surround() -> Self { // colour-science / colorjs.io surroundMap["dim"] = [0.9, 0.59, 0.9] @@ -206,19 +256,15 @@ impl ViewingConditions { } } - /// Whether these conditions describe a dimmed/darkened viewing environment — - /// the surround a dark theme resolves under, as opposed to the bright - /// average surround a light theme uses. + /// Определяет, описывают ли условия приглушённое или тёмное окружение, + /// используемое тёмной темой, в отличие от среднего окружения светлой темы. /// - /// The discriminator is the surround chromatic-induction factor `c`: the - /// average (light) preset fixes it at `0.69`; every dimmer preset (`dim` - /// 0.59, `dark` 0.525) sits below it. A single midpoint threshold (`0.64`) - /// separates them with float headroom on both sides, so the classification is - /// stable against rounding. This keeps the viewing conditions the single - /// source of truth for theme-ness: a role contract that calibrates per theme - /// (the dJ' decorative anchors, which the owner measured separately for light - /// and dark) reads the theme from the VC it is resolved under, never from a - /// flag duplicated elsewhere. + /// Дискриминатор — коэффициент хроматической адаптации окружения `c`: + /// средний пресет фиксирует `0.69`, а `dim` и `dark` — `0.59` и `0.525`. + /// Порог посередине (`0.64`) оставляет запас для округления. Условия + /// просмотра остаются единственным источником режима: контракт роли с + /// заданными по темам смещениями `J′` читает сторону из VC, а не из + /// дублирующего флага. pub fn is_dark_theme(&self) -> bool { const AVERAGE_DIM_MIDPOINT_C: f64 = 0.64; self.c < AVERAGE_DIM_MIDPOINT_C diff --git a/crates/labcolors-core/src/srgb8.rs b/crates/labcolors-core/src/srgb8.rs index 0fd66c97..a5521ace 100644 --- a/crates/labcolors-core/src/srgb8.rs +++ b/crates/labcolors-core/src/srgb8.rs @@ -15,24 +15,44 @@ pub(crate) fn hex_bytes(hex: &str) -> Result<[u8; 3], String> { } // END WCAG22_PARSER_CAPSULE_V1 -/// One exact final encoded-sRGB8 triplet. +/// Одна точная финальная encoded-sRGB8 тройка. /// -/// This is a physical value object, not a colour-role or client-semantic type. -/// It is intentionally unversioned: versioned profiles describe how bytes are -/// interpreted, while the bytes themselves remain exactly three octets. +/// Это физический value object, а не цветовая роль или клиентская семантика. +/// Он намеренно не версионируется: профили описывают интерпретацию байтов, сами +/// байты всегда остаются ровно тремя октетами. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Srgb8([u8; 3]); impl Srgb8 { - /// Construct one exact byte triplet. + /// Создать одну точную байтовую тройку. pub const fn new(bytes: [u8; 3]) -> Self { Self(bytes) } - /// Return the exact three encoded bytes. + /// Вернуть три точных encoded-байта. pub const fn bytes(self) -> [u8; 3] { self.0 } + + /// Проецировать байты в binary64-координаты `byte / 255` encoded-sRGB решётки. + pub(crate) fn encoded(self) -> [f64; 3] { + self.0.map(|byte| f64::from(byte) / 255.0) + } + + /// Сериализовать точную тройку как канонический uppercase `#RRGGBB`. + pub fn to_hex(self) -> String { + let [red, green, blue] = self.0; + format!("#{red:02X}{green:02X}{blue:02X}") + } + + /// Лежит ли encoded-стимул точно на серой оси sRGB8. + /// + /// Это дискретный факт представления, не перцептивный порог: равные байты + /// каналов не несут цветового направления для hue-производной операции, а + /// любая неравная тройка сохраняет заданное клиентом направление. + pub(crate) const fn is_achromatic(self) -> bool { + self.0[0] == self.0[1] && self.0[1] == self.0[2] + } } impl From<[u8; 3]> for Srgb8 { @@ -68,7 +88,41 @@ mod tests { fn typed_public_value_round_trips_exact_bytes() { let value = Srgb8::new([0x1A, 0x2B, 0x3C]); assert_eq!(value.bytes(), [0x1A, 0x2B, 0x3C]); + assert_eq!(value.to_hex(), "#1A2B3C"); assert_eq!(Srgb8::from(value.bytes()), value); assert_eq!(<[u8; 3]>::from(value), [0x1A, 0x2B, 0x3C]); } + + #[test] + fn achromatic_identity_is_exact_channel_equality() { + for base in 0_i16..=255 { + for red_delta in -1_i16..=1 { + for green_delta in -1_i16..=1 { + for blue_delta in -1_i16..=1 { + let channels = [base + red_delta, base + green_delta, base + blue_delta]; + if channels.iter().any(|channel| !(0..=255).contains(channel)) { + continue; + } + let bytes = channels.map(|channel| channel as u8); + assert_eq!( + Srgb8::new(bytes).is_achromatic(), + bytes[0] == bytes[1] && bytes[1] == bytes[2], + "exact grey-axis classification drifted for {bytes:?}" + ); + } + } + } + } + } + + #[test] + fn encoded_is_bit_exact_byte_over_255_for_every_channel() { + for byte in u8::MIN..=u8::MAX { + let bytes = [byte, byte.wrapping_add(1), byte.wrapping_sub(1)]; + assert_eq!( + Srgb8::new(bytes).encoded().map(f64::to_bits), + bytes.map(|channel| (f64::from(channel) / 255.0).to_bits()), + ); + } + } } diff --git a/crates/labcolors-core/src/wcag.rs b/crates/labcolors-core/src/wcag.rs index dbdbf275..6209c80a 100644 --- a/crates/labcolors-core/src/wcag.rs +++ b/crates/labcolors-core/src/wcag.rs @@ -109,12 +109,12 @@ pub(crate) fn ratio_from_luminances(la: f64, lb: f64) -> f64 { (lighter + 0.05) / (darker + 0.05) } -/// Frozen legacy WCAG 2.1 (2018) contrast ratio between two gamma-encoded sRGB -/// colours, in `[1, 21]`. +/// Зафиксированное legacy-отношение контраста WCAG 2.1 (2018) между двумя +/// gamma-кодированными цветами sRGB в `[1, 21]`. /// -/// `(L_lighter + 0.05) / (L_darker + 0.05)`. Symmetric and polarity-agnostic by -/// construction — unlike the signed perceptual LPC metric, which is why the two -/// numbers are reported separately and never folded into one. +/// `(L_lighter + 0.05) / (L_darker + 0.05)`. Формула симметрична и не зависит от +/// полярности, в отличие от знаковой candidate-кривой. Поэтому величины +/// возвращаются отдельно и не объединяются. pub(crate) fn contrast_ratio(a: [f64; 3], b: [f64; 3]) -> f64 { ratio_from_luminances(relative_luminance(a), relative_luminance(b)) } diff --git a/crates/labcolors-core/tests/agnostic_production_surface.rs b/crates/labcolors-core/tests/agnostic_production_surface.rs index 395ed5e3..1da80a09 100644 --- a/crates/labcolors-core/tests/agnostic_production_surface.rs +++ b/crates/labcolors-core/tests/agnostic_production_surface.rs @@ -1,50 +1,44 @@ -//! Agnostic-production-surface gate (ADR-0001 PR-c): поставляемый `src` не -//! содержит клиентских брендовых значений и showcase-типов — ядро агностично -//! к дизайн-системе, встроенные якоря живут только в `#[cfg(test)]`-оракулах. +//! Гейт агностичной production-поверхности (ADR-0001): поставляемый `src` не +//! содержит клиентских брендовых значений и showcase-типов. Встроенные якоря +//! живут только в `#[cfg(test)]`-оракулах. //! -//! BUG CLASS this guards: a built-in BRAND value or SHOWCASE type silently -//! re-enters the PRODUCTION surface of the agnostic core. PR-c made the engine -//! agnostic — the HIG/Figma anchor hexes (`#007AFF`/`#FF3B30`/…) and the built-in -//! showcase types (`Accent`/`Sentiment`/`Role`/`RoleTable`) survive ONLY as -//! `#[cfg(test)]` oracles for the string-keyed path. The failure mode this closes -//! is INVISIBLE to every behavioural test: if someone drops a `#[cfg(test)]`, -//! hardcodes an anchor hex inside a resolver, or re-exports a showcase enum, the -//! built-ins still WORK, so all the value/property/golden tests stay green — the -//! core has merely stopped being agnostic. This gate turns that regression RED by -//! scanning the production (non-`cfg(test)`, non-comment) `src` for either class. +//! Защищаемый класс регрессии: встроенное бренд-значение, showcase-тип или +//! удалённая sentiment-физика тихо возвращаются в production-поверхность Core. +//! HIG/Figma-якоря (`#007AFF`, `#FF3B30`, …), `Role` и `RoleTable` допустимы +//! только в `#[cfg(test)]`-оракулах; `Accent` и sentiment-схема удалены. +//! Поведенческие тесты не замечают такую утечку, потому что значения продолжают +//! вычисляться. Этот гейт сканирует production-код без `cfg(test)` и комментариев +//! и превращает потерю агностичности в RED. //! -//! Scope discipline (what is NOT a violation): -//! * Doc/line comments may still CITE a retired anchor — the physics rustdoc -//! explains why `#007AFF` was replaced. Comments are cut (at `//`) before the -//! scan, so a cited hex never trips the gate; only a hex in live code does. -//! * `#[cfg(test)]` blocks and whole `#[cfg(test)] mod X;` files (the relocated -//! byte-identity oracles, the labui fixture) legitimately hold every anchor and -//! enum — they are stripped/excluded before the scan. +//! Не считаются нарушением: +//! * ссылки на удалённый якорь в строковых и блок-комментариях: общий lexer +//! маскирует комментарии, не принимая `//` внутри строки за их начало; +//! * блоки и файлы `#[cfg(test)] mod X;` с byte-identity-оракулами и фикстурами: +//! они исключаются до сканирования. //! -//! INV-4 (no green-from-birth): the two GATE tests and the four `red_proof_*` -//! tests call the SAME scanner (`forbidden_hex_sites` / `forbidden_definition_sites` -//! over `production_lines`), so a mutation to the detector — or to the cfg(test) -//! stripper — is caught by a RED-proof, not merely asserted by an inlined check. +//! INV-4: GATE-тесты и `red_proof_*` вызывают одни scanner-функции поверх +//! `production_lines`. Мутацию детектора или удаления `cfg(test)` ловит +//! RED-proof, а не отдельная встроенная проверка. //! -//! Pure-`std`, zero new deps (labcolors-core stays zero-dep, issue #29); a -//! top-of-graph test-only consumer (Clean: depends on `src`, nothing depends on it). +//! Реализация использует только `std`: `labcolors-core` остаётся без зависимостей. use std::collections::BTreeSet; use std::path::{Path, PathBuf}; mod common; +use common::source::{production_code_lines, production_lines}; use common::src_dir; // ───────────────────────────────────────────────────────────────────────────── -// Forbidden production content. +// Запрещённое содержимое production-кода. // ───────────────────────────────────────────────────────────────────────────── -/// Brand-ANCHOR hex literals that must never appear in production code. These are -/// the culturally-recognised seed colours the built-in showcase baked in (Apple -/// HIG legacy blue/orange + the ten Figma `Accent/*` primitives). In the agnostic -/// core they live ONLY in the `#[cfg(test)]` `Accent::anchor_hex` oracle and the -/// labui fixture; a consumer supplies its own via `ThemeConfig`. -const BRAND_ANCHOR_HEXES: &[&str] = &[ +/// Клиентские литералы якорей, запрещённые в production-коде. +/// +/// Сюда входят прежние акцентные seeds и референсная нейтральная шкала Lab UI. +/// Они допустимы в characterization под `#[cfg(test)]`, но поставляемый helper +/// Core не хранит клиентскую фикстуру ради воспроизведения калибровки. +const CLIENT_ANCHOR_HEXES: &[&str] = &[ "#007AFF", // Apple HIG systemBlue — legacy Info anchor (now cited only in docs). "#FF9500", // Apple HIG systemOrange — legacy Warning anchor. "#FF3B30", // Figma Accent/Red — Danger. @@ -57,12 +51,22 @@ const BRAND_ANCHOR_HEXES: &[&str] = &[ "#5856D6", // Figma Accent/Indigo. "#AF52DE", // Figma Accent/Purple. "#FF2D55", // Figma Accent/Pink. + "#101012", "#151518", "#212125", "#303136", "#44444B", "#5B5C64", "#787881", "#9698A2", + "#B3B5BF", "#CDD0D9", "#E4E7ED", "#F6F8FA", ]; -/// Built-in SHOWCASE type DEFINITIONS that must stay `#[cfg(test)]`-only. A match -/// in production code means a `#[cfg(test)]` was dropped and the showcase re-entered -/// the shipped API. Matched with identifier boundaries so production types that -/// merely share a prefix (`RoleChroma`, `RoleSpec`, `NamedRoleTable`) are NOT hits. +/// Швы клиентской калибровки, допустимые только в characterization-тестах. +const CLIENT_CALIBRATION_IDENTIFIERS: &[&str] = &[ + "tint_target_sweep_repro", + "NEUTRAL_HUE_DEG", + "TINT_TARGET_MP", + "TINT_HUE_STIFFNESS", +]; + +/// Определения встроенных showcase-типов, допустимые только под `#[cfg(test)]`. +/// Совпадение в production-коде означает возврат showcase в API. Границы +/// идентификаторов исключают ложные срабатывания на `RoleChroma`, `RoleSpec` и +/// `NamedRoleTable`. const SHOWCASE_DEFINITIONS: &[&str] = &[ "enum Accent", "enum Sentiment", @@ -70,96 +74,68 @@ const SHOWCASE_DEFINITIONS: &[&str] = &[ "struct RoleTable", ]; -// ───────────────────────────────────────────────────────────────────────────── -// production_lines — strip `#[cfg(test)]`-guarded items, preserving 1-based line -// numbers for diagnostics. Mirrors the `empirical_inventory` gate's stripper: a -// braced item (`mod tests { … }`, a gated `impl`/`enum`) is removed by brace-match; -// a bracket/`;`-terminated item (`#[cfg(test)] pub(crate) const ALL … ;`, -// `#[cfg(test)] mod fixture;`) by `;`-match. Only test code is removed. -// ───────────────────────────────────────────────────────────────────────────── +/// Точные идентификаторы удалённой встроенной sentiment-модели. +/// +/// Общая лексика evidence и validators не запрещена. Список охраняет только +/// прежние физическую схему и resolver-поверхность, возврат которых заставил бы +/// Core снова интерпретировать клиентский смысл. +const RETIRED_SENTIMENT_IDENTIFIERS: &[&str] = &[ + "SentimentCategory", + "SentimentsConfig", + "SentimentCurve", + "SentimentResolution", + "UnknownSentiment", + "resolve_config_sentiment_solid", + "sentiment_solid_for_mode", + "WARNING_HUE_FLOOR_DEG", + "S_PERC_MIN", + "NeighborZone", + "hue_floor_deg", + "preferred_side", + "chroma_fraction", + "CHROMA_FRACTION", +]; -fn production_lines(source: &str) -> Vec<(usize, String)> { - let lines: Vec<&str> = source.lines().collect(); - let mut out = Vec::new(); - let mut i = 0; - while i < lines.len() { - if lines[i].trim_start().starts_with("#[cfg(test)]") { - // Skip further attribute/blank lines to reach the guarded item. - let mut j = i + 1; - while j < lines.len() { - let tj = lines[j].trim_start(); - if tj.starts_with('#') || tj.is_empty() { - j += 1; - } else { - break; - } - } - if j < lines.len() && lines[j].contains('{') { - // Braced item: brace-match to its close. - let mut depth = 0i32; - let mut opened = false; - let mut k = j; - while k < lines.len() { - depth += lines[k].matches('{').count() as i32; - depth -= lines[k].matches('}').count() as i32; - if lines[k].contains('{') { - opened = true; - } - if opened && depth <= 0 { - break; - } - k += 1; - } - i = k + 1; - } else { - // Non-braced item (`… ;`): skip through the terminating `;`. - let mut k = j; - while k < lines.len() && !lines[k].contains(';') { - k += 1; - } - i = k + 1; - } - continue; - } - out.push((i + 1, lines[i].to_string())); - i += 1; - } - out -} +/// Фрагменты синтаксиса, где пунктуация входит в удалённый контракт. +const RETIRED_SENTIMENT_FRAGMENTS: &[&str] = &[ + "LadderSource::Sentiment", + "pub mod sentiment", + "mod sentiment;", +]; -/// Cut a source line at the first `//` (line/inline/doc comment). No `/* */` block -/// comments exist in the production `src` (verified), so this is sufficient to keep -/// a cited anchor hex in a comment from tripping the gate. -fn code_only(line: &str) -> &str { - match line.split_once("//") { - Some((before, _)) => before, - None => line, - } -} +// ───────────────────────────────────────────────────────────────────────────── +// production_lines удаляет доказанно полные элементы под `#[cfg(test)]`, сохраняя +// нумерацию строк с единицы. Неоднозначный синтаксис остаётся видимым: гейт +// предпочитает ложный RED скрытому production-нарушению. +// ───────────────────────────────────────────────────────────────────────────── /// True when `decl` (e.g. `enum Role`) appears in `code` bounded by non-identifier /// characters on both sides — so `enum Role` does NOT match `enum RoleChroma`, and /// `struct RoleTable` does NOT match `struct NamedRoleTable`. -fn defines(code: &str, decl: &str) -> bool { +fn contains_bounded(code: &str, needle: &str) -> bool { let mut start = 0; - while let Some(pos) = code[start..].find(decl) { + while let Some(pos) = code[start..].find(needle) { let at = start + pos; let before_ok = code[..at] .chars() .last() .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_')); - let after_ok = code[at + decl.len()..] + let after_ok = code[at + needle.len()..] .chars() .next() .is_none_or(|c| !(c.is_ascii_alphanumeric() || c == '_')); if before_ok && after_ok { return true; } - start = at + decl.len(); + start = at + needle.len(); } false } +fn defines(code: &str, decl: &str) -> bool { + contains_bounded(code, decl) +} + // ───────────────────────────────────────────────────────────────────────────── // Shared scanners — called verbatim by the GATE tests and the RED-proofs (INV-4). // ───────────────────────────────────────────────────────────────────────────── @@ -173,13 +149,13 @@ struct Site { } /// Every brand-anchor hex literal in the PRODUCTION code of `source` -/// (`#[cfg(test)]` stripped, comments cut). Case-insensitive: `#3e87ff` and -/// `#3E87FF` are the same anchor. +/// (`#[cfg(test)]` удалён, комментарии лексически замаскированы). Регистр не +/// различается: `#3e87ff` и `#3E87FF` — один якорь. fn forbidden_hex_sites(module: &str, source: &str) -> Vec { let mut out = Vec::new(); - for (line, text) in production_lines(source) { - let code = code_only(&text).to_ascii_uppercase(); - for hex in BRAND_ANCHOR_HEXES { + for (line, text) in production_code_lines(source) { + let code = text.to_ascii_uppercase(); + for hex in CLIENT_ANCHOR_HEXES { if code.contains(&hex.to_ascii_uppercase()) { out.push(Site { module: module.to_string(), @@ -192,13 +168,29 @@ fn forbidden_hex_sites(module: &str, source: &str) -> Vec { out } -/// Every showcase type DEFINITION in the PRODUCTION code of `source`. +/// Все швы клиентской калибровки в production-коде. +fn client_calibration_sites(module: &str, source: &str) -> Vec { + let mut out = Vec::new(); + for (line, code) in production_code_lines(source) { + for identifier in CLIENT_CALIBRATION_IDENTIFIERS { + if contains_bounded(&code, identifier) { + out.push(Site { + module: module.to_string(), + line, + found: (*identifier).to_string(), + }); + } + } + } + out +} + +/// Все определения showcase-типов в production-коде `source`. fn forbidden_definition_sites(module: &str, source: &str) -> Vec { let mut out = Vec::new(); - for (line, text) in production_lines(source) { - let code = code_only(&text); + for (line, code) in production_code_lines(source) { for decl in SHOWCASE_DEFINITIONS { - if defines(code, decl) { + if defines(&code, decl) { out.push(Site { module: module.to_string(), line, @@ -210,6 +202,32 @@ fn forbidden_definition_sites(module: &str, source: &str) -> Vec { out } +/// Все точные остатки удалённых sentiment-физики и схемы. +fn retired_sentiment_sites(module: &str, source: &str) -> Vec { + let mut out = Vec::new(); + for (line, code) in production_code_lines(source) { + for identifier in RETIRED_SENTIMENT_IDENTIFIERS { + if contains_bounded(&code, identifier) { + out.push(Site { + module: module.to_string(), + line, + found: (*identifier).to_string(), + }); + } + } + for fragment in RETIRED_SENTIMENT_FRAGMENTS { + if code.contains(fragment) { + out.push(Site { + module: module.to_string(), + line, + found: (*fragment).to_string(), + }); + } + } + } + out +} + // ───────────────────────────────────────────────────────────────────────────── // Production-file enumeration — every `src/**/*.rs` EXCEPT files that are declared // as `#[cfg(test)] mod X;` (whole-file test modules: the relocated byte-identity @@ -328,7 +346,7 @@ fn module_label(file: &Path) -> String { // ───────────────────────────────────────────────────────────────────────────── #[test] -fn production_src_carries_no_brand_anchor_hex() { +fn production_src_carries_no_client_anchor_hex() { let mut leaks = Vec::new(); for file in production_src_files() { let source = std::fs::read_to_string(&file) @@ -337,11 +355,27 @@ fn production_src_carries_no_brand_anchor_hex() { } assert!( leaks.is_empty(), - "AGNOSTIC-CLEANLINESS: brand-anchor hex leaked into PRODUCTION code (must be \ + "AGNOSTIC-CLEANLINESS: client anchor hex leaked into PRODUCTION code (must be \ `#[cfg(test)]`-only or supplied via ThemeConfig). Offending sites: {leaks:#?}" ); } +#[test] +fn production_src_carries_no_client_calibration_seam() { + let mut leaks = Vec::new(); + for file in production_src_files() { + let source = std::fs::read_to_string(&file) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); + leaks.extend(client_calibration_sites(&module_label(&file), &source)); + } + assert!( + leaks.is_empty(), + "AGNOSTIC-CLEANLINESS: a client-specific calibration seam leaked into \ + PRODUCTION Core. Keep reproducibility fixtures under `#[cfg(test)]`. \ + Offending sites: {leaks:#?}" + ); +} + #[test] fn production_src_defines_no_builtin_showcase_type() { let mut leaks = Vec::new(); @@ -357,6 +391,164 @@ fn production_src_defines_no_builtin_showcase_type() { ); } +#[test] +fn production_src_carries_no_retired_sentiment_physics_or_schema() { + let mut leaks = Vec::new(); + for file in production_src_files() { + let source = std::fs::read_to_string(&file) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", file.display())); + leaks.extend(retired_sentiment_sites(&module_label(&file), &source)); + } + let retired_module = src_dir().join("sentiment.rs"); + assert!( + !retired_module.exists(), + "AGNOSTIC-CLEANLINESS: deleted sentiment module returned at {}", + retired_module.display() + ); + assert!( + leaks.is_empty(), + "AGNOSTIC-CLEANLINESS: retired built-in sentiment physics/schema re-entered \ + PRODUCTION Core. Offending sites: {leaks:#?}" + ); +} + +#[test] +fn red_proof_retired_sentiment_scanner_bites_on_every_retired_contract_token() { + for identifier in RETIRED_SENTIMENT_IDENTIFIERS { + let dirty = format!("fn probe(value: {identifier}) {{ let _ = value; }}\n"); + let hits = retired_sentiment_sites("probe.rs", &dirty); + assert_eq!( + hits.len(), + 1, + "scanner must flag retired identifier {identifier:?}" + ); + assert_eq!(hits[0].found, *identifier); + } + + for fragment in RETIRED_SENTIMENT_FRAGMENTS { + let dirty = format!("{fragment}\n"); + let hits = retired_sentiment_sites("probe.rs", &dirty); + assert_eq!( + hits.len(), + 1, + "scanner must flag retired syntax fragment {fragment:?}" + ); + assert_eq!(hits[0].found, *fragment); + } +} + +#[test] +fn red_proof_retired_sentiment_scanner_ignores_tests_comments_and_lookalikes() { + let gated = "#[cfg(test)]\nmod t {\n struct SentimentCurve;\n}\n"; + assert!(retired_sentiment_sites("probe.rs", gated).is_empty()); + + let comments = "// SentimentCategory was deleted\n/// no LadderSource::Sentiment\n"; + assert!(retired_sentiment_sites("probe.rs", comments).is_empty()); + + let lookalikes = "struct ClientSentimentCategory;\nfn chroma_fractional() {}\n"; + assert!(retired_sentiment_sites("probe.rs", lookalikes).is_empty()); +} + +#[test] +fn red_proof_cfg_test_stripper_ignores_non_code_braces_without_hiding_live_code() { + let opening_in_string = "#[cfg(test)]\nmod t {\n const S: &str = \"{\";\n}\n\ + struct SentimentCurve;\n"; + let hits = retired_sentiment_sites("probe.rs", opening_in_string); + assert_eq!( + hits.len(), + 1, + "a brace in a test string must not hide a later production violation" + ); + + let closing_in_test_text = "#[cfg(test)]\nmod t {\n const S: &str = \"}\";\n\ + /* } */\n struct SentimentCurve;\n}\n"; + assert!( + retired_sentiment_sites("probe.rs", closing_in_test_text).is_empty(), + "a brace in test text must not end the guarded item early" + ); + + let non_braced_item = "#[cfg(test)]\nconst TEST_TEXT: &str = \"{\";\nstruct SentimentCurve;\n"; + assert_eq!( + retired_sentiment_sites("probe.rs", non_braced_item).len(), + 1, + "a brace in a test constant must not turn it into a braced item" + ); + + let next_line_brace = "#[cfg(test)]\nmod t\n{\n struct SentimentCurve;\n}\n"; + assert!( + retired_sentiment_sites("probe.rs", next_line_brace).is_empty(), + "a guarded braced item may open on a later line" + ); + + let other_non_code = r####"#[cfg(test)] +mod t { + const RAW: &str = r##"{"##; + const CHAR: char = '{'; + /* { /* } */ { */ +} +struct SentimentCurve; +"####; + assert_eq!( + retired_sentiment_sites("probe.rs", other_non_code).len(), + 1, + "raw strings, chars, and nested block comments must not hide live code" + ); + + let fake_attribute_in_comment = "/*\n#[cfg(test)]\nmod fake {\n*/\nstruct SentimentCurve;\n"; + assert_eq!( + retired_sentiment_sites("probe.rs", fake_attribute_in_comment).len(), + 1, + "cfg-looking text inside a block comment is not an attribute" + ); + + let fake_attribute_in_raw = r####"const TEXT: &str = r##" +#[cfg(test)] +mod fake { +"##; +struct SentimentCurve; +"####; + assert_eq!( + retired_sentiment_sites("probe.rs", fake_attribute_in_raw).len(), + 1, + "cfg-looking text inside a raw string is not an attribute" + ); + + let same_line_attribute = "#[cfg(test)] mod t {}\nstruct SentimentCurve;\n"; + assert_eq!( + retired_sentiment_sites("probe.rs", same_line_attribute).len(), + 1, + "an unsupported same-line cfg item must fail closed instead of hiding later code" + ); + + let live_suffix = "#[cfg(test)]\nmod t {} struct SentimentCurve;\n"; + assert_eq!( + retired_sentiment_sites("probe.rs", live_suffix).len(), + 1, + "production code after a guarded item on the same line must remain visible" + ); + + let same_line_followup_attribute = + "#[cfg(test)]\n#[allow(dead_code)] mod t {}\nstruct SentimentCurve;\n"; + assert_eq!( + retired_sentiment_sites("probe.rs", same_line_followup_attribute).len(), + 1, + "a same-line follow-up attribute must not make the next production item guarded" + ); + + let block_comment_suffix = "#[cfg(test)]\nmod t { struct SentimentCurve; } /* rationale */\n"; + assert!( + retired_sentiment_sites("probe.rs", block_comment_suffix).is_empty(), + "a closed block-comment suffix is not production code" + ); + + let block_comment_on_marker = + "#[cfg(test)] /* test fixture */\nmod t { struct SentimentCurve; }\n"; + assert!( + retired_sentiment_sites("probe.rs", block_comment_on_marker).is_empty(), + "a closed block comment after cfg(test) must not make the marker ambiguous" + ); +} + // ───────────────────────────────────────────────────────────────────────────── // RED-proofs — prove the shared scanner BITES on a violation and is SILENT on the // two legitimate cases (cfg(test) block, comment). A mutation that neuters the @@ -375,6 +567,21 @@ fn red_proof_hex_scanner_bites_on_injected_anchor_in_code() { ); assert_eq!(hits[0].found, "#3E87FF"); + let scheme_literal = "const URL: &str = \"scheme://palette/#3E87FF\";\n"; + let hits = forbidden_hex_sites("probe.rs", scheme_literal); + assert_eq!( + hits.len(), + 1, + "comment syntax inside a string must not hide a production anchor" + ); + let raw_scheme_literal = r##"const URL: &str = r#"scheme://palette/#3E87FF"#; +"##; + assert_eq!( + forbidden_hex_sites("probe.rs", raw_scheme_literal).len(), + 1, + "comment syntax inside a raw string must remain production code" + ); + // Clean agnostic code has none. let clean = " let set = resolve_named_set(bg, table, vc)?;\n"; assert!( @@ -383,6 +590,23 @@ fn red_proof_hex_scanner_bites_on_injected_anchor_in_code() { ); } +#[test] +fn red_proof_client_calibration_scanner_bites_and_respects_cfg_test() { + for identifier in CLIENT_CALIBRATION_IDENTIFIERS { + let dirty = format!("fn probe() {{ let _ = {identifier}; }}\n"); + let hits = client_calibration_sites("probe.rs", &dirty); + assert_eq!(hits.len(), 1, "scanner must flag {identifier}"); + assert_eq!(hits[0].found, *identifier); + + let gated = + format!("#[cfg(test)]\nmod t {{\n fn probe() {{ let _ = {identifier}; }}\n}}\n"); + assert!( + client_calibration_sites("probe.rs", &gated).is_empty(), + "test-only calibration {identifier} must remain legal" + ); + } +} + #[test] fn red_proof_hex_scanner_is_silent_on_cfg_test_and_comments() { // Inside a `#[cfg(test)]` block: invisible (stripped). @@ -393,7 +617,7 @@ fn red_proof_hex_scanner_is_silent_on_cfg_test_and_comments() { ); // In a comment (line + doc): invisible (cut at `//`). - let commented = "// the retired anchor was #007AFF\n/// доки: было #FF9500\n"; + let commented = "// the retired anchor was #007AFF\n/// доки: было #FF9500\n/* #3E87FF */\n"; assert!( forbidden_hex_sites("probe.rs", commented).is_empty(), "a cited hex in a comment must NOT be flagged" diff --git a/crates/labcolors-core/tests/anchor_transfer_derivation.rs b/crates/labcolors-core/tests/anchor_transfer_derivation.rs deleted file mode 100644 index a8e9eeed..00000000 --- a/crates/labcolors-core/tests/anchor_transfer_derivation.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! GATE: деривация текстовых долей labui из Ys-переноса Figma-якорей. -//! -//! Доли лейблов (`LABEL_*_FRACTION` в `semantic.rs`, литералы пресета labui в -//! `config/preset.rs` и wasm-фикстуре `labui.config.json`) — НЕ магические -//! числа: каждая обязана выводиться из замера. Генезис-якоря Даниила сняты с -//! Figma «Labels/Neutral» в легаси-домене Y_hk (102.6/66.5/48.9/29.3 на белом -//! при максимуме ~106). При миграции мерила читаемости на Ys (ADR-0003; -//! derivation locks ниже) якоря перенесены с инвариантом «цвет, а не Lc-число»: -//! -//! * primary/secondary/quaternary — Ys-замер ПРИНЯТЫХ владельцем hex'ов -//! лестницы `#141414`/`#767676`/`#C2C2C2` (байт-идентичность эмиссии — -//! приёмочный критерий ревью; цель солвера ложится ровно на байт); -//! * tertiary — эмиссия защищена полом WCAG 3:1 (`#949494`), поэтому якорь -//! восстановлен побайтовой инверсией генезис-числа 48.9 в Y_hk → `#9C9C9C`. -//! -//! Если этот гейт упал — либо сдвинулось само мерило (Ys-конвейер), либо -//! кто-то тронул доли руками. И то и другое — не «подкрутить тест», а -//! перевывести доли по протоколу выше и синхронизировать все три носителя -//! (semantic.rs, preset.rs, labui.config.json) + README/whitepaper. - -use labcolors_core::lpc::{lpc_readability_ys, lpc_with_vc}; -use labcolors_core::{ViewingConditions, srgb_encoded_from_hex}; - -/// (роль, канонический якорь-hex, ожидаемая доля). -const EXPECTED: [(&str, &str, f64); 4] = [ - ("primary", "#141414", 0.97335917), - ("secondary", "#767676", 0.64359014), - ("tertiary", "#9C9C9C", 0.47572199), - ("quaternary", "#C2C2C2", 0.29335999), -]; - -/// Генезис-якорь tertiary в Y_hk, единственный не представленный принятым -/// hex'ом эмиссии (пол 3:1 поднимает эмиссию до `#949494`). -const TERTIARY_GENESIS_YHK: f64 = 48.9; - -fn ys_on_white(hex: &str) -> f64 { - let f = srgb_encoded_from_hex(hex).unwrap(); - let w = srgb_encoded_from_hex("#FFFFFF").unwrap(); - lpc_readability_ys(f, w) -} - -#[test] -fn fractions_derive_from_ys_anchor_transfer() { - let max = { - let b = srgb_encoded_from_hex("#000000").unwrap(); - let w = srgb_encoded_from_hex("#FFFFFF").unwrap(); - lpc_readability_ys(b, w) - }; - // Знаменатель — Ys-максимум на белом (чёрный текст). Калибровка долей - // выполнена при 106.0407; дрейф знаменателя = дрейф мерила. - assert!( - (max - 106.0407).abs() < 5e-5, - "Ys-максимум на белом сдвинулся: {max} (калибровка долей была при 106.0407)" - ); - - for (role, hex, expected) in EXPECTED { - let frac = ys_on_white(hex) / max; - assert!( - (frac - expected).abs() < 5e-9, - "{role}: доля {frac:.8} != запечатанной {expected:.8} (якорь {hex})" - ); - } - - // Иерархия долей строгая — санити переноса. - assert!(EXPECTED.windows(2).all(|w| w[0].2 > w[1].2)); -} - -#[test] -fn tertiary_anchor_is_byte_inversion_of_genesis_yhk() { - // Инверсия генезис-якоря 48.9 (Y_hk, на белом) по байтовой сетке серых - // обязана давать ровно #9C9C9C — иначе легаси-мерило Y_hk дрейфнуло и - // перенос требует пере-вывода. - let vc = ViewingConditions::srgb(); - let mut best: (u8, f64) = (0, f64::MAX); - for g in 0..=255_u8 { - let hex = format!("#{g:02X}{g:02X}{g:02X}"); - let d = (lpc_with_vc(&hex, "#FFFFFF", &vc) - TERTIARY_GENESIS_YHK).abs(); - if d < best.1 { - best = (g, d); - } - } - assert_eq!( - best.0, 0x9C, - "инверсия генезис-якоря {TERTIARY_GENESIS_YHK} дала #{0:02X}{0:02X}{0:02X}, не #9C9C9C", - best.0 - ); -} diff --git a/crates/labcolors-core/tests/chain_invariants.rs b/crates/labcolors-core/tests/chain_invariants.rs index 9759e0de..4290743f 100644 --- a/crates/labcolors-core/tests/chain_invariants.rs +++ b/crates/labcolors-core/tests/chain_invariants.rs @@ -10,7 +10,7 @@ //! //! Дыра, которую закрывает этот файл: тот же класс тотальности, но по оси //! ВЫРОЖДЕННОЙ СТРУКТУРЫ, а не цвета — 0 ролей, 1 роль, пустая палитра, -//! экстремальные ручки нейтрали/сентимента, чисто чёрный/белый бренд. Контракт: +//! экстремальные ручки нейтрали, чисто чёрный/белый бренд. Контракт: //! `compile_named_role_table` либо честно возвращает `Err`, либо даёт таблицу, //! чей `resolve_named_set` ТОТАЛЕН — валидный hex, конечные метрики, а //! CSS-эмиссия каждого решённого цвета не несёт `NaN`/`inf`. Достижение @@ -19,10 +19,10 @@ use labcolors_core::BgInput; use labcolors_core::{ - Brand, Floor, LadderPosition, LadderSource, NamedRoleTable, NeutralAnchors, NeutralConfig, - NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleRecipe, SentimentCategory, - SentimentsConfig, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, - oklch_css_from_hex, resolve_named_set, + Brand, Floor, LadderPosition, LadderSource, LadderTint, NamedRoleTable, NeutralAnchors, + NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleRecipe, RoleSpec, + TextAnchor, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, + oklch_css_from_hex, resolve_named_set, srgb_encoded_from_hex, }; /// `ThemeAnchors` с одинаковыми якорями во всех четырёх слотах — вход не о @@ -52,11 +52,10 @@ fn all_vcs() -> [ViewingConditions; 4] { const EXTREME_BGS: [&str; 5] = ["#000000", "#FFFFFF", "#808080", "#010101", "#FEFFFE"]; /// Минимальный НЕцветовой каркас, куда подставляются вырожденные ручки: -/// одна палитра/сентимент нужны только чтобы конфиг был структурно валиден, +/// палитра нужна только тем ролям, которые на неё ссылаются; /// роли добавляются аргументом. fn scaffold( neutral: NeutralConfig, - sentiments: SentimentsConfig, palette: Vec, roles: Vec<(String, RoleRecipe)>, ) -> ThemeConfig { @@ -66,7 +65,6 @@ fn scaffold( }, neutral, palette, - sentiments, ThemesConfig { entries: vec![ ("light".to_string(), VcPreset::Srgb), @@ -87,7 +85,6 @@ fn plain_neutral() -> NeutralConfig { dark: "#101012".to_string(), }, tint: NeutralTint { - ratio: 0.10, target_mp: 5.0, hue_stiffness: 8.0, hue_override_deg: Some(286.0), @@ -97,14 +94,6 @@ fn plain_neutral() -> NeutralConfig { } } -fn plain_sentiments() -> SentimentsConfig { - SentimentsConfig { - categories: vec![], - hardness: 5.0, - chroma_fraction: 0.88, - } -} - /// Проверить, что таблица ТОТАЛЬНА на всех экстремальных фонах × VC: каждый /// решённый цвет — валидный `#RRGGBB`, его oklch-эмиссия без `NaN`/`inf` и /// парсится в три КОНЕЧНЫХ компоненты, а метрики (Lc/WCAG, α, dJ') конечны. @@ -240,14 +229,13 @@ fn assert_oklch_components_finite(css: &str, label: &str, name: &str, bg: &str) // СТРУКТУРНЫЕ ВЫРОЖДЕНИЯ // ───────────────────────────────────────────────────────────────────────────── -/// РОВНО одна роль (текст-якорь без пола и без hue) на пустой палитре/сентименте: +/// РОВНО одна роль (текст-якорь без пола и без hue) на пустой палитре: /// самый тонкий контракт, что вообще эмитит цвет. Компилируется, тотален, эмитит /// одну роль. Закрывает «минимальный ненулевой контракт роняет резолв». #[test] fn single_role_config_is_total() { let cfg = scaffold( plain_neutral(), - plain_sentiments(), vec![], vec![( "solo".to_string(), @@ -271,12 +259,65 @@ fn single_role_config_is_total() { ); } +/// Opaque nodes are independent unless the client declares a relation. Merely +/// inserting an unrelated source before a token must not change that token's +/// physical occurrence or diagnostics. +#[test] +fn unrelated_opaque_anchor_cannot_change_existing_token() { + let tint = |hex: &str| { + LadderTint::new([srgb_encoded_from_hex(hex).expect("test hex is valid"); 4]) + .expect("encoded sRGB is inside the tint domain") + }; + let junior = RoleSpec::Anchor( + TextAnchor::new(0.999, Floor::None) + .unwrap() + .with_hue(tint("#34C759")), + ); + let isolated = NamedRoleTable::new( + vec![("junior".into(), junior)], + vec![], + labcolors_core::RoleChroma::Neutral, + ) + .unwrap(); + let preceded = NamedRoleTable::new( + vec![ + ( + "foreign".into(), + RoleSpec::Anchor( + TextAnchor::new(1.0, Floor::None) + .unwrap() + .with_hue(tint("#FF3B30")), + ), + ), + ("junior".into(), junior), + ], + vec![], + labcolors_core::RoleChroma::Neutral, + ) + .unwrap(); + let bg = BgInput::solid("#000000").unwrap(); + let vc = ViewingConditions::srgb(); + let resolve_junior = |table: &NamedRoleTable| { + resolve_named_set(&bg, table, &vc) + .unwrap() + .into_iter() + .find(|(name, _)| name == "junior") + .expect("junior is present") + .1 + }; + assert_eq!( + resolve_junior(&preceded), + resolve_junior(&isolated), + "declaration adjacency is not a client-declared dependency" + ); +} + /// НОЛЬ ролей на уровне ЯДРА (не границы, что его отклоняет): `compile` не /// паникует — либо `Err`, либо пустая таблица, чей `resolve_named_set` тотален /// (пустой набор). Закрывает «пустой словарь ролей роняет ядро». #[test] fn zero_role_config_is_meaningful_not_panic() { - let cfg = scaffold(plain_neutral(), plain_sentiments(), vec![], vec![]); + let cfg = scaffold(plain_neutral(), vec![], vec![]); match cfg.compile_named_role_table() { Ok(table) => { assert!(table.entries().is_empty(), "нет ролей ⇒ пустая таблица"); @@ -291,17 +332,15 @@ fn zero_role_config_is_meaningful_not_panic() { } } -/// Экстремальные ручки НЕЙТРАЛИ (ratio 0.0 и 1.0, огромный target_mp, жёсткость -/// у краёв): каждая комбинация либо честно `Err`, либо тотальна. Ловит деление +/// Экстремальные ручки НЕЙТРАЛИ (огромный target_mp, жёсткость у краёв): каждая +/// комбинация либо честно `Err`, либо тотальна. Ловит деление /// на ноль / переполнение в кривой нейтрали на вырожденных ручках. #[test] fn extreme_neutral_tint_knobs_never_panic_or_emit_nonfinite() { let knob_sets = [ - (0.0, 5.0, 8.0), // нулевой тинт - (1.0, 5.0, 8.0), // полный тинт - (0.10, 200.0, 8.0), // недостижимо большой target_mp - (0.10, 0.0, 0.0), // нулевые target_mp и жёсткость - (0.10, 5.0, 1000.0), // экстремальная жёсткость (жёсткая стена) + (200.0, 8.0), // недостижимо большой target_mp + (0.0, 0.0), // нулевые target_mp и жёсткость + (5.0, 1000.0), // экстремальная жёсткость (жёсткая стена) ]; let ladder_role = ( "neutral-fill".to_string(), @@ -311,7 +350,7 @@ fn extreme_neutral_tint_knobs_never_panic_or_emit_nonfinite() { floor: None, }, ); - for (ratio, target_mp, hue_stiffness) in knob_sets { + for (target_mp, hue_stiffness) in knob_sets { let neutral = NeutralConfig { anchors: NeutralAnchors { light: "#FBFBFD".to_string(), @@ -319,7 +358,6 @@ fn extreme_neutral_tint_knobs_never_panic_or_emit_nonfinite() { dark: "#101012".to_string(), }, tint: NeutralTint { - ratio, target_mp, hue_stiffness, hue_override_deg: None, @@ -327,65 +365,13 @@ fn extreme_neutral_tint_knobs_never_panic_or_emit_nonfinite() { edge: None, inverted: None, }; - let cfg = scaffold( - neutral, - plain_sentiments(), - vec![], - vec![ladder_role.clone()], - ); + let cfg = scaffold(neutral, vec![], vec![ladder_role.clone()]); if let Ok(table) = cfg.compile_named_role_table() { // Чистый гард тотальности/не-паники: достижимость на экстремальных // ручках не обязательна (роль может честно уйти в SolveFailure). let _ = assert_table_is_total( &table, - &format!("neutral(r={ratio},mp={target_mp},k={hue_stiffness})"), - ); - } - } -} - -/// Экстремальный СЕНТИМЕНТ (chroma_fraction у краёв (0,1], жёсткость у краёв) на -/// вырожденной палитре: pair-fill/dj роли не роняют резолв нечислом. -#[test] -fn extreme_sentiment_knobs_never_panic_or_emit_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 cfg = scaffold( - plain_neutral(), - sentiments, - vec![family.clone()], - vec![( - "alert-fill".to_string(), - RoleRecipe::Ladder { - source: LadderSource::Sentiment("alert".to_string()), - position: LadderPosition::FillPrimary, - floor: None, - }, - )], - ); - if let Ok(table) = cfg.compile_named_role_table() { - // Чистый гард тотальности/не-паники (как neutral выше). - let _ = assert_table_is_total( - &table, - &format!("sentiment(cf={chroma_fraction},h={hardness})"), + &format!("neutral(mp={target_mp},k={hue_stiffness})"), ); } } @@ -403,7 +389,6 @@ fn achromatic_extreme_brand_never_panics() { }, plain_neutral(), vec![], - plain_sentiments(), ThemesConfig { entries: vec![ ("light".to_string(), VcPreset::Srgb), diff --git a/crates/labcolors-core/tests/common/mod.rs b/crates/labcolors-core/tests/common/mod.rs index 56b8f3a7..88bca89d 100644 --- a/crates/labcolors-core/tests/common/mod.rs +++ b/crates/labcolors-core/tests/common/mod.rs @@ -12,6 +12,8 @@ use std::path::PathBuf; +pub mod source; + /// Корневая директория крейта (`crates/labcolors-core/`). /// Определяется через `CARGO_MANIFEST_DIR` — не зависит от рабочего каталога. pub fn crate_root() -> PathBuf { diff --git a/crates/labcolors-core/tests/common/source.rs b/crates/labcolors-core/tests/common/source.rs new file mode 100644 index 00000000..cf8defd8 --- /dev/null +++ b/crates/labcolors-core/tests/common/source.rs @@ -0,0 +1,342 @@ +//! Структурное отделение production-кода от `#[cfg(test)]` без интерпретации +//! скобок и `;` внутри строк, char-литералов и комментариев. + +#[derive(Default)] +struct LexState { + block_comment_depth: usize, + string: bool, + escaped: bool, + raw_hashes: Option, +} + +impl LexState { + fn is_code(&self) -> bool { + self.block_comment_depth == 0 && !self.string && self.raw_hashes.is_none() + } +} + +#[derive(Clone, Copy)] +enum TokenKind { + Open, + Close, + Semicolon, +} + +#[derive(Clone, Copy)] +struct Token { + at: usize, + kind: TokenKind, +} + +fn char_literal_end(line: &str, quote: usize) -> Option { + let rest = line.get(quote + 1..)?; + let mut chars = rest.char_indices(); + let (_, first) = chars.next()?; + if first == '\\' { + let (escape_at, escape) = chars.next()?; + let escape_end = quote + 1 + escape_at + escape.len_utf8(); + let closing = match escape { + 'x' => { + let digits = line.get(escape_end..escape_end + 2)?; + if !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + escape_end + 2 + } + 'u' => { + if line.as_bytes().get(escape_end) != Some(&b'{') { + return None; + } + let end = line.get(escape_end + 1..)?.find('}')? + escape_end + 1; + let digits = line.get(escape_end + 1..end)?; + if digits.is_empty() + || !digits + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'_') + { + return None; + } + end + 1 + } + _ => escape_end, + }; + return (line.as_bytes().get(closing) == Some(&b'\'')).then_some(closing + 1); + } + + if first == '\'' || first == '\r' || first == '\n' { + return None; + } + let closing = quote + 1 + first.len_utf8(); + (line.as_bytes().get(closing) == Some(&b'\'')).then_some(closing + 1) +} + +fn raw_string_start(line: &str, at: usize) -> Option<(usize, usize)> { + if line.as_bytes().get(at) != Some(&b'r') { + return None; + } + let mut cursor = at + 1; + while line.as_bytes().get(cursor) == Some(&b'#') { + cursor += 1; + } + (line.as_bytes().get(cursor) == Some(&b'"')).then_some((cursor + 1, cursor - at - 1)) +} + +fn blank(masked: &mut Option<&mut Vec>, start: usize, end: usize) { + if let Some(bytes) = masked.as_deref_mut() { + bytes[start..end].fill(b' '); + } +} + +fn scan_line( + line: &str, + state: &mut LexState, + mut uncommented: Option<&mut Vec>, + mut syntax: Option<&mut Vec>, +) -> Vec { + let bytes = line.as_bytes(); + let mut tokens = Vec::new(); + let mut cursor = 0; + while cursor < bytes.len() { + if state.block_comment_depth > 0 { + if bytes.get(cursor..cursor + 2) == Some(b"/*") { + blank(&mut uncommented, cursor, cursor + 2); + blank(&mut syntax, cursor, cursor + 2); + state.block_comment_depth += 1; + cursor += 2; + } else if bytes.get(cursor..cursor + 2) == Some(b"*/") { + blank(&mut uncommented, cursor, cursor + 2); + blank(&mut syntax, cursor, cursor + 2); + state.block_comment_depth -= 1; + cursor += 2; + } else { + blank(&mut uncommented, cursor, cursor + 1); + blank(&mut syntax, cursor, cursor + 1); + cursor += 1; + } + continue; + } + + if let Some(hashes) = state.raw_hashes { + if bytes[cursor] == b'"' + && bytes + .get(cursor + 1..cursor + 1 + hashes) + .is_some_and(|suffix| suffix.iter().all(|byte| *byte == b'#')) + { + blank(&mut syntax, cursor, cursor + hashes + 1); + state.raw_hashes = None; + cursor += hashes + 1; + } else { + blank(&mut syntax, cursor, cursor + 1); + cursor += 1; + } + continue; + } + + if state.string { + blank(&mut syntax, cursor, cursor + 1); + if state.escaped { + state.escaped = false; + } else if bytes[cursor] == b'\\' { + state.escaped = true; + } else if bytes[cursor] == b'"' { + state.string = false; + } + cursor += 1; + continue; + } + + if bytes.get(cursor..cursor + 2) == Some(b"//") { + blank(&mut uncommented, cursor, bytes.len()); + blank(&mut syntax, cursor, bytes.len()); + break; + } + if bytes.get(cursor..cursor + 2) == Some(b"/*") { + blank(&mut uncommented, cursor, cursor + 2); + blank(&mut syntax, cursor, cursor + 2); + state.block_comment_depth = 1; + cursor += 2; + continue; + } + if let Some((content, hashes)) = raw_string_start(line, cursor) { + blank(&mut syntax, cursor, content); + state.raw_hashes = Some(hashes); + cursor = content; + continue; + } + if bytes[cursor] == b'"' { + blank(&mut syntax, cursor, cursor + 1); + state.string = true; + cursor += 1; + continue; + } + if bytes[cursor] == b'\'' { + if let Some(end) = char_literal_end(line, cursor) { + blank(&mut syntax, cursor, end); + cursor = end; + continue; + } + } + match bytes[cursor] { + b'{' => tokens.push(Token { + at: cursor, + kind: TokenKind::Open, + }), + b'}' => tokens.push(Token { + at: cursor, + kind: TokenKind::Close, + }), + b';' => tokens.push(Token { + at: cursor, + kind: TokenKind::Semicolon, + }), + _ => {} + } + cursor += 1; + } + state.escaped = false; + tokens +} + +fn structural_tokens(line: &str, state: &mut LexState) -> Vec { + scan_line(line, state, None, None) +} + +fn only_comments_or_whitespace(text: &str) -> bool { + let mut state = LexState::default(); + let mut code = text.as_bytes().to_vec(); + scan_line(text, &mut state, Some(&mut code), None); + state.is_code() && code.iter().all(u8::is_ascii_whitespace) +} + +fn standalone_cfg_marker(line: &str) -> bool { + let Some(suffix) = line.trim_start().strip_prefix("#[cfg(test)]") else { + return false; + }; + only_comments_or_whitespace(suffix) +} + +fn harmless_item_tail(line: &str, end: usize) -> bool { + let mut tail = line.get(end..).unwrap_or_default().trim_start(); + if tail.starts_with(';') || tail.starts_with(',') { + tail = tail.get(1..).unwrap_or_default().trim_start(); + } + only_comments_or_whitespace(tail) +} + +/// Возвращает строки production-кода с исходной нумерацией, исключая каждый +/// item под `#[cfg(test)]`. Один lexer служит двум Rust integration-аудитам Core, +/// чтобы их представление границы cfg не расходилось. Неканоническая запись, +/// где атрибут или остаток после item делят строку с кодом, сохраняется целиком: +/// лучше ложный RED, чем скрытый production-суффикс. +pub fn production_lines(source: &str) -> Vec<(usize, String)> { + let lines: Vec<&str> = source.lines().collect(); + let mut out = Vec::new(); + let mut i = 0; + let mut outer_state = LexState::default(); + while i < lines.len() { + if outer_state.is_code() && standalone_cfg_marker(lines[i]) { + let mut j = i + 1; + while j < lines.len() && lines[j].trim().is_empty() { + j += 1; + } + + let mut depth = 0i32; + let mut opened = false; + let mut k = j; + let mut state = LexState::default(); + let mut boundary = None; + while k < lines.len() { + let tokens = structural_tokens(lines[k], &mut state); + for token in tokens { + match token.kind { + TokenKind::Open => { + depth += 1; + opened = true; + } + TokenKind::Close if opened => { + depth -= 1; + if depth == 0 { + boundary = Some((k, token.at + 1)); + break; + } + } + TokenKind::Semicolon if !opened => { + boundary = Some((k, token.at + 1)); + break; + } + TokenKind::Close | TokenKind::Semicolon => {} + } + } + if boundary.is_some() { + break; + } + k += 1; + } + if let Some((end_line, end)) = boundary { + if harmless_item_tail(lines[end_line], end) { + i = end_line + 1; + outer_state = LexState::default(); + continue; + } + } + } + out.push((i + 1, lines[i].to_string())); + structural_tokens(lines[i], &mut outer_state); + i += 1; + } + out +} + +/// Одна доказанная production-строка в трёх согласованных представлениях: +/// `raw` хранит provenance-комментарии, `code` — тот же текст с комментариями, +/// заменёнными пробелами, `syntax` дополнительно маскирует содержимое литералов. +/// Номер относится к исходному файлу. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductionLine { + pub number: usize, + pub raw: String, + pub code: String, + pub syntax: String, +} + +/// Та же production-поверхность, но с лексически доказанными комментариями, +/// заменёнными пробелами. Строковые/raw/byte-литералы остаются байт-в-байт: +/// `//` внутри URL не может скрыть следующий production-токен. Пробелы, а не +/// склейка, не превращают `foo/*…*/bar` в ложный идентификатор `foobar`. +pub fn production_records(source: &str) -> Vec { + let mut state = LexState::default(); + production_lines(source) + .into_iter() + .map(|(number, raw)| { + let mut code = raw.as_bytes().to_vec(); + let mut syntax = code.clone(); + scan_line(&raw, &mut state, Some(&mut code), Some(&mut syntax)); + let code = String::from_utf8(code) + .expect("замена байтов комментария на ASCII-пробелы сохраняет UTF-8"); + let syntax = String::from_utf8(syntax) + .expect("маскирование литералов ASCII-пробелами сохраняет UTF-8"); + ProductionLine { + number, + raw, + code, + syntax, + } + }) + .collect() +} + +/// Компактное представление для гейтов, которым provenance-комментарии не нужны. +pub fn production_code_lines(source: &str) -> Vec<(usize, String)> { + production_records(source) + .into_iter() + .map(|line| (line.number, line.code)) + .collect() +} + +/// Представление без комментариев и содержимого литералов для числовых гейтов. +pub fn production_syntax_lines(source: &str) -> Vec<(usize, String)> { + production_records(source) + .into_iter() + .map(|line| (line.number, line.syntax)) + .collect() +} diff --git a/crates/labcolors-core/tests/empirical_inventory.rs b/crates/labcolors-core/tests/empirical_inventory.rs index 47e4c119..491b1589 100644 --- a/crates/labcolors-core/tests/empirical_inventory.rs +++ b/crates/labcolors-core/tests/empirical_inventory.rs @@ -30,6 +30,7 @@ use std::collections::BTreeSet; mod common; +use common::source::{ProductionLine, production_records, production_syntax_lines}; // ───────────────────────────────────────────────────────────────────────────── // Audit surface — the perceptual modules the detector scans. @@ -38,7 +39,7 @@ mod common; const PERCEPTUAL_MODULES: [&str; 8] = [ "semantic.rs", "scale.rs", - "sentiment.rs", + "spaces/oklab.rs", "neutral.rs", "lpc.rs", "lcs.rs", @@ -57,9 +58,10 @@ const PERCEPTUAL_MODULES: [&str; 8] = [ // (0.968/0.627/0.461/0.276) and the Separator Lc (8.0) evaded GATE-1 until they // were extracted. The const-only detector (GATE-1/2/3) is blind to inline literals. // -// Why a SUBSET and not all six: the three modules below are POLICY modules — their -// magnitudes are tunable perceptual policy (role fractions, sentiment/curve -// thresholds). The other three (`scale.rs`, `lpc.rs`, `lcs.rs`) are STANDARD-MODEL +// Why a SUBSET: the three modules below are POLICY modules — their magnitudes are +// tunable perceptual policy (role fractions and curve thresholds). The remaining +// modules (`scale.rs`, `spaces/oklab.rs`, `lpc.rs`, `lcs.rs`, `solve.rs`) are +// STANDARD-MODEL // transform modules: their inline coefficients implement cited colour-appearance // models verbatim — CIECAM16 (`460/451/288/6300/1403…` in `lcs.rs`), the Hellwig // 2022 H-K first-harmonic (`0.080/0.132/0.160/0.405/0.792` in `lpc.rs`), the CAM16 @@ -74,9 +76,9 @@ const PERCEPTUAL_MODULES: [&str; 8] = [ // non-policy inline floats were the ITU-R BT.709 / WCAG relative-luminance coefficients // (`0.2126/0.7152/0.0722`), now extracted into the `WCAG_LUMA_{R,G,B}` consts on // `NUMERIC_METHOD_ALLOWLIST` (a cited standard, excluded by construction — INV-3). -// Every module in `PERCEPTUAL_MODULES` remains under the const-marker gate; the four +// Every module in `PERCEPTUAL_MODULES` remains under the const-marker gate; the three // POLICY modules below are additionally scanned for inline bare literals. -const POLICY_LITERAL_MODULES: &[&str] = &["semantic.rs", "sentiment.rs", "neutral.rs", "pair.rs"]; +const POLICY_LITERAL_MODULES: &[&str] = &["semantic.rs", "neutral.rs", "pair.rs"]; /// Bare float literal VALUES that are NOT tunable perceptual policy — universal /// domain/normalisation/degenerate/sentinel/numerical arithmetic, plus cited @@ -86,18 +88,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 +112,9 @@ 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", + // Representation-derived angular bounds (spaces/oklab.rs), not policy. + "HUE_DEG_MIN_INCLUSIVE", + "HUE_DEG_MAX_EXCLUSIVE", // Pure numeric epsilons — non-perceptual. "RATIO_BISECT_EPS", "RATIO_EPS", @@ -158,15 +153,16 @@ const NUMERIC_METHOD_ALLOWLIST: &[&str] = &[ /// observable half of INV-3 (exclusion is enforced, not merely implied by /// scoping). Checked directly against the SSOT rows. 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 - "YB", // UCS / Yb=20 - "D65", // illuminant + "HK_CHROMA_EXPONENT", // Hellwig 0.587 + "LC_SCALE", // APCA + "DELTA_Y_MIN", // APCA + "HUE_DEG_MIN_INCLUSIVE", // exact angular domain + "HUE_DEG_MAX_EXCLUSIVE", // exact angular domain + "RATIO_BISECT_EPS", // numeric EPS + "AA_TEXT_RATIO", // WCAG + "L_A", // CIECAM16 L_A=64 + "YB", // UCS / Yb=20 + "D65", // illuminant ]; // ───────────────────────────────────────────────────────────────────────────── @@ -349,7 +345,7 @@ fn is_policy_numeric_type(ty: &str) -> bool { /// `(field_name, normalised_value)`. Only *floating-point* literals (containing a /// `.`) count — perceptual magnitudes in this domain are always fractional, while /// bare integers in a default body are sizes/indices, not policy. A field bound to -/// a named const (e.g. `p_low: DEFAULT_HARDNESS`) is already covered by that +/// a named const (e.g. `ratio: DEFAULT_RATIO`) is already covered by that /// const's own row and is intentionally not re-detected here. fn parse_default_field_literal(line: &str) -> Option<(String, String)> { let t = line.trim_start(); @@ -378,7 +374,7 @@ fn parse_default_field_literal(line: &str) -> Option<(String, String)> { /// already-marked one (which would let an untracked const hide directly beneath a /// marked sibling — the marker-bleed false-negative). Each magnitude must own a /// marker that is not separated from it by another magnitude. -fn marker_above(lines: &[&str], site_idx: usize) -> (bool, bool, String) { +fn marker_above(lines: &[ProductionLine], site_idx: usize) -> (bool, bool, String) { let mut has = false; let mut needs = false; let mut marker_line = String::new(); @@ -386,13 +382,14 @@ fn marker_above(lines: &[&str], site_idx: usize) -> (bool, bool, String) { if site_idx < back { break; } - let prev = lines[site_idx - back].trim_start(); - if prev.contains("// SSOT-TRACKED") { + let previous = &lines[site_idx - back]; + let prev = previous.raw.trim_start(); + if prev.starts_with("// SSOT-TRACKED") { has = true; needs = true; marker_line = prev.to_string(); break; - } else if prev.contains("// GROUNDED") { + } else if prev.starts_with("// GROUNDED") { has = true; marker_line = prev.to_string(); break; @@ -400,8 +397,8 @@ fn marker_above(lines: &[&str], site_idx: usize) -> (bool, bool, String) { // An intervening magnitude line consumes its own marker — stop here so it // is not credited to the site below it. if back == 1 - && (parse_const_decl(lines[site_idx - back]).is_some() - || parse_default_field_literal(lines[site_idx - back]).is_some()) + && (parse_const_decl(&previous.syntax).is_some() + || parse_default_field_literal(&previous.syntax).is_some()) { break; } @@ -413,16 +410,17 @@ fn marker_above(lines: &[&str], site_idx: usize) -> (bool, bool, String) { /// `allowlist` is injected so the RED-proof can reuse the exact scanner. fn scan_source(module: &str, source: &str, allowlist: &[&str]) -> Vec { // Skip `#[cfg(test)]` blocks: a test-only const (a test tolerance / fixture) is - // never perceptual POLICY. `production_lines` removes them while preserving the - // original 1-based line numbers used in diagnostics. - let prod = production_lines(source); - let lines: Vec<&str> = prod.iter().map(|(_, s)| s.as_str()).collect(); + // never perceptual POLICY. Одна запись хранит raw provenance и лексически + // очищенный code, поэтому комментарий внутри объявления не может заставить + // разные гейты увидеть разные конструкции. + let lines = production_records(source); let module_stem = module.trim_end_matches(".rs").to_ascii_uppercase(); let mut out = Vec::new(); let mut in_default_body = false; let mut default_brace_depth: i32 = 0; - for (idx, line) in lines.iter().enumerate() { + for (idx, record) in lines.iter().enumerate() { + let line = record.syntax.as_str(); let trimmed = line.trim_start(); // Track `fn default()` bodies so field-literal detection is scoped to them @@ -446,7 +444,7 @@ fn scan_source(module: &str, source: &str, allowlist: &[&str]) -> Vec Vec Vec Vec<(usize, String)> { - let lines: Vec<&str> = source.lines().collect(); - let mut out = Vec::new(); - let mut i = 0; - while i < lines.len() { - if lines[i].trim_start().starts_with("#[cfg(test)]") { - // Skip any further attribute/blank lines to reach the guarded item. - let mut j = i + 1; - while j < lines.len() { - let tj = lines[j].trim_start(); - if tj.starts_with('#') || tj.is_empty() { - j += 1; - } else { - break; - } - } - if j < lines.len() && lines[j].contains('{') { - // Braced item: brace-match to its close. - let mut depth = 0i32; - let mut opened = false; - let mut k = j; - while k < lines.len() { - depth += lines[k].matches('{').count() as i32; - depth -= lines[k].matches('}').count() as i32; - if lines[k].contains('{') { - opened = true; - } - if opened && depth <= 0 { - break; - } - k += 1; - } - i = k + 1; - } else { - // Non-braced item (`… = [ … ];`, `use …;`): skip through the `;`. - let mut k = j; - while k < lines.len() && !lines[k].contains(';') { - k += 1; - } - i = k + 1; - } - continue; - } - out.push((i + 1, lines[i].to_string())); - i += 1; - } - out -} - /// Extract every bare FLOATING-POINT literal token from one line of source: a run /// that starts with a digit not glued to an identifier/`.`, and contains a literal /// decimal point. Returns each token normalised via `normalise_num` (so `1.5_f64` @@ -672,7 +616,7 @@ fn extract_float_literals(line: &str) -> Vec { } /// Scan a POLICY module's source for un-allowlisted inline bare float literals. -/// A site is reported when a production line (see `production_lines`) that is +/// A site is reported when a production line (see `production_code_lines`) that is /// neither a `const` declaration nor a `fn default()` field literal (both already /// covered by `scan_source`) carries a bare float whose value is not on /// `allowlist`. `allowlist` is injected so the RED-proof reuses the exact scanner. @@ -680,13 +624,8 @@ fn scan_bare_float_literals(module: &str, source: &str, allowlist: &[&str]) -> V let mut out = Vec::new(); let mut in_default_body = false; let mut default_brace_depth: i32 = 0; - for (lineno, raw) in production_lines(source) { - // Strip an inline/whole-line comment (no `/* */` block comments exist in - // the policy modules — verified — so cutting at `//` is sufficient). - let code = match raw.split_once("//") { - Some((before, _)) => before, - None => raw.as_str(), - }; + for (lineno, raw) in production_syntax_lines(source) { + let code = raw.as_str(); let trimmed = code.trim_start(); if trimmed.is_empty() { continue; @@ -1402,6 +1341,31 @@ fn red_proof_audit_probe() { v.contains(&"_AUDIT_PROBE".to_string()), "RED-proof FAILED (f64 const path) — GATE-1 did not flag the unmarked f64 const. Saw: {v:?}" ); + let v = + unmarked_after_splice("const _AUDIT_PROBE_COMMENTED_TYPE: /* provenance */ f64 = 42.0;"); + assert!( + v.contains(&"_AUDIT_PROBE_COMMENTED_TYPE".to_string()), + "RED-proof FAILED (shared raw/code view) — a block comment in a const declaration hid \ + an untracked policy magnitude. Saw: {v:?}" + ); + let v = unmarked_after_splice( + "const _AUDIT_PROBE_FIRST: f64 = 41.0; // GROUNDED not-a-marker\n\ + const _AUDIT_PROBE_SECOND: f64 = 42.0;", + ); + assert!( + v.contains(&"_AUDIT_PROBE_SECOND".to_string()), + "RED-proof FAILED (marker ownership) — an inline marker on a preceding const bled into \ + the next magnitude. Saw: {v:?}" + ); + let v = unmarked_after_splice( + "const _AUDIT_PROBE_NOTE: &str = \"// GROUNDED not-a-marker\";\n\ + const _AUDIT_PROBE_AFTER_STRING: f64 = 42.0;", + ); + assert!( + v.contains(&"_AUDIT_PROBE_AFTER_STRING".to_string()), + "RED-proof FAILED (marker lexical boundary) — marker text inside a string was credited \ + to the next magnitude. Saw: {v:?}" + ); // 2. DjMagnitude anchor const — invisible to a type==f64 gate. let v = @@ -1441,6 +1405,21 @@ fn red_proof_audit_probe() { POLICY module did NOT surface through the bare-literal scanner; a tunable policy magnitude \ could still hide as an inline literal. Saw: {v:?}" ); + let v = bare_floats_after_splice( + "fn _audit_probe_url() -> f64 { let _ = \"https://fixture\"; some_call(42.0) }", + ); + assert!( + v.contains(&"42.0".to_string()), + "RED-proof FAILED (GATE-5 string/comment boundary) — `//` inside a string hid later \ + production code. Saw: {v:?}" + ); + let v = + bare_floats_after_splice("fn _audit_probe_literal() { let _ = \"https://fixture/42.0\"; }"); + assert!( + !v.contains(&"42.0".to_string()), + "RED-proof FAILED (GATE-5 literal boundary) — a number inside a string was treated as \ + executable policy. Saw: {v:?}" + ); // And an ALLOWLISTED bare float must NOT be flagged (the allowlist is load-bearing, // not vacuous — a scanner that flagged everything would be green-from-birth here). let v_allow = bare_floats_after_splice("fn _audit_probe_ok() -> f64 { some_call(1.0) + 0.5 }"); diff --git a/crates/labcolors-core/tests/gate_green_smoke.rs b/crates/labcolors-core/tests/gate_green_smoke.rs index a1d1b481..c3a4707e 100644 --- a/crates/labcolors-core/tests/gate_green_smoke.rs +++ b/crates/labcolors-core/tests/gate_green_smoke.rs @@ -35,7 +35,7 @@ use common::{inventory_path, src_dir}; const PERCEPTUAL_MODULES_SMOKE: [&str; 6] = [ "semantic.rs", "scale.rs", - "sentiment.rs", + "spaces/oklab.rs", "neutral.rs", "lpc.rs", "lcs.rs", @@ -51,7 +51,8 @@ fn count_policy_consts_approx(source: &str) -> usize { "HK_CHROMA_EXPONENT", "LC_SCALE", "DELTA_Y_MIN", - "S_PERC_MIN", + "HUE_DEG_MIN_INCLUSIVE", + "HUE_DEG_MAX_EXCLUSIVE", "RATIO_BISECT_EPS", "RATIO_EPS", "FLOOR_EPS", diff --git a/crates/labcolors-core/tests/hue_sweep.rs b/crates/labcolors-core/tests/hue_sweep.rs index d213a859..fdcceceb 100644 --- a/crates/labcolors-core/tests/hue_sweep.rs +++ b/crates/labcolors-core/tests/hue_sweep.rs @@ -3,23 +3,21 @@ //! BUG CLASS this guards: *an axis the test suite never swept.* The crate's //! audit found that 6/10 mutations of key constants survived the suite, and the //! solver was exercised only at hue 0 (and a couple of incidental chromatic -//! points). Hue is a full degree of freedom of `solve`: the H-K correction is -//! hue-dependent (`f(h)` in lpc.rs), so the lightness the solver must pick to -//! hit a target contrast varies with hue. A regression in the H-K term, the -//! chroma cap, or the hue-to-lightness compensation would show up as the solver -//! *missing its contrast target at some hue* — and nothing tested that. +//! points). Hue and the gamut cap change which quantised colour can reproduce a +//! fixed Ys candidate score. A regression in that search could miss its numeric +//! target only on part of the hue domain. //! //! This sweeps hue 0..360 in 15 steps, both viewing conditions, on a light //! (#FFFFFF) and a dark (#1C1C1E) background, at a moderate chroma //! (`Relative(0.3)`), and asserts two contracts on every result: //! -//! 1. **Perceptual target held — unless the law overrode it.** When the WCAG +//! 1. **Ys candidate-score target held unless the WCAG floor overrode it.** When the WCAG //! floor did NOT override (`!floor_override`), the measured |Lc − target| ≤ 1 //! (the solver's own ±1 quantization budget), independently re-measured -//! through the public `lpc_with_vc` on the emitted hex — not trusting the -//! reported `lc()`. When the AA-text floor DID override (a +45 target on +//! through the public `recheck_against` on the emitted hex — not trusting +//! the reported `lc()`. When the AA-text floor DID override (a +45 target on //! white cannot clear 4.5:1, so the solver legitimately pushes the colour -//! darker), perception is *intentionally* exceeded: there the contract is +//! darker), the numeric target is intentionally exceeded: there the contract is //! |Lc| ≥ target, not |Lc − target| ≤ 1. Asserting the tight band there would //! be testing a bug into existence — the override is the documented, correct //! behaviour (`Solved::floor_override`). @@ -32,23 +30,16 @@ //! a malformed-input panic — and a moderate +45 target on pure white, which the //! background can clearly host, must NOT come back ExceedsRange. -use labcolors_core::lpc::{lpc_readability_ys, lpc_with_vc}; use labcolors_core::{ - BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveFailure, ViewingConditions, solve, - srgb_encoded_from_hex, + BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveFailure, ViewingConditions, + recheck_against, solve, }; -/// Independent re-measure of an emitted hex's `|Lc|` MAGNITUDE in the readability -/// domain the solver targets since глава #64 (ADR-0003): `Ys` (WCAG relative -/// luminance of the display bytes). Меряться через `lpc_with_vc` (домен `Y_hk`, -/// apparent contrast) в round-trip ВЕЛИЧИНЫ больше нельзя — ось читаемости -/// переехала в `Ys`, и сверять надо в домене цели, иначе Ys≈Y_hk-разрыв на -/// хроме даёт ложный недолёт (signum-проверки домен-агностичны, остаются на -/// `lpc_with_vc`). -fn readability_lc(fg_hex: &str, bg_hex: &str) -> f64 { - let fg = srgb_encoded_from_hex(fg_hex).expect("valid emitted hex"); - let bg = srgb_encoded_from_hex(bg_hex).expect("valid bg hex"); - lpc_readability_ys(fg, bg) +/// Независимое повторное измерение знаковой candidate-оценки `Lc` по `Ys` на +/// финальных sRGB8-байтах. Проверка через публичный `recheck_against` сохраняет +/// тест на фактически испускаемом состоянии и его полярности. +fn candidate_lc(fg_hex: &str, bg_hex: &str, vc: &ViewingConditions) -> f64 { + recheck_against(bg_hex, &[fg_hex], vc).expect("solver and fixture emit valid sRGB8 hex")[0].0 } /// The solver's own quantization budget (mirrors `solve::QUANT_BUDGET` / the @@ -66,11 +57,10 @@ fn vcs() -> [(ViewingConditions, &'static str); 2] { } #[test] -fn solver_holds_perceptual_target_across_the_full_hue_circle() { - // The core of contract 1: the PURE perceptual inversion (Floor::None, no - // WCAG override muddying the result) must land within ±1 Lc of the target at - // every hue. This is the invariant the hue axis never tested — a regression - // in the H-K hue term would surface here as a missed target at some hue. +fn solver_holds_ys_candidate_score_target_across_the_full_hue_circle() { + // Инверсия Ys candidate-score без юридического floor обязана попадать в + // ±1 Lc на каждом оттенке. Регрессия в hue/gamut search проявится здесь как + // промах по численной цели на части окружности. // // bg, signed target the background can host in its natural polarity: // dark-on-light (+) on white, light-on-dark (−) on near-black. @@ -95,24 +85,22 @@ fn solver_holds_perceptual_target_across_the_full_hue_circle() { match result { Ok(solved) => { reachable += 1; - // Re-measure independently on the emitted hex; never trust - // the reported lc(). SIGN (polarity) — домен-агностично, - // остаётся на `lpc_with_vc`; MAGNITUDE (err) — в домене - // цели `Ys` (глава #64), иначе round-trip врёт на хроме. - let sign_ref = lpc_with_vc(solved.hex(), bg_hex, &vc); + // Re-measure independently on the emitted hex; never + // trust the reported lc(). Sign and magnitude come from + // the same production recheck path. + let measured = candidate_lc(solved.hex(), bg_hex, &vc); assert_eq!( - sign_ref > 0.0, + measured > 0.0, target > 0.0, "{vc_name} {bg_hex} hue {hue_deg}: polarity sign mismatch, \ - measured {sign_ref} for target {target}", + measured {measured} for target {target}", ); // With Floor::None there is no override, so the tight ±1 - // perceptual budget must hold at every hue. + // candidate-score budget must hold at every hue. assert!( !solved.floor_override(), "{vc_name} {bg_hex} hue {hue_deg}: Floor::None must never override", ); - let measured = readability_lc(solved.hex(), bg_hex); let err = (measured - target).abs(); max_err = max_err.max(err); assert!( @@ -145,7 +133,7 @@ fn solver_holds_perceptual_target_across_the_full_hue_circle() { "hue sweep solved too few combinations ({reachable}); the solver may be \ refusing reachable targets across the hue circle" ); - eprintln!("hue perceptual sweep: {reachable} reachable, max |Lc - target| = {max_err:.4}"); + eprintln!("hue candidate-score sweep: {reachable} reachable, max |Lc - target| = {max_err:.4}"); } #[test] @@ -153,7 +141,7 @@ fn solver_holds_wcag_floor_across_the_full_hue_circle() { // The second half of contract 1's invariant: a default Contract::text carries // the AA-text floor (4.5:1), so EVERY successful solve — at every hue, both // VCs, both backgrounds — must clear it on the quantised colour, whether or - // not the floor had to override perception. A regression that let the floor + // not the floor had to override the candidate-score target. A regression that let the floor // be reported satisfied while the emitted hex falls short would surface here. let cases = [("#FFFFFF", 45.0_f64), ("#1C1C1E", -45.0_f64)]; diff --git a/crates/labcolors-core/tests/level3_polarity_validation.rs b/crates/labcolors-core/tests/level3_polarity_validation.rs deleted file mode 100644 index 474214f4..00000000 --- a/crates/labcolors-core/tests/level3_polarity_validation.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! ВАЛИДАЦИЯ (задача #64) — level-3 солвер полярности лейбла на замороженном -//! 49-якорном паспорте labui. -//! -//! # Что валидируется -//! -//! Level-3 солвер выбирает сторону лейбла как **максимизацию |Lc|** по -//! контраст-ядру APCA. Продакшн-предикат `pair::pair_side` — СМЕШАННО-ДОМЕННЫЙ -//! (глава #64, ADR-0003): чернильная сторона меряется от воспринимаемой яркости -//! якоря `Y_eff` (Гельмгольц–Кольрауш, `bg_luma`), белая — от чистого экранного -//! `Ys`. `Ink ⇔ |contrast_core(0, Y_eff)| > |contrast_core(1, Ys)|`. Насыщенный -//! фон выглядит светлее (`Y_eff > Ys`), поэтому раньше готов нести ЧЕРНИЛА. -//! -//! На АХРОМАТИЧЕСКОЙ оси H-K-член ≈ 0 (`Y_eff ≈ Ys`) и правило редуцируется к -//! чисто-люминансному перелому `PAIR_CROSSOVER_Y = 0.341955` — его ахроматический -//! терминал. На ХРОМАТИЧЕСКИХ фонах H-K поднимает якорь над порогом РАНЬШЕ: -//! продакшн отдаёт чернила там, где чистый порог ещё держит белый. Разница = -//! ровно те якоря, где level-3 H-K МЕНЯЕТ полярность (промоция Light→Ink). -//! -//! Тест гоняет ЧЕТЫРЕ величины на 49 РЕАЛЬНЫХ якорях и печатает таблицу главы: -//! 1. **Чисто-люминансный порог** (`Y < PAIR_CROSSOVER_Y`) — ахроматический -//! терминал / level-2 референс. -//! 2. **H-K-домен** (продакшн `lpc`, кормит `Y_hk` ОБЕ стороны) — argmax |Lc| -//! полной apparent-метрики, характеризация. -//! 3. **Домен `Ys`** (ADR-0003 вариант A, `lpc_readability_ys` ОБЕ стороны) — -//! argmax |Lc| в домене, где откалиброваны константы APCA SAPC-8. -//! 4. **Продакшн level-3** (`pair::pair_side`) — реально отгружаемое решение. -//! -//! Расхождение продакшна с чистым порогом — ровно якоря H-K-промоции; тест это -//! считает, печатает и запирает НАПРАВЛЕНИЕ (только Light→Ink) + поимённый набор. -//! -//! # Воспроизведение -//! ```text -//! cargo test -p labcolors-core --test level3_polarity_validation -- --nocapture -//! ``` -//! Все числа главы §4 whitepaper — из stdout `table_level3_polarity_over_49_anchors`. -//! -//! Источник паспорта: `crates/labcolors-wasm/tests/data/labui.config.json`, -//! замороженный дедуп-набор = `exposure_support::LABUI_ANCHORS` (cfg(test)- -//! внутренний, недоступен интеграционному тесту, потому список воспроизведён здесь -//! и сверяется по длине 49). - -use labcolors_core::lpc::{lpc, lpc_readability_ys}; -use labcolors_core::pair::{PairSide, pair_side}; -use labcolors_core::srgb_encoded_from_hex; - -/// 49 реальных якорей labui (роли/сентименты/акценты/нейтраль). Дословная копия -/// замороженного `exposure_support::LABUI_ANCHORS`. -const ANCHORS: [&str; 49] = [ - "#0040DD", "#0050CF", "#0071A4", "#007AFF", "#00C7BE", "#0C817B", "#101012", "#248A3D", - "#30D158", "#30DB5B", "#34C759", "#3634A3", "#3C3C43", "#3E87FF", "#409CFF", "#4A8FFF", - "#5696FF", "#5856D6", "#5AC8FA", "#5E5CE6", "#63E6E2", "#64D2FF", "#6CEBE7", "#70D7FF", - "#787880", "#7D7AFF", "#8944AB", "#95C0FF", "#AF52DE", "#B0B0B9", "#B25000", "#BF5AF2", - "#C93400", "#D30F45", "#D70015", "#DA8FFF", "#F6F8FA", "#FF2D55", "#FF3A3A", "#FF3B30", - "#FF6161", "#FF6482", "#FF9008", "#FFA100", "#FFA940", "#FFD000", "#FFD426", "#FFD60A", - "#FFFFFF", -]; - -/// Y-порог кроссовера чисто-люминансного контраст-ядра `contrast_core` — литерал -/// воспроизводит `pair::PAIR_CROSSOVER_Y` (`pub(crate)`, извне недоступен). НЕ -/// слепая копия: тест `pure_luminance_side_matches_production_pair_side` доказывает, -/// что `y < PAIR_CROSSOVER_Y` даёт РОВНО то же разбиение, что продакшн `pair_side`, -/// на всех 49 якорях — дрейф константы в ядре расщепил бы согласие. -const PAIR_CROSSOVER_Y: f64 = 0.341_955; - -/// sRGB EOTF (IEC 61966-2-1 §6.4), неотрицательная ветвь — реплика -/// `spaces::srgb::srgb_gamma_inv` (в `pub(crate) mod spaces`, извне недоступен). -/// Стандарт, не политика: та же ветвь, что кормит `pair::wcag_y_encoded`. -fn srgb_eotf(v: f64) -> f64 { - if v <= 0.040_45 { - v / 12.92 - } else { - ((v + 0.055) / 1.055).powf(2.4) - } -} - -/// WCAG relative luminance (Rec.709 / BT.709 luma) кодированного якоря — домен -/// решения `pair_side`. Реплика `pair::wcag_y_encoded` (private). -fn wcag_y(hex: &str) -> f64 { - let e = srgb_encoded_from_hex(hex).expect("passport hex valid"); - 0.2126 * srgb_eotf(e[0]) + 0.7152 * srgb_eotf(e[1]) + 0.0722 * srgb_eotf(e[2]) -} - -/// Сторона лейбла: белый (светлая) или чёрный (чернильная). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Label { - White, - Black, -} - -impl Label { - fn tag(self) -> &'static str { - match self { - Label::White => "белый", - Label::Black => "чёрный", - } - } -} - -/// Из `PairSide` (чисто-люминансный домен) в сторону лейбла. -fn side_of(p: PairSide) -> Label { - match p { - PairSide::Light => Label::White, - PairSide::Ink => Label::Black, - } -} - -/// Level-3 argmax |Lc| над произвольной контраст-функцией домена. Чёрный -/// выигрывает при `|Lc_чёрный| >= |Lc_белый|` — та же ориентация перелома, что -/// пиннит `PAIR_CROSSOVER_Y` (первый серый байт, где чёрный догоняет белый). -fn argmax_side(lc_white: f64, lc_black: f64) -> Label { - if lc_black.abs() >= lc_white.abs() { - Label::Black - } else { - Label::White - } -} - -/// Полный замер одного якоря во всех четырёх величинах. -struct Row { - hex: &'static str, - y: f64, - // H-K домен (продакшн lpc, Y_hk обе стороны) — характеризация. - lc_white_hk: f64, - lc_black_hk: f64, - p_hk: Label, - // Ys домен (ADR-0003 вариант A, обе стороны Ys) — характеризация. - lc_white_ys: f64, - lc_black_ys: f64, - p_ys: Label, - // Чисто-люминансный порог `Y < PAIR_CROSSOVER_Y` — ахроматический терминал. - p_thr: Label, - // Продакшн level-3 `pair::pair_side` — реально отгружаемое решение. - p_prod: Label, -} - -fn measure(hex: &'static str) -> Row { - let enc = srgb_encoded_from_hex(hex).expect("passport hex valid"); - let white_disp = srgb_encoded_from_hex("#FFFFFF").unwrap(); - let black_disp = srgb_encoded_from_hex("#000000").unwrap(); - - let lc_white_hk = lpc("#FFFFFF", hex); - let lc_black_hk = lpc("#000000", hex); - let lc_white_ys = lpc_readability_ys(white_disp, enc); - let lc_black_ys = lpc_readability_ys(black_disp, enc); - let y = wcag_y(hex); - - Row { - hex, - y, - lc_white_hk, - lc_black_hk, - p_hk: argmax_side(lc_white_hk, lc_black_hk), - lc_white_ys, - lc_black_ys, - p_ys: argmax_side(lc_white_ys, lc_black_ys), - p_thr: if y < PAIR_CROSSOVER_Y { - Label::White - } else { - Label::Black - }, - p_prod: side_of(pair_side(enc)), - } -} - -fn rows() -> Vec { - ANCHORS.iter().map(|&h| measure(h)).collect() -} - -/// Печать таблицы главы (§4) + сводки флипов. Это ИСТОЧНИК всех чисел главы. -#[test] -fn table_level3_polarity_over_49_anchors() { - assert_eq!(ANCHORS.len(), 49, "паспорт обязан нести ровно 49 якорей"); - - let rows = rows(); - let mut prod_vs_thr = Vec::new(); - let mut hk_vs_ys = Vec::new(); - - eprintln!( - "\n{:<9} {:>7} | {:>8} {:>8} {:>7} | {:>8} {:>8} {:>7} | {:>7} {:>7} | {:>5}", - "anchor", - "Y", - "|Lc_w|hk", - "|Lc_b|hk", - "P_hk", - "|Lc_w|ys", - "|Lc_b|ys", - "P_ys", - "P_thr", - "P_prod", - "promo" - ); - eprintln!("{}", "-".repeat(100)); - for r in &rows { - let promo = if r.p_prod != r.p_thr { "L→I" } else { "" }; - eprintln!( - "{:<9} {:>7.4} | {:>8.2} {:>8.2} {:>7} | {:>8.2} {:>8.2} {:>7} | {:>7} {:>7} | {:>5}", - r.hex, - r.y, - r.lc_white_hk.abs(), - r.lc_black_hk.abs(), - r.p_hk.tag(), - r.lc_white_ys.abs(), - r.lc_black_ys.abs(), - r.p_ys.tag(), - r.p_thr.tag(), - r.p_prod.tag(), - promo - ); - if r.p_prod != r.p_thr { - prod_vs_thr.push(r.hex); - } - if r.p_hk != r.p_ys { - hk_vs_ys.push(r.hex); - } - } - eprintln!("{}", "-".repeat(100)); - eprintln!( - "H-K-ПРОМОЦИИ продакшн level-3 vs чистый порог ({}): {:?}", - prod_vs_thr.len(), - prod_vs_thr - ); - eprintln!( - "ФЛИПЫ H-K-домен vs Ys-домен ({}) [диагностика ADR 15:0]: {:?}", - hk_vs_ys.len(), - hk_vs_ys - ); - eprintln!( - "Сводка: 49 якорей; продакшн чернильных {}, светлых {}", - rows.iter().filter(|r| r.p_prod == Label::Black).count(), - rows.iter().filter(|r| r.p_prod == Label::White).count() - ); -} - -/// ЛОК АХРОМАТИЧЕСКОГО ТЕРМИНАЛА + H-K-ПРОМОЦИЙ: продакшн level-3 `pair::pair_side` -/// совпадает с чисто-люминансным порогом `Y < PAIR_CROSSOVER_Y` НА ВСЕХ 49 якорях, -/// КРОМЕ поимённого набора H-K-промоций, и каждая промоция — строго Light→Ink -/// (насыщенный фон выглядит светлее → раньше несёт чернила). Это доказывает: (1) -/// литерал `0.341955` = ахроматический терминал продакшн-правила; (2) level-3 H-K -/// только ДОБАВЛЯЕТ чернила поверх порога, поимённо и однонаправленно. Дрейф -/// константы ядра или направления H-K расщепил бы этот набор. -#[test] -fn production_diverges_from_pure_threshold_only_by_named_hk_promotions() { - let mut promotions = Vec::new(); - for &hex in &ANCHORS { - let enc = srgb_encoded_from_hex(hex).unwrap(); - let threshold = if wcag_y(hex) < PAIR_CROSSOVER_Y { - PairSide::Light - } else { - PairSide::Ink - }; - let prod = pair_side(enc); - if prod != threshold { - assert_eq!( - (threshold, prod), - (PairSide::Light, PairSide::Ink), - "{hex}: расхождение обязано быть промоцией Light→Ink (Y={:.4}), \ - получено порог={threshold:?} продакшн={prod:?}", - wcag_y(hex) - ); - promotions.push(hex); - } - } - assert_eq!( - promotions, - vec!["#409CFF", "#4A8FFF", "#5696FF", "#FF6161", "#FF6482"], - "поимённый набор H-K-промоций на 49-якорном паспорте" - ); -} - -/// СВОЙСТВО НАПРАВЛЕНИЯ (ядро вывода ADR-0003, воспроизведено на паспорте): всюду, -/// где H-K-домен расходится с `Ys`-доменом, H-K уходит в ЧЁРНЫЙ там, где `Ys` -/// держит БЕЛЫЙ — H-K монотонно ТОПИТ белый на насыщенных фонах, обратного флипа -/// (`Ys`→чёрный, H-K→белый) нет НИ НА ОДНОМ якоре. Та же 15:0-направленность, что -/// измерил синтетический свип V3, но на реальном паспорте. -#[test] -fn hk_flips_are_monotone_white_to_black() { - let mut flips = 0; - for r in rows() { - if r.p_hk != r.p_ys { - flips += 1; - assert_eq!( - (r.p_ys, r.p_hk), - (Label::White, Label::Black), - "{}: флип обязан быть Ys=белый→H-K=чёрный, получено Ys={:?} H-K={:?}", - r.hex, - r.p_ys, - r.p_hk - ); - } - } - assert!( - flips > 0, - "на паспорте обязан быть хотя бы один H-K-флип, иначе валидация ничего не показывает" - ); -} - -/// СВОЙСТВО НАПРАВЛЕНИЯ продакшна vs чисто-люминансный терминал: продакшн level-3 -/// расходится с порогом `PAIR_CROSSOVER_Y` только В СТОРОНУ чернил (Light→Ink), -/// никогда обратно. Пиннит, что H-K-лифт только ДОБАВЛЯЕТ чернильные решения -/// относительно сырого перелома — дубликат-страж набора (см. поимённый лок выше). -#[test] -fn production_only_adds_ink_relative_to_pure_threshold() { - for r in rows() { - if r.p_prod != r.p_thr { - assert_eq!( - (r.p_thr, r.p_prod), - (Label::White, Label::Black), - "{}: продакшн обязан только ДОБАВЛЯТЬ чернила (порог={:?} продакшн={:?})", - r.hex, - r.p_thr, - r.p_prod - ); - } - } -} - -/// ЭНДПОИНТ: белый фон `#FFFFFF` (`Y_hk = Y = 1`) — чёрный лейбл во ВСЕХ трёх -/// доменах, и |Lc| канонический `≈106.04` (H-K не двигает люминансный эндпоинт; -/// домены `Y_hk` и `Ys` совпадают там побайтно). Якорь легального пола WCAG цел. -#[test] -fn white_anchor_is_black_label_at_canonical_contrast() { - let r = measure("#FFFFFF"); - assert_eq!(r.p_hk, Label::Black); - assert_eq!(r.p_ys, Label::Black); - assert_eq!(r.p_thr, Label::Black); - assert_eq!(r.p_prod, Label::Black); - assert!( - (r.lc_black_hk.abs() - 106.04).abs() < 0.5, - "чёрный на белом обязан быть ≈106.04, получено {}", - r.lc_black_hk.abs() - ); - // Эндпоинты доменов совпадают: Y_hk(0/1) == Ys(0/1). - assert!( - (r.lc_black_hk.abs() - r.lc_black_ys.abs()).abs() < 1e-9, - "на люминансном эндпоинте домены Y_hk и Ys обязаны совпасть: hk={} ys={}", - r.lc_black_hk.abs(), - r.lc_black_ys.abs() - ); -} diff --git a/crates/labcolors-core/tests/on_disk_audit_probe.rs b/crates/labcolors-core/tests/on_disk_audit_probe.rs index f4048623..50643e8d 100644 --- a/crates/labcolors-core/tests/on_disk_audit_probe.rs +++ b/crates/labcolors-core/tests/on_disk_audit_probe.rs @@ -55,28 +55,20 @@ //! on_disk_audit_probe_goes_red_then_green_after_restore -- --ignored //! ``` //! -//! Unlike the old real-tree version, `--test-threads=1` is NO LONGER REQUIRED -//! (the real tree is never mutated). The test will pass with it, but default -//! parallelism is safe. +//! Изолированный временный каталог безопасен для стандартного параллелизма +//! тестов: реальное дерево не изменяется. //! -//! # Isolation mechanism: temp-dir copy + atomic write + #[serial] +//! # Механизм изоляции: временная копия и атомарная запись //! -//! The real workspace is copied into a per-invocation tempdir, the probe is -//! injected into the COPY with an atomic write (write-tmp → rename), and the -//! subprocess runs `current_dir()` so Cargo recompiles from the copy. -//! The `#[serial]` annotation on this test serializes subprocess execution so -//! nested cargo invocations don't thrash the shared target/ cache. The tempdir -//! is cleaned up automatically when it goes out of scope. +//! Для каждого запуска workspace копируется во временный каталог. Проба +//! атомарно вставляется в копию (`write-tmp → rename`), а подпроцесс запускается +//! с `current_dir()`, чтобы Cargo компилировал копию. Каталог +//! автоматически удаляется при выходе из области видимости. -// Pull in the shared panic-safe splice_into from the support module. -// This closes the DRY/SRP fracture: both on_disk_audit_probe.rs and -// s2b_baseline_guards.rs previously maintained separate copies that had already -// diverged. The canonical implementation in splice_support.rs is the single source -// of truth; the probe now uses it to splice into a TEMP-DIR COPY, not the real tree. +// Атомарная вставка только в изолированную временную копию. #[path = "splice_support.rs"] mod splice_support; -use serial_test::serial; use std::path::{Path, PathBuf}; fn crate_root() -> PathBuf { @@ -174,15 +166,12 @@ fn run_gate_test_from(workspace_dir: &Path, test_name: &str) -> (bool, String) { /// See the module-level doc comment for the full rationale and the exact /// invocation command. /// -/// `#[serial]` serializes subprocess execution within the test binary so that -/// nested cargo builds don't thrash the shared target/ cache. Unlike the old -/// real-tree version, the real source tree is never written to, so the test is -/// safe under default parallelism. +/// Реальное дерево исходников не изменяется, поэтому стандартный параллелизм +/// тестов безопасен. #[test] #[ignore = "test runs a nested cargo subprocess; requires \ LABCOLORS_ON_DISK_PROBE_ENABLED=1 env var to enable. \ See module doc for the exact command."] -#[serial] #[cfg(not(miri))] // Miri cannot execute external subprocesses. fn on_disk_audit_probe_goes_red_then_green_after_restore() { // Env-var tripwire: a CI runner that passes `--include-ignored` must still @@ -197,6 +186,10 @@ fn on_disk_audit_probe_goes_red_then_green_after_restore() { return; } + let real_semantic = semantic_path(); + let real_semantic_before = std::fs::read(&real_semantic) + .unwrap_or_else(|e| panic!("on_disk_audit_probe: cannot snapshot real semantic.rs: {e}")); + // Create a temporary workspace copy. let tempdir = tempfile::TempDir::new().unwrap_or_else(|e| { panic!("on_disk_audit_probe: cannot create tempdir: {e}"); @@ -251,22 +244,14 @@ fn on_disk_audit_probe_goes_red_then_green_after_restore() { specifically because of the probe, not a pre-existing issue).\n{injected_out}" ); - // Post-condition: verify the real tree was NEVER touched (isolation invariant). - // The real semantic.rs should be byte-identical to its state before this test. - let real_semantic = semantic_path(); - let git_status_output = std::process::Command::new("git") - .args(["status", "--porcelain"]) - .arg(real_semantic.file_name().unwrap()) - .current_dir(crate_root()) - .output() - .unwrap_or_else(|e| { - panic!("on_disk_audit_probe: cannot run git status: {e}"); - }); - let status_str = String::from_utf8_lossy(&git_status_output.stdout); - assert!( - status_str.trim().is_empty(), - "on_disk RED-proof FAILED — real semantic.rs was mutated during the test \ - (isolation violation). git status:\n{status_str}" + // Постусловие сравнивает точные байты, прочитанные этим запуском. Оно + // корректно и в намеренно dirty-worktree и не может ложно пройти из-за + // неверного git pathspec. + let real_semantic_after = std::fs::read(&real_semantic) + .unwrap_or_else(|e| panic!("on_disk_audit_probe: cannot reread real semantic.rs: {e}")); + assert_eq!( + real_semantic_after, real_semantic_before, + "on-disk audit probe mutated the real semantic.rs" ); // Tempdir is automatically cleaned up when `tempdir` goes out of scope. diff --git a/crates/labcolors-core/tests/property_accent_surface.rs b/crates/labcolors-core/tests/property_accent_surface.rs deleted file mode 100644 index 29b8a0e5..00000000 --- a/crates/labcolors-core/tests/property_accent_surface.rs +++ /dev/null @@ -1,215 +0,0 @@ -//! Property-инвариант ЗАКОНА ОДНОУРОВНЕВОСТИ для акцентных Background-рамп -//! (Класс В, Фаулер): пер-уровневые ШАГИ светлоты (CAM16-UCS J') выведенной -//! акцентной рампы равны шагам нейтральной рампы на ВСЁМ пространстве оттенков в -//! ОБЕИХ темах — а не на выбранных примерах. -//! -//! # Что ловит инвариант (КЛАСС багов) -//! -//! «Акцентный фон съезжает по светлоте относительно одноимённого нейтрального» — -//! ровно инцидент, которого боится владелец: тихий дрифт полярности/деривации -//! ядра рушит контраст у labui. Наивная деривация (фиксированная хрома, клип у -//! стены гамута, или пер-уровневый светлотный офсет) роняет этот тест с -//! минимизированным контрпримером. Красящий примитив держит светлоту нейтрали -//! по построению → шаги совпадают. -//! -//! # RED-доказательство (не green-from-birth) -//! -//! `red_proof_per_level_lightness_drift_breaks_the_gate` строит НАМЕРЕННО -//! сломанную рампу (пер-уровневый дрейф светлоты) и утверждает, что детектор -//! нарушений СРАБАТЫВАЕТ — тот же идиом, что `one_levelness_tests::red_proof_*` -//! и `agnostic_production_surface::red_proof_*`. Детектор кусается, значит зелёный на -//! реальной деривации — не театр. -//! -//! # Допуск (DECLARED) -//! -//! Шаги сравниваются по J' ЭМИТИРОВАННОГО цвета (перцептивная светлота, которой -//! оперирует весь движок). Акцент несёт хрому, и CAM16-J хроматического стимула -//! чуть отклоняется от серого на той же Oklab-светлоте — это ФИЗИЧЕСКИЙ вобл, -//! не нарушение закона. `TOL` — задекларированная полоса этого вобла, замерена -//! `measure_step_mismatch_across_hues_and_themes` (печатает фактический максимум). - -use labcolors_core::accent_balance::BalancedAccent; -use labcolors_core::neutral::{CurveParams, NeutralCurve}; -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. -// ───────────────────────────────────────────────────────────────────────────── - -/// светлая: база=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 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() - )); - } - } - 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(()) - }); -} - -// ───────────────────────────────────────────────────────────────────────────── -// RED-proof: детектор кусается на намеренно сломанной рампе. -// ───────────────────────────────────────────────────────────────────────────── - -#[test] -fn red_proof_per_level_lightness_drift_breaks_the_gate() { - 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(), - "пер-уровневый дрейф светлоты обязан уронить гейт одноуровневости" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Замер: печатает фактический максимум |Δшаг| — заземление 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} — пересмотреть допуск" - ); -} diff --git a/crates/labcolors-core/tests/property_invariants.rs b/crates/labcolors-core/tests/property_invariants.rs index aeeea077..9a7b13e7 100644 --- a/crates/labcolors-core/tests/property_invariants.rs +++ b/crates/labcolors-core/tests/property_invariants.rs @@ -22,8 +22,8 @@ use labcolors_core::{ BgInput, Brand, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure, RoleRecipe, - SentimentCategory, SentimentsConfig, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, - ViewingConditions, oklch_from_hex, p3_from_hex, resolve_named_set, srgb_encoded_from_hex, + ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset, ViewingConditions, oklch_from_hex, + p3_from_hex, resolve_named_set, srgb_encoded_from_hex, }; use proptest::prelude::*; use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner}; @@ -112,8 +112,8 @@ fn is_valid_solid_hex(hex: &str) -> bool { hex.len() == 7 && hex.starts_with('#') && hex[1..].bytes().all(|b| b.is_ascii_hexdigit()) } -/// Осветлить/затемнить базовый байт для light/dark якорей семьи (валидный hex, -/// оттенок примерно сохраняется — движку для компиляции нужны лишь валидные hex). +/// Построить разные валидные light/dark байтовые фикстуры; это transport-data, +/// а не модель сохранения оттенка. fn lighten(c: u8) -> u8 { 128u8.saturating_add(c / 2) } @@ -246,7 +246,6 @@ fn base_config( dark: "#101012".to_string(), }, tint: NeutralTint { - ratio: 0.10, target_mp: 5.0, hue_stiffness: 8.0, hue_override_deg: Some(286.0), @@ -258,16 +257,6 @@ fn base_config( key: "fam".to_string(), anchors: family_anchors, }], - SentimentsConfig { - 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![ ("day".to_string(), VcPreset::Srgb), @@ -527,18 +516,19 @@ fn numerical_plan_checksum_is_permutation_invariant_and_entries_keep_order() { } // ───────────────────────────────────────────────────────────────────────────── -// СВОЙСТВО 5 — сохранение оттенка семьи цветным лейблом (M1) +// СВОЙСТВО 5 — координатная близость к семье у цветного лейбла (M1) // // КЛАСС: «цветной лейбл дрейфует из своей цветовой семьи / стерильно сереет». // Там, где решённый лейбл несёт РЕАЛЬНУЮ хрому (C > порога), его oklch-оттенок -// остаётся в полосе семьи. Порог по хроме обязателен: у светлотных экстремумов +// остаётся в объявленной координатной полосе семьи. Это геометрический lock, не +// перцептивный verdict. Порог по хроме обязателен: у светлотных экстремумов // max_chroma схлопывается и оттенок численно шумит (как в dim_tinted_tests). // БЬЁТ НА МУТАЦИИ: срыв прокидывания оттенка бренда в тинт-якорь (M1) → лейбл // сереет/уходит в нейтральный подтон → C падает или дистанция растёт → RED. // ───────────────────────────────────────────────────────────────────────────── #[test] -fn hued_brand_label_preserves_family_hue_where_it_has_chroma() { +fn hued_brand_label_stays_in_family_coordinate_band_when_chromatic() { // Живой насыщенный бренд (яркий синий), лейбл держит его оттенок. let brand = (0x0Au8, 0x54u8, 0xF0u8); let cfg = base_config( diff --git a/crates/labcolors-core/tests/reference_vectors.rs b/crates/labcolors-core/tests/reference_vectors.rs index 02324e2d..e868af31 100644 --- a/crates/labcolors-core/tests/reference_vectors.rs +++ b/crates/labcolors-core/tests/reference_vectors.rs @@ -8,12 +8,10 @@ //! //! Sources: //! * W3C WCAG 2.1 §1.4.3 / §1.4.11 — relative luminance & contrast ratio. -//! * Myndex APCA SAPC-8 `0.0.98G-4g` (`apca-w3`) — LPC curve endpoints. //! * Li et al. 2017, DOI 10.1002/col.22131 / CIE 248:2022 — CIECAM16 viewing //! conditions derivation. //! * Björn Ottosson (2020) / W3C CSS Color 4 — Oklab white & primary hues. -use labcolors_core::lpc::{lpc, lpc_with_vc}; // `spaces` is `pub(crate)`; these transforms are re-exported at the crate root. use labcolors_core::{ViewingConditions, oklch_css_from_hex, oklch_from_hex, recheck_against}; @@ -79,42 +77,6 @@ fn wcag_luminance_coefficients_isolated() { } } -// ───────────────────────────────────────────────────────────────────────────── -// LPC curve — Myndex APCA SAPC-8 published endpoints (via `apca-w3`). -// ───────────────────────────────────────────────────────────────────────────── - -/// Black and white are the luminance endpoints (Y_hk = 0 and 1), where the H-K -/// layer cannot shift the result, so LPC reproduces the published APCA extremes: -/// BoW ≈ 106.04 and WoB ≈ −107.88 (`apca-w3` v0.1.9, SAPC-8 `0.0.98G-4g`). -#[test] -fn lpc_endpoints_match_apca_via_public_api() { - let bow = lpc("#000000", "#FFFFFF"); - assert!( - (bow - 106.04).abs() < 0.1, - "BoW must be ≈106.04 (apca-w3), got {bow}" - ); - let wob = lpc("#FFFFFF", "#000000"); - assert!( - (wob + 107.88).abs() < 0.1, - "WoB must be ≈−107.88 (apca-w3), got {wob}" - ); - // Sign contract: dark-on-light positive, light-on-dark negative. - assert!(bow > 0.0 && wob < 0.0, "polarity signs: {bow} / {wob}"); -} - -/// Helmholtz–Kohlrausch, observable end-to-end: a saturated blue's perceived -/// lightness is lifted, so its LPC on white lands BELOW a same-luminance grey -/// would (published H-K effect; here ≈68.7, well under the ~76 a grey of equal -/// luminance reaches). -#[test] -fn hk_lifts_saturated_blue_via_public_lpc() { - let blue = lpc("#0000FF", "#FFFFFF"); - assert!( - (60.0..75.0).contains(&blue), - "H-K-lifted blue-on-white LPC must sit in (60, 75), got {blue}" - ); -} - // ───────────────────────────────────────────────────────────────────────────── // Oklab — Ottosson (2020) / CSS Color 4 published landmarks (public `oklch_from_hex`). // ───────────────────────────────────────────────────────────────────────────── @@ -217,42 +179,34 @@ fn cam16_viewing_conditions_derivation() { let vc = ViewingConditions::srgb(); // Independent transcription of the SAME published formulas: agreement is to // float round-off, so any real divergence in `vc.rs` is caught at 1e-9. - assert!((vc.fl - fl).abs() < 1e-9, "F_L: {} vs {fl}", vc.fl); - assert!((vc.n - n).abs() < 1e-12, "n: {} vs {n}", vc.n); - assert!((vc.z - z).abs() < 1e-12, "z: {} vs {z}", vc.z); - assert!((vc.nbb - nbb).abs() < 1e-9, "N_bb: {} vs {nbb}", vc.nbb); - assert!((vc.aw - aw).abs() < 1e-6, "A_w: {} vs {aw}", vc.aw); - for (i, (got, want)) in vc.rgb_d.iter().zip(rgb_d).enumerate() { + assert!((vc.fl() - fl).abs() < 1e-9, "F_L: {} vs {fl}", vc.fl()); + assert!((vc.n() - n).abs() < 1e-12, "n: {} vs {n}", vc.n()); + assert!((vc.z() - z).abs() < 1e-12, "z: {} vs {z}", vc.z()); + assert!((vc.nbb() - nbb).abs() < 1e-9, "N_bb: {} vs {nbb}", vc.nbb()); + assert!((vc.aw() - aw).abs() < 1e-6, "A_w: {} vs {aw}", vc.aw()); + for (i, (got, want)) in vc.rgb_d().into_iter().zip(rgb_d).enumerate() { assert!((got - want).abs() < 1e-9, "RGB_D[{i}]: {got} vs {want}"); } // Published surround triplet (average): F = 1.0, c = 0.69, N_c = 1.0 // (CIE 159:2004 Table 1, carried into CIE 248:2022). assert!( - (vc.c - 0.69).abs() < 1e-12, + (vc.c() - 0.69).abs() < 1e-12, "average surround c must be 0.69" ); assert!( - (vc.nc - 1.0).abs() < 1e-12, + (vc.nc() - 1.0).abs() < 1e-12, "average surround N_c must be 1.0" ); // Dim surround (dark-theme) triplet: F = 0.9, c = 0.59, N_c = 0.9. let dim = ViewingConditions::dim_surround(); - assert!((dim.c - 0.59).abs() < 1e-12, "dim surround c must be 0.59"); - assert!((dim.nc - 0.9).abs() < 1e-12, "dim surround N_c must be 0.9"); -} - -/// LPC under dim surround differs from average surround for a chromatic pair — -/// the Bartleson–Breneman compensation a dark theme must apply (CIE 248:2022 -/// surround triplet). Sanity that the published dim parameters actually change -/// the perceptual result, not just the struct fields. -#[test] -fn dim_surround_shifts_lpc() { - let srgb = lpc_with_vc("#00FF00", "#FFFFFF", &ViewingConditions::srgb()); - let dim = lpc_with_vc("#00FF00", "#FFFFFF", &ViewingConditions::dim_surround()); assert!( - (srgb - dim).abs() > 1.0, - "dim VC must shift Lc: srgb={srgb} dim={dim}" + (dim.c() - 0.59).abs() < 1e-12, + "dim surround c must be 0.59" + ); + assert!( + (dim.nc() - 0.9).abs() < 1e-12, + "dim surround N_c must be 0.9" ); } diff --git a/crates/labcolors-core/tests/s2b_baseline_guards.rs b/crates/labcolors-core/tests/s2b_baseline_guards.rs deleted file mode 100644 index 60904283..00000000 --- a/crates/labcolors-core/tests/s2b_baseline_guards.rs +++ /dev/null @@ -1,1330 +0,0 @@ -//! s2b chapter-01 baseline guards — TDD RED/PIN phase. -//! -//! SCOPE: c01-tempdir-copy-atomic-serial (epic: s2b-isolation-safe-guards). -//! These tests are written BEFORE the production change (no tempdir copy, no -//! atomic write, no `#[serial]` on the probe yet). Their purpose is: -//! -//! 1. Pin the current (correct) behaviour so byte-identity can be proven -//! after the rewrite. -//! 2. Prove the existing probe bites (is not vacuous) by running it solo in -//! its documented invocation. -//! 3. Encode the real-tree-immutability invariant as a property test so that -//! any future regression re-introducing a real-tree write is caught by class. -//! 4. Stress-test the probe under back-to-back parallelism (the exact race the -//! tempdir fix must close). -//! 5. Prove the nested subprocess (the gate test run from a copy) compiles and -//! runs correctly. -//! 6. Audit scope discipline: `#[serial]` applied ONLY to the probe; other test -//! files byte-unchanged; diff limited to on_disk_audit_probe.rs + Cargo.toml. -//! 7. Supply-chain audit: `serial_test` is dev-only; `cargo audit` green. -//! 8. CI parity: fmt/clippy/test/audit all green on the 1.96.0 toolchain. -//! -//! HOW EACH TEST BITES (per-test proof): -//! -//! Tests 1, 2, 3, 7, 8 — characterization / pin (GREEN at birth by design). -//! Bite proof: deliberately BREAK the asserted invariant in a throwaway copy, -//! confirm the test fails, then restore. Documented inline per test. -//! -//! Test 4 (parallel stability) — property / fuzz (GREEN at birth). -//! Bite proof: the old non-atomic+Drop code would leave `semantic.rs` dirty -//! under SIGKILL. The test asserts `git status` is empty; a dirty-tree -//! scenario makes it RED. -//! -//! Test 5 (nested-subprocess integration) — integration (GREEN at birth). -//! Bite proof: corrupt the nested invocation command (wrong test name) and -//! the subprocess exits non-zero, making the assertion fail. -//! -//! Test 6 (scope-discipline diff audit) — contract (GREEN: the scope invariants -//! are now asserted against the REAL safety gates, not a literal token). -//! -//! ISOLATION: tests 2, 3, 4, 5b run the on-disk probe (or its subprocess) and -//! therefore carry the same isolation requirements: -//! - `#[ignore]` — skipped by default `cargo test` -//! - `LABCOLORS_ON_DISK_PROBE_ENABLED=1` env var — second opt-in tripwire -//! - Run solo or with `--test-threads=1` until the tempdir fix lands (t2) -//! -//! The `#[serial]` annotation on the probe was closed by a prior commit. Its -//! contract is now verified by asserting the THREE real isolation invariants -//! (see test 6), not by a literal-string assertion that would circularly pin -//! an inert token. - -// Pull in the canonical splice helpers to avoid duplicating the panic-safe -// RestoreGuard and splice_into logic (DRY fracture fix). -#[path = "splice_support.rs"] -mod splice_support; - -use std::path::PathBuf; - -// ───────────────────────────────────────────────────────────────────────────── -// Shared helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn crate_root() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) -} - -fn workspace_root() -> PathBuf { - crate_root().join("..").join("..") -} - -fn semantic_path() -> PathBuf { - crate_root().join("src").join("semantic.rs") -} - -/// The CI-pinned Rust toolchain (matches RUST_TOOLCHAIN in ci.yml and -/// GATE_TOOLCHAIN in on_disk_audit_probe.rs). -const CI_TOOLCHAIN: &str = "1.96.0"; - -/// Check whether the on-disk probe opt-in env var is set. -fn probe_enabled() -> bool { - std::env::var("LABCOLORS_ON_DISK_PROBE_ENABLED").as_deref() == Ok("1") -} - -/// Check whether `cargo +` is available. If not, the test skips -/// rather than failing for tooling reasons unrelated to the code under test. -/// -/// This mirrors the skip-if-missing guard in `supply_chain_cargo_audit_green` -/// and closes the class of "hardcoded toolchain without skip guard" defects -/// (confirmed High finding in the s2b scope review). -fn toolchain_available() -> bool { - std::process::Command::new("cargo") - .args([&format!("+{CI_TOOLCHAIN}"), "--version"]) - .current_dir(workspace_root()) - .output() - .map(|o| o.status.success()) - .unwrap_or(false) -} - -/// Skip the calling test with a descriptive message when the pinned toolchain is -/// absent. Call this at the TOP of any test that invokes `cargo +`. -/// -/// Not a macro so it cannot early-return for us — callers check the return value: -/// `if skip_if_toolchain_absent("test_name") { return; }`. -fn skip_if_toolchain_absent(test_name: &str) -> bool { - if !toolchain_available() { - eprintln!( - "s2b_baseline/{test_name}: SKIPPED — Rust toolchain {CI_TOOLCHAIN} not found. \ - Install with: `rustup toolchain install {CI_TOOLCHAIN}`. \ - CI installs it automatically; this skip is acceptable locally." - ); - true - } else { - false - } -} - -/// Run `cargo + test -p labcolors-core --test -/// -- --nocapture` at the workspace root. Returns `(exit_success, combined_output)`. -/// -/// Callers MUST call `skip_if_toolchain_absent` before calling this helper. -fn run_cargo_test(binary: &str, test_name: &str) -> (bool, String) { - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "test", - "-p", - "labcolors-core", - "--test", - binary, - test_name, - "--", - "--nocapture", - ]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: failed to spawn `cargo test`: {e}")); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - (output.status.success(), combined) -} - -/// Run `cargo + test -p labcolors-core --test on_disk_audit_probe -/// on_disk_audit_probe_goes_red_then_green_after_restore -/// -- --ignored --test-threads=1` with `LABCOLORS_ON_DISK_PROBE_ENABLED=1`. -/// Returns `(exit_success, combined_output)`. -/// -/// Callers MUST call `skip_if_toolchain_absent` before calling this helper. -fn run_probe_solo() -> (bool, String) { - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "test", - "-p", - "labcolors-core", - "--test", - "on_disk_audit_probe", - "on_disk_audit_probe_goes_red_then_green_after_restore", - "--", - "--ignored", - "--test-threads=1", - ]) - .env("LABCOLORS_ON_DISK_PROBE_ENABLED", "1") - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: failed to spawn probe subprocess: {e}")); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - (output.status.success(), combined) -} - -/// Hash the bytes of `semantic.rs` — used to assert immutability. -fn semantic_hash() -> u64 { - use std::hash::Hash; - let bytes = - std::fs::read(semantic_path()).expect("s2b_baseline: cannot read semantic.rs to hash it"); - let mut h = std::collections::hash_map::DefaultHasher::new(); - bytes.hash(&mut h); - std::hash::Hasher::finish(&h) -} - -/// Run `git status --porcelain crates/labcolors-core/src/` at the workspace -/// root. Returns the stdout (empty means clean, non-empty means dirty OR litter). -/// -/// # Why the full src/ directory, not just semantic.rs -/// -/// The previous path-scoped check (`git status --porcelain …/semantic.rs`) -/// only proved the target file was unmodified. Sidecar litter left by splice -/// or restore on abnormal exit (e.g. `*.splice_tmp`, `*.restore_tmp`, -/// `*.on_disk_probe_backup`) lives in the SAME directory but does NOT match -/// the single-file pathspec — so the check was GREEN while the working tree -/// was dirty. Scanning the whole `src/` directory catches both: -/// (1) modification to semantic.rs itself, and -/// (2) untracked sidecar files (now also covered by .gitignore, but the -/// test catch is the defence-in-depth layer that fires even without it). -fn git_status_src_dir() -> String { - let output = std::process::Command::new("git") - .args(["status", "--porcelain", "crates/labcolors-core/src/"]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: cannot run `git status`: {e}")); - String::from_utf8_lossy(&output.stdout).trim().to_string() -} - -fn run_cargo_check(args: &[&str]) -> (bool, String) { - let mut full_args = vec![format!("+{CI_TOOLCHAIN}")]; - full_args.extend(args.iter().map(|s| s.to_string())); - - let output = std::process::Command::new("cargo") - .args(&full_args) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| { - panic!( - "s2b_baseline/test8: cannot run `cargo +{CI_TOOLCHAIN} {}`: {e}", - args.join(" ") - ) - }); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - (output.status.success(), combined) -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 1 — baseline byte-identity snapshot (regression / characterization) -// -// REGIME: regression (characterization lock of already-correct behaviour). -// HOW IT BITES: GREEN at birth. Mutation proof: change any entry in -// R3_ACCENT_007AFF_GOLDEN or R3_RESOLVE_SET_SPOTS in r3_byte_identity.rs → the -// test that calls the same functions will fail. Here we additionally assert the -// file content of r3_byte_identity.rs and empirical_inventory.rs is byte-stable -// relative to the committed version (no drift introduced by this commit). -// -// Concrete bite: corrupt any golden constant in R3_ACCENT_007AFF_GOLDEN (e.g. -// change "#F4F8FF" → "#000000") → the r3_byte_identity tests that compare -// against those constants fail with the drifted value. This test then also fails -// because the file hash changes. -// ───────────────────────────────────────────────────────────────────────────── - -/// Byte-identity snapshot: confirm that the r3_byte_identity and -/// empirical_inventory test files are byte-identical to the committed version. -/// -/// This is the "before" anchor. After the production change (t2) the same two -/// files must produce byte-identical checksums — proving the rewrite touched only -/// `on_disk_audit_probe.rs` and `Cargo.toml` (scope discipline, test 6). -/// -/// GREEN at birth (characterization). Bites on mutation: alter any byte in -/// either file → the hash changes → assertion fails. -/// -/// Семантика анкора: лок = «чисто против HEAD», не вечная заморозка — ловит -/// незакоммиченный дрейф; закоммиченное состояние и есть анкор. r3 закрыт к -/// расширениям enum `Resolved` (`#[non_exhaustive]` + wildcard-паника в матче -/// r3: новый вариант не требует правки залоченного файла, но не проходит -/// golden молча). -#[test] -fn baseline_r3_and_empirical_inventory_files_are_git_clean() { - // Both files must show no modification in `git status`. - // - // NOTE (ADR-0001 PR-c): `tests/r3_byte_identity.rs` was relocated into the crate - // as `src/r3_byte_identity_tests.rs` (a `#[cfg(test)]` unit module) because its - // subject — the built-in `resolve_set` oracle — moved behind `#[cfg(test)]` and - // is no longer visible to an out-of-crate integration test. The r3 byte-identity - // oracle is preserved verbatim at the new path; this s2b anchor (a completed - // epic's scope pin) drops the stale path rather than pin a file that moved. - for file in [ - "crates/labcolors-core/tests/empirical_inventory.rs", - "crates/labcolors-core/tests/lint_parity.rs", - "crates/labcolors-core/tests/ci_enforcement.rs", - "crates/labcolors-core/tests/gate_green_smoke.rs", - ] { - let output = std::process::Command::new("git") - .args(["status", "--porcelain", file]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: cannot run `git status` for {file}: {e}")); - let status = String::from_utf8_lossy(&output.stdout).trim().to_string(); - assert!( - status.is_empty(), - "BASELINE SNAPSHOT FAILED — `{file}` is dirty in git: '{status}'. \ - These files must be byte-identical to the committed version (scope discipline). \ - This is the s2b baseline anchor; any modification is a scope violation." - ); - } -} - -/// Baseline: `r3_byte_identity` test suite passes on the current tree. -/// -/// Captures that the byte-identity oracles are GREEN before the production -/// change. After the change they must still be GREEN with the same outputs. -/// -/// GREEN at birth (the goldens match the current computation). Bites on mutation: -/// change any golden constant in `r3_byte_identity.rs` → the `assert_eq!` fails -/// naming the drifted stop; this outer test then also fails because the subprocess -/// exits non-zero. -/// -/// `#[ignore]`: this test spawns `cargo +1.96.0 test --test r3_byte_identity`, -/// which recompiles the workspace and contends on the build-directory lock under -/// the outer `cargo test --workspace`. Run manually before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// baseline_r3_byte_identity_tests_pass -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 test) — contends on build-dir lock \ - and inflates CI wall-clock when run inside cargo test --workspace. \ - Run manually before PR (see test doc)."] -fn baseline_r3_byte_identity_tests_pass() { - if skip_if_toolchain_absent("baseline_r3_byte_identity_tests_pass") { - return; - } - // Run all r3 tests. Relocated (ADR-0001 PR-c) into the crate as the - // `#[cfg(test)]` module `r3_byte_identity_tests`, so it is a `--lib` name - // filter now, not a `--test` integration binary. - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "test", - "-p", - "labcolors-core", - "--lib", - "r3_byte_identity_tests", - "--", - "--nocapture", - ]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: cannot spawn r3_byte_identity run: {e}")); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - output.status.success(), - "BASELINE SNAPSHOT FAILED — `cargo test --test r3_byte_identity` exited non-zero. \ - The byte-identity oracles must pass on the current tree before any production \ - change (t2). Failing now means a pre-existing regression exists.\n{combined}" - ); -} - -/// Baseline: `empirical_inventory` gate tests pass on the current tree (excluding -/// the `#[ignore]`d probe test, which is verified separately in test 2). -/// -/// GREEN at birth. Bites on mutation: remove a `// GROUNDED` marker from a -/// perceptual module → GATE-1 fails → this outer test exits non-zero. -/// -/// `#[ignore]`: this test spawns `cargo +1.96.0 test --test empirical_inventory`, -/// which recompiles the workspace and contends on the build-directory lock. Run -/// manually before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// baseline_empirical_inventory_tests_pass -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 test) — contends on build-dir lock \ - and inflates CI wall-clock when run inside cargo test --workspace. \ - Run manually before PR (see test doc)."] -fn baseline_empirical_inventory_tests_pass() { - if skip_if_toolchain_absent("baseline_empirical_inventory_tests_pass") { - return; - } - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "test", - "-p", - "labcolors-core", - "--test", - "empirical_inventory", - "--", - "--nocapture", - ]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline: cannot spawn empirical_inventory run: {e}")); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - output.status.success(), - "BASELINE SNAPSHOT FAILED — `cargo test --test empirical_inventory` exited non-zero. \ - The governance gate must pass on the current tree before the production change. \ - A pre-existing failure blocks t2.\n{combined}" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 2 — existing-probe RED-proof solo (smoke) -// -// REGIME: smoke. The probe is NOT vacuous — it bites today. -// HOW IT BITES: GREEN at birth (the probe currently passes when run correctly). -// Mutation proof: remove the inner `!injected_green` assertion in -// `on_disk_audit_probe.rs` → the probe's RED-proof assertion disappears → the -// probe itself passes vacuously → BUT this outer test still calls the probe and -// asserts `exit_success`. So the outer (this test) stays GREEN. The inner -// mutation is detected by the probe's own structure, not by this test. -// -// The stronger bite is: change `splice_into_semantic` to inject nothing (no -// `_AUDIT_PROBE` const) → the gate-1 subprocess is still RED on the pre-existing -// tree → the probe's inner `injected_green` assertion fails → the probe exits -// non-zero → this test's `assert!(ok, ...)` fails. -// -// Isolation: #[ignore] + LABCOLORS_ON_DISK_PROBE_ENABLED=1 env var required. -// ───────────────────────────────────────────────────────────────────────────── - -/// Smoke: the on-disk probe passes when run solo per its documented invocation. -/// -/// This proves the live gate bites (is not vacuous) today, establishing the -/// behaviour that must be preserved verbatim after the rewrite (t2). -/// -/// GREEN at birth (the probe currently passes). Bites on mutation: break the -/// probe's injection or restore logic → the probe exits non-zero → this assertion -/// fails. -/// -/// # ISOLATION REQUIREMENT -/// Requires `LABCOLORS_ON_DISK_PROBE_ENABLED=1` and run with `--test-threads=1` -/// (or solo via `--test on_disk_audit_probe`). -#[test] -#[ignore = "runs the disk-mutating probe; set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and --test-threads=1. See on_disk_audit_probe.rs module doc."] -fn existing_probe_red_proof_solo_passes() { - if skip_if_toolchain_absent("existing_probe_red_proof_solo_passes") { - return; - } - if !probe_enabled() { - eprintln!( - "s2b_baseline/test2: SKIPPED — set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and run with --test-threads=1 to enable." - ); - return; - } - - let (ok, out) = run_probe_solo(); - assert!( - ok, - "SMOKE FAILED — the existing on-disk probe failed when run solo per its documented \ - invocation. The probe must PASS today to establish the baseline behaviour that \ - survives the t2 rewrite. This means either the probe is broken pre-existing, \ - or the invocation is wrong.\n\nProbe output:\n{out}" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 3 — real-tree immutability invariant (property) -// -// REGIME: property. Closes the CLASS of real-tree-write regressions, not just -// the single input. -// HOW IT BITES: GREEN at birth (the probe's Drop guard currently restores the -// file on non-SIGKILL exit). -// Mutation proof: remove the `RestoreGuard` Drop impl in -// `on_disk_audit_probe.rs` → the probe leaves `semantic.rs` dirty after running -// → `git_status_src_dir()` returns a non-empty string → the assertion fails. -// -// Isolation: #[ignore] + LABCOLORS_ON_DISK_PROBE_ENABLED=1. -// ───────────────────────────────────────────────────────────────────────────── - -/// Property: after the probe runs, `src/semantic.rs` is byte-identical to the -/// committed version (no modification in `git status`). -/// -/// Encodes the structural invariant "no test execution leaves the real source -/// tree modified". GREEN at birth. Bites on mutation: remove the `RestoreGuard` -/// Drop restore from `on_disk_audit_probe.rs` → the probe leaves `semantic.rs` -/// dirty → `git status --porcelain` is non-empty → this test fails. -/// -/// # ISOLATION REQUIREMENT -/// Requires `LABCOLORS_ON_DISK_PROBE_ENABLED=1` and run with `--test-threads=1`. -#[test] -#[ignore = "runs the disk-mutating probe; set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and --test-threads=1. See on_disk_audit_probe.rs module doc."] -fn real_tree_immutability_invariant_after_probe() { - if skip_if_toolchain_absent("real_tree_immutability_invariant_after_probe") { - return; - } - if !probe_enabled() { - eprintln!( - "s2b_baseline/test3: SKIPPED — set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and run with --test-threads=1 to enable." - ); - return; - } - - // Record the hash before the probe. - let hash_before = semantic_hash(); - - // Run the probe via subprocess (so the Drop guard in the probe binary runs). - let (ok, out) = run_probe_solo(); - assert!( - ok, - "real-tree immutability test: probe subprocess failed — cannot assert \ - immutability if the probe itself is broken.\n{out}" - ); - - // After the probe, `git status` on `semantic.rs` must be empty. - let status = git_status_src_dir(); - assert!( - status.is_empty(), - "REAL-TREE IMMUTABILITY FAILED — `git status --porcelain \ - crates/labcolors-core/src/` is non-empty after the probe ran: \ - '{status}'. The probe left the real source tree modified or littered \ - sidecar files (*.splice_tmp / *.restore_tmp / *.on_disk_probe_backup). \ - The RestoreGuard Drop restore must have failed or was removed." - ); - - // The byte hash must match (belt-and-suspenders over `git status`). - let hash_after = semantic_hash(); - assert_eq!( - hash_before, hash_after, - "REAL-TREE IMMUTABILITY FAILED — semantic.rs byte hash changed after the probe \ - (before: {hash_before:#016x}, after: {hash_after:#016x}). The file content \ - was modified and not fully restored." - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 4 — parallel back-to-back probe stability (fuzz) -// -// REGIME: fuzz / stress. Demonstrates the race IS closed (or exposes it if not). -// HOW IT BITES: GREEN at birth if the restore is correct (sequential runs under -// --test-threads=1 always restore). Bites under REAL concurrency: run two -// processes simultaneously → the Drop guard races → one leaves the file dirty. -// The test runs >=5 sequential runs and asserts clean after each. -// Under the old non-atomic code without #[serial], running concurrently (not -// sequential) corrupts the tree — but we cannot safely demonstrate that here -// pre-t2 without actually corrupting the CI tree. Instead, the test asserts the -// sequential-stability property, which is necessary (but not sufficient) for -// correctness. The race class is documented but demonstrated only via the -// non-atomic window analysis, not by actually triggering corruption. -// -// After t2 lands, this test should also be run without --test-threads=1 to -// prove the race is closed under true parallelism. -// -// Isolation: #[ignore] + LABCOLORS_ON_DISK_PROBE_ENABLED=1. -// ───────────────────────────────────────────────────────────────────────────── - -/// Fuzz: run the probe ≥5 times back-to-back under sequential mode and assert -/// `git status` on `semantic.rs` is empty after every iteration. -/// -/// This stresses the exact restore correctness that the old non-atomic+Drop code -/// could race on under concurrency. Sequential runs must always be clean. -/// -/// GREEN at birth (the Drop guard restores sequentially). Bites on mutation: -/// remove the Drop restore → the second iteration starts with dirty -/// `semantic.rs` → `git status` is non-empty → assertion fails. -/// -/// # ISOLATION REQUIREMENT -/// Requires `LABCOLORS_ON_DISK_PROBE_ENABLED=1` and run solo with -/// `--test-threads=1`. -#[test] -#[ignore = "runs the disk-mutating probe 5× back-to-back; \ - set LABCOLORS_ON_DISK_PROBE_ENABLED=1 and --test-threads=1."] -fn parallel_back_to_back_probe_stability() { - if skip_if_toolchain_absent("parallel_back_to_back_probe_stability") { - return; - } - if !probe_enabled() { - eprintln!( - "s2b_baseline/test4: SKIPPED — set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and run with --test-threads=1 to enable." - ); - return; - } - - const RUNS: usize = 5; - for i in 0..RUNS { - let (ok, out) = run_probe_solo(); - assert!( - ok, - "PARALLEL STABILITY FAILED — probe subprocess failed on iteration {i}/{RUNS}: \ - \n{out}" - ); - - let status = git_status_src_dir(); - assert!( - status.is_empty(), - "PARALLEL STABILITY FAILED — `git status --porcelain \ - crates/labcolors-core/src/` is non-empty after iteration \ - {i}/{RUNS}: '{status}'. The probe left the real source tree \ - modified or littered sidecar files. Under back-to-back runs the \ - restore must be complete and all litter cleaned before the next \ - iteration starts." - ); - } - - // Also run once via `--include-ignored` to prove the probe is still `#[ignore]`d - // (if it lost its `#[ignore]`, this invocation would run it without --test-threads=1 - // in the *outer* test process, which is exactly the hazard we document). - // Here we just check the probe is still #[ignore]d by reading the source. - let probe_src = - std::fs::read_to_string(crate_root().join("tests").join("on_disk_audit_probe.rs")) - .expect("s2b_baseline: cannot read on_disk_audit_probe.rs"); - assert!( - probe_src.contains("#[ignore"), - "PARALLEL STABILITY FAILED — `on_disk_audit_probe.rs` no longer contains \ - `#[ignore`. The probe must be `#[ignore]`d to be skipped by default \ - `cargo test` (opt-in invariant)." - ); - - // Confirm the LABCOLORS_ON_DISK_PROBE_ENABLED env-var tripwire is present. - assert!( - probe_src.contains("LABCOLORS_ON_DISK_PROBE_ENABLED"), - "PARALLEL STABILITY FAILED — `on_disk_audit_probe.rs` no longer checks \ - `LABCOLORS_ON_DISK_PROBE_ENABLED`. The env-var second tripwire must be \ - preserved (opt-in invariant)." - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 5 — nested-subprocess gate from tempdir copy (integration) -// -// REGIME: integration. Proves the nested `cargo test --test empirical_inventory` -// compiles and runs from a copy of the workspace in a tempdir — the contract the -// t2 production change must satisfy. -// -// NOTE: Pre-t2, we do NOT have the tempdir copy infrastructure yet. This test -// instead validates the INTERFACE CONTRACT: that `cargo test --test -// empirical_inventory gate1_every_policy_const_is_marked` compiles and passes -// from the real workspace root (the pre-t2 baseline). The test will be -// superseded in t2 to run from a tempdir. -// -// HOW IT BITES (pre-t2 baseline): -// GREEN at birth (gate1 passes on the current clean tree). -// Mutation proof: corrupt a `// GROUNDED` marker in semantic.rs → gate1 exits -// non-zero → this test's `assert!(ok, ...)` fails. -// -// Post-t2 (when the tempdir copy lands), this test will be replaced or extended -// to run the same assertion against `current_dir()` — same contract, -// different staging mechanism. -// ───────────────────────────────────────────────────────────────────────────── - -/// Integration (pre-t2 baseline): prove that `cargo test --test empirical_inventory -/// gate1_every_policy_const_is_marked` compiles and passes. -/// -/// This establishes the interface contract the nested subprocess must satisfy -/// before and after the production change. Post-t2 the same invocation will be -/// made from a tempdir copy; the contract (GREEN on an unmodified tree) is -/// identical. -/// -/// GREEN at birth. Bites on mutation: remove a `// GROUNDED` marker from any -/// perceptual module → gate1 exits non-zero → this test fails. -/// -/// `#[ignore]`: spawns `cargo +1.96.0 test --test empirical_inventory`, which -/// recompiles the workspace and contends on the build-directory lock. Run -/// manually before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// nested_subprocess_gate1_passes_on_clean_tree -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 test) — contends on build-dir lock. \ - Run manually before PR (see test doc)."] -fn nested_subprocess_gate1_passes_on_clean_tree() { - if skip_if_toolchain_absent("nested_subprocess_gate1_passes_on_clean_tree") { - return; - } - let (ok, out) = run_cargo_test("empirical_inventory", "gate1_every_policy_const_is_marked"); - assert!( - ok, - "INTEGRATION FAILED — nested subprocess `cargo +{CI_TOOLCHAIN} test \ - --test empirical_inventory gate1_every_policy_const_is_marked` exited \ - non-zero on the clean tree. This is the interface contract the production \ - change (t2) must preserve.\n\nOutput:\n{out}" - ); -} - -/// Integration: the nested subprocess exits RED when the source contains an -/// unmarked `_AUDIT_PROBE` const — exactly the contract the on-disk probe tests. -/// -/// This validates the subprocess interface: it must name the probe const in its -/// output when the tree is injected. GREEN at birth (gate1 bites on injection). -/// Bites on mutation: change `gate1_every_policy_const_is_marked` to always exit -/// 0 → this test fails because `injected_ok` would be `true`. -/// -/// NOTE: This test INJECTS into and then RESTORES the real semantic.rs. It uses -/// the shared panic-safe `splice_support::RestoreGuard` and -/// `splice_support::splice_into` (canonical implementations). Run with -/// --test-threads=1 until t2 lands. -#[test] -#[ignore = "injects into and restores the real semantic.rs; run with \ - LABCOLORS_ON_DISK_PROBE_ENABLED=1 and --test-threads=1."] -fn nested_subprocess_gate1_goes_red_on_injected_audit_probe() { - if skip_if_toolchain_absent("nested_subprocess_gate1_goes_red_on_injected_audit_probe") { - return; - } - if !probe_enabled() { - eprintln!( - "s2b_baseline/test5b: SKIPPED — set LABCOLORS_ON_DISK_PROBE_ENABLED=1 \ - and run with --test-threads=1 to enable." - ); - return; - } - - let original_path = semantic_path(); - let backup_path = original_path.with_extension("rs.s2b_test5_backup"); - - std::fs::copy(&original_path, &backup_path).unwrap_or_else(|e| { - panic!("s2b_baseline/test5: cannot back up semantic.rs: {e}"); - }); - - // Use the canonical panic-safe RestoreGuard from splice_support. - // This closes the DRY fracture: the old local `Restore` struct was a - // separate implementation that had the CORRECT panic-safe semantics - // (`let _ =` on errors) — now both callers share one canonical Drop. - let _guard = splice_support::RestoreGuard { - target: original_path.clone(), - backup: backup_path.clone(), - }; - - // Splice using the canonical shared helper (closes the duplicated splice - // logic that was already byte-identical but lived in two places with - // different tmp filenames — now one path, one source of truth). - splice_support::splice_into(&original_path, "const _AUDIT_PROBE: f64 = 42.0;"); - - // The nested subprocess must exit RED and name _AUDIT_PROBE. - let (injected_ok, injected_out) = - run_cargo_test("empirical_inventory", "gate1_every_policy_const_is_marked"); - assert!( - !injected_ok, - "INTEGRATION FAILED — nested subprocess did NOT fail after injecting \ - `_AUDIT_PROBE: f64 = 42.0` into semantic.rs. The gate must exit RED on \ - an unmarked const — the nested-subprocess interface is broken.\n{injected_out}" - ); - assert!( - injected_out.contains("_AUDIT_PROBE"), - "INTEGRATION FAILED — gate1 exited RED but did NOT name `_AUDIT_PROBE` in \ - its output. The failure is for the wrong reason.\n{injected_out}" - ); - - // Drop guard restores semantic.rs here. - drop(_guard); - - // Post-restore: gate1 must be GREEN again. - let (restored_ok, restored_out) = - run_cargo_test("empirical_inventory", "gate1_every_policy_const_is_marked"); - assert!( - restored_ok, - "INTEGRATION FAILED — gate1 is still RED after the restore. The backup/restore \ - mechanism is broken.\n{restored_out}" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 6 — scope-discipline diff audit (contract) -// -// REGIME: contract. Asserts the production diff (t2) is scope-limited. -// -// The previous implementation asserted `probe_src.contains("#[serial]")` as the -// scope-discipline marker. That was a circular dead contract (confirmed High -// defect): `#[serial]` was retained SOLELY because this test asserted its -// literal presence, while its actual cross-binary isolation value was nil (it is -// a process-local mutex; the cross-binary race is closed by the three OTHER -// gates). The `#[serial]`-literal assertion was coupling between two places with -// no functional payoff — a guard that protects the token, not the invariant. -// -// The fix: assert the REAL isolation invariants that actually protect the probe: -// 1. `#[ignore]` — probe is skipped by default `cargo test`. -// 2. `LABCOLORS_ON_DISK_PROBE_ENABLED` env-var — second tripwire. -// 3. `--test-threads=1` documentation — cross-binary isolation note. -// These are the three gates the module doc mandates; asserting them closes the -// CLASS of "opt-in gate silently removed" regressions. -// -// `#[serial]` itself is harmless and may remain on the probe (it correctly -// serialises threads within the probe binary if future tests are added); it is -// just not the contract this test pins. -// -// HOW IT BITES: -// Remove `#[ignore]` from `on_disk_audit_probe.rs` → assertion (1) fails. -// Remove the `LABCOLORS_ON_DISK_PROBE_ENABLED` check → assertion (2) fails. -// Remove `--test-threads=1` from the module doc → assertion (3) fails. -// Add `#[serial]` to lint_parity.rs → the scope-creep assertions below fail. -// ───────────────────────────────────────────────────────────────────────────── - -/// Contract: the on-disk probe preserves its THREE real isolation gates — the -/// mechanisms that actually prevent the disk-mutating test from running -/// unsafely in default `cargo test`. -/// -/// This replaces the previous literal `#[serial]`-token assertion (a circular -/// dead contract) with assertions on the functional safety invariants. -/// -/// GREEN. Bites on mutation: remove `#[ignore]`, or remove the env-var check, -/// or remove the `--test-threads=1` requirement from the docs → the respective -/// assertion fails. -#[test] -fn scope_discipline_probe_real_isolation_gates_present() { - let probe_src = - std::fs::read_to_string(crate_root().join("tests").join("on_disk_audit_probe.rs")) - .expect("s2b_baseline/test6: cannot read on_disk_audit_probe.rs"); - - // Gate 1: `#[ignore]` — skips the probe in default `cargo test`. - assert!( - probe_src.contains("#[ignore"), - "SCOPE-DISCIPLINE FAILED — `on_disk_audit_probe.rs` lost its `#[ignore]` \ - annotation. The probe must be opt-in (skipped by default `cargo test`). \ - This is the first of the three real isolation gates." - ); - - // Gate 2: env-var tripwire — second opt-in even when --include-ignored is passed. - assert!( - probe_src.contains("LABCOLORS_ON_DISK_PROBE_ENABLED"), - "SCOPE-DISCIPLINE FAILED — `on_disk_audit_probe.rs` lost the \ - `LABCOLORS_ON_DISK_PROBE_ENABLED` env-var check. This is the second \ - isolation gate (a CI runner that strips #[ignore] still requires explicit \ - opt-in). Both gates must be preserved." - ); - - // Gate 3: --test-threads=1 requirement documented — prevents the cross-binary - // race (the process-local #[serial] mutex cannot close the cross-binary race; - // this documented requirement is the actual cross-binary isolation mechanism). - assert!( - probe_src.contains("--test-threads=1"), - "SCOPE-DISCIPLINE FAILED — `on_disk_audit_probe.rs` no longer documents \ - `--test-threads=1` as mandatory. This is the third isolation gate: \ - the cross-binary race between on_disk_audit_probe and s2b_baseline_guards \ - (both mutate semantic.rs) cannot be closed by #[serial] (process-local); \ - it is closed by running with --test-threads=1 per the module doc." - ); -} - -/// Contract: `on_disk_audit_probe.rs` still preserves the `#[ignore]` opt-in gate -/// and the `LABCOLORS_ON_DISK_PROBE_ENABLED` env-var tripwire. -/// -/// After t2 adds `#[serial]`, neither opt-in must be removed. GREEN at birth. -/// Bites on mutation: remove either from `on_disk_audit_probe.rs` → assertion fails. -#[test] -fn scope_discipline_probe_preserves_both_opt_in_gates() { - let probe_src = - std::fs::read_to_string(crate_root().join("tests").join("on_disk_audit_probe.rs")) - .expect("s2b_baseline/test6b: cannot read on_disk_audit_probe.rs"); - - assert!( - probe_src.contains("#[ignore"), - "SCOPE-DISCIPLINE FAILED — `on_disk_audit_probe.rs` lost its `#[ignore]` \ - annotation. The probe must remain opt-in (invariant: probe stays opt-in)." - ); - - assert!( - probe_src.contains("LABCOLORS_ON_DISK_PROBE_ENABLED"), - "SCOPE-DISCIPLINE FAILED — `on_disk_audit_probe.rs` lost the \ - `LABCOLORS_ON_DISK_PROBE_ENABLED` env-var check. Both opt-in gates \ - must be preserved after t2." - ); -} - -/// Contract: `#[serial]` is NOT present in the read-only test files. Scope -/// creep: if someone adds `#[serial]` to an unrelated file, this test detects it. -/// -/// Note: whether `on_disk_audit_probe.rs` carries `#[serial]` is not asserted -/// here (it is harmless and may remain), but the five files below must not. -/// -/// GREEN. Bites on mutation: add `#[serial]` to any of the listed files → -/// the assertion fails. -#[test] -fn scope_discipline_serial_absent_from_readonly_test_files() { - // `r3_byte_identity.rs` relocated to `src/r3_byte_identity_tests.rs` - // (ADR-0001 PR-c) — see the note in test 1. The `#[serial]`-scope-creep guard - // covers the read-only integration-test files that remain in `tests/`. - for file in [ - "lint_parity.rs", - "ci_enforcement.rs", - "gate_green_smoke.rs", - "empirical_inventory.rs", - ] { - let src = std::fs::read_to_string(crate_root().join("tests").join(file)) - .unwrap_or_else(|e| panic!("s2b_baseline/test6c: cannot read {file}: {e}")); - assert!( - !src.contains("#[serial]"), - "SCOPE-DISCIPLINE FAILED — `{file}` contains `#[serial]`, which must \ - NOT appear in read-only test files (scope creep). `#[serial]` is \ - only appropriate on tree-mutating tests." - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 7 — supply-chain dev-dep audit (contract) -// -// REGIME: contract. Asserts zero-runtime-dep invariant and cargo audit clean. -// HOW IT BITES: GREEN at birth (serial_test is added as dev-dep in this commit). -// Mutation proof: move `serial_test` from `[dev-dependencies]` to `[dependencies]` -// in Cargo.toml → `cargo tree -e normal -i serial_test` returns non-empty → -// assertion fails (runtime dep path detected). -// ───────────────────────────────────────────────────────────────────────────── - -/// Contract: `serial_test` appears only under `[dev-dependencies]` in -/// `crates/labcolors-core/Cargo.toml` — zero-runtime-dep invariant (issue #29). -/// -/// GREEN at birth. Bites on mutation: move `serial_test` to `[dependencies]` → -/// this test fails (the Cargo.toml content check fails). -#[test] -fn supply_chain_serial_test_is_dev_dep_only() { - let cargo_toml_path = crate_root().join("Cargo.toml"); - let cargo_toml = std::fs::read_to_string(&cargo_toml_path) - .expect("s2b_baseline/test7: cannot read Cargo.toml"); - - // `serial_test` must appear under [dev-dependencies], not [dependencies]. - // Strategy: find all `serial_test` occurrences and verify each appears after - // `[dev-dependencies]` and before the next `[` section header. - let lines: Vec<&str> = cargo_toml.lines().collect(); - let mut in_dev_deps = false; - let mut found_in_dev = false; - let mut found_in_runtime = false; - - for line in &lines { - let t = line.trim(); - if t.starts_with('[') { - in_dev_deps = t == "[dev-dependencies]"; - } - if t.starts_with("serial_test") || t.starts_with(r#""serial_test""#) { - if in_dev_deps { - found_in_dev = true; - } else { - found_in_runtime = true; - } - } - } - - assert!( - found_in_dev, - "SUPPLY-CHAIN FAILED — `serial_test` is not present under [dev-dependencies] \ - in `crates/labcolors-core/Cargo.toml`. It must be added as a dev-dep \ - (required for the `#[serial]` annotation in t2). Zero-runtime-dep \ - invariant (issue #29) requires it stays dev-only." - ); - - assert!( - !found_in_runtime, - "SUPPLY-CHAIN FAILED — `serial_test` appears under [dependencies] (runtime) \ - in `crates/labcolors-core/Cargo.toml`. It must be dev-only. \ - Zero-runtime-dep invariant (issue #29) is violated." - ); -} - -/// Contract: `cargo tree -e normal -i serial_test` returns empty — serial_test -/// has no path through the normal (runtime) dependency graph. -/// -/// GREEN at birth. Bites on mutation: promote serial_test to a runtime dep → -/// `cargo tree -e normal -i serial_test` returns non-empty → assertion fails. -/// -/// `#[ignore]`: spawns `cargo +1.96.0 tree`, which resolves the workspace -/// dependency graph and may contend on the build-directory lock. Run manually -/// before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// supply_chain_serial_test_absent_from_normal_dep_tree -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 tree) — may contend on build-dir lock. \ - Run manually before PR (see test doc)."] -fn supply_chain_serial_test_absent_from_normal_dep_tree() { - if skip_if_toolchain_absent("supply_chain_serial_test_absent_from_normal_dep_tree") { - return; - } - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "tree", - "-p", - "labcolors-core", - "-e", - "normal", - "-i", - "serial_test", - ]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| { - panic!("s2b_baseline/test7b: cannot run `cargo tree -e normal -i serial_test`: {e}") - }); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // `cargo tree -i ` exits non-zero and prints nothing if the crate is - // not present in the specified edge-kind graph. A non-zero exit + empty - // stdout means "not in the normal dep tree" — that is the GREEN condition. - // If it exits zero and returns output, serial_test is in the runtime tree. - if output.status.success() && !stdout.is_empty() { - panic!( - "SUPPLY-CHAIN FAILED — `cargo tree -p labcolors-core -e normal -i serial_test` \ - returned non-empty output. `serial_test` appears in the RUNTIME dependency \ - graph — it must be dev-only (issue #29 zero-runtime-dep invariant).\n\ - Output:\n{stdout}" - ); - } - // Either non-zero exit (not found = good) or zero exit with empty output (also fine). - // No assertion needed for the empty case — the test passes by not panicking. -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 7d/7e — supply-chain guard for `proptest` (property/fuzz dev-dep) -// -// REGIME: contract. Same zero-RUNTIME-dep invariant (issue #29) as serial_test, -// extended to the property/fuzz engine `proptest` (tests/property_invariants.rs). -// `proptest` MUST stay dev-only: it never enters the runtime dependency graph. -// ───────────────────────────────────────────────────────────────────────────── - -/// Contract: `proptest` appears only under `[dev-dependencies]` in -/// `crates/labcolors-core/Cargo.toml` — zero-runtime-dep invariant (issue #29). -/// -/// GREEN at birth. Bites on mutation: move `proptest` to `[dependencies]` → -/// this test fails (the Cargo.toml content check fails). -#[test] -fn supply_chain_proptest_is_dev_dep_only() { - let cargo_toml_path = crate_root().join("Cargo.toml"); - let cargo_toml = std::fs::read_to_string(&cargo_toml_path) - .expect("s2b_baseline/test7d: cannot read Cargo.toml"); - - // `proptest` must appear under [dev-dependencies], not [dependencies]. - // Strategy mirrors `supply_chain_serial_test_is_dev_dep_only`: track which - // section each `proptest` occurrence sits in. - let lines: Vec<&str> = cargo_toml.lines().collect(); - let mut in_dev_deps = false; - let mut found_in_dev = false; - let mut found_in_runtime = false; - - for line in &lines { - let t = line.trim(); - if t.starts_with('[') { - in_dev_deps = t == "[dev-dependencies]"; - } - if t.starts_with("proptest") || t.starts_with(r#""proptest""#) { - if in_dev_deps { - found_in_dev = true; - } else { - found_in_runtime = true; - } - } - } - - assert!( - found_in_dev, - "SUPPLY-CHAIN FAILED — `proptest` is not present under [dev-dependencies] \ - in `crates/labcolors-core/Cargo.toml`. It must be a dev-dep (required for \ - the property/fuzz invariants in tests/property_invariants.rs). Zero-runtime-dep \ - invariant (issue #29) requires it stays dev-only." - ); - - assert!( - !found_in_runtime, - "SUPPLY-CHAIN FAILED — `proptest` appears under [dependencies] (runtime) \ - in `crates/labcolors-core/Cargo.toml`. It must be dev-only. \ - Zero-runtime-dep invariant (issue #29) is violated." - ); -} - -/// Contract: the `[dependencies]` section of `labcolors-core/Cargo.toml` is -/// EMPTY — zero runtime dependencies (issue #29). The `serial_test`/`proptest` -/// guards above are crate-name-specific; this one catches ANY arbitrary new -/// runtime dep (e.g. `libc`, a math crate) that a perf candidate might add. -/// -/// GREEN at birth (the section holds only a comment). Bites on mutation: add any -/// entry under `[dependencies]` → this test fails. Parses the manifest directly -/// (no nested `cargo tree`), so it runs in the DEFAULT gate, not behind -/// `#[ignore]`. -#[test] -fn supply_chain_runtime_dependencies_section_is_empty() { - let cargo_toml_path = crate_root().join("Cargo.toml"); - let cargo_toml = - std::fs::read_to_string(&cargo_toml_path).expect("s2b_baseline: cannot read Cargo.toml"); - - let mut in_runtime_deps = false; - let mut offenders: Vec = Vec::new(); - - for line in cargo_toml.lines() { - let t = line.trim(); - if t.starts_with('[') { - // Exactly `[dependencies]` — NOT `[dev-dependencies]`, - // `[build-dependencies]`, or `[target.'…'.dependencies]`. - in_runtime_deps = t == "[dependencies]"; - continue; - } - if !in_runtime_deps { - continue; - } - // Inside `[dependencies]`: ignore blank lines and comments; any other - // non-empty line declares a runtime dependency. - if t.is_empty() || t.starts_with('#') { - continue; - } - offenders.push(t.to_string()); - } - - assert!( - offenders.is_empty(), - "SUPPLY-CHAIN FAILED — `[dependencies]` in \ - `crates/labcolors-core/Cargo.toml` is NOT empty: {offenders:?}. \ - labcolors-core must have ZERO runtime dependencies (std only, issue #29). \ - Any perf win must be algorithmic, not a new crate." - ); -} - -/// Contract: `cargo tree -e normal -i proptest` returns empty — proptest has no -/// path through the normal (runtime) dependency graph. -/// -/// GREEN at birth. Bites on mutation: promote proptest to a runtime dep → -/// `cargo tree -e normal -i proptest` returns non-empty → assertion fails. -/// -/// `#[ignore]`: spawns `cargo +1.96.0 tree`, which resolves the workspace -/// dependency graph and may contend on the build-directory lock. Run manually -/// before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// supply_chain_proptest_absent_from_normal_dep_tree -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 tree) — may contend on build-dir lock. \ - Run manually before PR (see test doc)."] -fn supply_chain_proptest_absent_from_normal_dep_tree() { - if skip_if_toolchain_absent("supply_chain_proptest_absent_from_normal_dep_tree") { - return; - } - let output = std::process::Command::new("cargo") - .args([ - &format!("+{CI_TOOLCHAIN}"), - "tree", - "-p", - "labcolors-core", - "-e", - "normal", - "-i", - "proptest", - ]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| { - panic!("s2b_baseline/test7e: cannot run `cargo tree -e normal -i proptest`: {e}") - }); - - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // A non-zero exit + empty stdout means "not in the normal dep tree" — GREEN. - // A zero exit with output means proptest is in the runtime tree — RED. - if output.status.success() && !stdout.is_empty() { - panic!( - "SUPPLY-CHAIN FAILED — `cargo tree -p labcolors-core -e normal -i proptest` \ - returned non-empty output. `proptest` appears in the RUNTIME dependency \ - graph — it must be dev-only (issue #29 zero-runtime-dep invariant).\n\ - Output:\n{stdout}" - ); - } -} - -/// Contract: `cargo audit --deny warnings` is green — no known advisories -/// introduced by adding `serial_test` (or any other dev-dep in this diff). -/// -/// GREEN at birth. Bites on mutation: introduce a dep with a known RUSTSEC -/// advisory → cargo audit exits non-zero → this test fails. -#[test] -fn supply_chain_cargo_audit_green() { - // cargo-audit must be installed. If it is not, the test fails with a clear - // install instruction rather than a mysterious spawn error. - let audit_check = std::process::Command::new("cargo") - .args(["audit", "--version"]) - .current_dir(workspace_root()) - .output(); - - if audit_check.is_err() || !audit_check.unwrap().status.success() { - // `cargo audit` not installed — skip with a clear message rather than fail. - // The CI audit job enforces this; local absence is acceptable. - eprintln!( - "s2b_baseline/test7c: `cargo audit` not found — \ - install with `cargo install cargo-audit`. Skipping local audit check." - ); - return; - } - - let output = std::process::Command::new("cargo") - .args(["audit", "--deny", "warnings"]) - .current_dir(workspace_root()) - .output() - .unwrap_or_else(|e| panic!("s2b_baseline/test7c: cannot run `cargo audit`: {e}")); - - let combined = format!( - "stdout:\n{}\nstderr:\n{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - assert!( - output.status.success(), - "SUPPLY-CHAIN FAILED — `cargo audit --deny warnings` exited non-zero. \ - Adding `serial_test` as a dev-dep introduced a known advisory or warning. \ - Review the audit output and either update the dep or add an exception.\n{combined}" - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Test 8 — CI parity on pinned toolchain (smoke) -// -// REGIME: smoke. Verifies fmt/clippy/test/audit all green locally on 1.96.0. -// HOW IT BITES: GREEN at birth. -// Mutation proof: introduce a clippy warning → clippy exits non-zero → the -// clippy assertion fails. Introduce a format deviation → fmt --check fails. -// -// These are the same checks as lint_parity.rs, but wired here as s2b baseline -// guards with the explicit "s2b gate green pre-t2" label so the CI check is -// independently identifiable. -// -// NOTE: ci_parity_fmt_check_passes and ci_parity_clippy_workspace_clean are now -// `#[ignore]`d because they spawn `cargo +1.96.0 fmt/clippy`, both of which -// trigger compilation and contend on the build-directory lock under an outer -// `cargo test --workspace`. The equivalent non-ignored guards live in -// `lint_parity.rs` (which already runs fmt+clippy without ignore). The s2b -// variants exist as independently-labelled contracts; run them manually before PR. -// ───────────────────────────────────────────────────────────────────────────── - -/// CI parity smoke: `cargo +1.96.0 fmt --all --check` passes. -/// -/// GREEN at birth. Bites on mutation: introduce any formatting deviation → -/// fmt exits non-zero → assertion fails. -/// -/// `#[ignore]`: spawns `cargo +1.96.0 fmt`, which compiles and may contend on -/// the build-dir lock. Equivalent non-ignored check: `lint_parity.rs: -/// fmt_check_passes_on_ci_toolchain`. Run manually before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// ci_parity_fmt_check_passes -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 fmt) — contends on build-dir lock; \ - equivalent non-ignored guard in lint_parity.rs. Run manually before PR."] -fn ci_parity_fmt_check_passes() { - if skip_if_toolchain_absent("ci_parity_fmt_check_passes") { - return; - } - let (ok, out) = run_cargo_check(&["fmt", "--all", "--check"]); - assert!( - ok, - "CI PARITY FAILED — `cargo +{CI_TOOLCHAIN} fmt --all --check` failed. \ - The s2b baseline must be clean on the pinned CI toolchain before t2 \ - starts. Fix formatting deviations before claiming t2 is ready.\n{out}" - ); -} - -/// CI parity smoke: `cargo +1.96.0 clippy --workspace --all-targets -D warnings` passes. -/// -/// GREEN at birth. Bites on mutation: introduce any clippy warning → -/// clippy exits non-zero → assertion fails. -/// -/// `#[ignore]`: spawns `cargo +1.96.0 clippy --workspace`, which recompiles the -/// workspace and contends on the build-dir lock. Equivalent non-ignored check: -/// `lint_parity.rs: clippy_workspace_all_targets_d_warnings_passes_on_ci_toolchain`. -/// Run manually before PR: -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// ci_parity_clippy_workspace_clean -- --ignored -/// ``` -#[test] -#[ignore = "spawns nested cargo (+1.96.0 clippy --workspace) — recompiles workspace \ - and contends on build-dir lock; equivalent non-ignored guard in \ - lint_parity.rs. Run manually before PR."] -fn ci_parity_clippy_workspace_clean() { - if skip_if_toolchain_absent("ci_parity_clippy_workspace_clean") { - return; - } - let (ok, out) = run_cargo_check(&[ - "clippy", - "--workspace", - "--all-targets", - "--", - "-D", - "warnings", - ]); - assert!( - ok, - "CI PARITY FAILED — `cargo +{CI_TOOLCHAIN} clippy --workspace --all-targets \ - -- -D warnings` failed. Fix all lint warnings before claiming the s2b \ - baseline is clean.\n{out}" - ); -} - -/// CI parity gate: `cargo +1.96.0 test --workspace` passes (no `--include-ignored`, -/// matching the CI test job exactly). -/// -/// ## Why this test remains `#[ignore]`d (structural, not pre-t2) -/// -/// This test spawns `cargo test --workspace` as a subprocess. If it were NOT -/// ignored, it would itself be included in any `cargo test --workspace` -/// invocation — which would then recursively spawn another `cargo test -/// --workspace`, creating an infinite subprocess chain and consuming all -/// available resources. -/// -/// The `#[ignore]` here is permanent and structural: a test that runs the -/// workspace test suite MUST be excluded from the workspace test suite it -/// runs. This is not a shortcoming of the test — it is the correct design. -/// -/// ## How to use -/// -/// Run this test manually before pushing the PR, as a one-shot check: -/// -/// ```sh -/// cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ -/// ci_parity_test_workspace_passes -- --ignored -/// ``` -/// -/// This runs the workspace check WITHOUT this test binary being part of -/// that workspace run (cargo compiles and filters at the per-binary level), -/// so there is no recursion. -/// -/// Bites: break any non-ignored test in the workspace → -/// the subprocess exits non-zero → assertion fails. -#[test] -#[ignore = "structural: this test spawns `cargo test --workspace`; \ - including it in `cargo test --workspace` creates infinite subprocess \ - recursion. Run manually before PR: \ - `cargo +1.96.0 test -p labcolors-core --test s2b_baseline_guards \ - ci_parity_test_workspace_passes -- --ignored`"] -fn ci_parity_test_workspace_passes() { - if skip_if_toolchain_absent("ci_parity_test_workspace_passes") { - return; - } - // This is the exact CI invocation. It MUST NOT include `--include-ignored` - // (the probe is #[ignore]d and must not run in CI). - let (ok, out) = run_cargo_check(&["test", "--workspace"]); - assert!( - ok, - "CI PARITY FAILED — `cargo +{CI_TOOLCHAIN} test --workspace` failed. \ - The s2b baseline must produce a green test run on the CI toolchain before \ - t2 is considered done. Investigate pre-existing failures.\n{out}" - ); -} diff --git a/crates/labcolors-core/tests/sentiment_categorical_zones.rs b/crates/labcolors-core/tests/sentiment_categorical_zones.rs deleted file mode 100644 index cd98d6b3..00000000 --- a/crates/labcolors-core/tests/sentiment_categorical_zones.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! Задача Волны 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 уходит в пурпур), после закона зон — зелёный. - -use labcolors_core::oklch_from_hex; -use labcolors_core::sentiment::resolve_config_sentiment_solid; - -/// 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}) — \ - инфо-зона включает синий, совпадение с брендом не ошибка" - ); -} - -/// КЛАСС инварианта (обобщение): для ЛЮБОГО сентимента, когда бренд совпадает с -/// оттенком его собственного якоря, сентимент отдыхает на якоре — совпадение с -/// брендом внутри своей зоны легитимно для всех категорий (инверсия по всем -/// сентиментам, как требует задача). -#[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}°) — сепарация от бренда больше не форсируется" - ); - } -} - -/// 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° (сполз в красную зону)" - ); -} diff --git a/crates/labcolors-core/tests/solve_characterization.rs b/crates/labcolors-core/tests/solve_characterization.rs index 688dcab1..ba2a2f34 100644 --- a/crates/labcolors-core/tests/solve_characterization.rs +++ b/crates/labcolors-core/tests/solve_characterization.rs @@ -234,9 +234,9 @@ fn outcome_line(result: &Result) -> String { bits(solved.lc()), bits(solved.wcag_ratio()), solved.floor_override(), - bits(solved.color().jp), - bits(solved.color().h_ok), - bits(solved.color().s), + bits(solved.color().jp()), + bits(solved.color().h_ok()), + bits(solved.color().s()), ), Err(SolveFailure::BelowContrastFloor { target }) => { format!("err below_contrast_floor target_bits={}", bits(*target)) @@ -699,13 +699,13 @@ fn jnd_band_resolves_within_budget_with_tolerant_acceptance() { /// Экспонат платформенной зависимости текущего релиза (#297 «current path is /// LegacyPlatformDependent»): между канонической macOS-arm64 и каноническим /// Linux-x64 расходится РОВНО хвост ulp одного поля — Oklab-hue коррелята -/// `h_ok` — в трёх кейсах матрицы. Всё остальное (hex-байты, `lc`, +/// `h_ok` — в двух хроматических кейсах матрицы. Всё остальное (hex-байты, `lc`, /// `wcag_ratio`, `floor_override`, `jp`, `s`, все payload'ы ошибок) — -/// бит-идентично на всех 77 кейсах. Два кейса — честная libm-разница -/// (atan2/cbrt, 5 ulp на хроматике); третий — ахроматический результат, где -/// hue вырожден (atan2 шума о шум) и платформенный шум усиливается до -/// величины ~1e-8 при том же значении в градусах. Рост этого множества — -/// изменение численного поведения, а не «шум новой платформы». +/// бит-идентично на всех 77 кейсах. Оба кейса — libm-разница +/// (atan2/cbrt, 5 ulp на хроматике). У точного sRGB-серого `h_ok = 0` по +/// определению, поэтому его прежний atan2-шум больше не является частью +/// платформенного контракта. Рост этого множества — изменение численного +/// поведения, а не «шум новой платформы». #[test] fn platform_fixtures_agree_except_documented_hue_ulp_drift() { let load = |path: &str| -> BTreeMap { @@ -769,14 +769,10 @@ fn platform_fixtures_agree_except_documented_hue_ulp_drift() { "bg=#7A7A7A contract=text(35) floor=default hue=145 chroma=relative(0.35)".to_string(), vec!["h_ok_bits".to_string()], ), - ( - "bg=#FFFFFF contract=ui(45) floor=aa-ui hue=30 chroma=neutral".to_string(), - vec!["h_ok_bits".to_string()], - ), ]; assert_eq!( drifted, expected, "the cross-platform drift exhibit must stay exactly the documented \ - three h_ok ulp-tail cases" + two chromatic h_ok ulp-tail cases" ); } diff --git a/crates/labcolors-core/tests/splice_support.rs b/crates/labcolors-core/tests/splice_support.rs index 5706c9c6..e25a260c 100644 --- a/crates/labcolors-core/tests/splice_support.rs +++ b/crates/labcolors-core/tests/splice_support.rs @@ -1,111 +1,6 @@ -//! Shared helper: panic-safe RestoreGuard and atomic splice for on-disk probe tests. -//! -//! # Why this module exists -//! -//! Two integration-test files (`on_disk_audit_probe.rs` and -//! `s2b_baseline_guards.rs`) both implement: -//! * A backup + atomic-rename splice into `semantic.rs` -//! * A Drop-based restore guard that reverts the splice -//! -//! Those two implementations diverged: the probe's `RestoreGuard::drop` used -//! `.expect()` on every fs operation (panicking in Drop → process abort during -//! test unwind), while the baseline guard used `let _ =` (swallowing errors, -//! preferable during unwind). That divergence was itself a confirmed High defect -//! (DRY/SRP fracture with already-diverged semantics). -//! -//! This module closes the class: one canonical implementation, panic-safe by -//! design, used by both callers. The rule: **Drop must never panic**. If a -//! restore op fails during unwind, log to stderr and leave the backup for human -//! recovery — do not re-panic. -//! -//! # Usage (in integration test files) -//! -//! ```rust,ignore -//! // At the top of the test file: -//! #[path = "splice_support.rs"] -//! mod splice_support; -//! use splice_support::{splice_into, RestoreGuard}; -//! ``` -//! -//! # Atomicity guarantee -//! -//! Both `splice_into` and `RestoreGuard::drop` use write-to-tmp + rename-over-target -//! semantics. The real `semantic.rs` is never left in a half-written state: -//! at any instant it is either the original (pre-rename) or the fully-written -//! spliced/restored version (post-rename). A crash between write and rename -//! leaves `*.splice_tmp` / `*.restore_tmp` as litter — both are safe to remove. -//! -//! # SIGKILL window -//! -//! A SIGKILL between the rename completing and the Drop guard running leaves -//! `semantic.rs` spliced with the backup file as the only recovery. This is -//! the unavoidable minimum window; the pre-splice backup ensures recoverability -//! and `RestoreGuard` is registered immediately after backup creation. +//! Атомарная вставка в исходник только внутри изолированного временного дерева. -use std::path::{Path, PathBuf}; - -/// A Drop guard that restores `target` from `backup` on drop (even on panic). -/// -/// The restore is **panic-safe**: if any fs operation fails during Drop, the -/// error is printed to stderr and the remaining operations are attempted, but -/// no panic is raised. This is correct for Drop: a second panic during unwind -/// aborts the process and skips all remaining cleanup. The backup file exists -/// for manual recovery regardless of whether the rename succeeded. -/// -/// # Invariant upheld -/// -/// `target` is always either the original content or the fully-written restore -/// content — never truncated — because we write to a `*.restore_tmp` sibling -/// first, then atomically rename over `target`. -/// -/// Currently used by s2b_baseline_guards.rs (on_disk_audit_probe.rs uses -/// temp-dir isolation instead). The #[allow(dead_code)] covers scenarios where -/// a particular test file doesn't use it. -#[allow(dead_code)] -pub struct RestoreGuard { - pub target: PathBuf, - pub backup: PathBuf, -} - -impl Drop for RestoreGuard { - fn drop(&mut self) { - if !self.backup.exists() { - // Nothing to restore (backup was already cleaned up or never created). - return; - } - // Atomic restore: copy backup → *.restore_tmp, then rename over target. - // - // PANIC POLICY: do NOT panic in Drop. If an op fails during unwind a - // second panic aborts the process and skips remaining cleanup. We log - // to stderr so a human can recover using the backup file. - let tmp = self.target.with_extension("rs.restore_tmp"); - if let Err(e) = std::fs::copy(&self.backup, &tmp) { - eprintln!( - "[RestoreGuard] ERROR: failed to copy backup {:?} → {:?}: {e}\n\ - Manual recovery: `cp {:?} {:?}`", - self.backup, tmp, self.backup, self.target, - ); - return; // Do not attempt rename without the tmp in place. - } - if let Err(e) = std::fs::rename(&tmp, &self.target) { - eprintln!( - "[RestoreGuard] ERROR: failed to rename {:?} → {:?}: {e}\n\ - Manual recovery: `cp {:?} {:?}`", - tmp, self.target, self.backup, self.target, - ); - // Leave litter (tmp exists, target still spliced); backup is the recovery path. - return; - } - if let Err(e) = std::fs::remove_file(&self.backup) { - // Non-fatal: the backup is harmless litter. Log and continue. - eprintln!( - "[RestoreGuard] WARNING: failed to remove backup {:?}: {e} \ - (harmless; remove manually)", - self.backup, - ); - } - } -} +use std::path::Path; /// Splice `snippet` into `target_path` after the leading `//!` module-doc comment /// block using an **atomic rename**, so the file is never left half-written. diff --git a/crates/labcolors-core/tests/symmetry.rs b/crates/labcolors-core/tests/symmetry.rs deleted file mode 100644 index 905b7a18..00000000 --- a/crates/labcolors-core/tests/symmetry.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! Contract 6 — polarity near-symmetry with an active undertone. -//! -//! BUG CLASS this guards: *asymmetry that only shows on chromatic pairs.* The -//! crate already pins polarity-swap near-symmetry for the achromatic axis -//! (lpc.rs `polarity_swap_negates*`: swapping fg/bg near-negates the contrast to -//! within ~3 Lc). But that test uses pure greys, where the H-K hue term is out -//! of the comparison. The forward curve has TWO sources of asymmetry — the -//! exponent split (`EXP_FG_LIGHT` ≠ `EXP_FG_DARK`, `EXP_BG_LIGHT` ≠ -//! `EXP_BG_DARK`) and the hue-dependent H-K lift — and only the first is -//! exercised by greys. A regression that broke the polarity symmetry *specific -//! to tinted colours* (a wrong H-K sign, a hue term applied to one polarity -//! only) would pass every existing test. This pins the swap symmetry on -//! genuinely chromatic pairs, under both viewing conditions. -//! -//! Empirically discovered constant (probed through `lpc_with_vc`, then frozen): -//! the largest swap residual `|lpc(a,b) + lpc(b,a)|` across a spread of tinted -//! pairs is **5.50 Lc** (the cool near-grey `#6D6C7E` on white, where the -//! exponent split bites hardest). Frozen here at **6.5 Lc**, a modest margin -//! above the measured worst case — a real symmetry break (the historical bugs -//! were tens of Lc) trips it; the inherent exponent-split asymmetry does not. - -use labcolors_core::ViewingConditions; -use labcolors_core::lpc::lpc_with_vc; - -/// Frozen bound on the polarity-swap residual for tinted pairs (~1.2× the -/// measured worst case of 5.50). See the module docs for the derivation. -const SWAP_SYMMETRY_BOUND: f64 = 6.5; - -/// Chromatic / tinted foreground–background pairs spanning the hue circle and -/// both natural polarities. Greys are deliberately excluded — they are already -/// covered by the achromatic test in lpc.rs; here the H-K term must be live. -const TINTED_PAIRS: [(&str, &str); 7] = [ - ("#3478F6", "#FFFFFF"), // brand blue on white - ("#0E0E12", "#FFFFFF"), // cool near-black on white (faint tint) - ("#34C759", "#FFFFFF"), // green on white - ("#FF3B30", "#FFFFFF"), // red on white - ("#6D6C7E", "#FFFFFF"), // cool mid-grey-violet on white (worst case) - ("#007AFF", "#101012"), // azure on near-black - ("#FFD700", "#101012"), // gold on near-black -]; - -fn vcs() -> [(ViewingConditions, &'static str); 2] { - [ - (ViewingConditions::srgb(), "srgb"), - (ViewingConditions::dim_surround(), "dim"), - ] -} - -#[test] -fn polarity_swap_is_near_symmetric_for_tinted_pairs() { - // For each tinted pair, swapping foreground and background must near-negate - // the contrast: lpc(a,b) ≈ −lpc(b,a). The residual |lpc(a,b) + lpc(b,a)| - // bounds the asymmetry; it must stay within SWAP_SYMMETRY_BOUND in both - // viewing conditions. The two directions must also have opposite signs (a - // genuine polarity flip, not two same-signed numbers). - for (vc, vc_name) in vcs() { - let mut max_resid = 0.0_f64; - let mut worst = String::new(); - for (a, b) in TINTED_PAIRS { - let lc_ab = lpc_with_vc(a, b, &vc); - let lc_ba = lpc_with_vc(b, a, &vc); - assert!( - lc_ab.signum() != lc_ba.signum(), - "{vc_name} {a}/{b}: swap did not flip polarity — {lc_ab:.3} and {lc_ba:.3} \ - share a sign", - ); - let resid = (lc_ab + lc_ba).abs(); - if resid > max_resid { - max_resid = resid; - worst = format!("{a}/{b}: {lc_ab:.3} vs {lc_ba:.3}"); - } - assert!( - resid <= SWAP_SYMMETRY_BOUND, - "{vc_name} {a}/{b}: polarity-swap residual {resid:.4} > {SWAP_SYMMETRY_BOUND} \ - — tinted symmetry broke ({lc_ab:.3} vs {lc_ba:.3})", - ); - } - eprintln!("{vc_name}: max tinted swap residual = {max_resid:.4} at {worst}"); - } -} - -#[test] -fn tinted_symmetry_is_no_worse_than_a_documented_margin_over_neutral() { - // Cross-check the constant is honest: the tinted worst case must sit within - // the frozen bound, and the bound must genuinely exceed the achromatic-axis - // residual (otherwise it would be a vacuous re-test of the grey case). Pure - // black/white near-negates to within ~2 Lc; the tinted bound is meaningfully - // larger, which is the whole point — the H-K term widens the asymmetry. - let vc = ViewingConditions::srgb(); - let neutral_resid = - (lpc_with_vc("#000000", "#FFFFFF", &vc) + lpc_with_vc("#FFFFFF", "#000000", &vc)).abs(); - assert!( - neutral_resid < SWAP_SYMMETRY_BOUND, - "neutral residual {neutral_resid:.4} should be well under the tinted bound" - ); - assert!( - SWAP_SYMMETRY_BOUND > neutral_resid, - "the tinted bound must exceed the neutral residual to be a non-trivial contract" - ); -} diff --git a/crates/labcolors-ffi/src/lib.rs b/crates/labcolors-ffi/src/lib.rs index f7226579..76a2c33d 100644 --- a/crates/labcolors-ffi/src/lib.rs +++ b/crates/labcolors-ffi/src/lib.rs @@ -8,8 +8,8 @@ //! Рантайм-ядро Rust — обязательный слой на КАЖДОЙ платформе; запечённые //! build-time токены — лишь опция, не архитектура. Этот крейт зовёт ядро В //! РАНТАЙМЕ (через FFI), а не сериализует его выход на этапе сборки. Swift -//! получает те же перцептивные вычисления (CIECAM16 / LCS / LPC / WCAG), что и -//! WASM-граница — из одного и того же Rust-ядра. +//! получает те же CAM16/LCS-координаты, замороженную Ys candidate-кривую и +//! нормативные WCAG-выходы, что и WASM-граница — из одного Rust-ядра. //! //! # Срез API — «рантайм-контраст-ядро» //! @@ -92,17 +92,17 @@ impl Theme { } } -/// Контракт резолва — какой контраст обязан достичь передний план. +/// Контракт резолва в переходной координате Ys candidate score. #[derive(Debug, Clone, Copy, PartialEq, uniffi::Enum)] pub enum ContractSpec { - /// Текст: цель `Lc`, юридический пол WCAG AA-text (4.5:1). + /// Текст: цель Ys candidate score `Lc`, юридический пол WCAG AA-text (4.5:1). Text { - /// Целевой перцептивный `Lc`. + /// Целевая кандидатная оценка `Lc` по `Ys`. lc: f64, }, - /// UI-элемент: цель `Lc`, пол WCAG AA-UI (3:1). + /// UI-элемент: цель Ys candidate score `Lc`, пол WCAG AA-UI (3:1). Ui { - /// Целевой перцептивный `Lc`. + /// Целевая кандидатная оценка `Lc` по `Ys`. lc: f64, }, /// Декоративная полоса `[floor, ceiling]` без юридического пола. @@ -124,11 +124,12 @@ impl ContractSpec { } } -/// Пара контрастов переднего плана на фоне: перцептивный `Lc` и юридический -/// WCAG-ratio. Отчитываются РАЗДЕЛЬНО (инвариант ядра — не смешивать). +/// Две независимые координаты переднего плана на фоне: знаковая кандидатная +/// оценка по `Ys` и юридический WCAG-ratio. #[derive(Debug, Clone, Copy, PartialEq, uniffi::Record)] pub struct Contrast { - /// Знаковый перцептивный контраст (LPC `Lc`). + /// Знаковая кандидатная оценка `Lc` по `Ys` из замороженной SAPC-shaped + /// кривой; не LPC/readability evidence. pub lc: f64, /// Контраст-ratio WCAG 2.1 (1–21). pub wcag_ratio: f64, @@ -227,16 +228,16 @@ pub struct Wcag22Assessment { pub evidence: Wcag22Evidence, } -/// Резолвнутый цвет и достигнутые им контрасты. +/// Резолвнутый цвет и его численные координаты на declared background. #[derive(Debug, Clone, PartialEq, uniffi::Record)] pub struct Solved { /// Резолвнутый цвет, `#RRGGBB`. pub hex: String, - /// Знаковый перцептивный `Lc` на отданном hex. + /// Знаковая кандидатная оценка `Lc` по `Ys` на отданном hex. pub lc: f64, /// WCAG-ratio на отданном hex. pub wcag_ratio: f64, - /// Юридический пол переопределил перцептивную цель. + /// Юридический пол переопределил Ys candidate-score цель. pub floor_override: bool, } @@ -453,9 +454,9 @@ fn single_contrast_result(pairs: Vec<(f64, f64)>) -> Result, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub preferred_side: Option, -} - -/// Сентимент-политика. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SentimentsDto { - pub categories: Vec, - pub hardness: f64, - pub chroma_fraction: f64, -} - /// VC-пресет закрытого меню. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -99,6 +85,7 @@ pub enum VcPresetDto { /// Запись словаря тем. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ThemeEntryDto { pub name: String, pub preset: VcPresetDto, @@ -106,11 +93,10 @@ pub struct ThemeEntryDto { /// Источник тинта лестницы/альфа-аналога. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case")] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] pub enum LadderSourceDto { - Brand, + Brand {}, Family { key: String }, - Sentiment { name: String }, Neutral { pick: NeutralPickDto }, } @@ -134,15 +120,18 @@ pub enum FloorDto { None, } -/// Рецепт роли (физическое меню ядра). +/// Рецепт роли из закрытого физического меню текущего resolver-а. +/// +/// Это переходная compatibility surface, не target IR и не extension point. +/// Новая физика не должна добавляться новым recipe variant. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "kind", rename_all = "kebab-case")] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] pub enum RoleRecipeDto { TextAnchor { fraction: f64, floor: FloorDto, - /// Опциональный источник оттенка семьи (M1 ch5c) — аддитивен: отсутствие - /// = нейтральный лейбл (прежние конфиги читаются без изменений). + /// Опциональный источник физической цветовой идентичности; отсутствие + /// выбирает neutral policy таблицы. #[serde(default, skip_serializing_if = "Option::is_none")] hue: Option, }, @@ -156,8 +145,8 @@ pub enum RoleRecipeDto { Ladder { source: LadderSourceDto, position: String, - /// Опциональный юр. пол UI для солидной семейной границы (M2 ch5c) — - /// аддитивен: отсутствие = прежний путь без пола. + /// Опциональный юридический пол UI для solid-позиции; у полупрозрачной + /// позиции поле должно отсутствовать. #[serde(default, skip_serializing_if = "Option::is_none")] floor: Option, }, @@ -178,20 +167,20 @@ pub enum RoleRecipeDto { of: LadderSourceDto, alpha: f64, }, - /// Двухслойный материал (стекло/акрил; whitepaper §3.7): база на целевом |ΔJ'| тира + - /// тинт с выведенной α. `source` — оттенок семьи, `tone_light`/`tone_dark` — - /// |ΔJ'| тира по теме, `floor` — пол читаемости выводимой α. + /// Переходная двухслойная point-композиция: base на заданном |ΔJ'| и tint + /// с вычисленной alpha. Не является моделью glass, blur или spatial field. Material { source: LadderSourceDto, tone_light: f64, tone_dark: f64, floor: FloorDto, }, - Zero, + Zero {}, } /// Роль: имя + рецепт. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct RoleDto { pub name: String, pub recipe: RoleRecipeDto, @@ -199,6 +188,7 @@ pub struct RoleDto { /// Компонентный алиас. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AliasDto { pub alias: String, pub target: String, @@ -206,11 +196,11 @@ pub struct AliasDto { /// Полный конфиг темы потребителя — JSON-форма [`ThemeConfig`]. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ConfigDto { pub brand: AnchorsDto, pub neutral: NeutralDto, pub palette: Vec, - pub sentiments: SentimentsDto, pub themes: Vec, /// `#[serde(default)]` разрешает ОПУСТИТЬ словарь синтаксически, но конфиг /// обязан нести собственные роли: пустой контракт (без `roles` и `aliases`) @@ -224,9 +214,9 @@ pub struct ConfigDto { /// FNV-1a 64 над канонической JSON-сериализацией DTO — отпечаток конфига. /// -/// Отпечаток есть идентичность конфига: два разных конфига обязаны дать разные -/// ключи (кэш-коллизия = чужие цвета). Конфиг несёт собственный словарь ролей и -/// хэшируется как есть — форма входа и есть форма, что реально резолвится. +/// Детерминированный вероятностный идентификатор конфига. Конфиг несёт +/// собственный словарь ролей и хэшируется как есть — форма входа и есть форма, +/// что реально резолвится. /// /// Не криптографический: различение конфигов ВЕРОЯТНОСТНОЕ, поэтому оно не /// несущая гарантия — корректность кэша держит очистка при загрузке (в кэше @@ -296,9 +286,8 @@ impl From for Floor { impl From for LadderSource { fn from(s: LadderSourceDto) -> Self { match s { - LadderSourceDto::Brand => LadderSource::Brand, + LadderSourceDto::Brand {} => LadderSource::Brand, LadderSourceDto::Family { key } => LadderSource::Family(key), - LadderSourceDto::Sentiment { name } => LadderSource::Sentiment(name), LadderSourceDto::Neutral { pick } => LadderSource::Neutral(pick.into()), } } @@ -392,7 +381,7 @@ impl TryFrom for RoleRecipe { tone_dark, floor: floor.into(), }, - RoleRecipeDto::Zero => RoleRecipe::Zero, + RoleRecipeDto::Zero {} => RoleRecipe::Zero, }) } } @@ -415,7 +404,6 @@ impl TryFrom for ThemeConfig { dark: dto.neutral.anchors.dark, }, tint: NeutralTint { - ratio: dto.neutral.tint.ratio, target_mp: dto.neutral.tint.target_mp, hue_stiffness: dto.neutral.tint.hue_stiffness, hue_override_deg: dto.neutral.tint.hue_override_deg, @@ -431,21 +419,6 @@ impl TryFrom for ThemeConfig { anchors: f.anchors.into(), }) .collect(); - 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, - }; let themes = ThemesConfig { entries: dto .themes @@ -461,7 +434,7 @@ impl TryFrom for ThemeConfig { // ThemeConfig — #[non_exhaustive]: сборка через конструктор ядра, не // struct-литералом (запрещён вне крейта ядра). - let cfg = ThemeConfig::new(brand, neutral, palette, sentiments, themes, roles, aliases); + let cfg = ThemeConfig::new(brand, neutral, palette, themes, roles, aliases); Ok(cfg) } } @@ -486,9 +459,8 @@ impl TryFrom<&LadderSource> for LadderSourceDto { fn try_from(s: &LadderSource) -> Result { Ok(match s { - LadderSource::Brand => LadderSourceDto::Brand, + LadderSource::Brand => LadderSourceDto::Brand {}, LadderSource::Family(key) => LadderSourceDto::Family { key: key.clone() }, - LadderSource::Sentiment(name) => LadderSourceDto::Sentiment { name: name.clone() }, LadderSource::Neutral(pick) => LadderSourceDto::Neutral { pick: match pick { NeutralPick::Mid => NeutralPickDto::Mid, @@ -570,7 +542,7 @@ impl TryFrom<&RoleRecipe> for RoleRecipeDto { tone_dark: *tone_dark, floor: floor_to_dto(*floor)?, }, - RoleRecipe::Zero => RoleRecipeDto::Zero, + RoleRecipe::Zero => RoleRecipeDto::Zero {}, other => return Err(format!("несериализуемый RoleRecipe: {other:?}")), }) } @@ -596,7 +568,6 @@ impl TryFrom<&ThemeConfig> for ConfigDto { dark: cfg.neutral.anchors.dark.clone(), }, tint: NeutralTintDto { - ratio: cfg.neutral.tint.ratio, target_mp: cfg.neutral.tint.target_mp, hue_stiffness: cfg.neutral.tint.hue_stiffness, hue_override_deg: cfg.neutral.tint.hue_override_deg, @@ -612,21 +583,6 @@ impl TryFrom<&ThemeConfig> for ConfigDto { anchors: (&f.anchors).into(), }) .collect(), - sentiments: SentimentsDto { - categories: cfg - .sentiments - .categories - .iter() - .map(|c| SentimentCategoryDto { - name: c.name.clone(), - family: c.family.clone(), - hue_floor_deg: c.hue_floor_deg, - preferred_side: c.preferred_side, - }) - .collect(), - hardness: cfg.sentiments.hardness, - chroma_fraction: cfg.sentiments.chroma_fraction, - }, themes: cfg .themes .entries @@ -659,7 +615,7 @@ mod tests { use super::*; /// Паспорт labui как статический SSOT (`tests/data/labui.config.json`): дерево - /// Даниила вынесено из прод-API ядра (ADR-0001 PR-c), граница читает паспорт. + /// Даниила вынесено из прод-API ядра (ADR-0001), граница читает паспорт. fn labui_dto() -> ConfigDto { serde_json::from_str(include_str!("../tests/data/labui.config.json")) .expect("паспорт labui парсится") @@ -713,7 +669,7 @@ mod tests { #[test] fn pair_label_recipe_round_trips_through_json() { use labcolors_core::solve::Floor; - let json = r#"{"kind":"pair-label","source":{"kind":"sentiment","name":"warning"},"fraction":0.461,"floor":"aa-ui"}"#; + let json = r#"{"kind":"pair-label","source":{"kind":"family","key":"warning"},"fraction":0.461,"floor":"aa-ui"}"#; let dto: RoleRecipeDto = serde_json::from_str(json).expect("pair-label парсится"); let core = RoleRecipe::try_from(dto).expect("DTO → RoleRecipe"); assert!( @@ -728,10 +684,97 @@ mod tests { let re = serde_json::to_string(&back).expect("сериализуем"); assert!(re.contains(r#""kind":"pair-label""#), "kebab-тег цел: {re}"); assert!(re.contains(r#""floor":"aa-ui""#), "пол цел: {re}"); - assert!(re.contains(r#""name":"warning""#), "источник цел: {re}"); + assert!(re.contains(r#""key":"warning""#), "источник цел: {re}"); + } + + /// C6 RED: удалённая специальная sentiment-схема обязана стать неизвестной, + /// а не тихо игнорироваться serde после удаления поля/варианта. + #[test] + fn retired_sentiment_schema_is_rejected() { + let mut root: serde_json::Value = + serde_json::from_str(include_str!("../tests/data/labui.config.json")) + .expect("fixture JSON"); + root["sentiments"] = serde_json::json!({ + "categories": [], + "hardness": 5.0, + "chroma_fraction": 0.88 + }); + assert!( + serde_json::from_value::(root).is_err(), + "retired root `sentiments` must be rejected, never ignored" + ); + + assert!( + serde_json::from_str::(r#"{"kind":"sentiment","name":"warning"}"#) + .is_err(), + "retired source kind `sentiment` must be rejected" + ); + } + + /// Неизвестное поле не может тихо превратить опечатку или удалённую + /// ручку в no-op. Гейт покрывает каждую объектную границу public config. + #[test] + fn unknown_fields_are_rejected_at_every_config_object_boundary() { + macro_rules! rejects { + ($ty:ty, $json:literal) => { + assert!( + serde_json::from_str::<$ty>($json).is_err(), + "{} accepted unknown fields from {}", + stringify!($ty), + $json + ); + }; + } + + rejects!( + AnchorsDto, + r##"{"light":"#000000","dark":"#000000","light_ic":"#000000","dark_ic":"#000000","retired":true}"## + ); + rejects!( + NeutralAnchorsDto, + r##"{"light":"#FFFFFF","mid":"#808080","dark":"#000000","retired":true}"## + ); + rejects!( + NeutralTintDto, + r#"{"target_mp":1.5,"hue_stiffness":9.0,"hue_override_deq":286.0}"# + ); + rejects!( + NeutralTintDto, + r#"{"ratio":0.1,"target_mp":1.5,"hue_stiffness":9.0}"# + ); + rejects!( + NeutralDto, + r##"{"anchors":{"light":"#FFFFFF","mid":"#808080","dark":"#000000"},"tint":{"target_mp":1.5,"hue_stiffness":9.0},"retired":true}"## + ); + rejects!( + FamilyDto, + r##"{"key":"red","anchors":{"light":"#000000","dark":"#000000","light_ic":"#000000","dark_ic":"#000000"},"retired":true}"## + ); + rejects!( + ThemeEntryDto, + r#"{"name":"light","preset":"srgb","retired":true}"# + ); + rejects!( + LadderSourceDto, + r#"{"kind":"family","key":"red","name":"warning"}"# + ); + rejects!(LadderSourceDto, r#"{"kind":"brand","name":"warning"}"#); + rejects!( + RoleRecipeDto, + r#"{"kind":"ladder","source":{"kind":"brand"},"position":"fill-primary","retired":true}"# + ); + rejects!( + RoleRecipeDto, + r#"{"kind":"zero","source":{"kind":"brand"}}"# + ); + rejects!( + RoleDto, + r#"{"name":"none","recipe":{"kind":"zero"},"retired":true}"# + ); + rejects!(AliasDto, r#"{"alias":"a","target":"b","retired":true}"#); } - /// Рецепт `material` (whitepaper §3.7) гоняется через JSON без потерь: kebab-тег + /// Рецепт `material` (whitepaper, «Точечные композиции») гоняется через JSON без потерь: kebab-тег /// `material`, источник/тон/пол целы туда-обратно (поля snake_case /// `tone_light`/`tone_dark`, как остальная config-схема). Закрывает класс /// «DTO-ветка компилируется, но круг-трип врёт». @@ -822,27 +865,17 @@ mod tests { // ── Слой 3: отпечаток полного паспорта закреплён (характеризационный пин) ── - /// Характеризационный ЗАМОК ТЕКУЩЕГО main: отпечаток полного labui-конфига — - /// константа. Это НЕ инвариант навсегда: при ЛЕГИТИМНОЙ смене паспорта labui - /// (значения якорей/ручек) отпечаток сменится — тогда ОБНОВИ это число здесь - /// и пин `PASSPORT_FINGERPRINT` на стороне labui. Пин доказывает, что снятие - /// механизма пресета БАЙТ-НЕЙТРАЛЬНО для полного паспорта (то же число). - /// - /// ГЛАВА #64 (ADR-0003, активация оси читаемости в `Ys`): доли текстовой - /// лестницы перенесены из генезис-домена `Y_hk` в `Ys` (0.968/0.627/0.461/ - /// 0.276 → 0.97335917/0.64359014/0.47572199/0.29335999, инвариант переноса — - /// принятые владельцем hex'ы #141414/#767676/#C2C2C2). Это ЛЕГИТИМНАЯ смена - /// паспорта (ADR помечает её semver-major), потому пин обновлён - /// 5013ba77a61f58ff → f2a892a62f7bc91e. Глава #282 добавила обязательный - /// `decision_profile` в каждый Glow recipe: explicit legacy сохраняет - /// прежнюю эмиссию, но profile обязан менять cache/config identity, поэтому - /// пин легитимно стал c51445fcd167781a. + /// Характеризационный пин канонической JSON-формы текущего Lab UI-паспорта. + /// Обновляется только вместе с проверенным изменением схемы или данных. + /// C6 удалил корневой sentiment-объект, заменил специальные source-теги + /// обычными family-ссылками и удалил неиспользуемый `neutral.tint.ratio`; + /// именно эти изменения канонического JSON объясняют смену отпечатка. #[test] fn full_labui_fingerprint_pin_current_main() { let full = labui_dto(); assert_eq!( format!("{:016x}", fingerprint(&full)), - "c51445fcd167781a", + "1adb2876102d77f3", "пин паспорта main; при легитимной смене паспорта обнови это число" ); } diff --git a/crates/labcolors-wasm/src/dto.rs b/crates/labcolors-wasm/src/dto.rs index 6f1fe4e3..f44deaac 100644 --- a/crates/labcolors-wasm/src/dto.rs +++ b/crates/labcolors-wasm/src/dto.rs @@ -49,7 +49,7 @@ pub enum RoleOutcome { Glow(GlowColor), /// Stable Glow terminal result: no sound numerical decision, no CSS vars. GlowIndeterminate(GlowIndeterminateColor), - /// Двухслойный материал (kind material; whitepaper §3.7): тинт `01` (с выведенной α) + + /// Двухслойный материал (kind material; whitepaper, «Точечные композиции»): тинт `01` (с выведенной α) + /// опаковая база `02`, обе — один тон. `--lab--01` несёт /// `oklch( / α)`, `--lab--02` и `--lab-` — `oklch()` /// (солид-канон/опаковая база); композит-гарантия читаемости — в полях. @@ -159,8 +159,6 @@ pub struct MaterialColor { /// кандидат имеет минимальную ошибку среди просмотренных, не среди всего /// гамута. pub tone_compressed: bool, - /// Оттенок семьи выродился у края гамута (честный флаг; `false` у нейтрали). - pub hue_vanished: bool, /// Солид-канон отличим от фона резолва на 8-битной сетке дисплея. pub distinct: bool, } @@ -174,7 +172,7 @@ pub struct RgbaColor { pub alpha: f64, /// The solid the tint composites to on the resolve background. pub composite_hex: String, - /// The signed perceptual contrast `Lc` of the composite. + /// The signed score of the frozen SAPC-shaped candidate curve for the composite. pub composite_lc: f64, /// The WCAG 2.1 ratio of the composite. pub composite_wcag: f64, @@ -187,38 +185,36 @@ pub struct RgbaColor { /// `true` when a solid family border (`border--strong`, M2 ch5c) was /// darkened along the family curve to meet the AA UI floor (3:1), because the /// raw family tint did not clear it on this background — an honest, flagged - /// minimal legal shift (family hue/chroma preserved, only lightness moved). - /// `false` for a direct ladder emission and for legal family solids. + /// minimal legal shift along the declared family curve. Final bytes remain + /// the representation truth; this flag does not claim perceptual hue/chroma + /// preservation. `false` for a direct ladder emission and legal family solids. pub floor_coerced: bool, } -/// A resolved colour and the contrasts it actually achieves. +/// A resolved colour with its Ys candidate score and WCAG ratio. #[derive(Debug, Clone, PartialEq)] pub struct SolvedColor { /// The colour as `#RRGGBB`. pub hex: String, - /// The signed perceptual contrast `Lc` against the background. + /// The signed Ys candidate score from the frozen SAPC-shaped curve against the background; + /// not LPC/readability evidence. pub lc: f64, /// The WCAG 2.1 ratio (1–21) against the background. pub wcag_ratio: f64, - /// `true` when the legal floor squeezed this role onto the smallest step - /// below its senior (an honest, flagged hierarchy degradation). + /// `true`, если точная цель роли не сохранена: legal floor/иерархия сжали + /// contrast-target либо ограниченный dJ′-поиск вернул лучший из просмотренных + /// кандидатов. Глобальный оптимум не заявляется. pub compressed: bool, - /// `true` when a coloured family label (M1 ch5c) lost perceptible colour on - /// its contract-solved lightness: the colour's `M'` fell below the tint - /// perceptibility floor, so at the family curve's extremes (near-white / - /// near-black) the hue is physically indistinguishable. An honest, flagged - /// outcome — not a silent degradation to grey. `false` for neutral labels and - /// for coloured labels that kept a distinguishable colour. - pub hue_vanished: bool, /// Честный замер |ΔJ'| на отданном hex для dJ'-ролей (симметрия с glow); - /// `None` у контраст-ролей (их метрика — Lc). + /// `None` у contrast-score ролей; для них доступна знаковая Ys + /// candidate-координата `lc`. pub achieved_dj: Option, - /// `true` when the WCAG legal floor overrode the perceptual target. + /// `true` when the WCAG legal floor overrode the Ys candidate-score target. pub floor_override: bool, - /// The minimum WCAG ratio this role is legally clamped to (`AaText` → 4.5, - /// `AaUi` → 3.0), or `None` for decorative / JND / zero roles. A property of - /// the role's contract, not of this solve: a runtime easing between themes - /// uses it to hold the floor every frame of the transition. + /// Минимальное отношение WCAG из контракта роли (`AaText` → 4.5, + /// `AaUi` → 3.0) либо `None`, если пола нет. Solve проверяет финальную + /// эмитированную пару. Default runtime не удерживает пол на каждом + /// промежуточном кадре; `strict` использует охарактеризованный clamp, но не + /// является сертификатом. pub legal_floor: Option, } diff --git a/crates/labcolors-wasm/src/engine.rs b/crates/labcolors-wasm/src/engine.rs index a163fdfa..bd94a193 100644 --- a/crates/labcolors-wasm/src/engine.rs +++ b/crates/labcolors-wasm/src/engine.rs @@ -4,7 +4,7 @@ //! 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 +//! [`ResolvedTheme`]. The engine is agnostic (ADR-0001): 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 @@ -87,8 +87,9 @@ impl Engine { /// (validate = компиляция) + вычисленный отпечаток. После успешной /// загрузки [`resolve_theme`](Self::resolve_theme) эмитит РОЛИ КОНФИГА /// (string-keyed контракт) той же физикой; сигнатура resolve_theme - /// неизменна. Возвращает отпечаток — компонент ключа кэша: другой конфиг - /// даёт другой отпечаток, записи не делятся (нет кэш-коллизии). + /// неизменна. Возвращает детерминированный 64-битный отпечаток. Это + /// вероятностный идентификатор, не доказательство уникальности; пространства + /// записей не смешиваются благодаря полному очищению кэша при reload. /// /// Ошибочный конфиг НЕ трогает текущее состояние: движок остаётся на /// прежней таблице (загрузка атомарна). @@ -294,14 +295,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> Result RoleOutcome::Color(map_solved( - solved, - compressed, - achieved_dj, - hue_vanished, - legal_floor, - )), + } => RoleOutcome::Color(map_solved(solved, compressed, achieved_dj, legal_floor)), Resolved::None => RoleOutcome::None, Resolved::Failure(failure) => RoleOutcome::Failure { category: failure.category(), @@ -350,7 +344,7 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> Result RoleOutcome::Material(crate::dto::MaterialColor { tone_hex: m.tint_hex().to_string(), alpha: m.alpha(), @@ -361,7 +355,6 @@ fn map_resolved(resolved: Resolved, legal_floor: Option) -> Result, - hue_vanished: bool, legal_floor: Option, ) -> SolvedColor { SolvedColor { @@ -392,7 +384,6 @@ fn map_solved( achieved_dj, floor_override: solved.floor_override(), legal_floor, - hue_vanished, } } @@ -821,7 +812,7 @@ mod tests { /// JSON канонического labui-конфига — статический SSOT-паспорт /// (`tests/data/labui.config.json`). Дерево Даниила вынесено из прод-API ядра - /// (ADR-0001 PR-c), поэтому граница читает замороженный паспорт, а не строит + /// (ADR-0001), поэтому граница читает замороженный паспорт, а не строит /// его из `labui_reference` (её больше нет в публичном API). fn labui_json() -> String { include_str!("../tests/data/labui.config.json").to_string() @@ -951,6 +942,26 @@ mod tests { assert!(engine.load_config("{не json").is_err()); // И валидный JSON с невалидным конфигом: assert!(engine.load_config("{}").is_err()); + // C6: прежнее специальное поле не становится ignored compatibility + // после выреза. Строгий schema-отказ также обязан быть атомарным. + let mut legacy: serde_json::Value = + serde_json::from_str(&labui_json()).expect("fixture JSON"); + let retired_field = legacy.get("sentiments").cloned().unwrap_or_else(|| { + serde_json::json!({ + "categories": [], + "hardness": 5.0, + "chroma_fraction": 0.88 + }) + }); + legacy["sentiments"] = retired_field; + assert!(engine.load_config(&legacy.to_string()).is_err()); + + // Вложенная удалённая ручка тоже не становится тихим no-op: + // strictness действует на всей объектной границе, а не только в корне. + let mut nested_legacy: serde_json::Value = + serde_json::from_str(&labui_json()).expect("fixture JSON"); + nested_legacy["palette"][0]["preferred_side"] = serde_json::json!(1); + assert!(engine.load_config(&nested_legacy.to_string()).is_err()); let after = engine.resolve_theme("#FFFFFF", "light").unwrap(); assert!( @@ -965,10 +976,9 @@ mod tests { "brand": {"light": "#7C3AED", "dark": "#8B5CF6", "light_ic": "#5B21B6", "dark_ic": "#A78BFA"}, "neutral": { "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"}, - "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} + "tint": {"target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, "themes": [{"name": "light", "preset": "srgb"}, {"name": "dark", "preset": "dim"}], "roles": [ {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, @@ -1115,7 +1125,6 @@ mod tests { solved, compressed, achieved_dj, - hue_vanished, }, RoleOutcome::Color(c), ) => { @@ -1124,7 +1133,6 @@ mod tests { assert_eq!(solved.wcag_ratio(), c.wcag_ratio, "{name}: wcag"); assert_eq!(*compressed, c.compressed, "{name}: compressed"); assert_eq!(*achieved_dj, c.achieved_dj, "{name}: achieved_dj"); - assert_eq!(*hue_vanished, c.hue_vanished, "{name}: hue_vanished"); assert_eq!( solved.floor_override(), c.floor_override, @@ -1176,15 +1184,6 @@ mod tests { assert_eq!(g.halo_achieved_dj(), o.halo_achieved_dj); assert_eq!(g.core_composite_hex(), o.core_composite_hex); assert_eq!(g.core_achieved_dj(), o.core_achieved_dj); - assert_eq!( - g.degraded(), - matches!( - o.target_status, - labcolors_core::GlowTargetStatus::ExactNoopUnreachable - | labcolors_core::GlowTargetStatus::LegacyUnreachable - ) - ); - assert_eq!(g.achieved_dj().to_bits(), o.halo_achieved_dj.to_bits()); } (Resolved::GlowIndeterminate(g), RoleOutcome::GlowIndeterminate(o)) => { assert_eq!(g.source_hex(), o.source_hex); @@ -1207,11 +1206,6 @@ mod tests { o.alpha_status, "{name}: material alpha status" ); - assert_eq!( - m.guaranteed(), - o.alpha_status == labcolors_core::MaterialAlphaStatusV1::Satisfied, - "{name}: material guaranteed compatibility alias" - ); assert_eq!( matches!(m.pole(), labcolors_core::Pole::White), o.pole_white, @@ -1227,11 +1221,6 @@ mod tests { o.tone_compressed, "{name}: material tone_compressed" ); - assert_eq!( - m.hue_vanished(), - o.hue_vanished, - "{name}: material hue_vanished" - ); assert_eq!(m.distinct(), o.distinct, "{name}: material distinct"); } (Resolved::None, RoleOutcome::None) => {} diff --git a/crates/labcolors-wasm/src/error.rs b/crates/labcolors-wasm/src/error.rs index 9e934773..53186a43 100644 --- a/crates/labcolors-wasm/src/error.rs +++ b/crates/labcolors-wasm/src/error.rs @@ -44,7 +44,7 @@ pub enum BindingError { }, /// `resolve_theme` was called before any config was loaded. The engine is - /// agnostic (ADR-0001 PR-c): it carries no built-in design system, so a + /// agnostic (ADR-0001): it carries no built-in design system, so a /// resolve has nothing to emit until `load_config` supplies one. Honest, /// matchable failure — never a panic and never a silent built-in default. #[error("no config loaded: call load_config before resolve_theme or recheck")] diff --git a/crates/labcolors-wasm/src/lib.rs b/crates/labcolors-wasm/src/lib.rs index 2aa31c5d..8ce7ed7f 100644 --- a/crates/labcolors-wasm/src/lib.rs +++ b/crates/labcolors-wasm/src/lib.rs @@ -1,18 +1,16 @@ -//! `@labpics/colors` — WASM bindings over the `labcolors-core` contrast engine. +//! `@labpics/colors` — WASM-граница контрастного движка `labcolors-core`. //! -//! The whole crate is one Clean-Architecture slice: -//! - `theme` — the public theme vocabulary (value object) → core viewing -//! conditions. -//! - `error` — matchable boundary errors (`thiserror`). -//! - `dto` — framework-free result types (output boundary). -//! - `cache` — the contract cache. -//! - `engine` — the application core: `resolve_set` made generic over roles. -//! - this module — the *only* place `#[wasm_bindgen]` appears: the adapter that -//! projects the engine's pure results into JS objects. +//! Весь крейт образует один срез Clean Architecture: +//! - `theme` — публичный словарь тем (value object) → viewing conditions ядра; +//! - `error` — сопоставимые ошибки границы (`thiserror`); +//! - `dto` — независимые от фреймворка типы результата (output boundary); +//! - `cache` — кэш контрактов; +//! - `engine` — application core: обобщённый по ролям `resolve_set`; +//! - этот модуль — единственное место с `#[wasm_bindgen]`: адаптер проецирует +//! чистые результаты движка в JS-объекты. //! -//! No DOM writes, no CSS side effects — the bindings return data. Applying it to -//! the page (CSS custom properties) is the css-injection-runtime chapter's job; -//! a vanilla helper for that lives in the npm package, not in the WASM core. +//! Граница возвращает данные без записи в DOM и CSS-side effects. Применение +//! CSS custom properties выполняет runtime npm-пакета, а не WASM-ядро. mod cache; // pub: сериализация канонического конфига через DTO (output boundary); @@ -32,9 +30,9 @@ use crate::dto::ResolvedTheme; use crate::engine::{Engine, hex_for_recheck}; use crate::error::BindingError; -/// TypeScript shapes for the values `resolveTheme` returns. wasm-bindgen emits -/// `LabColors.resolveTheme(...): ResolvedTheme` against these, so consumers get -/// full typing without a hand-written `.d.ts`. +/// TypeScript-формы значений `resolveTheme`. `wasm-bindgen` связывает с ними +/// `LabColors.resolveTheme(...): ResolvedTheme`, поэтому потребителю не нужен +/// вручную написанный `.d.ts`. #[wasm_bindgen(typescript_custom_section)] const TS_RESULT_TYPES: &'static str = r##" import type { Wcag22CriterionV1 } from "../wcag22.js"; @@ -42,79 +40,74 @@ import type { Wcag22CriterionV1 } from "../wcag22.js"; /** Ключ темы из словаря `themes` загруженного конфига (клиентское имя). */ export type ThemeName = string; -/** A solved colour and the contrasts it actually achieves. */ +/** Решённый цвет и фактически достигнутые им контрасты. */ export interface SolvedColor { readonly kind: "color"; - /** The CSS custom-property name for this role, e.g. "--lab-label-primary". */ + /** Имя CSS custom property роли, например "--lab-label-primary". */ readonly cssVar: string; - /** The resolved colour as #RRGGBB (data; `css`/`vars` carry oklch). */ + /** Решённый цвет как #RRGGBB; `css` и `vars` содержат oklch. */ readonly hex: string; - /** Ready-to-serve CSS value: "oklch(L% C H)". `vars` carries the same string. */ + /** Готовое CSS-значение "oklch(L% C H)"; в `vars` лежит та же строка. */ readonly css: string; - /** Signed perceptual contrast (Lc) against the background. */ + /** Знаковая candidate-координата Ys (`lc`) замороженной SAPC-shaped кривой; не доказательство LPC или читаемости. */ readonly lc: number; - /** WCAG 2.1 ratio (1–21) against the background. */ + /** Отношение WCAG 2.1 к фону в диапазоне 1–21. */ readonly wcagRatio: number; - /** The legal floor squeezed this role onto the smallest step below its senior. */ - readonly compressed: boolean; /** - * `true` when a coloured family label (M1) lost perceptible colour on its - * contract-solved lightness: the colour's M′ fell below the tint perceptibility - * floor, so at the family curve's extremes (near-white / near-black) the hue is - * physically indistinguishable. An honest, flagged outcome — not a silent - * degradation to grey. `false` for neutral labels and coloured labels that kept - * a distinguishable colour. + * `true`, если точная цель роли не сохранена: legal floor/иерархия сжали + * contrast-target либо ограниченный dJ′-поиск вернул лучший из просмотренных + * кандидатов. Глобальный оптимум не заявляется. */ - readonly hueVanished: boolean; + readonly compressed: boolean; /** Честный замер |ΔJ'| на отданном hex для dJ'-ролей; null у контраст-ролей (метрика — lc). */ readonly achievedDj: number | null; - /** The WCAG floor overrode the perceptual target. */ + /** Пол WCAG переопределил целевую candidate-координату Ys. */ readonly floorOverride: boolean; /** - * The minimum WCAG ratio this role is legally clamped to (4.5 for AA text, - * 3.0 for AA UI), or `null` for decorative / zero roles. A property of - * the role's contract, not of this solve: a runtime easing between themes - * uses it to hold the floor every frame of the transition. + * Минимальное отношение WCAG из контракта роли: 4.5 для AA-текста, 3.0 для + * AA-UI или `null`, если пола нет. Solve проверяет финальную эмитированную + * пару. Default runtime не удерживает пол на каждом промежуточном кадре; + * `strict` использует охарактеризованный clamp, но не является сертификатом. */ readonly legalFloor: number | null; } -/** The explicit zero token: no colour here, by design (not a failure). */ +/** Явный нулевой токен: цвета здесь намеренно нет; это не отказ. */ export interface NoneRole { readonly kind: "none"; readonly cssVar: string; } -/** Admitted role-local failure. Only `unreachable` proves absence of a solution in the declared domain. */ +/** Допущенная локальная категория отказа. Только `unreachable` доказывает отсутствие решения в объявленном домене. */ export type FailureCategory = | "unreachable" | "unresolved"; -/** A typed role-local failure. Rejected, unsupported and internal failures reject the whole call instead. */ +/** Типизированный локальный отказ роли. Rejected, unsupported и internal вместо этого отклоняют весь вызов. */ export interface FailureRole { readonly kind: "failure"; readonly cssVar: string; - /** Core-owned semantic category. */ + /** Семантическая категория, которой владеет Core. */ readonly category: FailureCategory; - /** Core-owned stable machine code, e.g. "floor_unreachable". */ + /** Стабильный машинный код Core, например "floor_unreachable". */ readonly code: string; - /** Human-readable explanation. */ + /** Человекочитаемое объяснение. */ 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 / A), а браузер композитит её. */ export interface TranslucentRole { readonly kind: "translucent"; readonly cssVar: string; - /** The tint as #RRGGBB (data) — the colour the oklch(… / A) carries. */ + /** Тинт как #RRGGBB — цвет, который несёт oklch(… / A). */ readonly tintHex: string; - /** The alpha of the emission, (0, 1]. */ + /** Альфа эмиссии, (0, 1]. */ readonly alpha: number; - /** The solid the tint composites to on the resolve background. */ + /** Солид, в который тинт композитится на фоне резолва. */ readonly compositeHex: string; - /** Signed perceptual contrast (Lc) of the composite. */ + /** Знаковая candidate-координата Ys (`lc`) композита; не доказательство LPC или читаемости. */ readonly compositeLc: number; - /** WCAG 2.1 ratio of the composite. */ + /** Отношение WCAG 2.1 композита. */ readonly compositeWcag: number; /** * `true`, если запрошенная alpha не допускает ни одного byte-тинта, @@ -125,14 +118,14 @@ export interface TranslucentRole { */ readonly alphaCoerced: boolean; /** - * `true` when a solid family border (`border--strong`, M2) was darkened - * along the family curve to meet the AA UI floor (3:1), because the raw family - * tint did not clear it on this background — an honest, flagged minimal legal - * shift (family hue/chroma preserved, only lightness moved). `false` for a - * direct ladder emission and for legal family solids. + * `true`, если solid-граница семейства (`border--strong`, M2) + * затемнена вдоль его кривой до пола AA UI 3:1, потому что исходный тинт не + * проходил на этом фоне. Это явный минимальный сдвиг по объявленной кривой. + * Истина представления — финальные байты; сохранение воспринимаемых оттенка и + * хромы не заявляется. Для прямой лестницы и legal family solids — `false`. */ readonly floorCoerced: boolean; - /** Ready-to-serve CSS value: "oklch(L% C H / A)". `vars` carries the same string. */ + /** Готовое CSS-значение "oklch(L% C H / A)"; в `vars` лежит та же строка. */ readonly css: string; } @@ -200,8 +193,6 @@ export interface GlowDeterminateRoleBase { readonly coreCompositeHex: string; /** Фактический |ΔJ'| изолированного core-композита. */ readonly coreAchievedDj: number; - /** @deprecated Alias `haloAchievedDj` для совместимости. */ - readonly achievedDj: number; /** CSS-значение halo: `oklch(L% C H)`. */ readonly css: string; } @@ -212,8 +203,6 @@ export interface GlowStableExactNoopRole extends GlowDeterminateRoleBase { readonly decisionGuarantee: GlowBitExactDecisionGuaranteeV1; readonly selectionDiagnosticProfile: null; readonly targetStatus: "exact-noop-unreachable"; - /** @deprecated Выведено из targetStatus. */ - readonly degraded: true; } /** Legacy-выбор CAM16 достиг целевого |ΔJ'| в охарактеризованной среде исполнения. */ @@ -222,8 +211,6 @@ export interface GlowLegacyReachedRole extends GlowDeterminateRoleBase { readonly decisionGuarantee: GlowLegacyDecisionGuaranteeV1; readonly selectionDiagnosticProfile: GlowDiagnosticProfileV1; readonly targetStatus: "legacy-reached"; - /** @deprecated Выведено из targetStatus. */ - readonly degraded: false; } /** Legacy-выбор CAM16 вернул максимум, не достигший целевого |ΔJ'|. */ @@ -232,8 +219,6 @@ export interface GlowLegacyUnreachableRole extends GlowDeterminateRoleBase { readonly decisionGuarantee: GlowLegacyDecisionGuaranteeV1; readonly selectionDiagnosticProfile: GlowDiagnosticProfileV1; readonly targetStatus: "legacy-unreachable"; - /** @deprecated Выведено из targetStatus. */ - readonly degraded: true; } export type GlowDeterminateRole = @@ -241,7 +226,7 @@ export type GlowDeterminateRole = | GlowLegacyReachedRole | GlowLegacyUnreachableRole; -/** Stable terminal outcome: no sound target/max decision, therefore no CSS vars. */ +/** Терминальный stable-исход: sound target/max-решения нет, поэтому CSS-переменные отсутствуют. */ export interface GlowIndeterminateRoleBase { readonly kind: "glow-indeterminate"; readonly cssVar: string; @@ -260,13 +245,15 @@ export type GlowRole = GlowDeterminateRole | GlowIndeterminateRole; * проверенный результат профиля, а не строгую межплатформенную границу * минимальной alpha. */ export interface MaterialAlphaGuaranteeBaseV1 { - /** Byte-scale affine binary64 compositor + original WCAG 2.1 (2018) - * `0.03928` EOTF, with a conservative channel envelope and both crossed seam - * sides. Legacy-platform-dependent: `powf` не outward-bounded (attestation — #258). */ + /** Byte-scale affine binary64-композитор + исходная WCAG 2.1 (2018) EOTF + * с порогом `0.03928`, консервативной оболочкой каналов и обеими сторонами + * пересечённого seam. Legacy-platform-dependent: `powf` не имеет outward-bound + * (attestation — #258). */ readonly numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1"; } -/** Rechecked fail/pass endpoints from a fixed-step binary partition after the - * directed-search guard. No global-monotonicity, first-state or minimum claim. */ +/** Повторно проверенные fail/pass-границы fixed-step binary partition после + * directed-search guard. Глобальная монотонность, первый state и минимум не + * заявляются. */ export interface MaterialBisectionBracketGuaranteeV1 extends MaterialAlphaGuaranteeBaseV1 { readonly kind: "bisection-bracket-characterized-v1"; readonly iterations: number; @@ -284,7 +271,7 @@ export type MaterialAlphaGuaranteeV1 = | MaterialTransparentEndpointGuaranteeV1 | MaterialOpaqueEndpointGuaranteeV1; -/** Двухслойный материал (kind material; whitepaper §3.7): тинт `01` (с выведенной α) над +/** Двухслойный материал (kind material; whitepaper, «Точечные композиции»): тинт `01` (с выведенной α) над * опаковой базой `02`, обе — один тон. `vars` несёт --lab- (солид-канон, * опаковый), --lab--01 (тинт oklch/α) и --lab--02 (база, опаковая). * Композит-гарантия читаемости пересчитываема из toneHex/alpha (α-граница). */ @@ -304,11 +291,9 @@ export interface MaterialRoleBase { /** Целевой |ΔJ'| тона не попал в бюджет ограниченного обхода; выбран кандидат * с минимальной ошибкой среди просмотренных, не оптимум всего гамута. */ readonly toneCompressed: boolean; - /** Оттенок семьи выродился у края гамута (честный флаг; false у нейтрали). */ - readonly hueVanished: boolean; /** Солид-канон отличим от фона резолва на 8-битной сетке. */ readonly distinct: boolean; - /** Ready-to-serve CSS value солид-канона: "oklch(L% C H)". */ + /** Готовое CSS-значение солид-канона: "oklch(L% C H)". */ readonly css: string; } @@ -317,7 +302,6 @@ export interface MaterialSatisfiedTransparentRole extends MaterialRoleBase { readonly alpha: 0; readonly alphaGuarantee: MaterialTransparentEndpointGuaranteeV1; readonly alphaStatus: "satisfied"; - readonly guaranteed: true; } /** Повторно проверенная верхняя граница интервала держит запрошенный пол. */ @@ -325,7 +309,6 @@ export interface MaterialSatisfiedBracketRole extends MaterialRoleBase { readonly alpha: number; readonly alphaGuarantee: MaterialBisectionBracketGuaranteeV1; readonly alphaStatus: "satisfied"; - readonly guaranteed: true; } /** Даже непрозрачная граничная точка не держит запрошенный пол; возвращается alpha 1. */ @@ -333,7 +316,6 @@ export interface MaterialDegradedOpaqueRole extends MaterialRoleBase { readonly alpha: 1; readonly alphaGuarantee: MaterialOpaqueEndpointGuaranteeV1; readonly alphaStatus: "degraded"; - readonly guaranteed: false; } export type MaterialRole = @@ -361,10 +343,9 @@ export interface ThemeAnchors { export type LadderSource = | { kind: "brand" } | { kind: "family"; key: string } - | { kind: "sentiment"; name: string } | { kind: "neutral"; pick: "mid" | "edge" | "inverted" | "light" | "dark" }; -/** Closed physical ladder menu accepted by the config compiler. */ +/** Закрытое меню физических позиций лестницы, принимаемое компилятором конфига. */ export type LadderPositionV1 = | "label-primary" | "label-secondary" @@ -392,7 +373,11 @@ export type LadderPositionV1 = | "shadow-penumbra" | "shadow-major"; -/** Рецепт роли из физического меню движка. */ +/** + * Рецепт роли из закрытого физического меню текущего resolver-а. + * Это переходная compatibility surface, не target IR и не extension point. + * Новая физика не должна добавляться новым recipe variant. + */ export type RoleRecipe = | { kind: "text-anchor"; @@ -406,7 +391,7 @@ export type RoleRecipe = kind: "ladder"; source: LadderSource; position: LadderPositionV1; - floor?: "aa-text" | "aa-ui" | "none"; + floor?: "aa-text" | "aa-ui"; } | { kind: "glow"; @@ -426,7 +411,6 @@ export interface ThemeConfig { readonly neutral: { readonly anchors: { light: string; mid: string; dark: string }; readonly tint: { - ratio: number; target_mp: number; hue_stiffness: number; hue_override_deg?: number; @@ -435,16 +419,6 @@ export interface ThemeConfig { readonly inverted?: ThemeAnchors; }; readonly palette: ReadonlyArray<{ key: string; anchors: ThemeAnchors }>; - readonly sentiments: { - readonly categories: ReadonlyArray<{ - name: string; - family: string; - hue_floor_deg?: number; - preferred_side?: -1 | 1; - }>; - readonly hardness: number; - readonly chroma_fraction: number; - }; readonly themes: ReadonlyArray<{ name: string; preset: "srgb" | "dim" | "srgb-ic" | "dim-ic" }>; /** Словарь ролей дизайн-системы. Конфиг обязан нести собственные роли; пустой * контракт (без `roles` и `aliases`) отклоняется на загрузке. */ @@ -452,7 +426,7 @@ export interface ThemeConfig { readonly aliases?: ReadonlyArray<{ alias: string; target: string }>; } -/** Proof-capable V2 site. Empty arrays explicitly mean no admitted evidence. */ +/** Site V2 с поддержкой доказательств. Пустые массивы явно означают отсутствие допущенного evidence. */ export interface NumericalCapabilitySiteV2 { readonly siteId: string; readonly stableOutcomes: ReadonlyArray; @@ -464,7 +438,7 @@ export interface NumericalCapabilitySiteV2 { readonly runtimeAttestations: ReadonlyArray; } -/** Proof-capable numerical capability manifest used by conformance pack 4. */ +/** Манифест численных возможностей с доказательствами для conformance pack 4. */ export interface NumericalCapabilityManifestV2 { readonly schemaVersion: 2; readonly coverage: string; @@ -474,7 +448,7 @@ export interface NumericalCapabilityManifestV2 { export type Wcag22DecisionV1 = "pass" | "fail"; export interface Wcag22Q55BoundsV1 { - /** Decimal u64 string: Q55 values exceed JavaScript's safe integer range. */ + /** Десятичная строка u64: значения Q55 выходят за безопасный целочисленный диапазон JavaScript. */ readonly lower: string; readonly upper: string; } @@ -503,23 +477,23 @@ export interface Wcag22AssessmentV1 { }; } -/** The full result of resolving one background under one theme. */ +/** Полный результат резолва одного фона под одной темой. */ export interface ResolvedTheme { readonly theme: ThemeName; readonly background: string; /** - * Roles with a selected CSS value 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(L% C H)" для солидов и "oklch(L% C H / A)" для + * полупрозрачных лестниц/альфа-аналогов. Solve выполняется в гамуте sRGB: + * oklch здесь нотация, а не расширение гамута. Значения побайтно согласованы с + * `SolvedColor.hex` и `TranslucentRole.tintHex`; `compositeHex` зависит от фона + * и не является эмитируемым токеном. + * Это контракт resolveTheme: applyTheme/watchTheme записывают значения без + * изменений. Покадровый easing adaptTheme пишет конкретные интерполированные + * цвета и этой формой эмиссии не ограничен. */ readonly vars: Record; - /** Every role, keyed by its stable role key (without the --lab- prefix). */ + /** Все роли по стабильному ключу без префикса --lab-. */ readonly roles: Record; } "##; @@ -537,7 +511,7 @@ extern "C" { } -/// Единственный public numerical capability manifest: proof-capable V2. +/// Единственный публичный манифест численных возможностей: V2 с доказательствами. /// /// Свободная функция, а не метод движка: манифест — статическое свойство /// сборки (core registry SSOT), он не зависит ни от загруженного конфига, ни @@ -556,7 +530,7 @@ pub fn numerical_capability_manifest() -> Result LabColors { LabColors { @@ -622,9 +598,9 @@ impl LabColors { } } - /// Resolve every role for `bgHex` under `theme` — КЛИЕНТСКОГО ключа из - /// словаря `themes` загруженного конфига (канонический путь: ключ → - /// `VcPreset` → viewing conditions). Ключ вне словаря — `unknown_theme`. + /// Решает каждую роль для `bgHex` в `theme` — КЛИЕНТСКОМ ключе из словаря + /// `themes` загруженного конфига (канонический путь: ключ → `VcPreset` → + /// viewing conditions). Ключ вне словаря — `unknown_theme`. /// /// Возвращает полный `ResolvedTheme`. Локальный `unreachable`/`unresolved` /// остаётся типизированными данными роли. Rejected/unsupported/internal @@ -671,8 +647,9 @@ impl LabColors { /// ошибкой `invalid_config: …` и НЕ меняет состояние. После успешной /// загрузки `resolveTheme` эмитит роли конфига (включая полупрозрачные /// роли лестницы — эмиссия `oklch(L% C H / α)`). Возвращает отпечаток - /// конфига — 16 hex-символов; - /// разные конфиги дают разные отпечатки (и разные кэш-пространства). + /// конфига — 16 hex-символов. Это детерминированный вероятностный + /// идентификатор; корректность reload держится на полном очищении кэша, а + /// не на предположении об отсутствии 64-битных коллизий. #[wasm_bindgen(js_name = loadConfig)] pub fn load_config(&mut self, json: &str) -> Result { let fp = self.inner.load_config(json).map_err(to_js_error)?; @@ -683,17 +660,16 @@ impl LabColors { Ok(format!("{fp:016x}")) } - /// Recheck the contrasts `fgHexes` achieve against `bgHex` under `theme` — - /// the cheap per-frame primitive a reactive runtime uses to decide whether - /// already-resolved colours still pass against a changed background (re-solve - /// only when they stably do not). No full solve: one perceptual-model forward - /// for the background plus one per foreground. + /// Повторно проверяет контрасты `fgHexes` к `bgHex` в теме `theme`. + /// Реактивный runtime вызывает этот дешёвый примитив покадрово и запускает + /// новый solve лишь после устойчивого провала уже решённых цветов. Полного + /// solve нет: одна оценка замороженной кривой для фона и по одной для foreground. /// - /// Returns a `Float64Array` of `[lc, wcagRatio]` pairs, interleaved and in the - /// order of `fgHexes`: index `2*i` is foreground `i`'s signed `Lc`, `2*i+1` - /// its WCAG ratio. On invalid hex or an unknown theme, rejects with an - /// ordinary JS `Error` whose message starts with the stable - /// `": "` prefix. + /// Возвращает `Float64Array` чередующихся пар `[lc, wcagRatio]` в порядке + /// `fgHexes`: `2*i` — знаковая candidate-координата Ys foreground `i` из + /// замороженной SAPC-shaped кривой, а не вердикт LPC/читаемости; `2*i+1` — + /// отношение WCAG. Невалидный hex или неизвестная тема дают обычный JS + /// `Error` со стабильным префиксом `": "`. #[wasm_bindgen(js_name = recheckContrast)] pub fn recheck_contrast( &self, @@ -706,11 +682,11 @@ impl LabColors { .map_err(to_js_error) } - /// Exact stable-Glow runtime recheck. Returns whether the point screen - /// layer is a byte-for-byte no-op for every alpha under encoded sRGB8. - /// `adaptTheme` uses this cheap core-owned predicate to detect the only - /// stable determinate/indeterminate class transition without running CAM16 - /// or re-solving on every animated-background frame. + /// Точная runtime-перепроверка stable Glow. Возвращает, является ли точечный + /// screen-слой побайтным no-op для любой альфы в encoded sRGB8. `adaptTheme` + /// использует этот дешёвый предикат Core для обнаружения единственного + /// перехода stable determinate/indeterminate без запуска CAM16 и нового solve + /// на каждом кадре анимированного фона. #[wasm_bindgen(js_name = isStableGlowPointNoop)] pub fn is_stable_glow_point_noop(&self, tint_hex: &str, bg_hex: &str) -> Result { let tint = hex_for_recheck(tint_hex).map_err(|error| match error { @@ -724,17 +700,15 @@ impl LabColors { .map_err(|reason| to_js_error(stable_glow_recheck_core_error(reason))) } - /// Recheck one foreground set against MANY background samples in a single - /// call. The reactive controller's worst-case loop rechecks the same - /// foregrounds against every sample of a varying backdrop (gradient / image / - /// bg-blur / glass); the dominant per-foreground CAM16 forward is background- - /// independent, so this shares it across all samples instead of recomputing it - /// per `recheckContrast` call. + /// Одним вызовом проверяет набор foreground против многих образцов фона. + /// Контроллер использует это для меняющегося backdrop: gradient, image, + /// bg-blur или glass. Каждый foreground декодируется и квантуется один раз; + /// его display-relative luminance переиспользуется для всех образцов. /// - /// Returns a flat, background-major `Float64Array`: sample `s`, foreground `i` - /// is at `(s * fgHexes.length + i) * 2` (`lc`) and `+1` (`wcagRatio`). The - /// values are byte-identical to calling `recheckContrast(bgHexes[s], fgHexes, - /// theme)` for each `s`. + /// Возвращает плоский background-major `Float64Array`: для образца `s` и + /// foreground `i` индекс `(s * fgHexes.length + i) * 2` содержит `lc`, а + /// следующий — `wcagRatio`. Значения побайтно совпадают с отдельным вызовом + /// `recheckContrast(bgHexes[s], fgHexes, theme)` для каждого `s`. #[wasm_bindgen(js_name = recheckContrastMulti)] pub fn recheck_contrast_multi( &self, @@ -754,13 +728,13 @@ impl Default for LabColors { } } -/// Turn a boundary error into a JS `Error` carrying both the stable machine -/// code and the human reason, so JS can branch on the cause without a custom -/// error class. Format: `": "`. +/// Преобразует ошибку границы в JS `Error` со стабильным машинным кодом и +/// человекочитаемой причиной, чтобы JS мог ветвиться без отдельного класса +/// ошибки. Формат: `": "`. /// -/// A free function rather than a `From` impl: `thiserror` already gives -/// `BindingError` a blanket `From for JsError` via wasm-bindgen, and -/// that path would drop the stable code. This keeps the code in the message. +/// Это свободная функция, а не `From`: `thiserror` уже даёт `BindingError` +/// blanket-реализацию `From for JsError` через wasm-bindgen, и тот путь +/// потерял бы стабильный код. Здесь код сохраняется в сообщении. fn to_js_error(err: BindingError) -> JsError { JsError::new(&format!("{}: {}", err.code(), err)) } @@ -819,7 +793,9 @@ mod native_contract_tests { ); assert_eq!(declared_set, core_set, "TS ladder menu must equal core ALL"); assert!(types.contains("hue?: LadderSource")); - assert!(types.contains("floor?: \"aa-text\" | \"aa-ui\" | \"none\"")); + assert!(types.contains("floor?: \"aa-text\" | \"aa-ui\";")); + assert!(types.contains("floor: \"aa-text\" | \"aa-ui\" | \"none\"")); + assert!(!types.contains("ratio: number")); assert!(types.contains("readonly roles: ReadonlyArray")); } diff --git a/crates/labcolors-wasm/src/projection.rs b/crates/labcolors-wasm/src/projection.rs index 93e85d70..50064b4c 100644 --- a/crates/labcolors-wasm/src/projection.rs +++ b/crates/labcolors-wasm/src/projection.rs @@ -139,7 +139,6 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { field_num(&mut roles, "lc", c.lc)?; field_num(&mut roles, "wcagRatio", c.wcag_ratio)?; field_bool(&mut roles, "compressed", c.compressed); - field_bool(&mut roles, "hueVanished", c.hue_vanished); field_opt_num(&mut roles, "achievedDj", c.achieved_dj)?; field_bool(&mut roles, "floorOverride", c.floor_override); field_opt_num(&mut roles, "legalFloor", c.legal_floor)?; @@ -165,7 +164,7 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { push_var(&mut vars, &css_var, &css); } RoleOutcome::Glow(g) => { - let degraded = glow_degraded_from_provenance(g)?; + validate_glow_provenance(g)?; // Свечение: слои для screen-наложения потребителем. // --lab- несёт halo (единая oklch-форма), сателлиты // --lab--core / --lab--alpha — анатомия и @@ -239,9 +238,6 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { field_num(&mut roles, "haloAchievedDj", g.halo_achieved_dj)?; field_str(&mut roles, "coreCompositeHex", &g.core_composite_hex); field_num(&mut roles, "coreAchievedDj", g.core_achieved_dj)?; - // Aliases совместимости старого неоднозначного контракта. - field_num(&mut roles, "achievedDj", g.halo_achieved_dj)?; - field_bool(&mut roles, "degraded", degraded); let halo_css = oklch_css(&g.halo_hex, None)?; let core_css = oklch_css(&g.core_hex, None)?; field_str(&mut roles, "css", &halo_css); @@ -275,8 +271,8 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { // устаревший или выбранный платформой резервный цвет. } RoleOutcome::Material(m) => { - let guaranteed = material_guaranteed_from_provenance(m)?; - // Материал (whitepaper §3.7): тинт 01 (oklch/α) над опаковой базой 02 (oklch). + validate_material_provenance(m)?; + // Материал (whitepaper, «Точечные композиции»): тинт 01 (oklch/α) над опаковой базой 02 (oklch). // --lab- несёт солид-канон (= тон, опаковый) как SOLID- // фолбэк; --lab--01 — тинт со слэш-альфой; --lab--02 — // база. Тон/01/02 несут один тон (композит T над T есть T). @@ -291,11 +287,9 @@ pub fn resolved_json(resolved: &ResolvedTheme) -> Result { material_alpha_status_key(m.alpha_status)?, ); field_num(&mut roles, "floor", m.floor)?; - field_bool(&mut roles, "guaranteed", guaranteed); field_bool(&mut roles, "poleWhite", m.pole_white); field_num(&mut roles, "achievedDj", m.achieved_dj)?; field_bool(&mut roles, "toneCompressed", m.tone_compressed); - field_bool(&mut roles, "hueVanished", m.hue_vanished); field_bool(&mut roles, "distinct", m.distinct); let solid_css = oklch_css(&m.tone_hex, None)?; let tint_css = oklch_css(&m.tone_hex, Some(m.alpha))?; @@ -408,7 +402,7 @@ fn unknown_output_variant(type_name: &str) -> BindingError { } } -fn glow_degraded_from_provenance(glow: &GlowColor) -> Result { +fn validate_glow_provenance(glow: &GlowColor) -> Result<(), BindingError> { use labcolors_core::glow::GlowDecisionOutcomeV1; // Атомарный decision_outcome (#292) уже делает незаконную пару // profile × guarantee непредставимой; проекции осталось сверить его с @@ -423,7 +417,7 @@ fn glow_degraded_from_provenance(glow: &GlowColor) -> Result GlowDecisionOutcomeV1::StableExactNoop { .. }, None, labcolors_core::GlowTargetStatus::ExactNoopUnreachable, - ) => Ok(true), + ) => Ok(()), ( GlowDecisionOutcomeV1::Compatibility { release_id: @@ -432,7 +426,7 @@ fn glow_degraded_from_provenance(glow: &GlowColor) -> Result }, Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), labcolors_core::GlowTargetStatus::LegacyReached, - ) => Ok(false), + ) => Ok(()), ( GlowDecisionOutcomeV1::Compatibility { release_id: @@ -441,7 +435,7 @@ fn glow_degraded_from_provenance(glow: &GlowColor) -> Result }, Some(labcolors_core::GlowDiagnosticProfileV1::Cam16UcsJPrimeLi2017V1), labcolors_core::GlowTargetStatus::LegacyUnreachable, - ) => Ok(true), + ) => Ok(()), _ => Err(BindingError::Internal { reason: "проекция: несогласованный Glow provenance".to_string(), }), @@ -459,23 +453,23 @@ fn validate_glow_indeterminate_provenance( } } -fn material_guaranteed_from_provenance(material: &MaterialColor) -> Result { +fn validate_material_provenance(material: &MaterialColor) -> Result<(), BindingError> { match (material.alpha_status, material.alpha_guarantee) { ( labcolors_core::MaterialAlphaStatusV1::Satisfied, labcolors_core::MaterialAlphaGuaranteeV1::TransparentEndpointCharacterizedV1 { .. }, - ) if material.alpha.to_bits() == 0.0_f64.to_bits() => Ok(true), + ) if material.alpha.to_bits() == 0.0_f64.to_bits() => Ok(()), ( labcolors_core::MaterialAlphaStatusV1::Satisfied, labcolors_core::MaterialAlphaGuaranteeV1::BisectionBracketCharacterizedV1 { upper_alpha, .. }, - ) if material.alpha.to_bits() == upper_alpha.to_bits() => Ok(true), + ) if material.alpha.to_bits() == upper_alpha.to_bits() => Ok(()), ( labcolors_core::MaterialAlphaStatusV1::Degraded, labcolors_core::MaterialAlphaGuaranteeV1::OpaqueEndpointCharacterizedV1 { .. }, - ) if material.alpha.to_bits() == 1.0_f64.to_bits() => Ok(false), + ) if material.alpha.to_bits() == 1.0_f64.to_bits() => Ok(()), _ => Err(BindingError::Internal { reason: "проекция: несогласованный Material provenance".to_string(), }), @@ -854,7 +848,6 @@ mod tests { lc: 62.375, wcag_ratio: 7.25, compressed: false, - hue_vanished: false, achieved_dj: None, floor_override: true, legal_floor: Some(4.5), @@ -928,6 +921,10 @@ mod tests { #[test] fn shape_and_order_match_the_reflect_projection() { let json = resolved_json(&fixture()).unwrap(); + assert!( + !json.contains("hueVanished"), + "final bytes are the representation truth; the boundary must not infer perceptual hue visibility" + ); let css_label = labcolors_core::oklch_css_from_hex("#D5D5D7", None).unwrap(); let css_veil = labcolors_core::oklch_css_from_hex("#89CFF0", Some(0.35)).unwrap(); let css_halo = labcolors_core::oklch_css_from_hex("#4A8FFF", None).unwrap(); @@ -943,7 +940,7 @@ mod tests { "}},\"roles\":{{", "\"label-primary\":{{\"cssVar\":\"--lab-label-primary\",\"kind\":\"color\",", "\"hex\":\"#D5D5D7\",\"lc\":62.375,\"wcagRatio\":7.25,\"compressed\":false,", - "\"hueVanished\":false,\"achievedDj\":null,\"floorOverride\":true,", + "\"achievedDj\":null,\"floorOverride\":true,", "\"legalFloor\":4.5,\"css\":\"{lab}\"}},", "\"spacer\":{{\"cssVar\":\"--lab-spacer\",\"kind\":\"none\"}},", "\"veil\":{{\"cssVar\":\"--lab-veil\",\"kind\":\"translucent\",", @@ -963,7 +960,7 @@ mod tests { "\"constraintLayer\":\"halo\",\"targetDj\":2.3006,\"targetStatus\":\"legacy-reached\",", "\"haloCompositeHex\":\"#13151B\",\"haloAchievedDj\":2.373123785729128,", "\"coreCompositeHex\":\"#15171B\",\"coreAchievedDj\":3.235504076619437,", - "\"achievedDj\":2.373123785729128,\"degraded\":false,\"css\":\"{halo}\"}},", + "\"css\":\"{halo}\"}},", "\"unresolved\":{{\"cssVar\":\"--lab-unresolved\",\"kind\":\"failure\",", "\"category\":\"unresolved\",\"code\":\"bounded_search_exhausted\",", "\"message\":\"нет цвета: \\\"предел\\\"\\nвторая строка\"}}", @@ -979,6 +976,9 @@ mod tests { // Anti-vacuum: `kind: none` публикует client-owned `cssVar` как // метаданные контракта, но никогда не присваивает этому имени значение. let projected: serde_json::Value = serde_json::from_str(&json).unwrap(); + let glow = &projected["roles"]["pulse"]; + assert!(glow.get("achievedDj").is_none()); + assert!(glow.get("degraded").is_none()); let none = &projected["roles"]["spacer"]; assert_eq!(none["kind"], "none"); assert_eq!(none["cssVar"], "--lab-spacer"); @@ -1242,7 +1242,7 @@ mod tests { })); } - /// Материал (whitepaper §3.7) проецируется в контрактные CSS-переменные: `--lab-` = + /// Материал (whitepaper, «Точечные композиции») проецируется в контрактные CSS-переменные: `--lab-` = /// солид-канон (oklch), `--lab--01` = тинт (oklch со слэш-альфой), /// `--lab--02` = опаковая база (oklch). Плюс полный набор полей исхода. /// Пин ИМЁН переменных — публичный CSS-контракт сателлитов материала. @@ -1272,7 +1272,6 @@ mod tests { pole_white: false, achieved_dj: 18.25, tone_compressed: false, - hue_vanished: false, distinct: true, }), }], @@ -1323,17 +1322,16 @@ mod tests { ); assert_eq!(r["alphaStatus"], "satisfied"); assert_eq!(r["floor"].as_f64().unwrap(), 4.5); - assert_eq!(r["guaranteed"], true); + assert!(r.get("guaranteed").is_none()); assert_eq!(r["poleWhite"], false); assert_eq!(r["achievedDj"].as_f64().unwrap(), 18.25); assert_eq!(r["toneCompressed"], false); - assert_eq!(r["hueVanished"], false); assert_eq!(r["distinct"], true); assert_eq!(r["css"].as_str().unwrap(), solid); } #[test] - fn material_terminal_variants_keep_status_guarantee_and_boolean_correlated() { + fn material_terminal_variants_keep_status_and_guarantee_correlated_without_boolean_alias() { let numerical_profile = labcolors_core::MaterialNumericalProfileV1::EncodedSrgbByteScaleAffinePlatformBinary64PowfV1; let role = |alpha, alpha_guarantee, alpha_status| { RoleOutcome::Material(MaterialColor { @@ -1346,7 +1344,6 @@ mod tests { pole_white: false, achieved_dj: 18.25, tone_compressed: false, - hue_vanished: false, distinct: true, }) }; @@ -1386,7 +1383,7 @@ mod tests { "transparent-endpoint-characterized-v1" ); assert_eq!(transparent["alphaStatus"], "satisfied"); - assert_eq!(transparent["guaranteed"], true); + assert!(transparent.get("guaranteed").is_none()); let opaque = &value["roles"]["opaque"]; assert_eq!(opaque["alpha"], 1.0); @@ -1395,7 +1392,7 @@ mod tests { "opaque-endpoint-characterized-v1" ); assert_eq!(opaque["alphaStatus"], "degraded"); - assert_eq!(opaque["guaranteed"], false); + assert!(opaque.get("guaranteed").is_none()); } #[test] @@ -1459,7 +1456,6 @@ mod tests { pole_white: false, achieved_dj: 18.25, tone_compressed: false, - hue_vanished: false, distinct: true, }), }], diff --git a/crates/labcolors-wasm/tests/chain_invariants.rs b/crates/labcolors-wasm/tests/chain_invariants.rs index 13337ca5..bc0c533e 100644 --- a/crates/labcolors-wasm/tests/chain_invariants.rs +++ b/crates/labcolors-wasm/tests/chain_invariants.rs @@ -7,7 +7,8 @@ //! `ThemeConfig` (PartialEq ЯДРА) на круг-трипе паспорт→DTO→JSON→DTO→ядро; //! - `config_dto.rs::fingerprint_is_deterministic_and_discriminating` — //! отпечаток стабилен к pretty-репарсу и различает конфиги; -//! - `config_dto.rs::thin_and_full_labui_share_one_fingerprint` — тонкий==полный. +//! - `config_dto.rs::full_labui_fingerprint_pin_current_main` — канонический +//! отпечаток меняется только вместе с публичной схемой. //! //! Дыры, которые закрывает этот файл: //! 1. КРУГ-ТРИП ЧЕРЕЗ ДВИЖОК ПО ОТПЕЧАТКУ И БАЙТАМ. Существующий тест сверяет @@ -134,15 +135,14 @@ fn fingerprint_is_invariant_to_lf_crlf_and_indentation() { /// прямой контроль порядка без борьбы с сортировкой `serde_json::Value`. #[test] fn fingerprint_is_invariant_to_json_key_order() { - // Порядок A: brand, neutral, palette, sentiments, themes, roles. + // Порядок A: brand, neutral, palette, themes, roles. let order_a = r##"{ "brand": {"light": "#7C3AED", "dark": "#8B5CF6", "light_ic": "#5B21B6", "dark_ic": "#A78BFA"}, "neutral": { "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"}, - "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} + "tint": {"target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, "themes": [{"name": "light", "preset": "srgb"}], "roles": [ {"name": "body-text", "recipe": {"kind": "text-anchor", "fraction": 0.62, "floor": "aa-text"}} @@ -154,10 +154,9 @@ 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}, "palette": [], "neutral": { - "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0}, + "tint": {"target_mp": 6.1, "hue_stiffness": 9.0}, "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"} }, "brand": {"dark_ic": "#A78BFA", "light_ic": "#5B21B6", "dark": "#8B5CF6", "light": "#7C3AED"} diff --git a/crates/labcolors-wasm/tests/data/labui.config.json b/crates/labcolors-wasm/tests/data/labui.config.json index 3bc47dbc..69fc367b 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.json +++ b/crates/labcolors-wasm/tests/data/labui.config.json @@ -12,7 +12,6 @@ "dark": "#101012" }, "tint": { - "ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9, "hue_override_deg": 286 @@ -122,30 +121,6 @@ } } ], - "sentiments": { - "categories": [ - { - "name": "danger", - "family": "red" - }, - { - "name": "warning", - "family": "orange", - "hue_floor_deg": 45, - "preferred_side": 1 - }, - { - "name": "success", - "family": "green" - }, - { - "name": "info", - "family": "blue" - } - ], - "hardness": 5, - "chroma_fraction": 1 - }, "themes": [ { "name": "light", @@ -471,8 +446,8 @@ "fraction": 0.97335917, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -483,8 +458,8 @@ "fraction": 0.64359014, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -495,8 +470,8 @@ "fraction": 0.47572199, "floor": "aa-ui", "hue": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -507,8 +482,8 @@ "fraction": 0.29335999, "floor": "none", "hue": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -517,8 +492,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-primary" } @@ -528,8 +503,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-secondary" } @@ -539,8 +514,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-tertiary" } @@ -550,8 +525,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-quaternary" } @@ -561,8 +536,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-strong", "floor": "aa-ui" @@ -573,8 +548,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-base" } @@ -584,8 +559,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-soft" } @@ -597,8 +572,8 @@ "fraction": 0.97335917, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -609,8 +584,8 @@ "fraction": 0.64359014, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -621,8 +596,8 @@ "fraction": 0.47572199, "floor": "aa-ui", "hue": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -633,8 +608,8 @@ "fraction": 0.29335999, "floor": "none", "hue": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -643,8 +618,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-primary" } @@ -654,8 +629,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-secondary" } @@ -665,8 +640,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-tertiary" } @@ -676,8 +651,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-quaternary" } @@ -687,8 +662,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-strong", "floor": "aa-ui" @@ -699,8 +674,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-base" } @@ -710,8 +685,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-soft" } @@ -723,8 +698,8 @@ "fraction": 0.97335917, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -735,8 +710,8 @@ "fraction": 0.64359014, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -747,8 +722,8 @@ "fraction": 0.47572199, "floor": "aa-ui", "hue": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -759,8 +734,8 @@ "fraction": 0.29335999, "floor": "none", "hue": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -769,8 +744,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-primary" } @@ -780,8 +755,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-secondary" } @@ -791,8 +766,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-tertiary" } @@ -802,8 +777,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-quaternary" } @@ -813,8 +788,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-strong", "floor": "aa-ui" @@ -825,8 +800,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-base" } @@ -836,8 +811,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-soft" } @@ -849,8 +824,8 @@ "fraction": 0.97335917, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, @@ -861,8 +836,8 @@ "fraction": 0.64359014, "floor": "aa-text", "hue": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, @@ -873,8 +848,8 @@ "fraction": 0.47572199, "floor": "aa-ui", "hue": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, @@ -885,8 +860,8 @@ "fraction": 0.29335999, "floor": "none", "hue": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, @@ -895,8 +870,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-primary" } @@ -906,8 +881,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-secondary" } @@ -917,8 +892,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-tertiary" } @@ -928,8 +903,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-quaternary" } @@ -939,8 +914,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-strong", "floor": "aa-ui" @@ -951,8 +926,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-base" } @@ -962,8 +937,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-soft" } @@ -983,8 +958,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "focus-ring" } @@ -994,8 +969,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "focus-ring" } @@ -1027,8 +1002,8 @@ "recipe": { "kind": "glow", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "step": "base", "decision_profile": "legacy-platform-dependent-v1" @@ -1039,8 +1014,8 @@ "recipe": { "kind": "glow", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "step": "base", "decision_profile": "legacy-platform-dependent-v1" @@ -1106,8 +1081,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-primary" } @@ -1127,8 +1102,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-primary" } @@ -1148,8 +1123,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-base" } @@ -1178,8 +1153,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -1188,8 +1163,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -1198,8 +1173,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -1208,8 +1183,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, diff --git a/crates/labcolors-wasm/tests/data/labui.config.prod.json b/crates/labcolors-wasm/tests/data/labui.config.prod.json index a10f6b0a..24c4ddea 100644 --- a/crates/labcolors-wasm/tests/data/labui.config.prod.json +++ b/crates/labcolors-wasm/tests/data/labui.config.prod.json @@ -12,7 +12,6 @@ "dark": "#101012" }, "tint": { - "ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0, "hue_override_deg": 286.0 @@ -122,30 +121,6 @@ } } ], - "sentiments": { - "categories": [ - { - "name": "danger", - "family": "red" - }, - { - "name": "warning", - "family": "orange", - "hue_floor_deg": 45.0, - "preferred_side": 1 - }, - { - "name": "success", - "family": "green" - }, - { - "name": "info", - "family": "blue" - } - ], - "hardness": 5.0, - "chroma_fraction": 1.0 - }, "themes": [ { "name": "light", @@ -464,8 +439,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-primary" } @@ -475,8 +450,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-secondary" } @@ -486,8 +461,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-tertiary" } @@ -497,8 +472,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-quaternary" } @@ -508,8 +483,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-primary" } @@ -519,8 +494,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-secondary" } @@ -530,8 +505,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-tertiary" } @@ -541,8 +516,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-quaternary" } @@ -552,8 +527,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-strong" } @@ -563,8 +538,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-base" } @@ -574,8 +549,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-soft" } @@ -585,8 +560,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "label-primary" } @@ -596,8 +571,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "label-secondary" } @@ -607,8 +582,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "label-tertiary" } @@ -618,8 +593,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "label-quaternary" } @@ -629,8 +604,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-primary" } @@ -640,8 +615,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-secondary" } @@ -651,8 +626,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-tertiary" } @@ -662,8 +637,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "fill-quaternary" } @@ -673,8 +648,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-strong" } @@ -684,8 +659,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-base" } @@ -695,8 +670,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "border-soft" } @@ -706,8 +681,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "label-primary" } @@ -717,8 +692,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "label-secondary" } @@ -728,8 +703,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "label-tertiary" } @@ -739,8 +714,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "label-quaternary" } @@ -750,8 +725,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-primary" } @@ -761,8 +736,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-secondary" } @@ -772,8 +747,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-tertiary" } @@ -783,8 +758,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "fill-quaternary" } @@ -794,8 +769,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-strong" } @@ -805,8 +780,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-base" } @@ -816,8 +791,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" }, "position": "border-soft" } @@ -827,8 +802,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "label-primary" } @@ -838,8 +813,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "label-secondary" } @@ -849,8 +824,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "label-tertiary" } @@ -860,8 +835,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "label-quaternary" } @@ -871,8 +846,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-primary" } @@ -882,8 +857,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-secondary" } @@ -893,8 +868,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-tertiary" } @@ -904,8 +879,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "fill-quaternary" } @@ -915,8 +890,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-strong" } @@ -926,8 +901,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-base" } @@ -937,8 +912,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" }, "position": "border-soft" } @@ -958,8 +933,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "focus-ring" } @@ -969,8 +944,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "focus-ring" } @@ -1001,8 +976,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "glow" } @@ -1012,8 +987,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" }, "position": "glow" } @@ -1077,8 +1052,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "fill-primary" } @@ -1098,8 +1073,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "label-primary" } @@ -1119,8 +1094,8 @@ "recipe": { "kind": "ladder", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" }, "position": "border-base" } @@ -1149,8 +1124,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "danger" + "kind": "family", + "key": "red" } } }, @@ -1159,8 +1134,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "warning" + "kind": "family", + "key": "orange" } } }, @@ -1169,8 +1144,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "success" + "kind": "family", + "key": "green" } } }, @@ -1179,8 +1154,8 @@ "recipe": { "kind": "pair-fill", "source": { - "kind": "sentiment", - "name": "info" + "kind": "family", + "key": "blue" } } }, diff --git a/crates/labcolors-wasm/tests/wasm_parity.rs b/crates/labcolors-wasm/tests/wasm_parity.rs index cfd25c17..c487fc0a 100644 --- a/crates/labcolors-wasm/tests/wasm_parity.rs +++ b/crates/labcolors-wasm/tests/wasm_parity.rs @@ -2,7 +2,7 @@ //! `resolve_named_set` it wraps, role for role, внутри одного wasm runtime. //! //! Run with `wasm-pack test --headless --chrome` (D1 default from the chapter). -//! The engine is agnostic (ADR-0001 PR-c): it has no built-in table, so parity +//! The engine is agnostic (ADR-0001): it has no built-in table, so parity //! is proven against a LOADED config (the frozen labui passport). Expectations //! are GENERATED from the core's own `resolve_named_set` inside the same wasm //! runtime — never hand-typed — so this test cannot drift from the engine and @@ -88,12 +88,6 @@ fn get_num(obj: &JsValue, key: &str) -> f64 { .unwrap_or_else(|| panic!("{key} must be a number")) } -fn get_bool(obj: &JsValue, key: &str) -> bool { - get_obj(obj, key) - .as_bool() - .unwrap_or_else(|| panic!("{key} must be a boolean")) -} - /// Read the `message` of a rejected `JsError`. A `JsError` crosses as a JS /// `Error` object, so the human text (carrying our stable code) is its /// `.message` property, not the value's own string form. @@ -389,18 +383,8 @@ fn assert_parity(passport: &str, bg_hex: &str, theme: &str) { get_num(&entry, "coreAchievedDj").to_bits(), g.core_achieved_dj().to_bits() ); - assert_eq!( - get_num(&entry, "achievedDj").to_bits(), - g.halo_achieved_dj().to_bits() - ); - assert_eq!( - get_bool(&entry, "degraded"), - matches!( - g.target_status(), - labcolors_core::GlowTargetStatus::ExactNoopUnreachable - | labcolors_core::GlowTargetStatus::LegacyUnreachable - ) - ); + assert!(!js_sys::Reflect::has(&entry, &JsValue::from_str("achievedDj")).unwrap()); + assert!(!js_sys::Reflect::has(&entry, &JsValue::from_str("degraded")).unwrap()); } // `Resolved` is `#[non_exhaustive]`: a future core variant must be // re-accounted here loudly, never masked. @@ -744,10 +728,9 @@ fn config_boundary_two_configs_diverge() { "brand": {"light": "#7C3AED", "dark": "#8B5CF6", "light_ic": "#5B21B6", "dark_ic": "#A78BFA"}, "neutral": { "anchors": {"light": "#FFFFFF", "mid": "#7A7A82", "dark": "#17171A"}, - "tint": {"ratio": 0.1, "target_mp": 6.1, "hue_stiffness": 9.0} + "tint": {"target_mp": 6.1, "hue_stiffness": 9.0} }, "palette": [], - "sentiments": {"categories": [], "hardness": 5.0, "chroma_fraction": 0.88}, "themes": [{"name": "light", "preset": "srgb"}], "roles": [ {"name": "accent-fill", "recipe": {"kind": "ladder", "source": {"kind": "brand"}, "position": "fill-primary"}}, diff --git a/docs/decisions/0001-config-boundary.md b/docs/decisions/0001-config-boundary.md index e6ac6730..c0e113b1 100644 --- a/docs/decisions/0001-config-boundary.md +++ b/docs/decisions/0001-config-boundary.md @@ -1,308 +1,88 @@ -# ADR-0001. Конфиг-граница: физика в движке, семантика в конфиге потребителя +# ADR-0001. Клиент владеет схемой, Core — физикой -Статус: принято (2026-07-02), внедрено — ядро `ThemeConfig` (#121), WASM -`loadConfig` (#122). Решение владельца об архитектуре зон — -2026-07-02: «цвета агностичны, чтобы ими пользовались разные ДС с разной -семантикой; каждое в рамках своей зоны; система для любого клиента студии: -взял → закастомил быстро → внедрил». +Статус: принято. -## Глоссарий +## Контекст -Отпечаток конфига (fingerprint) — ключ кэша; эталонная фиксация (golden) — -байт-в-байт пин эмиссии; стартовые значения первого кадра (baseline) — -до загрузки движка, против вспышки нестилизованного цвета (FOUC); -git-зависимость (git-dep) — зависимость на git-тег; сквозные тесты (e2e); -условия просмотра (VC, viewing conditions) — параметры CIECAM16-адаптации. +Lab Colors должен обслуживать разные дизайн-системы. Имена токенов, их смысл, +иерархия и состояния меняются от клиента к клиенту; цветовая математика, +композитинг и проверка ограничений — нет. -## Решение - -Движок не знает ни одного имени роли и ни одного брендового значения. -Всё, что меняется при смене клиента студии, — конфиг потребителя -(`ThemeConfig`); всё, что является законом восприятия/математикой/WCAG, — -физика ядра. Правило одного вопроса для каждой единицы: **«изменится ли это -при смене клиента?»** - -Уточнение планки: «физика» в смысле этого ADR распадается на GROUNDED -(закон с проверенной цитатой/выводом) и SSOT-TRACKED (задекларированная -величина без цитаты — ОТКРЫТАЯ научная проблема реестра, напр. -TINT_PERCEPTIBLE_MP_FLOOR, CUSP_HALF_WINDOW_DEG, HUE_DRIFT_PENALTY_SLOPE). -Обе категории живут в ядре и НЕ экспонируются конфигом; SSOT-TRACKED -закрывается научными задачами (реестр — docs/empirical-inventory.md), а не -объявлением. Это прямое следствие планки владельца: «каждая константа = -ссылка на измеренное ИЛИ вывод из геометрии; пробел = открытая проблема». - -Основание — инвентарь семантики ядра (2026-07-02, main@31643d9): единственный -load-bearing продакшн-hex движка — 10 якорей `Accent::anchor_hex` -(accent.rs:115-124); эмиссия — ровно 20 ролей из запечённого -`RoleTable::default()`. Слот `table_fingerprint` в кэш-ключе WASM (cache.rs) -с тех пор наполнен: PR-b вычисляет отпечаток загруженного конфига -(FNV-1a над канонической сериализацией DTO, `config_dto.rs`) и продевает его -в ключ через `loadConfig`. Встроенная дефолт-таблица остаётся на -`DEFAULT_TABLE_FINGERPRINT = 0` (конфига нет), а корректность при смене -конфига держит wholesale `clear()` кэша, не уникальность 64-битного отпечатка. - -## Схема ThemeConfig (сущность; полная спека — при реализации CH-02) - -- `brand.anchors` — пер-темная четвёрка hex `{light, dark, light_ic, dark_ic}` - (тип `ThemeAnchors` — определён в ladder.rs, config.rs использует его полем - `Brand.anchors`; hue выводит физика) — дефолта в ядре нет. -- `neutral.anchors {light, mid, dark}` (hex-тройка: конфиг несёт ИЗМЕРЕННОЕ, - движок выводит производное — 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 - входит лишь в вывод порога. -- `themes` — словарь `имя → пресет условий просмотра (VC)` из закрытого физического меню - (`srgb | dim | srgb-ic | dim-ic`, kebab-контракт JSON); произвольных VC-чисел - в конфиге нет. -- `roles` — имя → рецепт из физического меню (10 вариантов; `kind` в JSON — kebab): - `text-anchor(fraction, floor, hue?)` · `dj-anchor(light, dark)` · - `decorative-lc(magnitude)` (Lc-величина, стек теней) · - `ladder(source, position, floor?)` · `glow(source, step, decision_profile)` · - `pair-fill(source)` · `pair-label(source, fraction, floor)` · - `alpha-analog(of, alpha)` (композит-инверсия, alpha.rs) · - `material(source, tone_light, tone_dark, floor)` · `zero`. - `position` — ЗАКРЫТОЕ меню позиций лестницы (label / fill P–Q / border / - focus …); перечень зафиксирован приложением A. Рецепт `ladder` реализован - (`RoleSpec::Ladder`, `crate::ladder`) и поглотил акцентный GAP #59; как и - остальные рецепты, он опирается на существующий код ядра (`TextAnchor`, - `DjMagnitude`, `alpha.rs`). -- `aliases` — компонентные алиасы на существующие роли. - -Имена ролей — произвольные строки (`[a-z0-9-]+`). Каждая роль и каждый алиас -резервируют client-owned primary `--lab-{имя}` ещё до resolve; `Zero` сохраняет -это имя в metadata, хотя значения не эмитит. Многоключевые outcomes дополнительно -резервируют свой shape: Glow — `-core/-alpha`, Material — `-01/-02`. Поэтому -порядок JSON-писателей или фон не могут решить коллизию молча. Коллапс-контракт -исполняется схемой: в конфиге labui нет `static-*/inverted-*/on-*/mat-*` — роли -решаются от фактического фона. - -## API - -- Ядро: `ThemeConfig::compile_named_role_table(&self) -> Result` - (semantic.rs `NamedRoleTable`); `resolve_named_set(bg, &table, vc) -> - Result, ResolveSetError>` атомарно допускает весь - именованный набор. В `Ok` локальными могут быть только `unreachable` и - `unresolved`; остальные причины возвращают `Err` без частичного набора. -- WASM: `engine.load_config(json) -> fingerprint`; конфиг является состоянием - движка, а кэш успешных полных тем разделён по fingerprint. Ошибки не кэшируются. -- Ручки политики подтона (`target_mp`, `hue_stiffness`, `ratio`) и сентиментов - (`hardness`, `chroma_fraction`) экспонируются конфигом с сегодняшними - значениями labui как дефолтами; каждая остаётся SSOT-TRACKED в науч-реестре. - Пороги решателя вроде `IC_DECORATIVE_FLOOR_MIN` конфигом НЕ экспонированы — - остаются физикой ядра. -- **Валидатор конфига обязателен**: каждая экспонируемая ручка несёт пределы - (bounds по образцу реестра, напр. «0.030 ≤ C0 ≤ 0.050»); значение вне - пределов = `ConfigError`, не тихое принятие — клиент не может молча сломать - различимость сентиментов или WCAG-полы. Конкретные пределы фиксирует PR-a - из реестра + перцептивных инвариантов (тесты различимости остаются гейтом). - `HUE_DRIFT_PENALTY_SLOPE = 0.15` (бывший незадекларированный поле-литерал - slope, scale.rs) НЕ экспонируется; этим же PR вынесен в именованную - константу и задекларирован в реестре (docs/empirical-inventory.md, строка 37, - SSOT-TRACKED) — вывод или обоснование датасетом = научная задача роадмапа. +Если Core анализирует имя токена или содержит встроенный словарь продукта, +клиентская семантика превращается в скрытую физическую политику. Такой граф +невозможно безопасно переиспользовать. -## Судьба golden-lock - -240-строчный owner-approved golden (semantic.rs, тест -`resolve_set_golden_hex_is_byte_for_byte_stable`, const `GOLDEN` ≈ строка 3785) -пинит ПАРУ «физика × семантика labui» — после конфиг-границы он переезжает в labui как -пин ЕГО конфига на живом движке. В ядре остаётся физический golden -(golden_tests.rs: CIECAM16 против colour-science, Lc против apca-w3). -До переезда: тест ядра держит текущую эмиссию против labui-конфиг-фикстуры -байт-в-байт по ВСЕМ 20 эмитируемым ролям (не только 17 потребляемым labui — -иначе три роли уехали бы без золотой защиты); golden-совместимость доказуема -на каждом шаге. - -## Путь миграции - -1. **[сделано, #121]** lab-colors PR-a: `ThemeConfig::compile_named_role_table` - + меню рецептов (ladder акцентов поглотил issue #59; alpha_analog на - alpha.rs) + labui-конфиг-фикстура с байт-в-байт тестом против эмиссии. -2. **[сделано, #122]** lab-colors PR-b: WASM `loadConfig` + fingerprint-кэш - + TS-типы. -3. **[открыто]** Поезд labui (ОДИН PR, решение владельца «не дробить»): пакет - конфига + провайдер (FOUC-baseline + adaptTheme) + замена colors-stub на - git-dep + коллапс + миграция 27 компонентов + гарды против эмиссии конфига - + полный e2e + переезд золота. -4. **[сделано, PR-c, ломающий]** Пост-поезд, lab-colors PR-c: встроенная витрина - (`RoleTable::default()`, `enum Role`/`RoleTable`/`resolve_set`, энумы - `Accent`/`Sentiment` с их брендовыми hex) убрана из ПРОДАКШН-API — не удалена - физически, а переведена под `#[cfg(test)]`: она остаётся байт-идентичным - ОРАКУЛОМ именованного пути (тесты `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 (ломающая). - Grep-гейт чистоты — `tests/agnostic_production_surface.rs`: продакшн-`src` - (после снятия `#[cfg(test)]` и комментариев) не несёт ни брендовых якорных hex, - ни определений витринных типов; гейт с живым RED-proof. - -## Последствия - -- Агностичность доказуема исполнением: второй конфиг (иные имена/якоря/темы) - резолвится без правки кода — демо входит в exit-criteria поезда. -- Ломающие изменения ядра отложены за зелёный поезд (шаг 4) — ни одного дня - без работающей пары. -- Двойной enum тем унифицирован, затем (C5.1) fixed-enum вырезан целиком: - канонический словарь тем принадлежит клиентскому конфигу (`themes` — имя → - `VcPreset`), встроенных имён у движка нет. -- Нейминг теней канонизирует конфиг labui (`fx-shadow-*`). Тени уже мигрированы - на полупрозрачные ladder-позиции (`Shadow*` с пер-темными α, тёмный якорь - нейтрали `#101012` в обеих темах); открыта лишь научная подзадача - «противоположная сторона тени» (на тёмной базе тинт ≈ фон ⇒ dJ вырожден). - -## Приложение A. Закрытое меню позиций лестницы - -Фиксирует `position` рецепта `ladder(source, position)` (объявлено в разделе -API). Меню закрыто: движок эмитит полупрозрачный тинт НАПРЯМУЮ как -`oklch(L% C H / α)` (закон лестницы labui — композитит браузер), где тинт = -якорь источника ПО ТЕМЕ, а α — данные позиции ниже. Резолв несёт эту пару -(тинт, α) + солид-композит на фоне резолва для честного замера контраста -(фаза 1 AA меряет композит). Реализация — `crate::ladder` (`LadderPosition`), -`crate::semantic` (`RoleSpec::Ladder` → `Resolved::Translucent`). +## Решение -### Заземление меню +Клиент объявляет собственные opaque ID и связи между ними. Core: -Снято 2026-07-02 из живого потребления labui: стаб `packages/colors-stub/ -contract.css` (несёт Figma-значения дословно) + `reference/labui-accent- -primitives.md` §2 (пер-темные якоря) + переменные Figma «🧪Lab UI (v.1)» -(`Accent/Derivable/*`, обход через figma-console). -Акцентная лестница устроена КАК нейтральная: один тинт-якорь × закрытая рампа -альф Figma `Accent/Derivable//@NN`. Единый паттерн проверен на -brand/danger/info/success. +- не интерпретирует строки ID; +- не содержит брендовых цветов или встроенного словаря клиентских ID; +- сохраняет авторские якоря как входные данные; +- компилирует поддерживаемые декларации в `NamedRoleTable`; +- резолвит таблицу в текущем контексте и возвращает измеримый результат либо + типизированную причину недостижимости. -### Позиции и ПЕР-ТЕМНЫЕ альфы (провенанс — стаб labui `contract.css`) +Переименование ID вместе со всеми ссылками не меняет физический результат. -Альфы — данные, снятые ПОСТРОЧНО из стаба labui `packages/colors-stub/contract.css` -(light-scope `[data-theme="light"]` и dark-scope `[data-theme="dark"]`, 2026-07-02), -НЕ POLICY-константы: провенанс держит doc-строка `crate::ladder` + тест -`position_alpha_pairs_match_grounded_stub` (нетавтологичный пин из стаба). +Текущая версия ещё содержит закрытое физическое меню рецептов и позиций +лестницы. Оно не анализирует client ID, но и не является целевой произвольной +топологией: новые продуктовые потребности не должны расширять его очередным +семантически названным special case. -Альфа — ПЕР-ТЕМНАЯ пара `(light, dark)`. Всего 25 позиций (`LadderPosition::ALL`, -ladder.rs); ниже сперва акцентные + скелетон (15), затем нейтральная рампа и тени -(10). У акцентных позиций пара равна (меняется только тинт по теме — у Figma ещё -нет отдельных dark-рампов альф акцентов, стаб держит пары равными). Разные -light/dark несут `SkeletonBase`, нейтральная рампа (`NeutralFill*`, -`NeutralBorder*`) и стек теней (`Shadow*`). IC-темы наследуют альфу базовой темы -(стаб не несёт ic-скоупов; IC отличается только тинтом). +## JSON-схема -| позиция | α (light) | α (dark) | стаб @NN | -|---------------------|-----------|----------|--------------| -| `LabelPrimary` | 1.0 | 1.0 | солид | -| `LabelSecondary` | 0.722 | 0.722 | @72 | -| `LabelTertiary` | 0.522 | 0.522 | @52 | -| `LabelQuaternary` | 0.322 | 0.322 | @32 | -| `FillPrimary` | 0.122 | 0.122 | @12 | -| `FillSecondary` | 0.078 | 0.078 | @8 | -| `FillTertiary` | 0.039 | 0.039 | @4 | -| `FillQuaternary` | 0.02 | 0.02 | @2 | -| `BorderBase` | 0.2 | 0.2 | @20 | -| `BorderSoft` | 0.122 | 0.122 | @12 | -| `BorderStrong` | 1.0 | 1.0 | солид | -| `FocusRing` | 1.0 | 1.0 | солид | -| `Glow` | 0.522 | 0.522 | @52 | -| `SkeletonBase` | 0.078 | 0.122 | @8 / @12 | -| `SkeletonHighlight` | 0.039 | 0.039 | @4 | +`ThemeConfig` содержит: -Остальные 10 позиций (нейтральная рампа и тени) несут ПЕР-ТЕМНЫЕ пары (light≠dark); -альфы — `LadderPosition::alpha_pair` (ladder.rs): +- `brand` — четыре авторских якоря для объявленных контекстов; +- `neutral` — якоря нейтрального семейства и его физические параметры; +- `palette` — семейства `{ key, anchors }`, где `key` является opaque ID; +- `themes` — клиентский ключ темы и закрытый физический VC-пресет; +- `roles` — клиентский ключ и recipe из закрытого физического меню; +- `aliases` — клиентские ссылки на роли. -| позиция (нейтраль / тень) | α (light) | α (dark) | -|---------------------------|-----------|----------| -| `NeutralFillPrimary` | 0.2 | 0.361 | -| `NeutralFillSecondary` | 0.161 | 0.322 | -| `NeutralFillTertiary` | 0.122 | 0.239 | -| `NeutralFillQuaternary` | 0.078 | 0.161 | -| `NeutralBorderBase` | 0.161 | 0.2 | -| `NeutralBorderSoft` | 0.078 | 0.122 | -| `ShadowMinor` | 0.012 | 0.02 | -| `ShadowAmbient` | 0.02 | 0.039 | -| `ShadowPenumbra` | 0.039 | 0.122 | -| `ShadowMajor` | 0.122 | 0.2 | +Физические источники recipe: -### Источник тинта (`source`) +- `brand`; +- `family { key }`; +- `neutral { pick }`. -- `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`). -- `neutral(pick)` — НЕЙТРАЛЬНЫЙ тинт из `neutral.anchors` (НЕ семейство палитры). - Заземление — стаб: `Neutral/Derivable` тинтуется краями нейтральной шкалы. - `pick` — 5 вариантов (`NeutralPick`, config.rs): `Mid` (`#787880`) — скелетон/ - нейтральные тинты; `Light` (`#FFFFFF`) — нейтральное свечение; `Dark` (`#101012`) - — нейтральный фокус; `Edge` (контурный край, контрастный теме — `neutral.edge`) — - кольцо фокуса на инвертированной поверхности; `Inverted` (инвертированный средний - тон — `neutral.inverted`) — свечение на инвертированной поверхности. +Recipe не кодирует назначение цвета. Например, строка `danger` допустима как +имя роли клиента, но не имеет особого поведения в Core. -Пер-темность якорей (а не «светлый + вывод») — из reference §2: тёмный/IC-варианты -Figma-примитивов замерены, не выведены. +Все объектные уровни схемы строгие: неизвестное поле, неизвестный enum-вариант +или неизвестная ссылка дают ошибку загрузки. Опечатка не может стать тихим +no-op. Поддерживаемый JSON сериализуется обратно без потерь. -### Значенческая сверка (класс «имя без значения») +## API и атомарность -`representative_roles_match_stub_values_light_and_dark` сверяет значение -представителя КАЖДОЙ группы со строкой стаба ПОБАЙТНО в light И dark. Формат -сверки — внутренний нормализованный `rgba(тинт, α)` core-теста против rgb-стаба -(это НЕ форма CSS-эмиссии наружу — та `oklch(L% C H / α)`, см. WASM-границу): -сверяется пара (тинт, α), а не нотация. Закрывает класс дефекта «роль в diff по -имени, но эмитит не то значение». Из общего свипа вынесены: `*-info-*` (Info -намеренно смещён сентимент-солвером от синего бренда — см. ниже); края нейтрали -`fx-focus-ring-neutral` / `fx-glow-inverted` — заземлены пиками `Edge` / -`Inverted` (из `neutral.edge` / `neutral.inverted`, а не из тройки -`{light, mid, dark}`, config/tests.rs) и держатся отдельным пином; `fill-neutral` -— PROVISIONAL-литерал. +Ядро: -### Деривационная идентичность сентимента +```text +ThemeConfig::compile_named_role_table() +resolve_named_set(background, table, viewing_conditions) +``` -При бренде 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`, не спрятано и не подогнано. +WASM-граница: -### Альфа-аналог (`alpha_analog(of, alpha)`) +```text +engine.load_config(json) -> fingerprint +engine.resolve_theme(background, client_theme_key) +``` -Отдельный рецепт (#119): солид-цель `of` (по теме) ФИКСИРОВАНА, тинт выводится -композит-инверсией (`crate::alpha::resolve_alpha_analog`) над фоном резолва. -Композит фактической пары равен солиду по построению (наследует контраст). -Фактическая α поднимается до `α_min`, если запрошенная неразрешима в гамуте -(кламп тинта запрещён — двигается прозрачность, не цвет). Применяется для ролей, -где нужен полупрозрачный аналог УЖЕ РЕШЁННОГО контраст-солида; прямые `-tinted` -заливки labui эмитятся как `ladder(..., FillPrimary)` (тинт-якорь напрямую), -т.к. насыщенный солид над белым дал бы `α_min≈1` и перестал быть полупрозрачным. +`load_config` сначала полностью парсит и компилирует новый контракт. Только +после успешного preflight он заменяет состояние и очищает кэш. Ошибка оставляет +прежнее состояние и его кэш нетронутыми. -### Категориальные ручки: `preferred_side` (мёртв для паспорта) vs `hardness` (жив) +Fingerprint вычисляется из канонической сериализации поддерживаемой схемы. Он +служит идентичностью конфига и частью ключа кэша; корректность смены конфига +дополнительно обеспечивается полным очищением кэша. -Диагноз 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-асимптоте). - Ручка честная и функциональная, документируется как живая, не мёртвая. +- Lab UI хранится как клиентская fixture, а не как знание Core. +- Новый клиент подключается данными, без правки физического движка. +- Новая физическая возможность входит через общий graph/constraint-контракт, а + не через распознавание имени токена или новый ролевой special recipe. +- Изменение клиентского смысла не требует миграции Core. +- Неизвестный ввод отклоняется явно; silent fallback отсутствует. diff --git a/docs/decisions/0003-hk-scope.md b/docs/decisions/0003-hk-scope.md index fa316ce8..fd4a589f 100644 --- a/docs/decisions/0003-hk-scope.md +++ b/docs/decisions/0003-hk-scope.md @@ -1,32 +1,31 @@ -# ADR-0003. H-K не входит в ось читаемости +# ADR-0003. H-K не входит в Ys candidate score Статус: **реализовано**. ## Решение -Ось читаемости Lab Colors применяет контрастную кривую к display-referred -относительной люминансе `Ys`. Поправка Гельмгольца–Кольрауша (H-K) остаётся -координатой воспринимаемой яркости и не подставляется вместо `Ys` при проверке -текстового или UI-контраста. +Замороженная SAPC-shaped кривая получает display-referred относительную +люминансу `Ys`. Поправка Гельмгольца–Кольрауша (H-K) остаётся appearance- +коррелятом яркости и не подставляется вместо `Ys` в этот численный путь. Это разделение доменов, а не выбор между «цветом» и «серым»: -- `solve` и runtime recheck измеряют readability-контраст по `Ys` уже +- `solve` и runtime recheck вычисляют знаковую кандидатную оценку по `Ys` уже квантованных display-цветов; -- `lpc_readability_ys` является низкоуровневым reference-входом той же формулы; -- `lpc`/`lpc_lcs` сохраняют H-K как appearance-метрику; -- brightness-matching сентимента сохраняет H-K; -- `pair_side` явно считает белую сторону в `Ys`, а appearance-оценку чернильной - стороны отдельно. Ни одна из этих координат не выдаётся за универсальную - модель разборчивости. +- эта оценка остаётся внутренней координатой solver/recheck, а не публичным + LPC/readability verdict; +- H-K сохраняется во внутренней appearance-математике и её характеризации; +- brightness-matching семейных цветов сохраняет H-K; +- внутренний `pair_side` использует `Ys`-кривую и H-K appearance-координату в + разных членах своей переходной эвристики. Ни одна из них не выдаётся за + универсальную модель разборчивости. ## Почему Перенесённые в `contrast_core` константы SAPC-8 определены над экранной люминансой. H-K, напротив, оценивает рост воспринимаемой яркости насыщенного -стимула при той же фотометрической люминансе. Подстановка H-K-яркости в -readability-кривую смешивает разные величины и может изменить выбранную -полярность на насыщенном фоне. +стимула при той же фотометрической люминансе. Подстановка H-K-яркости меняет +домен входа опубликованной формулы и смешивает разные величины. Разделение согласуется с двумя независимыми границами применимости: @@ -35,9 +34,10 @@ readability-кривую смешивает разные величины и м - H-K-модель описывает perceived brightness, а не читаемость текста (Hellwig, Stolitzka, Fairchild, 2022). -Это не доказывает универсальную читаемость. Размер, начертание, пространственная -частота, адаптация, поле фона и задача наблюдателя принадлежат более высокому -профилю LPC/applicability и не выводятся из одной пары цветов. +Эти источники обосновывают разделение доменов, но не валидируют текущую кривую +как LPC или модель читаемости. Размер, начертание, пространственная частота, +адаптация, поле фона и задача наблюдателя принадлежат отдельному versioned +evaluator/applicability profile. ## Исполняемая граница @@ -47,20 +47,23 @@ Production-путь закреплён кодом и тестами: измеряют цель в `Ys`; - `semantic::measure_contrast` и `recheck_against*` используют ту же display-люминансу; -- `lpc_readability_ys` принимает уже проверенные finite sRGB-каналы в `[0, 1]` - и не является parser boundary; +- низкоуровневый вход кривой принимает только уже проверенные finite + display-люминансы и не является публичной parser boundary; - H-K-математика остаётся доступна только там, где контракт явно относится к appearance/brightness. +Публичного scalar-LPC API до versioned evaluator-профиля нет. Поле `lc`, которое +пока проходит через resolver и wire, — знаковая кандидатная оценка по `Ys` из +замороженной SAPC-shaped кривой, а не самостоятельная модель читаемости. + Численные пороги и коэффициенты не определяются этим ADR. Их provenance и executable gates принадлежат соответствующим формулам и inventory. ## Следствия -- readability-координата не зависит от viewing-condition компенсации яркости; +- Ys candidate score не зависит от viewing-condition компенсации яркости; - H-K не удаляется и не маскируется под APCA/LPC; -- одинаковая display-пара получает одну readability-оценку в solver и runtime - recheck; +- одинаковая display-пара получает один candidate score в solver и runtime recheck; - изменение этой границы требует отдельного versioned evidence profile и дифференциальной проверки всего пути resolve → emit → recheck. diff --git a/docs/decisions/0004-finite-alpha-glow-reference.md b/docs/decisions/0004-finite-alpha-glow-reference.md index ae080b92..c67ddbac 100644 --- a/docs/decisions/0004-finite-alpha-glow-reference.md +++ b/docs/decisions/0004-finite-alpha-glow-reference.md @@ -208,13 +208,15 @@ WASM/TypeScript: - каждый Glow-рецепт обязан явно нести `decision_profile`; отсутствие и неизвестная строка отвергаются при `loadConfig`, а профиль входит в fingerprint; - determinate mocks обязаны добавить `alphaCss`, `constraintLayer`, `targetDj`, - `targetStatus`, оба composite, оба `achievedDj`, а также раздельные + `targetStatus`, оба composite и явно названные `haloAchievedDj` / + `coreAchievedDj`, а также раздельные `compositeProfile`, `compositeGuarantee`, `layerRecipeProfile`, `appearanceDiagnosticProfile`, nullable `selectionDiagnosticProfile`, `decisionProfile`, `decisionGuarantee`; - exhaustive union обязан обрабатывать `kind: "glow-indeterminate"`; этот terminal outcome несёт site/reason/bounds, но не создаёт halo/core/alpha vars; -- legacy `achievedDj` и `degraded` остаются точными aliases на период миграции; +- дублирующие `achievedDj` и `degraded` отсутствуют: измерения и typed status + имеют по одному источнику истины; - изменение строк alpha и соответствующих golden ожидаемо. При выпуске breaking-версии по #259 общий conformance pack должен быть повышен @@ -273,4 +275,4 @@ legacy-исход всё ещё выглядел как «determinate со сл полями, разрешающими минт bounded evidence. - Pack 4.0.0 добавляет отдельное `wcag22`-семейство. Это terminal standard certificate для явно объявленного criterion, а не новая Glow-эвристика и не - замена LPC-перцептивной цели. + замена переходной Ys candidate-score цели. diff --git a/docs/empirical-inventory.md b/docs/empirical-inventory.md index 1e7ca089..75f934a2 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 and numerical identities (`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). @@ -15,21 +15,15 @@ Terminal classes описывают, почему число может нахо - **(c) INTERVAL-INSENSITIVE** — output is proven invariant across the declared interval; this is a behavioural proof, not provenance of the exact value; - **(e) DESIGN-CHOICE** — an explicit policy with a legal domain and sensitivity evidence, never presented as a universal perceptual law. -> **ADR-0001 PR-c (agnostic core):** the built-in `RoleTable::default` showcase and its 16 measured anchors (`FILL_*_DJ`, `BORDER_*_DJ`, `SHADOW_*_LC`, `TINT_TARGET_MP`, `LABEL_*_FRACTION`, `SEPARATOR_DECORATIVE_LC`) moved to `#[cfg(test)]` test-support — the shipped engine bakes no design system, so they are no longer production perceptual-policy constants and their rows were removed. Their measured values now live in the frozen labui passport (`crates/labcolors-wasm/tests/data/labui.config.json`) and in consumer configs; the Figma-measurement provenance is preserved in git history. Remaining row numbers keep their original join-keys (gaps are intentional; row# is a primary key, not a sequence). +> **ADR-0001 (agnostic core):** the built-in `RoleTable::default` showcase and its client calibrations (`FILL_*_DJ`, `BORDER_*_DJ`, `SHADOW_*_LC`, `TINT_TARGET_MP`, `NEUTRAL_HUE_DEG`, `TINT_HUE_STIFFNESS`, `LABEL_*_FRACTION`, `SEPARATOR_DECORATIVE_LC`) moved to `#[cfg(test)]` test-support — the shipped engine bakes no design system, so they are no longer production perceptual-policy constants and their rows were removed. The role measurements belong to `reference/labui-figma-structure.md`; family source anchors belong to `reference/labui-accent-primitives.md`; the executable client snapshot is `crates/labcolors-wasm/tests/data/labui.config.json`. Consumer configs may declare their own values. Remaining row numbers keep their original join-keys (gaps are intentional; row# is a primary key, not a sequence). | row# | name | value | module | marker | rationale | |------|------|-------|--------|--------|-----------| | 1 | `DECORATIVE_FLOOR_MIN` | `7.5` | `semantic.rs` | SSOT-TRACKED | **(a) DERIVED** = `MODEL_LC_FLOOR` (7.3) + `QUANT_GUARD` (0.2). Модельный минимум ненулевого контраста решателя = (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE = (0.1 − 0.027) × 100 = **7.3** — алгебраическая идентичность из GROUNDED APCA `0.0.98G-4g` клипа (полярности симметричны, `LO_WOB_OFFSET == LO_BOW_OFFSET`, иначе floor = минимум по полярностям). Ниже 7.3 решатель упирается в порог клипа и возвращает нулевой контраст (issue #44); `QUANT_GUARD` держит пол строго выше. Значение оставлено литералом 7.5 ради байт-идентичности (7.3 + 0.2 в f64 отличается от 7.5 на ~1e-15); идентичность 7.5 == MODEL_LC_FLOOR + QUANT_GUARD закреплена компайл-тайм `const _`-проверкой и `decorative_floor_is_model_floor_plus_guard`. `MODEL_LC_FLOOR` — отдельная (a)-строка 54 (класс DERIVED, значение 7.3). Скан `no_pair_emits_contrast_below_model_floor` подтверждает: ни одна квантованная пара не эмитит контраст в (0, 7.3). | -| 12 | `NEUTRAL_HUE_DEG` | `286.0` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — ИЗМЕРЕННЫЙ якорь дизайна: Oklab-оттенок семейства нейтралей labui (#101012 → 285.97°, #3C3C43 → 285.78°, #787880 → 286.01°). **Волна 2:** легальный диапазон = измеренный разброс **[285.78°, 286.01°]** (0.23°); эмитируемый тинт-цвет **байт-инвариантен (ΔE_ok=0)** по всему разбросу (лок `neutral_hue_emits_byte_invariant_across_measured_family_spread`), даже грубая ошибка ±20° даёт лишь ΔE_ok≈0.0114 (~1 JND). Оттенок ВСЁ ЖЕ материален (питает `cusp_attracted_hue` как канонический, в режиме пиннинга →выход ~1:1) — потому честный (e), не (c). Протокол: пере-замерить Oklab-оттенок семейства нейтралей потребителя. | -| 13 | `NEUTRAL_TINT_RATIO` | `0.10` | `semantic.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE** — доля хромы нейтрального подтона; ставит `text-primary` на белом в холодном near-чёрном семействе `#101012`, а не в чистый серый. **Волна 2:** genuine свободная ручка; blast radius — ТОЛЬКО opt-in v1-политика [`flat_neutral_tint`] (дефолт `Curve` не использует). Легальный диапазон **[0,1]** (валидатор). Sensitivity: свип [0,0.20] → max ΔE_ok≈**0.0288** (>1 JND, материальна — прямо масштабирует хрому) → честный (e), не (c) (лок `neutral_tint_ratio_sensitivity_is_bounded`). Протокол: поднимать до перцептивного потолка «считывается как цвет». | -| 15 | `TINT_HUE_STIFFNESS` | `9.0` | `semantic.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-классификация Волны 2, 2026-07-08; ранее (e)) — жёсткость прижатия оттенка к каспу. **Замер `cusp_attracted_hue`** на светлотной сетке [0.05,0.95]: выше измеренного **порога пиннинга ≈0.36** штраф `stiffness/100·drift` подавляет весь выигрыш хромы каспа, argmax встаёт РОВНО на канонический оттенок (drift=0). Отклонение эмитируемого оттенка по полосе жёсткости [1,100] = **0.000000°** (байт-инвариант выхода); при `stiffness→0` оттенок уходит до края окна ±40° (материален НИЖЕ режима). Дефолт `9.0` = **25×** порога — точное значение доказуемо нематериально для выхода в своём режиме. Лок `stiffness_pins_hue_to_canonical_above_threshold`. Легальный конфиг `hue_stiffness ≥ 0`; режим пиннинга `[≈0.36, ∞)` (ниже — (e)-ручка потребителя). | -| 16 | `TINT_PERCEPTIBLE_MP_FLOOR` | `1.5` | `semantic.rs` | SSOT-TRACKED | **(c) INTERVAL-INSENSITIVE** (ре-аудит `science/reclassify-e-buckets`, 2026-07-07; ранее (e)) — Порог перцептируемости в CAM16-UCS M'; ниже него undertone читается мёртвой серой зоной, а не ощущаемым тоном. Замер (дефолтный VC): порог 1.5 стоит вплотную ПОД потолком ахроматического M'-шума серых (максимум — у белого, ≈1.53), тест `derivator_locks::tint_floor_tracks_achromatic_mp_noise_ceiling`. **Экспозиция:** 0.07% гаммы (`exposure_tint_perceptible_mp_floor`) — самая низкая среди всех трекаемых констант; точное значение доказуемо не меняет классификацию «ощущаемый тон / мёртвая серость» почти нигде во входном пространстве. | | 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) не применим. | -| 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 | **(e) DESIGN-CHOICE / COMPATIBILITY POLICY** — `1.75` заморожено как база совместимости прежней эмиссии; это терминальный провенанс shipped-значения, не универсальный закон. **НАУЧНАЯ ЗАМЕНА: OPEN** — равные шаги Oklab, кодированного sRGB, CAM16 J′ с окружением и прямое прочтение метрики Уиттла опровергнуты по магнитуде/вырождению. #219/#261 могут исследовать человеко-ориентированную замену; generic fact-only pipeline от неё не зависит. Свип [1.3,2.2] материален (max ΔE_ok≈0.0466). | -| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | `1.5` | `neutral.rs` | SSOT-TRACKED | **(e) DESIGN-CHOICE / COMPATIBILITY POLICY** — `1.5` заморожено как база совместимости прежней эмиссии; это терминальный провенанс shipped-значения, не универсальный закон. **НАУЧНАЯ ЗАМЕНА: OPEN** — Oklab/sRGB/CAM16-J′ и прямое прочтение метрики Уиттла дают большой промах; последнее уходит от сингулярности около чёрного вместо объяснения прогрессии. #219/#261 могут исследовать человеко-ориентированную замену; generic fact-only pipeline от неё не зависит. Свип [1.2,1.9] материален (max ΔE_ok≈0.0325). | -| 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`). | +| 22 | `NEUTRAL_DEFAULT_GAMMA_LIGHT` | `1.75` | `neutral.rs` | SSOT-TRACKED | **(e) DECLARED ENGINE CALIBRATION** — внутренняя форма генератора, не public knob и не универсальный закон. Равные шаги Oklab, кодированного sRGB, CAM16 J′ с окружением и прямое прочтение метрики Уиттла опровергнуты по магнитуде/вырождению. Свип [1.3,2.2] материален (max ΔE_ok≈0.0466). | +| 23 | `NEUTRAL_DEFAULT_GAMMA_DARK` | `1.5` | `neutral.rs` | SSOT-TRACKED | **(e) DECLARED ENGINE CALIBRATION** — внутренняя форма генератора, не public knob и не универсальный закон. Oklab/sRGB/CAM16-J′ и прямое прочтение метрики Уиттла дают большой промах; последнее уходит от сингулярности около чёрного вместо объяснения прогрессии. Свип [1.2,1.9] материален (max ΔE_ok≈0.0325). | +| 24 | `NEUTRAL_DEFAULT_CHROMA_PEAK_T` | `0.35` | `neutral.rs` | SSOT-TRACKED | **(e) DECLARED ENGINE CALIBRATION** — внутреннее значение в `(0,0.5]`, проверяется инвариантом и никогда молча не клампится. Hue-зависимая позиция светлотного cusp'а остаётся исследовательским кандидатом, а не доказанным законом нейтралей. 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. | @@ -43,24 +37,12 @@ Terminal classes описывают, почему число может нахо | 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) запрещает научная таксономия репозитория). Робастность доказана: хроматические якоря (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`), не эта константа. | +| 52 | `PAIR_CROSSOVER_Y` | `0.341955` | `pair.rs` | SSOT-TRACKED | **(a) DERIVED** — Y-порог кроссовера переходной pair-эвристики. **Вывод:** корень равенства модулей двух ветвей замороженной SAPC-shaped кривой для чёрного и белого foreground; бисекция на `[0.2, 0.6]` даёт **0.341955**. Это математическое свойство frozen Ys candidate-score функции, не LPC/readability optimum и не психофизический закон. Пиннится ре-деривацией `pair_crossover_equals_measured_core_polarity_flip`. Значение лежит внутри пустого интервала 10 Figma-якорей `(0.246, 0.423)`, поэтому смена прежнего 0.30 байт-идентична на этом наборе; экспозиция вне набора — 21.69% (`exposure_pair_crossover`). H-K в этот порог не входит (ADR-0003). Сосед `WHITE_WINS_Y = 0.179` — отдельная граница двойной законности WCAG. | | 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. | - -## Sentiment-заметки - -Строки S-* относятся к `sentiment.rs`. - -| id | name | value | module | status | rationale | -|--------|------|-------|--------|--------|-----------| -| 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 однотельного закона). | diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 4462230c..cbefa289 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -1,795 +1,202 @@ -# Lab Colors: whitepaper +# Архитектура Lab Colors -Технический документ системы lab-colors: перцептуальная модель, семантика LCS, -математика решателя и режим эмпирической дисциплины. Документ следует правилу -источника: **каждое численное утверждение сопровождается путём файла и символом**, -из которого оно взято; чисел без источника здесь нет. Статусы констант — только -из [`docs/empirical-inventory.md`](empirical-inventory.md) и `SSOT-TRACKED`-маркеров -в коде; где значение — калибровка, а не измерение, это сказано явно. +Этот документ объясняет устройство продукта. Публичное использование описано в +корневом [`README`](../README.md) и в +[`packages/colors/README.md`](../packages/colors/README.md). Численные доказательства +принадлежат исполняемым тестам и conformance-артефактам, а не этому тексту. -Содержание: +## Модель продукта -1. [Фундамент: зачем перцептуальная модель](#1-фундамент-зачем-перцептуальная-модель) -2. [LCS: семантика и контрактная модель](#2-lcs-семантика-и-контрактная-модель) -3. [Математика: солвер, констрейнты, полы, квантование](#3-математика-солвер-констрейнты-полы-квантование) -4. [Эмпирика: инвентарь констант и статусы калибровки](#4-эмпирика-инвентарь-констант-и-статусы-калибровки) -5. [Ограничения и не-цели](#5-ограничения-и-не-цели) +Lab Colors — контекстный компилятор и runtime-resolver цветовых токенов. +Целевая модель: ---- - -## 1. Фундамент: зачем перцептуальная модель - -### 1.1. Проблема - -Инструментальные шкалы (HSL lightness, «сырой» Oklab L) не согласованы с -восприятием ([`README.md`](../README.md), «Проблема»): - -- серый `#808080` не воспринимается «половиной» между чёрным и белым; -- синий и жёлтый одинаковой фотометрической яркости воспринимаются по-разному - (эффект Гельмгольца-Кольрауша); -- один и тот же стимул в светлом и тёмном окружении выглядит по-разному - (адаптация зрения). +```text +client-owned program +→ strict parse + graph compilation +→ resolve occurrences against local context +→ final emission recheck + typed evidence +→ runtime invalidation + selective re-resolve +``` -Задача дизайн-системы — выразить требуемые отношения цвета и контекста, а затем -вычислить допустимый физический результат. Текущий код хранит представления -CAM16-UCS и Oklab и содержит несколько возможных путей вычисления контраста; он -ещё не реализует допущенные контракты **Labpics Colors Space (LCS)** и **Labpics -Perceptual Contrast (LPC)** и не обещает универсальный решатель воспринимаемого -контраста. +Клиент один раз объявляет словарь и связи. Физическое значение производного +цвета вычисляется для текущего локального контекста. Имена токенов не участвуют +в математике. -### 1.2. Выбор пространства +Текущая реализация ещё компилирует закрытый `ThemeConfig` в +`NamedRoleTable`. Это переходная граница, а не модель продукта: целевой IR +разделяет `Color`, `Paint`, `Surface`, `Occurrence` и типизированные constraints. +Новую возможность нельзя добавлять как очередной вариант recipe enum. -Текущее переходное представление `LcsColor` сочетает CAM16-UCS и Oklab. Это не -завершённое определение LCS и не заявка на независимое соответствие CIE 248:2022 -([`README.md`](../README.md), «LCS — Labpics Colors Space»): +## Владение -**`J'` и `M'` — текущие координаты CAM16-UCS, а не завершённые оси LCS/LPC.** -CIECAM16 даёт корреляты `J` и `M`; текущее представление применяет опубликованный -рескейлинг CAM16-UCS (Li et al. 2017, DOI -[10.1002/col.22131](https://doi.org/10.1002/col.22131)): +Клиент владеет: -```text -J' = 1.7 × J / (1 + 0.007 × J) -M' = ln(1 + 0.0228 × M) / 0.0228 -``` +- именами токенов, тем и состояний; +- семантическими категориями и алиасами; +- точными цветовыми якорями; +- тем, какие роли и поверхности связаны; +- выбором нормативного критерия и типографического контекста. + +Core владеет: -Формулы — [`spaces/cam16.rs`](../crates/labcolors-core/src/spaces/cam16.rs) -(`ucs_j` / `ucs_m`); применяет их [`lcs.rs`](../crates/labcolors-core/src/lcs.rs). -Формулы задают числовое преобразование координат. Они не объявляют `J'` -универсальной шкалой воспринимаемой яркости, а `M'` или хранимое -`s = M' / (J' + 1)` — универсальной шкалой ощущаемой цветности. `s` является -только внутренней репараметризацией и не равно корреляту насыщенности CAM16. +- цветовыми преобразованиями и gamut mapping; +- композитингом и построением производных цветов; +- контрастными и appearance-вычислениями; +- конечной эмиссией и повторной проверкой; +- типизированными результатами, ошибками и сертификатами. -**Два оттенка — переходные входы реализации, не закон LCS.** Для текущей -интерполяции используется Oklab hue `h_ok` (Ottosson 2020, «A perceptual color -space for image processing»; реализация — -[`spaces/oklab.rs`](../crates/labcolors-core/src/spaces/oklab.rs)). CAM16 hue -`h_cam` сохраняется только для обратной конвертации в XYZ/hex. +`primary`, `danger`, `hover` и любые другие client IDs непрозрачны для Core. +Последовательное переименование объявления семейства и всех ссылок не меняет +физический результат; этот инвариант закреплён дифференциальным +тестом в [`config/tests.rs`](../crates/labcolors-core/src/config/tests.rs). -Итоговый носитель ([`README.md`](../README.md), «LCS»): +## Конфиг и компиляция -```text -struct LcsColor { - jp: f64, // текущая координата J' CAM16-UCS - h_ok: f64, // Oklab hue для текущей интерполяции - s: f64, // внутренняя репараметризация M' / (J' + 1), не saturation - h_cam: f64, // CAM16 hue для обратной конвертации в hex -} -``` +Корневая схема содержит `brand`, `neutral`, `palette`, `themes`, `roles` и +`aliases`. JSON-граница отвергает неизвестные поля на каждом вложенном объекте и +неизвестные tagged-варианты. Удалённая или опечатанная ручка не может стать +тихим no-op. -Цена гибридности честно фиксируется: в системе живут **два** оттенка с разной -семантикой (`h_ok` — интерполяционный, `h_cam` — конвертационный), и их нельзя -путать; все прототипы сентиментов заданы в Oklab-градусах, о чём прямо -предупреждает док [`sentiment.rs`](../crates/labcolors-core/src/sentiment.rs) -(`prototype_hue`: «NOT HSB/HSL», расхождение Oklab с HSB по кругу 12–46°). - -### 1.3. Условия просмотра как часть значения цвета - -LCS учитывает окружение через параметры CIECAM16: `ViewingConditions::srgb()` -(impact of surround `c = 0.69`) и `ViewingConditions::dim_surround()` (`c = 0.59`) -([`README.md`](../README.md), «ViewingConditions»; -[`spaces/vc.rs`](../crates/labcolors-core/src/spaces/vc.rs)). Один и тот же -`#787880` даёт `J' ≈ 53.5` в светлой теме и `J' ≈ 59.2` в тёмной. Каждая кривая -хранит VC, которым была создана; смешение VC на roundtrip ловится тестом -`wrong_vc_roundtrip_drifts`. В клиентском конфиге произвольных VC-чисел нет — -только закрытое меню номинальных пресетов `VcPreset`; они фиксируют параметры -модели, но не заявлены как отдельно откалиброванные на наблюдателях -([`config.rs`](../crates/labcolors-core/src/config.rs)). - -### 1.4. Непрерывные кривые вместо ступеней - -Палитра — не список шагов, а непрерывная функция `at(t)`, `t ∈ [0, 1]` -([`README.md`](../README.md), «Кривая — NeutralCurve»): - -- **NeutralCurve** — три якоря (светлый/базовый/тёмный), степенная интерполяция - J' через `u^γ`; дефолты `gamma_light = 1.75`, `gamma_dark = 1.5` — - `CurveParams::default` ([`neutral.rs`](../crates/labcolors-core/src/neutral.rs)); -- **hue-purity** — вес чистоты оттенка `purity = (mp / mp_ref)^0.6` - (`HUE_PURITY_EXPONENT = 0.6`, - [`neutral.rs`](../crates/labcolors-core/src/neutral.rs)) гасит шумный - `atan2`-оттенок почти-серых якорей вместо жёсткого порога; -- **chroma envelope** — C¹-непрерывная огибающая хром трёх якорей; якоря - воспроизводятся точно, непрерывность закреплена property-тестом; -- **AccentCurve** — акцент протягивается по J'-профилю нейтрали на канонический - оттенок с насыщенностью `sat_ratio × max_chroma` и гамут-коррекцией. - ---- - -## 2. LCS: семантика и контрактная модель - -### 2.1. Агностичное ядро и граница конфига (ADR-0001) - -Ядро **не знает ни одного имени роли и ни одного брендового значения** -([ADR-0001](decisions/0001-config-boundary.md); модульный док -[`config.rs`](../crates/labcolors-core/src/config.rs)). Всё, что меняется при -смене клиента, живёт в `ThemeConfig`; законы восприятия / математика / WCAG — -физика ядра ([`semantic.rs`](../crates/labcolors-core/src/semantic.rs), -[`solve.rs`](../crates/labcolors-core/src/solve.rs)). Направление зависимостей — -только внутрь: конфиг знает доменные типы ядра, ядро о конфиге не знает ничего. - -Путь значения: `ThemeConfig::validate` (пределы **каждой** экспонируемой ручки; -вне предела — `ConfigError`, не тихое принятие) → -`ThemeConfig::compile_named_role_table` → `NamedRoleTable` → -`resolve_named_set(bg, &table, vc) -> Result, ResolveSetError>`. -Успешный набор сохраняет порядок объявления и допускает локально только -`unreachable` или `unresolved`; `rejected`, `unsupported` и `internal` возвращают -атомарный `Err` без частичного вектора. JSON-парсинг — забота границы WASM, не ядра -([`config.rs`](../crates/labcolors-core/src/config.rs)). - -### 2.2. Рецепты ролей - -Семантика роли задаётся рецептом `RoleRecipe` -([`config.rs`](../crates/labcolors-core/src/config.rs)): - -| Рецепт | Смысл | Констрейнт | -|---|---|---| -| `TextAnchor` | текст: доля от per-фон максимума контраста | LPC-таргет + WCAG-пол | -| `Decorative` | декор: величина Lc | без юридического пола | -| `DecorativeDj` | декор: буквальные dJ'-якоря парой light/dark | без юридического пола | -| `Ladder` | лестница акцент/сентимент/бренд/нейтраль: источник × позиция | позиция несёт альфу Figma-рампы | -| `AlphaAnalog` | альфа-аналог солида через композит-инверсию ([`alpha.rs`](../crates/labcolors-core/src/alpha.rs), #119) | композит на фоне резолва равен солиду | -| `PairLabel` | лейбл тинт-бейджа (§2.5) | WCAG-пол **против тинт-поверхности** | -| `Material` | двухслойный материал стекла/акрила (§3.7): тинт `01` + база `02` | α ВЫВЕДЕНА из композит-неравенства над коридором фонов | -| `Zero` | явный ноль | резолвится в `Resolved::None`, не пропуск ключа | - -`RoleRecipe::Ladder` поглотил акцентный GAP #59; источники — -`LadderSource::Brand / Family / Sentiment / Neutral` -([`config.rs`](../crates/labcolors-core/src/config.rs)). - -### 2.3. Контрактная модель: паспорт labui - -Референсная дизайн-система labui — **не встроенная таблица**, а один конкретный -конфиг потребителя, замороженный как паспорт -[`crates/labcolors-wasm/tests/data/labui.config.json`](../crates/labcolors-wasm/tests/data/labui.config.json) -и как байт-идентичный тест-оракул ядра ([`README.md`](../README.md), «Пример -дизайн-системы»): 20 ролей HIG-таксономии владельца (`label-*`, `fill-*`, -`border-*`, тени, `separator`, `none`), текстовые доли 0.97335917 / -0.64359014 / 0.47572199 / 0.29335999 от максимума, полы AA 4.5:1 / 3:1 там, -где WCAG применим, и явные нули. -Гарантия неизменности эмиссии — гейт -`agnostic_gates::labui_fixture_emission_is_byte_identical_to_frozen_golden` -([`agnostic_gates.rs`](../crates/labcolors-core/src/agnostic_gates.rs)). - -### 2.4. Тонкий конфиг: пресет BL-007 - -Клиент вносит **только значения** (якоря нейтрали/бренда, ручки), не семантику -из 101 роли: `ThemeConfig::preset = Some(RolePreset::Labui)` наполняет словарь -ролей/алиасов до валидации словарём -[`config/preset.rs`](../crates/labcolors-core/src/config/preset.rs) (план -BL-007) — имена, фракции, позиции лестницы, полы, **ни одного цветового -значения**; модуль продовый и сканируется гейтом чистоты -`tests/agnostic_production_surface.rs`. Тонкий конфиг компилируется в таблицу, -**байт-идентичную** полному эталону; пресет — всё-или-ничего: `preset` + -непустые `roles`/`aliases` → `ConfigError::PresetWithRoles` -([`config.rs`](../crates/labcolors-core/src/config.rs), модульный док). - -### 2.5. PairLabel: пол против реальной подложки - -Класс бага: контраст `label ↔ tinted-fill` у бейджа был **эмерджентным** — -обычные `label-*` решаются против фона страницы, и на тинт-подложке -(`fill-*-primary`) их взаимный контраст никем не констрейнился; для -warning/success «цветной» лейбл доли 0.461 оседал к ~2.8:1 < 3:1 -([`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs), -task #29). `RoleRecipe::PairLabel` решает лейбл штатным законом **на -тинт-поверхности** (композит семейного тинта при альфе `fill-*-primary` над -фоном резолва), поэтому пол гарантирован по построению; недостижимость пола -клампит тон с флагом `compressed`, а не молчит -([`config.rs`](../crates/labcolors-core/src/config.rs), `RoleRecipe::PairLabel`). -Дифференциальный RED-proof: тот же контракт, решённый против страницы, -проваливает 3:1 на тинте — `pair_label_beats_page_resolved_label` -([`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs)). - -С миграции #307 production-путь `PairLabel` не собирает derived-поверхность -вручную: физическая схема компонента объявлена приватным **appearance-графом** -([`appearance.rs`](../crates/labcolors-core/src/appearance.rs)) — +Загрузка атомарна: новый конфиг публикуется только после parse, проверки и +компиляции. Ошибка сохраняет предыдущую таблицу и её cache identity. Канонический +fingerprint считается по распарсенной поддерживаемой схеме. + +Источники цвета закрыты тремя физическими вариантами: -```text -непрозрачный источник + локальный контекст + объявленная альфа -→ exact encoded-sRGB8 source-over (versioned profile, replayable certificate) -→ derived-поверхность -→ foreground occurrence против этой поверхности -→ прежний foreground-резолвер -``` +- `Brand` — клиентская четвёрка якорей по режимам; +- `Family(id)` — ссылка на клиентское семейство; +- `Neutral(pick)` — выбранный нейтральный якорь. + +`Family(id)` проверяет только существование ссылки. Значение `id` не выбирает +формулу, оттенок или поведение. + +## Переходное меню рецептов -Граф не знает клиентских имён (`PairLabel`, `Warning` и т. п.): роль переднего -плана и фона задаётся только топологией типизированных handles. Точность -композиции определяется объявленным encoded-sRGB8 профилем и независимо -проверяемым replay-сертификатом. Вхождение переднего плана содержит только -физические факты source/against/backdrop; доказательный статус последующего -решения граф не назначает. Дифференциальный тест закрепляет подключение и -результаты миграции на проверяемом домене; оба пути используют один последующий -резолвер и не являются независимым эталоном его -математики. Лейбл не несёт типографических фактов, поэтому -ни размер, ни вес текста здесь не учитываются и не обещаются. Старая ручная -композиция заморожена как тестовый эталон для дифференциального сравнения; матрица -5 семей × 4 режима × 6 фонов + property-тесты доказывают байт-идентичность -production-пути ей (`migration_*`, -[`pair_label_tests.rs`](../crates/labcolors-core/src/pair_label_tests.rs)). -Для дизайнера: лейбл не «знает», что он warning или badge — клиент объявляет, -на какой реально нарисованной поверхности стоит foreground; core сначала точно -собирает эту поверхность из слоёв, затем решает foreground относительно неё. -Нейтральный и цветной случаи проходят один физический закон; различается только -входной контракт. Цвета этим PR не изменились — изменилась форма системы. - -### 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.7. Полярность, принцип якоря, типизированные исходы - -**Полярность — из фона, в первую очередь по WCAG-достижимости** -([`README.md`](../README.md), «Полярность»): сначала — какая полярность вообще -достигает юридический пол 4.5:1 (свойство одного фона, стабильное между -light/dim); при ничьей (узкая полоса `Y ∈ [0.175, 0.1833]`, около `#767676`) -выбирается белый — люминансное ядро LPC на всей полосе предпочитает белый, а -его перелом лежит много выше (измерен: `Y ≈ 0.342`, лок -`pair_crossover_equals_measured_core_polarity_flip`). Это заменило правило «большая -WCAG-маржа», которое отдавало Fluent-фон `#0078D4` (белый легален 4.529:1) -чёрному вопреки конвенции. - -**Принцип якоря.** Контраст текста — не фиксированная дельта, а доля от -максимума, который фон способен дать; доли labui откалиброваны по референсным -Figma-якорям, перенесённым в Ys-мерило (генезис-замеры Y_hk: 102.6 / 66.5 / -48.9 / 29.3; инвариант переноса — цвет, не Lc-число: Ys-замер принятых hex'ов -лестницы `#141414`/`#767676`/`#C2C2C2` при Ys-максимуме 106.0407, tertiary — -инверсия генезис-якоря 48.9 → `#9C9C9C`; итого 0.97335917 / 0.64359014 / -0.47572199 / 0.29335999) и помечены «калибруется» до визуальной приёмки -([`README.md`](../README.md)). - -**Типизированные исходы.** Строгие config/WASM-парсеры отклоняют -неинтерпретируемые или находящиеся вне объявленного домена поля. Это не -универсальное обещание всей нынешней Rust-поверхности: низкоуровневые числовые -примитивы принимают уже проверенные значения или документированно ограничивают -параметр, а infallible LPC-обёртки пока не дают типизированной ошибки. -Физическая недостижимость solver-а не скрывается: -резолвер возвращает предметный терминальный исход либо явный статус/флаг рядом с -фактически эмитированным значением. Для dJ'-ветви `compressed` означает только, -что цель не попала в бюджет объявленного локального поиска и выбран кандидат с -минимальной ошибкой среди фактически просмотренных точек; это не оптимум по всему -гамуту. Иерархия `primary ≥ secondary ≥ muted ≥ disabled` нестрогая там, где -юридический пол сжимает окно; такая компрессия тоже выражена явно -([`README.md`](../README.md)). - -### 2.8. Примитив баланса акцента - -У любой цветной детали два конкурирующих требования: **идентичность** (быть -узнаваемой по оттенку) и **функция** (яркость центра свечения, контраст-пол -текста, читаемость заливки бейджа). Наивная фикс-доля хромы (например «половина -хромы источника») на функциональной светлоте размывает оттенок к -near-white/near-black — идентичность теряется молча. Закон -([`accent_balance.rs`](../crates/labcolors-core/src/accent_balance.rs), #187): -найти светлоту `L`, удовлетворяющую ФУНКЦИИ, и на ней взять **максимальную** -хрому (`max_chroma` — стена гамута). Идентичность держится ровно так сильно, как -позволяет гамут на нужной `L`, а не срезается произвольной долей; если даже -стена ниже перцептивного пола (`TINT_PERCEPTIBLE_MP_FLOOR`, `M' = 1.5`) — оттенок -физически вырожден, и это честный флаг `hue_vanished`, не молчание. Примитив не -несёт НИ ОДНОЙ новой числовой константы (переиспользует `max_chroma` и -существующий пол) и строго агностичен (оттенок/светлота/тема — параметры). - -### 2.9. Одноуровневость акцентных Background-рамп - -Из нейтральной surface-рампы, акцентного оттенка и темы выводится акцентная -Background-рампа, КАЖДАЯ ступень которой сидит на ТОЙ ЖЕ перцептивной светлоте -(CAM16-UCS `J'`), что одноимённая нейтральная ступень -([`accent_surface.rs`](../crates/labcolors-core/src/accent_surface.rs), #184). -Светлота не решается заново — она НАСЛЕДУЕТСЯ из нейтрального скелета, поэтому -пер-уровневые шаги светлоты акцентной рампы по построению равны шагам нейтрали -(property-тест на ≥20 случайных оттенках в обеих темах). Хрома — не второй -рецепт-доля, а ЕДИНЫЙ примитив баланса (§2.8): на фиксированной светлоте берётся -стена гамута (DRY). Материал — флаг, не ветка: `Alpha`-композит над базой темы -побайтно равен солиду, поэтому `J'` — а с ним одноуровневость — наследуется -алгеброй, не переизобретается. - -### 2.10. FNV-1a: портируемый хеш ядра - -Детерминированный выбор авто-акцента по непрозрачному ключу потребителя строится -поверх агностичного примитива-хеша ([`hash.rs`](../crates/labcolors-core/src/hash.rs), -`#183`): FNV-1a 32-бит (`offset_basis = 2166136261`, `prime = 16777619` — -опубликованная спецификация Fowler–Noll–Vo). ~10 строк обёрточной целочисленной -арифметики без таблиц делают JS-зеркало (`packages/colors/fnv1a.js`) тривиально -верифицируемым: дифференциальный тест на якорных векторах `""`, `"a"`, `"foobar"` -против опубликованных значений гарантирует побайтово идентичный `u32` в Rust и JS. - -### 2.11. Level-3 полярность пары: смешанный H-K/Ys домен (глава #64) - -**Проблема.** У выводимой поверхности «заливка × лейбл» (бейдж, кнопка) есть -степень свободы, которой нет у фона страницы: заливку можно двинуть. На -насыщенных фирменных фонах буква WCAG расходится с глазом и платформенной -конвенцией — синий `#007AFF` (Y≈0.21) по чистому люминансному ратио тянет к -чернилам, но все iOS/Material конвенции ставят на нём БЕЛЫЙ. Причина расхождения -измерена в [ADR-0003](decisions/0003-hk-scope.md), Фаза 1: один зрительный эффект -применён не по адресу. - -**Два домена, которые нельзя смешивать (обоснование ADR-0003).** Разборчивость -мелкого текста несёт **люминансный (ахроматический) контраст** — люминансная -контрастная чувствительность простирается до ~50–60 cpd против ~11–12 cpd -хроматической (Mullen 1985, *J. Physiol.* 359:381–400); константы APCA/SAPC-8 -`0.0.98G-4g` откалиброваны в домене экранной яркости `Ys`, БЕЗ поправки -Гельмгольца–Кольрауша. H-K же — модель воспринимаемой **яркости** (brightness): -насыщенный цвет кажется ярче своей фотометрической luminance (Hellwig, Stolitzka, -Fairchild 2022, *Color Res. Appl.* 47(5):1096, `J_HK = J + f(h)·C^0.587`). H-K -уместен там, где вопрос про яркость, и вреден на оси, решающей, читается ли текст. - -**Правило level-3 (`pair::pair_side`).** Сторона лейбла выбирается как -максимизация достижимого |Lc| двух архетипов, но КАЖДЫЙ архетип меряется в своём -домене: - -> `Ink ⇔ |contrast_core(0, Y_eff)| > |contrast_core(1, Ys)|` - -где `Ys` — экранная яркость якоря (домен читаемости белого лейбла), а -`Y_eff = bg_luma` — воспринимаемая яркость якоря с H-K (домен суждения «поверхность -выглядит светлой → ей место нести чернила»). Ни одной новой константы: обе стороны -— существующие функции движка. Тай отдан свету (полярностная асимметрия чтения: -белый предпочтителен, пока чернила строго не выиграли). - -**Ахроматический терминал.** На серой оси H-K-член ≈ 0 (`Y_eff ≈ Ys`) и правило -редуцируется ровно к чисто-люминансному перелому `PAIR_CROSSOVER_Y = 0.341955` -(§3.5) — измеренный бисекцией перелом `contrast_core`, где |Lc| чёрного догоняет -белый. Порог — не рантайм-вход, а ТЕРМИНАЛ, к которому правило сходится на -нейтралях (лок `achromatic_reduction_matches_derived_crossover`; остаточный сдвиг -0.008 от неполной хроматической адаптации CAM16 строго однонаправлен и -задекларирован). - -**Границы применимости.** Сторона — свойство ИДЕНТИЧНОСТИ СЕМЬИ: решается ОДИН -РАЗ по каноническому светлому якорю семьи и не флипается между темами/IC (иначе -«Brand» носил бы белый в light и чёрный в dark). H-K строго ОДНОнаправлен: он -только ПРОМОТИРУЕТ Light→Ink (насыщенная поверхность выглядит светлее — раньше -готова нести чернила), никогда обратно; контр-конвенционный класс «H-K топит -белый лейбл» пуст по построению (белая сторона меряется в `Ys`, куда H-K не -допущен). Люминансные эндпоинты (`Y = 0/1`) в обоих доменах совпадают побайтно, -`106.04` сохранён. - -**Валидация на 49-якорном паспорте labui** -([`level3_polarity_validation.rs`](../crates/labcolors-core/tests/level3_polarity_validation.rs), -таблица §4). Прогон четырёх величин — чистый порог, H-K-домен (`lpc`), `Ys`-домен -(`lpc_readability_ys`), продакшн `pair_side` — на 49 реальных якорях: - -- **5 H-K-промоций** продакшна против чистого порога, ВСЕ Light→Ink и поимённо - запертые: `#409CFF`, `#4A8FFF`, `#5696FF`, `#FF6161`, `#FF6482` — насыщенные - средне-светлые блю/красно-розовые пастели с `Y ∈ [0.28, 0.32]`, которые H-K - поднимает над кроссовером. Известные конвенции нетронуты: `#007AFF` (Y=0.21) - остаётся БЕЛЫМ — ровно фикс, ради которого затевался ADR-0003. -- **12 флипов H-K-домена против `Ys`-домена** (диагностика 15:0 из V3, - воспроизведённая на паспорте), ВСЕ в направлении `Ys`=белый → H-K=чёрный, ноль - обратных: полная apparent-метрика `lpc` (кормит `Y_hk` обе стороны) утопила бы - белый на 12 насыщенных якорях — именно тот домен-мисматч, который level-3 - снимает, меряя белую сторону в `Ys`. -- Итог паспорта: 25 чернильных / 24 светлых; направление промоций и флипов - заперто property-локами (`hk_flips_are_monotone_white_to_black`, - `production_only_adds_ink_relative_to_pure_threshold`), поимённый набор — точным - снапшотом. - ---- - -## 3. Математика: солвер, констрейнты, полы, квантование - -### 3.1. Текущие компоненты LPC - -LPC означает **Labpics Perceptual Contrast**. Текущая реализация ещё не является -полным вычислителем LPC: она содержит кривую формы APCA, отдельный вход -читаемости на величину яркости из старого WCAG и путь H-K/CAM16 для -воспринимаемой яркости. Последний моделирует ощущаемую яркость, а не распознавание -глифов. Эти пути сохраняются как охарактеризованные кандидаты до замены -версионированным многомерным отчётом LPC; ни один из них не доказывает -превосходство над APCA или универсальную читаемость. Допущенный вычислитель LPC в -отгружаемом продукте пока отсутствует; API и документация не должны обещать -более сильный статус. - -### 3.2. Обратный решатель - -`solve(bg, contract, …) → colour` — обратный решатель: он аналитически -инвертирует скалярное контрастное ядро, затем строит, квантует и повторно -проверяет эмитируемый sRGB8-кандидат. Полный путь остаётся дискретным поиском -в объявленном допуске, а не алгебраическим тождеством; полный алгоритм — в -модульном доке [`solve.rs`](../crates/labcolors-core/src/solve.rs): - -1. `BgInput` квантуется в фактически эмитируемый sRGB8 и редуцируется к - интервалу `[Ys_lo, Ys_hi]` относительной display-люминансы; текущий opaque - solid — вырожденный интервал. Контракт проверяется на обоих концах. -2. Контрастное ядро **инвертируется аналитически** (по таргету Lc и яркости фона - восстанавливается клампленная яркость переднего плана нужной полярности, - затем инвертируется мягкая отсечка чёрного) — теми же каноническими - константами, что и прямая кривая: «this module declares none of them», - ни одного дублированного литерала. -3. Полученная `Ys_fg` переводится в Oklab-светлоту бисекцией по монотонной - display-люминансе вдоль запрошенных hue/chroma policy; хрома ограничивается - гамутным максимумом (`max_chroma`, - [`scale.rs`](../crates/labcolors-core/src/scale.rs)). H-K/CAM16 в этой оси - не участвуют: они остаются appearance-координатами результата. -4. Цвет квантуется в sRGB8, обе границы и настроенный compatibility-floor - повторно проверяются на фактически эмитируемых байтах. Если аналитический - кандидат не попал в симметричный допуск, bounded search проверяет только - объявленный конечный набор соседних эмитируемых состояний в направлении - большего `|Lc|`. `closest_examined` описывает лучший результат лишь среди - проверенных кандидатов; исчерпание поиска имеет категорию `unresolved` и - не характеризует непроверенную часть sRGB. - -Если цвет не возвращён, solver отдаёт типизированный `SolveFailure`, а не тихий -клип. Только категория `unreachable` доказывает отсутствие решения в полном -объявленном домене; `unresolved`, `rejected` и `unsupported` имеют другой смысл -([`solve.rs`](../crates/labcolors-core/src/solve.rs)). Это широкая таксономия -низкоуровневого одиночного solve. На границе набора `resolve_named_set` допускает -в роль только `unreachable | unresolved`; остальные исходы закрывают весь вызов. - -### 3.3. Двухслойные полы: перцепция и закон раздельно - -Решатель целится в LPC-таргет, затем проверяет настроенный compatibility-floor -по legacy WCAG 2.x ratios (4.5:1 текст, 3:1 UI). Это технический safety gate, -а не юридическое заключение о применимости EN 301 549 или EAA к продукту; -если закон строже перцепции — цвет досдвигается до пола с флагом -`floor_override`. Оба числа (`lc`, `wcag_ratio`) отчитываются раздельно и не -смешиваются. Декоративные контракты юридического пола не получают — -к сепараторам и теням требования читаемости не применяются -([`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.5. Квантование и допуски - -- `QUANT_BUDGET = 1.0` ([`solve.rs`](../crates/labcolors-core/src/solve.rs)) — - допуск приёмки Lc в единицах шага сетки (±1 Lc): контракт `±1` явный и - симметричный для соседского поиска, зеркалит тестовый допуск `TOL`. -- `DECORATIVE_FLOOR_MIN = 7.5` - ([`semantic.rs`](../crates/labcolors-core/src/semantic.rs)) — **выведен**: - `MODEL_LC_FLOOR` (7.3) + `QUANT_GUARD` (0.2). `MODEL_LC_FLOOR = - (LO_CLIP − LO_BOW_OFFSET) × LC_SCALE = 7.3` — минимальный ненулевой контраст - модели: за низкоконтрастным клипом кривая стартует ровно с 7.3, внутри клипа — - ноль (issue #44). Это следствие GROUNDED APCA `0.0.98G-4g` набора, а guard 0.2 - держит цель выше инфимума 7.3, чтобы 8-бит квантование решённого цвета не уронило - контраст в ноль. Скан подтверждает: ни одна квантованная пара не эмитит контраст - в (0, 7.3) (замер минимума 7.3005). Декоративные Lc-величины labui (separator - 8.0; тени 8.0 / 9.5 / 11.5 / 14.0 — таблица ролей в - [`README.md`](../README.md)) держатся выше этого пола. -- `PAIR_CROSSOVER_Y = 0.341955` ([`pair.rs`](../crates/labcolors-core/src/pair.rs)) — - Y-перелом кроссовера стороны тинт-пары; терминал **(a) DERIVED**: перелом - полярности чисто-люминансного контраст-ядра APCA (`contrast_core`, набор - `0.0.98G-4g`), найденный бисекцией — фон, где |Lc| чёрного лейбла догоняет - белый. С главы #64 (level-3, §2.11) это **АХРОМАТИЧЕСКИЙ ТЕРМИНАЛ** правила - `pair_side`: на серой оси H-K-член ≈ 0 и смешанно-доменное решение редуцируется - ровно к этому порогу (лок `achromatic_reduction_matches_derived_crossover`); - сам порог перестал быть рантайм-входом (`pair_side` выводит кроссовер из - достижимого |Lc|), но остаётся живой (a)-DERIVED-константой, пиннится - ре-деривацией `pair_crossover_equals_measured_core_polarity_flip` (дрейф любой - APCA-экспоненты ломает лок). Согласовано с палитрой: 0.341955 ∈ интервал 10 - Figma-якорей (0.246, 0.423). Владелец 2026-07-07 (`science/crossover-derived`) - перешёл на выведенное значение; смена с прежнего 0.30 **байт-идентична для - labui**, потому что оба значения лежат в зазоре палитры labui без якорей — фонов - с `Y ∈ [0.30, 0.342)` там нет. Прежние клеймы «shipped 0.30» и «перелом ≈ 0.36» - опровергнуты замером. - -### 3.6. Байт-идентичность как контракт - -Эмиссия — детерминированная функция паспорта: - -- гейт `labui_fixture_emission_is_byte_identical_to_frozen_golden` - ([`agnostic_gates.rs`](../crates/labcolors-core/src/agnostic_gates.rs)) - сверяет эмиссию фикстуры с замороженным 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)); -- WASM-обёртка гоняет headless-Chrome паритет-тест JS-границы против core- - `resolve_named_set` внутри того же wasm runtime. Это проверка проекции и - контракта границы, не native↔WASM/libm доказательство (#223); наружная форма - ролей едина — CSS-эмиссия oklch - ([`spaces/oklch.rs`](../crates/labcolors-core/src/spaces/oklch.rs); - [`README.md`](../README.md), «Тесты»). - -`golden_tests` сверяют выбранные CAM16/CAM16-UCS преобразования с эталонными -значениями и задокументированными допусками. Это проверка конкретных числовых -путей, не сертификат полной CIECAM16-конформности -([`README.md`](../README.md), «Тесты»). - -### 3.7. Материал: двухслойный контракт с ВЫВЕДЕННОЙ альфой - -Материальная поверхность (стекло/акрил) — полупрозрачный тинт `01` над -непрозрачной базой `02` ([`material.rs`](../crates/labcolors-core/src/material.rs), -рецепт `Material` §2.2). В SOLID-режиме виден солид-канон `01`-над-`02`; в -GLASS-режиме база отброшена и `01` лежит над ЖИВЫМ, авторски неизвестным фоном -(`backdrop-filter`). База и тинт — **один тон `T`**: семейно-оттеночная опаковая -поверхность на целевом |ΔJ'| тира (base = крупный/заметный, subtle = малый/тонкий), -построенная тем же dj-anchor-солвером, что декоративные |ΔJ'|-роли (различимость -от фона наследуется его физикой). Нейтральный материал держит подтон таблицы -(оттенок 286°, как остальные нейтральные эмиссии); семейный подставляет оттенок -якоря семьи — это и разблокирует акцент-стекло. - -**Единственная решаемая величина — альфа**, и она НЕ рукописная. Солид-канон -`01`-над-`02` = `α·T + (1−α)·T = T` — байт-точно равен тону при любой α (композит -`T` над `T` есть `T`), поэтому солид тривиально верен; альфа выводится из -GLASS-режима. Тинт над фоном `B` считается в объявленном byte-scale affine -порядке binary64-операций -`(B_byte + α·(T_byte−B_byte))/255`. Ядро -[`material.rs`](../crates/labcolors-core/src/material.rs) и официальный -потребитель [`effective-bg.js`](../packages/colors/effective-bg.js) исполняют -этот порядок одинаково. Алгебраически эквивалентная expanded-запись не является -тем же численным профилем: на WCAG-швах она может выбрать соседнюю alpha. -Профиль заземлён измеренными Figma-парами, но не объявляет любой -browser/color-management pipeline тождественным. -Контракт: композит над ЛЮБЫМ разрешённым фоном обязан держать читаемость -поверхности. Поверхность коммитит **ахроматический полюс** максимального контраста -на тоне (белый на тёмном `T`, чёрный на светлом — параметр-свободный механизм -полярности). Солвер возвращает проходящий верхний кандидат 60-шаговой бинарной -partition для WCAG-пола `floor` (4.5 / 3.0) по всему коридору достижимых фонов -`[чёрный, белый]` (стекло над неизвестным фоном — худший коридор) и повторно -проверяет обе стороны найденного fail/pass bracket в объявленном runtime. - -Профиль намеренно заморожен на original WCAG 2.1 (2018) EOTF split `0.03928`; -текущий текст W3C использует `0.04045`, а его версионированная миграция -принадлежит issue #284. У legacy-функции есть малый downward seam, а binary64- -округление byte-scale affine композитора может опустить interior background ниже -обоих endpoint-значений. Поэтому ядро строит conservative поканальный envelope -фактических endpoint-вычислений, добавляет доказанный абсолютный запас для -compositor rounding, включает обе стороны пересечённого EOTF seam и только затем -складывает положительно взвешенные channel ranges. Это all-backdrop -characterization, а не двухугловая теорема. - -Fixed-step бисекция разрешена только после directed-search guard: отношение тона -и backdrop обязано стягивать допустимый интервал в нужном направлении полюса. -Полный коридор `[чёрный, белый]` удовлетворяет guard; неподдерживаемое узкое -отношение возвращает typed `UnsupportedDirectedSearchRelation`, а не ложную -alpha. Ни глобальная монотонность branch-sensitive predicate, ни первый passing -state, ни точная минимальная alpha не заявляются. WCAG-светлота содержит -platform/toolchain-sensitive `powf`, поэтому результат остаётся -legacy-platform-dependent (immutable attestation отсутствует, #258) и не -является sound cross-runtime bound. На α = 1 -полоса вырождается в `L(T)`, а полюс максимального контраста на -ЛЮБОМ тоне даёт ≥ 4.58:1 (кроссовер чёрного и белого полюса при `L ≈ 0.179`), -поэтому для пола ≤ AA годная α ∈ (0, 1] существует всегда; при более высоком -поле честно возвращается typed `Degraded` с повторно проверенным opaque endpoint -`α = 1` — не молчание. - -Порядок тиров **выводится физикой, не подбором**: на светлой теме тон дальше от -белого (крупнее |ΔJ'| = base) требует ПЛОТНЕЕ α, чем ближе к фону (subtle) — -инвариант запинен тестами. Замер идёт на КВАНТОВАННОМ тоне-тинте (эмитируемый `01`) -с непереквантованным reference-композитом и conservative channel/EOTF envelope -— ровно в byte-scale affine order официального потребителя. Выбранный upper candidate -повторно проходит floor в том же охарактеризованном numerical profile (тест -`material_guarantee_recomputable_over_worst_backdrop`); это re-check выбранного -state, не proof его точной минимальности или cross-runtime bit identity. - ---- - -## 4. Эмпирика: инвентарь констант и статусы калибровки - -### 4.1. Режим R4: каждый policy-литерал маркирован и инвентаризован - -Единственный источник численных policy-утверждений — -[`docs/empirical-inventory.md`](empirical-inventory.md) (SSOT) плюс маркеры -`// SSOT-TRACKED` / `// GROUNDED` в коде. Гейт -[`tests/empirical_inventory.rs`](../crates/labcolors-core/tests/empirical_inventory.rs) -закрывает класс бага «численная перцептуальная константа приземлилась без -бумажного следа»: - -- одна строка инвентаря на POLICY-константу; join-key `(row#, name)`; колонки - `value` и `module` несущие (GATE-2 ловит дрейф значения/модуля); -- поверхность аудита — `PERCEPTUAL_MODULES` (8 модулей: `semantic.rs`, - `scale.rs`, `sentiment.rs`, `neutral.rs`, `lpc.rs`, `lcs.rs`, `solve.rs`, - `pair.rs`); -- GATE-5 дополнительно сканирует **инлайн голые литералы** в POLICY-модулях - (`semantic.rs`, `sentiment.rs`, `neutral.rs`, `pair.rs`) — именно так доли - текстовой иерархии (тогда 0.968/0.627/0.461/0.276) и Separator Lc 8.0 однажды - ускользали от const-детектора, пока не были извлечены в именованные - константы; -- цитируемые стандарты исключены по построению аллоулистом - `NUMERIC_METHOD_ALLOWLIST` (INV-3, N5): `HK_CHROMA_EXPONENT`, `LC_SCALE`, - `DELTA_Y_MIN`, `S_PERC_MIN`, WCAG-коэффициенты, D65, L_A, Yb — это не - настраиваемая политика, а имплементация цитированных моделей. - -Разделение режимов (INV-7): R4 утверждает **только наличие бумажного следа** — -не математику (R1), не деривационную идентичность (R2), не поведенческий -не-дрейф (R3). Гейт чистый `std`, ядро остаётся zero-dep (issue #29). - -### 4.2. Избранные константы со статусами - -Полный перечень — [`empirical-inventory.md`](empirical-inventory.md); ниже — -несущие константы этого документа, статус процитирован по их -`SSOT-TRACKED`-строкам и инвентарю: - -| Константа | Значение | Модуль | Статус источника | -|---|---|---|---| -| `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) | -| `PAIR_CROSSOVER_Y` | 0.341955 | [`pair.rs`](../crates/labcolors-core/src/pair.rs) | **выведен** — перелом чисто-люминансного ядра `contrast_core` (бисекция), домен решения `pair_side`; ∈ интервал якорей (0.246, 0.423), смена с 0.30 байт-идентична для labui; терминал **(a) DERIVED** (владелец 2026-07-07) | -| `HUE_PURITY_EXPONENT` | 0.6 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) | экспонента веса чистоты оттенка; форма мотивирована Abney (1909; K-S-S 1984), магнитуда — дизайн-ручка, терминал **(e)** (хроматические якоря инвариантны) | -| `NEUTRAL_TINT_RATIO` | 0.10 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | ручка нейтрального подтона; терминал **(e)** design-choice (opt-in v1-политика; sensitivity max ΔE_ok 0.0288 по [0,0.2], Волна 2) | -| `TINT_TARGET_MP` | 6.1 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | целевая перцептивная красочность подтона | -| `TINT_HUE_STIFFNESS` | 9.0 | [`semantic.rs`](../crates/labcolors-core/src/semantic.rs) | жёсткость притяжения оттенка подтона; терминал **(c) interval-insensitive** (Волна 2): выше порога пиннинга ≈0.36 выход байт-инвариантен по [1,100], дефолт 9.0 = 25× порога | -| `gamma_light` / `gamma_dark` | 1.75 / 1.5 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) (`CurveParams::default`) | терминал **(e) COMPATIBILITY POLICY** для замороженной прежней эмиссии; **НАУЧНАЯ ЗАМЕНА: OPEN (#219/#261)** — четыре скалярных объяснения опровергнуты по магнитуде; это не human law и не блокер generic fact-only construction | -| `chroma_peak_t` | 0.35 | [`neutral.rs`](../crates/labcolors-core/src/neutral.rs) (`CurveParams::default`) | позиция пика огибающей хромы; **КОНТЕКСТ-ФУНКЦИЯ (hue) ПОДТВЕРЖДЕНА (#95)** — `f(hue) =` cusp-светлота, диапазон [0.225,0.479] по акцентам, среднее 0.379 ≈ 0.35; эмиссионно-нейтрален (кандидат в байт-идентичную объективизацию) | -| доли текста | 0.97335917 / 0.64359014 / 0.47572199 / 0.29335999 | пресет labui | Ys-перенос Figma-якорей (генезис Y_hk: 102.6 / 66.5 / 48.9 / 29.3; инвариант — цвет: Ys принятых `#141414`/`#767676`/`#C2C2C2` ÷ 106.0407, tertiary — инверсия 48.9 → `#9C9C9C`); деривация запечатана гейтом `anchor_transfer_derivation`; «калибруется» до визуальной приёмки ([`README.md`](../README.md)) | - -### 4.3. Честная классификация происхождения - -Инвентарь различает происхождение значений, и это различие — содержательное: - -- **выведенные** привязки (деривация из других величин системы; пример — - `DECORATIVE_FLOOR_MIN` = `MODEL_LC_FLOOR` 7.3 клипа APCA + guard 0.2; прежний - пример «натуральный минимум Warning-зоны» снят — его деривация опиралась на - brand-displacement, убранный Волной 1, а пол Warning теперь design-choice); -- **заземлённые в опубликованном источнике** (GROUNDED-строки инвентаря; пример — - `IC_DECORATIVE_FLOOR_MIN` = APCA-уровень Lc 15, черновиковый дизайн-уровень с - явной оговоркой draft/single-origin) — в отличие от жёстких стандартов - (Hellwig/WCAG/D65), которые вне инвентаря по построению (INV-3); -- **интервал-нечувствительные** (терминал (c), ре-аудит `science/reclassify-e-buckets` - 2026-07-07 + `science/wave2-e-terminal` 2026-07-08; **7 констант** — - `TINT_PERCEPTIBLE_MP_FLOOR`, `HUE_SEARCH_HALF_WINDOW`, `ACHROMATIC_MP_THRESHOLD`, - `QUANT_BUDGET`, `DJ_BUDGET`, `QUANT_GUARD`, `TINT_HUE_STIFFNESS`) — точное - значение ДОКАЗУЕМО не влияет на выход: измеренная низкая экспозиция (≤ ~2% - гаммы, для `TINT_HUE_STIFFNESS` — 0% флипа выше порога пиннинга ≈0.36) плюс - тест/лок, подтверждающий инвариантность выхода по интервалу. Это СИЛЬНЕЕ - «design-choice» — утверждение о поведении, не о происхождении числа; живые - статусы и соответствующие тесты перечислены в `docs/empirical-inventory.md`; -- **калибровки к референсу владельца** (Figma-якоря labui; доли текста) — - воспроизводят конкретную дизайн-практику, а не независимые психофизические - измерения; -- **design-choice** — осознанный выбор без претензии на вывод; несёт - под-пометку **MODEL-CONFLICT**, когда выведенное из модели значение - существует и расходится с shipped. Единственная такая строка — - `HUE_DRIFT_PENALTY_SLOPE` (ИЗМЕРЕН И ОТКЛОНЁН — единственный строгий - кандидат-вывод проверен и признан хуже shipped 0.15, вопрос закрыт замером). - `PAIR_CROSSOVER_Y` больше НЕ в этом списке: владелец 2026-07-07 принял - выведенное значение 0.341955 (терминал (a) DERIVED, §4.2), прежний конфликт - «shipped 0.30 против модели» закрыт — код отгружает выведенное значение; -- **цитированные жёсткие стандарты** — вне инвентаря по построению (INV-3). - -Ни одна константа этого документа не выдаётся за независимо измеренную -психофизическую величину, если её статус в инвентаре — калибровка, design-choice -или черновиковый заземлённый уровень. - -**Созависимые константы: научный статус отделён от compatibility provenance (#281).** -Для константы, значение которой co-зависит от контекста, рефут скалярной -деривации открывает кандидат функции контекста, но не стирает терминальный -провенанс уже отгруженного значения. Gamma остаётся (e) compatibility policy, -а её возможная человеко-ориентированная замена — отдельный OPEN-вопрос. -Прецедент рефута скалярного кандидата — `HUE_DRIFT_PENALTY_SLOPE` (хорда Oklab -отклонена замером) — валиден для НЕ-созависимых ручек; у `CurveParams` контекст есть. -Замеры #95/#108: `gamma_light/dark` — четыре скалярные метрики рефутированы по -магнитуде (CAM16 J′ с окружением даёт верное НАПРАВЛЕНИЕ асимметрии, но малую -магнитуду относительно 1.75/1.5; прямое прочтение метрики Уиттла вырождается); -корневая контекстная модель остаётся открытой в #219/#261. `chroma_peak_t` — hue-функция -подтверждена (cusp-светлота, среднее по акцентам 0.379 ≈ 0.35), эмиссионно-нейтральна. -Живые статусы shipped-констант и их проверяющие тесты перечислены в -`docs/empirical-inventory.md`. - ---- - -## 5. Ограничения и не-цели - -### 5.1. Ограничения модели - -- **LPC не является APCA.** Не APCA-совместима и не комплаентна, не одобрена - Myndex Research или Andrew Somers; переиспользуется только опубликованная - кривая (константы формул не охраняются, 17 U.S.C. § 102(b)); товарный знак - «APCA» в публичном API не используется ([`README.md`](../README.md), - «LPC vs APCA»). -- **Эффект Эбни не моделируется** — hue-purity кривая им не является; коррекция - Эбни вынесена в отдельную задачу (issue #27, `abney_correct`) - ([`README.md`](../README.md)). При этом ФОРМА кривой (гасить тон near-нейтралей) - мотивирована сходящимися основаниями: численным (atan2-шум у серой оси) и - перцептивным — эффект Эбни (Abney 1909, Proc. R. Soc. Lond. A 83; Kurtenbach, - 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). -- **Декоративные ВЕЛИЧИНЫ (не пол) — заглушки до JND-калибровки.** Сам пол - `DECORATIVE_FLOOR_MIN` теперь выведен (7.3 клипа APCA + guard 0.2, §3.5), а - `IC_DECORATIVE_FLOOR_MIN` заземлён в APCA Lc 15; но конкретные Lc теней - (8.0 / 9.5 / 11.5 / 14.0) и `separator` держат лишь строгий порядок над полом — - их финальные контракты выведет глава surface-jnd из альфа-якорей LabUI - ([`README.md`](../README.md), «Что дальше»). -- **Текстовые доли — калибровка до визуальной приёмки** - ([`README.md`](../README.md)). -- **R4-гейт не утверждает корректность математики** — только наличие - бумажного следа (INV-7, - [`tests/empirical_inventory.rs`](../crates/labcolors-core/tests/empirical_inventory.rs)). - -### 5.2. Не-цели - -- **Не встроенная дизайн-система.** Таксономию ролей поставляет потребитель; - labui — паспорт одного клиента, не дефолт ядра - ([ADR-0001](decisions/0001-config-boundary.md)). -- **Не CSS-рантайм.** Сериализация в custom properties — забота рантайма; - ядро отдаёт структурированную карту `имя роли → Resolved` - ([`README.md`](../README.md), «Семантика»). -- **Не JSON-парсер.** Парсинг — граница WASM; ядро несёт типы без сериализации - ([`config.rs`](../crates/labcolors-core/src/config.rs)). -- **Не библиотека с деревом зависимостей.** `labcolors-core` — zero-dep - (issue #29). -- **Не меню произвольных условий просмотра.** VC — закрытый набор номинальных - пресетов `VcPreset`; отдельная observer-calibration не заявлена - ([`config.rs`](../crates/labcolors-core/src/config.rs)). -- **Не оверрайд-система пресетов (пока).** Пресет BL-007 — всё-или-ничего; - оверрайд отдельных ролей поверх пресета — будущий слой, если владелец решит - ([`config.rs`](../crates/labcolors-core/src/config.rs)). -- **Не универсальная модель цветовосприятия.** LCS — прикладной гибрид - (CAM16-UCS × Oklab hue) внутри contextual compiler/runtime-resolver клиентских - color-token contracts; претензий на новую CAM или palette-generator semantics - у системы нет. +| Рецепт | Физический контракт | +|---|---| +| `TextAnchor` | foreground с долей доступного контраста и явным floor | +| `DjAnchor` | декоративный шаг appearance-коррелята по темам | +| `DecorativeLc` | декоративная контрастная величина без нормативного текстового смысла | +| `Ladder` | якорь источника при alpha закрытой позиции | +| `AlphaAnalog` | прозрачная форма объявленной solid-цели на локальном фоне | +| `PairFill` | solid-поверхность, сдвинутая до выбранной стороны foreground | +| `PairLabel` | foreground против объявленной derived tint-поверхности | +| `Material` | точечная двухслойная композиция с выведенной alpha | +| `Glow` | точечные screen-слои с явным numerical profile | +| `Zero` | явное отсутствие цветового значения | + +Эти варианты описывают только существующую совместимую поверхность. Их полезные +законы принадлежат общей физике, но сама упаковка по UI-рецептам — архитектурный +долг. `PairFill`, `PairLabel`, `Material`, `Glow` и `Ladder` должны исчезнуть из +Core после переноса source-over, screen, ordering и occurrence-отношений в общий +граф. Клиент может назвать token как угодно; Core не выводит физику из имени. + +## Resolve и зависимости + +Переходный `ThemeConfig::compile_named_role_table` строит `NamedRoleTable`. +`resolve_named_set` принимает локальный `BgInput` и `ViewingConditions`, затем +возвращает значения ролей в порядке объявления. + +Зависимые поверхности собирает приватный appearance-граф. Он содержит только +typed handles, физические входы и рёбра. Например, `PairLabel` решается против +точного source-over композита своего tint-источника над локальным фоном. +Эмитированный `PairFill` не подменяет эту поверхность и не образует скрытую +зависимость по имени. + +Произвольный client-authored graph и совместный solver пока не являются +публичным API. Поэтому `NamedRoleTable` нельзя расширять как будто это будущий +IR: он удаляется по мере переноса сохранённых законов в общую topology. + +## Якоря и производные цвета + +Точный якорь — immutable client input. Core не двигает его из-за значения ID, +брендовой близости или культурной категории. Рецепт может вывести другой +физический цвет только потому, что сам контракт рецепта требует адаптации к +фону, floor, композиции или выходному домену. + +Семейный `Ladder` сохраняет authored anchor выбранного режима побайтно. Этот +инвариант проверяется для всех семейств референсной фикстуры во всех четырёх +режимах. Производные foreground, surface и effect-роли используют тот же якорь +как источник идентичности, но итог определяется физическим контрактом каждой роли. + +`NeutralCurve`, `AccentCurve` и `ColorCurve` — низкоуровневые непрерывные +примитивы. Параметр кривой не является встроенной клиентской иерархией. + +## LCS, LPC и доступность + +`LCS` означает **Labpics Colors Space**, `LPC` — **Labpics Perceptual Contrast**. +Это пространства понятий Labpics, а не новые названия CAM16, Oklab, APCA или +WCAG. Текущий код использует компоненты этих моделей, но сам факт композиции не +доказывает более точное человеческое восприятие. + +Нормативная проверка отделена от appearance-метрик. Версионированный WCAG 2.2 +evaluator работает на финальной паре sRGB8 и явно выбранном критерии. Он не +назначает размер текста, essentialness или disabled/decorative status по имени +токена. Типографические факты должен передать клиентский контракт. + +APCA-shaped компонент и H-K brightness correction не рекламируются как +универсальный закон читаемости. Их применимость и доказательный статус должны +быть объявлены отдельно от обязательного нормативного результата. + +## Точечные композиции + +`AlphaAnalog`, `Material` и `Glow` решают разные точечные физические задачи, но +используют общие правила владения источником, локальным фоном и конечной +проверкой. + +- `AlphaAnalog` выводит tint и достижимую alpha для объявленной solid-цели. +- `Material` возвращает точечные слои материала; он не сертифицирует blur, + преломление или пространственное стекло. +- `Glow` возвращает точечные screen-слои и numerical outcome; он не + сертифицирует поле после blur, HDR или конкретный renderer. + +Exact-сертификат относится только к названной конечной арифметике и +версионированному reference profile. Он не превращается в browser-observed или +display-measured evidence без соответствующего измерения. + +## Runtime + +`watchTheme` выполняет полный resolve при изменении поддерживаемого reference-фона +или имени темы (либо при явном forced refresh). `adaptTheme` отдельно повторно +проверяет метрики текущего снимка на конечном объявленном наборе образцов и только +после устойчивого относительного снижения запускает новый resolve и переход к его +результату. + +`effectiveBackground` — reference estimate поддерживаемой цепочки CSS-цветов, +не наблюдение пикселя. Image, gradient, video, filter, blend mode и +`backdrop-filter` требуют явных образцов или другого источника фактов. Строгий +occurrence-контракт не вправе заменять неизвестный фон белым или чёрным. Текущий +legacy helper всё ещё использует белую базу по умолчанию и пропускает +неподдерживаемые CSS-слои, поэтому его результат — только compatibility estimate, +не evidence; для обязательной проверки caller передаёт явный фон или samples. + +## Доказательный статус + +Система различает: + +- точную геометрию и конечную арифметику, которую можно проверить + исчерпывающе или независимым oracle; +- versioned compatibility behaviour с ограниченной областью применимости; +- человеческий смысл координат, допустимый только по опубликованным источникам; +- экспериментальные модели, которые не входят в default resolve. + +Sentiment-назначения и оценка cleanliness не встроены в физический граф. Пока +нет допущенного versioned evidence profile, они могут существовать только как +явный клиентский или experimental слой и не вправе двигать authored anchors. + +## Ограничения текущей версии + +- Основной выходной домен — sRGB; P3 и HDR не сводятся к нему молча. +- Фон оценивается по объявленным точкам и поддерживаемым CSS-операциям, а не по + всему пространственному полю браузера. +- Источник `Brand`/`Family` классифицируется по точным эмитируемым sRGB8-байтам: + равные каналы точно ахроматичны; из неравных вычисляется Oklab-направление + входного представления. Это не доказывает сохранение или различимость + authored hue в финальном output без отдельного relation-constraint и + сертификата. Для `neutral.tint` exact-gray выбирает neutral policy, а явный + `hue_override_deg` задаёт входное направление подтона. +- Cleanliness и культурные sentiment-конвенции не имеют default production + verdict без отдельно допущенного evidence profile. + +Эти границы являются частью API-честности: неподдерживаемый человеческий смысл +не маскируется под точную цветовую математику. diff --git a/packages/colors/README.md b/packages/colors/README.md index 52de0eab..6f37a65b 100644 --- a/packages/colors/README.md +++ b/packages/colors/README.md @@ -202,7 +202,7 @@ type RoleResult = состояния: - `SolvedColor` — цвет найден (`kind: "color"`, поля `css` — готовое `oklch(L% C H)`, `hex` — тот же цвет как данные, `lc`, `wcagRatio`, …). -- `TranslucentRole` — полупрозрачная роль лестницы или альфа-аналога (`kind: "translucent"`): `css` — готовое `oklch(L% C H / A)`, `tintHex` — тинт как данные, `alpha`, плюс `compositeHex` / `compositeLc` / `compositeWcag` — exact encoded-sRGB8 reference-композит на фоне резолва и его контраст. Конкретный renderer и color-management pipeline проверяются отдельно. +- `TranslucentRole` — полупрозрачная роль лестницы или альфа-аналога (`kind: "translucent"`): `css` — готовое `oklch(L% C H / A)`, `tintHex` — тинт как данные, `alpha`, плюс `compositeHex` / `compositeLc` / `compositeWcag` — exact encoded-sRGB8 reference-композит, знаковая кандидатная оценка по `Ys` и WCAG ratio на фоне резолва. `compositeLc` не является LPC/readability verdict; конкретный renderer и color-management pipeline проверяются отдельно. - `GlowRole` — discriminated union. `kind: "glow"` несёт два цвета для `mix-blend-mode: screen`: `cssVar` — halo, `${cssVar}-core` — core, `${cssVar}-alpha` — каноническая `alphaCss`. `compositeProfile` / @@ -215,8 +215,8 @@ type RoleResult = `"cam16-ucs-jprime-li2017-v1"`. `targetStatus` различает эти ветви явно: `"exact-noop-unreachable"`, `"legacy-reached"` или `"legacy-unreachable"`. Форма `kind: "glow"` — объединение этих трёх - согласованных ветвей: смешать stable/legacy профиль, гарантию, диагностику - выбора, статус и `degraded` на уровне TypeScript невозможно. Legacy-ветви + согласованных ветвей: смешать stable/legacy профиль, гарантию, диагностику и + статус на уровне TypeScript невозможно. Legacy-ветви (`decisionGuarantee: { kind: "legacy-platform-dependent-v1" }`) — это результат совместимости зарегистрированного алгоритма, а не доказанная численная гарантия. Интервал с внешним округлением не @@ -227,8 +227,8 @@ type RoleResult = point-слоям, а не к полному blur/overlap-эффекту, браузеру или дисплею. - `MaterialRole` — объединение трёх терминальных исходов: satisfied transparent endpoint (`alpha = 0`), satisfied bisection bracket (`alpha` побитно равна - `upperAlpha`) и degraded opaque endpoint (`alpha = 1`). `alphaStatus`, - `alphaGuarantee` и compatibility-поле `guaranteed` согласованы типом. `floor` + `upperAlpha`) и degraded opaque endpoint (`alpha = 1`). `alphaStatus` и + `alphaGuarantee` согласованы типом. `floor` — запрошенный пол; он держится только при `alphaStatus: "satisfied"`. Primary остаётся солид-каноном, `-01` — тинтом, `-02` — опаковой базой. Численный профиль явно фиксирует operation order: @@ -261,7 +261,7 @@ type RoleResult = ### `engine.loadConfig(json): string` -Загружает конфиг дизайн-системы (JSON по типу `ThemeConfig`; схема — в репозитории: `docs/decisions/0001-config-boundary.md`, TS-типы — в поставляемом `labcolors.d.ts`). Это **единственный** источник словаря ролей — встроенной таблицы в движке нет: до загрузки конфига `resolveTheme` отклоняется ошибкой `config_required`. Полный preflight проверяет не только имена ролей, но и итоговый CSS-namespace: `-core`/`-alpha` Glow и `-01`/`-02` Material не могут быть затёрты другой ролью или алиасом. Невалидный конфиг отклоняется структурной ошибкой `invalid_config: …` и НЕ меняет состояние. Возвращает отпечаток конфига — 16 hex-символов; разные конфиги дают разные отпечатки и разные кэш-пространства. +Загружает конфиг дизайн-системы (JSON по типу `ThemeConfig`; схема — в репозитории: `docs/decisions/0001-config-boundary.md`, TS-типы — в поставляемом `labcolors.d.ts`). Это **единственный** источник словаря ролей — встроенной таблицы в движке нет: до загрузки конфига `resolveTheme` отклоняется ошибкой `config_required`. Полный preflight проверяет не только имена ролей, но и итоговый CSS-namespace: `-core`/`-alpha` Glow и `-01`/`-02` Material не могут быть затёрты другой ролью или алиасом. Невалидный конфиг отклоняется структурной ошибкой `invalid_config: …` и НЕ меняет состояние. Возвращает детерминированный 64-битный отпечаток как 16 hex-символов; это вероятностный идентификатор, а корректность reload обеспечивается полным очищением кэша. Каждый Glow-рецепт обязан явно выбрать numerical-decision profile; default и неявного legacy-пути нет: @@ -317,7 +317,7 @@ Core не выводит критерий из имени токена, CSS-кл bound-law и воспроизводимого full-domain proof. Файлы доказательства входят в npm-тарбол в `evidence/`; proof также SHA-256-связан с фактической typed registry-строкой, разрешающей Core минтить terminal evidence. APCA-shaped -diagnostic-компонент текущего LPC и legacy `wcagRatio` не могут изменить этот +Ys candidate score `lc` и диагностический `wcagRatio` не могут изменить этот вердикт. ### `numericalCapabilityManifest(): NumericalCapabilityManifestV2` @@ -332,7 +332,7 @@ diagnostic-компонент текущего LPC и legacy `wcagRatio` не м ### `engine.recheckContrast(bgHex, fgHexes, theme): Float64Array` -Дешёвая покадровая проверка: какие контрасты дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва (один прямой ход модели на фон плюс по одному на каждый передний план). Требует загруженный конфиг — `theme` ищется в его словаре (`config_required` без конфига, `unknown_theme` для необъявленного ключа), как и `resolveTheme`. Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковый `Lc` цвета `i`, `2·i+1` — его WCAG-отношение. Это примитив, которым `adaptTheme` решает, пора ли пересчитывать. +Дешёвая покадровая проверка: какие Ys candidate score и WCAG ratio дают цвета `fgHexes` на фоне `bgHex` под темой `theme`, без полного резолва. Требует загруженный конфиг — `theme` ищется в его словаре (`config_required` без конфига, `unknown_theme` для необъявленного ключа), как и `resolveTheme`. Возвращает `Float64Array` пар `[lc, wcagRatio]` в порядке `fgHexes`: индекс `2·i` — знаковая кандидатная оценка по `Ys` из frozen SAPC-shaped curve, `2·i+1` — WCAG-отношение. `lc` не является LPC/readability verdict; runtime использует его только как координату текущего transitional solver-а. --- @@ -492,10 +492,11 @@ const bg2 = effectiveBackground(panel, { fallback: "#101012" }); ## Размер бандла -Raw-размер WASM — hard gate с append-only историей. Текущий -`bench/wasm-size-budget-v11.json` содержит exact Linux-x64 size-бюджет `runtime` -с нулевым headroom; checker выбирает текущую версию, а все предыдущие -versioned-файлы остаются неизменяемой историей. Size policy не притворяется +Raw-размер WASM — hard gate с append-only историей. SSOT текущего exact +Linux-x64 size-бюджета `runtime` — `scripts/check-wasm-size-budget.mjs`: его +`DEFAULT_BUDGET` указывает на текущий versioned artifact в +`packages/colors/bench/` с нулевым headroom, а все предыдущие versioned-файлы +остаются неизменяемой историей. Size policy не притворяется идентификатором артефакта: фактический SHA артефакта вместе с source SHA записывается в `build-metadata.json` и повторно сверяется с точными байтами tarball при публикации. Release-equivalent CI требует точного размера и diff --git a/packages/colors/adapt-theme.js b/packages/colors/adapt-theme.js index 23caceaf..a7b77c59 100644 --- a/packages/colors/adapt-theme.js +++ b/packages/colors/adapt-theme.js @@ -290,10 +290,8 @@ export function adaptTheme(element, options) { `adaptTheme: legacy Glow '${key}' cannot be Indeterminate`, ); } - const reached = - role.targetStatus === "legacy-reached" && role.degraded === false; - const unreachable = - role.targetStatus === "legacy-unreachable" && role.degraded === true; + const reached = role.targetStatus === "legacy-reached"; + const unreachable = role.targetStatus === "legacy-unreachable"; if ( !hasDeterminateGlowEnvelope(result, role, emittedKeys) || role.decisionGuarantee?.kind !== "legacy-platform-dependent-v1" || @@ -314,8 +312,7 @@ export function adaptTheme(element, options) { role.decisionGuarantee?.kind !== "bit-exact" || !hasDeterminateGlowEnvelope(result, role, emittedKeys) || role.selectionDiagnosticProfile !== null || - role.targetStatus !== "exact-noop-unreachable" || - role.degraded !== true + role.targetStatus !== "exact-noop-unreachable" ) { throw new TypeError(`adaptTheme: stable Glow '${key}' lacks BitExact evidence`); } diff --git a/packages/colors/bench/wasm-size-budget-v14.json b/packages/colors/bench/wasm-size-budget-v14.json new file mode 100644 index 00000000..c4761aeb --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v14.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 8, + "budgetId": "labcolors-wasm-runtime-c6-legacy-excision-v14", + "predecessor": { + "path": "packages/colors/bench/wasm-size-budget-v13.json", + "fileSha256": "3cc88303a0f43e8ca33ae70d723a3179c68b0cc2744310a791e8f43885482f34" + }, + "toolchainSource": { + "path": "packages/colors/bench/wasm-size-budget-v1.json", + "fileSha256": "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1" + }, + "buildRecipes": { + "runtime": { + "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", + "recipeSha256": "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4" + } + }, + "roles": { + "runtime": { + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "measurement": { + "source": "github-actions-run-29647236232", + "measurementPlatform": "linux-x64", + "rawBytes": 428540 + }, + "policy": { + "maxRawBytes": 428540, + "basis": "accepted-c6-legacy-excision-snapshot", + "gzip": "diagnostic-only" + } + } + } +} diff --git a/packages/colors/bench/wasm-size-budget-v15.json b/packages/colors/bench/wasm-size-budget-v15.json new file mode 100644 index 00000000..79f02826 --- /dev/null +++ b/packages/colors/bench/wasm-size-budget-v15.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 8, + "budgetId": "labcolors-wasm-runtime-c6-validation-srgb-ssot-v15", + "predecessor": { + "path": "packages/colors/bench/wasm-size-budget-v14.json", + "fileSha256": "20c5886e3edaa6eaf3e37b915d81982a3a13e30064fc7fa8eb702eda38a20fb6" + }, + "toolchainSource": { + "path": "packages/colors/bench/wasm-size-budget-v1.json", + "fileSha256": "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1" + }, + "buildRecipes": { + "runtime": { + "command": "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", + "recipeSha256": "0ea74cb070e0a5facb7280f6124930a0bb673ee4dcee9c99fff110db6c9389d4" + } + }, + "roles": { + "runtime": { + "artifact": "packages/colors/pkg/labcolors_bg.wasm", + "measurement": { + "source": "github-actions-run-29654779091", + "measurementPlatform": "linux-x64", + "rawBytes": 428403 + }, + "policy": { + "maxRawBytes": 428403, + "basis": "accepted-c6-validation-srgb-ssot-snapshot", + "gzip": "diagnostic-only" + } + } + } +} diff --git a/packages/colors/effective-bg.js b/packages/colors/effective-bg.js index d47f25a6..4f2ab641 100644 --- a/packages/colors/effective-bg.js +++ b/packages/colors/effective-bg.js @@ -98,19 +98,17 @@ 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 H / A)`, а computed style + * браузера может вернуть `oklch( C H [/ A])`. После oklch → Oklab + * используются те же `oklabToLinearRgb` и `linearToSrgb`, что в локальном + * sRGB-пути. Поканальный clamp перед округлением совпадает с законом финальной + * sRGB8-эмиссии Core и сохраняет побайтовый round-trip; out-of-gamut каналы + * ограничиваются независимо, как в `oklabLerp`/`toHex`. * - * @param {string} inner the text between `oklch(` and `)` + * @param {string} inner текст между `oklch(` и `)` * @returns {Rgba | null} */ function parseOklch(inner) { diff --git a/packages/colors/package-lock.json b/packages/colors/package-lock.json index 7170dfdb..3d791ddd 100644 --- a/packages/colors/package-lock.json +++ b/packages/colors/package-lock.json @@ -1,12 +1,12 @@ { "name": "@labpics/colors", - "version": "0.10.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@labpics/colors", - "version": "0.10.0", + "version": "0.11.0", "license": "MIT", "devDependencies": { "typescript": "^5.5.0", diff --git a/packages/colors/package.json b/packages/colors/package.json index efd9c599..df26758e 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -1,6 +1,6 @@ { "name": "@labpics/colors", - "version": "0.10.0", + "version": "0.11.0", "packageManager": "npm@11.9.0", "description": "Runtime resolver for client-owned color-token contracts. WASM core, zero runtime dependencies.", "type": "module", diff --git a/packages/colors/smoke.consumer.ts b/packages/colors/smoke.consumer.ts index 630be6fc..3f10b6e2 100644 --- a/packages/colors/smoke.consumer.ts +++ b/packages/colors/smoke.consumer.ts @@ -25,6 +25,15 @@ import type { ThemeName, } from "./index.js"; +declare const noAliasGlow: GlowDeterminateRole; +// @ts-expect-error ambiguous Glow measurement alias was removed before first client. +noAliasGlow.achievedDj; +// @ts-expect-error boolean duplicates targetStatus and was removed. +noAliasGlow.degraded; +declare const noAliasMaterial: MaterialRole; +// @ts-expect-error boolean duplicates alphaStatus and was removed. +noAliasMaterial.guaranteed; + const requireFailure = (_role: FailureRole): void => {}; requireFailure({ kind: "failure", @@ -77,7 +86,6 @@ requireGlowDeterminate({ decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: null, targetStatus: "exact-noop-unreachable", - degraded: true, }); requireGlowDeterminate({ ...glowDeterminateCommon, @@ -85,7 +93,6 @@ requireGlowDeterminate({ decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached", - degraded: false, }); requireGlowDeterminate({ ...glowDeterminateCommon, @@ -93,38 +100,33 @@ requireGlowDeterminate({ decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-unreachable", - degraded: true, }); // @ts-expect-error stable-профиль не может нести legacy-status. -requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: null, targetStatus: "legacy-reached", degraded: false }); +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: null, targetStatus: "legacy-reached" }); // @ts-expect-error legacy-профиль не может рекламировать bit-exact decision. -requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached", degraded: false }); +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached" }); // @ts-expect-error exact no-op не выполняет CAM16 selection diagnostic. -requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "exact-noop-unreachable", degraded: true }); -// @ts-expect-error reached-ветвь не является degraded. -requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "legacy-reached", degraded: true }); +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "exact-noop-unreachable" }); +// @ts-expect-error legacy-профиль не может нести exact stable status. +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "legacy-platform-dependent-v1", decisionGuarantee: { kind: "legacy-platform-dependent-v1" }, selectionDiagnosticProfile: "cam16-ucs-jprime-li2017-v1", targetStatus: "exact-noop-unreachable" }); // @ts-expect-error Glow API не поддерживает outward decision guarantee. -requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "outward-interval-v1", lower: 0, upper: 1 }, selectionDiagnosticProfile: null, targetStatus: "exact-noop-unreachable", degraded: true }); +requireGlowDeterminate({ ...glowDeterminateCommon, decisionProfile: "stable-v1", decisionGuarantee: { kind: "outward-interval-v1", lower: 0, upper: 1 }, selectionDiagnosticProfile: null, targetStatus: "exact-noop-unreachable" }); function narrowGlowDeterminate(role: GlowDeterminateRole): void { if (role.decisionProfile === "stable-v1") { const status: "exact-noop-unreachable" = role.targetStatus; const guarantee: "bit-exact" = role.decisionGuarantee.kind; const diagnostic: null = role.selectionDiagnosticProfile; - const degraded: true = role.degraded; void status; void guarantee; void diagnostic; - void degraded; } else if (role.targetStatus === "legacy-reached") { - const degraded: false = role.degraded; - void degraded; + const status: "legacy-reached" = role.targetStatus; + void status; } else { const status: "legacy-unreachable" = role.targetStatus; - const degraded: true = role.degraded; void status; - void degraded; } } @@ -139,7 +141,6 @@ requireMaterial({ numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", }, alphaStatus: "satisfied", - guaranteed: true, }); requireMaterial({ ...materialCommon, @@ -152,7 +153,6 @@ requireMaterial({ numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", }, alphaStatus: "satisfied", - guaranteed: true, }); requireMaterial({ ...materialCommon, @@ -162,35 +162,28 @@ requireMaterial({ numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", }, alphaStatus: "degraded", - guaranteed: false, }); // @ts-expect-error degraded несовместим с transparent endpoint. -requireMaterial({ ...materialCommon, alpha: 0, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded", guaranteed: false }); +requireMaterial({ ...materialCommon, alpha: 0, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded" }); // @ts-expect-error satisfied несовместим с opaque endpoint. -requireMaterial({ ...materialCommon, alpha: 1, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: true }); -// @ts-expect-error compatibility boolean выводится из typed status. -requireMaterial({ ...materialCommon, alpha: 0.5, alphaGuarantee: { kind: "bisection-bracket-characterized-v1", iterations: 60, lowerAlpha: 0.49, upperAlpha: 0.5, numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: false }); +requireMaterial({ ...materialCommon, alpha: 1, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied" }); // @ts-expect-error transparent endpoint конструктивно имеет alpha 0. -requireMaterial({ ...materialCommon, alpha: 0.25, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied", guaranteed: true }); +requireMaterial({ ...materialCommon, alpha: 0.25, alphaGuarantee: { kind: "transparent-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "satisfied" }); // @ts-expect-error degraded opaque endpoint конструктивно имеет alpha 1. -requireMaterial({ ...materialCommon, alpha: 0.9, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded", guaranteed: false }); +requireMaterial({ ...materialCommon, alpha: 0.9, alphaGuarantee: { kind: "opaque-endpoint-characterized-v1", numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1" }, alphaStatus: "degraded" }); function narrowMaterial(role: MaterialRole): void { if (role.alphaStatus === "degraded") { const alpha: 1 = role.alpha; const guarantee: "opaque-endpoint-characterized-v1" = role.alphaGuarantee.kind; - const guaranteed: false = role.guaranteed; void alpha; void guarantee; - void guaranteed; return; } const status: "satisfied" = role.alphaStatus; - const guaranteed: true = role.guaranteed; void status; - void guaranteed; if (role.alphaGuarantee.kind === "transparent-endpoint-characterized-v1") { const guarantee: "transparent-endpoint-characterized-v1" = role.alphaGuarantee.kind; void guarantee; @@ -223,6 +216,8 @@ async function consume(clientConfigJson: string): Promise { const lc: number = primary.lc; const wcag: number = primary.wcagRatio; const legalFloor: number | null = primary.legalFloor; + // @ts-expect-error exact bytes do not expose a hue-visibility verdict. + primary.hueVanished; void hex; void lc; void wcag; diff --git a/packages/colors/test/adapt-theme.test.mjs b/packages/colors/test/adapt-theme.test.mjs index 52ed1efe..f4b51600 100644 --- a/packages/colors/test/adapt-theme.test.mjs +++ b/packages/colors/test/adapt-theme.test.mjs @@ -207,7 +207,6 @@ test("a failed stable-Glow reconciliation does not publish its candidate", () => selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, }, }, }; @@ -277,7 +276,6 @@ test("tick does not commit a Glow class change before a later set resolve succee selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, }, }, }; @@ -647,7 +645,6 @@ test("stable Glow class changes re-resolve and clear/restore satellites synchron selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, }, }, }; @@ -734,7 +731,6 @@ test("stable Glow rejects a missing exact recheck capability or malformed eviden selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, }, }, }; @@ -821,21 +817,6 @@ test("stable Glow rejects a missing exact recheck capability or malformed eviden "generic unreachable must not erase exact provenance", ); - const falseDegraded = structuredClone(stable); - falseDegraded.roles.fx.degraded = false; - assert.throws( - () => - adaptTheme(fakeElement(), { - ...options, - colors: { - resolveTheme: () => falseDegraded, - recheckContrast: () => [], - isStableGlowPointNoop: () => true, - }, - }), - /lacks BitExact evidence/u, - ); - const incomplete = structuredClone(stable); delete incomplete.vars["--lab-fx-alpha"]; assert.throws( @@ -967,8 +948,6 @@ test("legacy Glow validates correlated provenance before adopting external resul haloAchievedDj: 2.4, coreCompositeHex: "#D8CEFF", coreAchievedDj: 3.1, - achievedDj: 2.4, - degraded: false, css: "oklch(70% 0.1 280)", }, }, @@ -993,7 +972,6 @@ test("legacy Glow validates correlated provenance before adopting external resul const legacyUnreachable = structuredClone(legacyReached); legacyUnreachable.roles.fx.targetStatus = "legacy-unreachable"; - legacyUnreachable.roles.fx.degraded = true; const acceptedUnreachable = fakeElement(); adaptTheme(acceptedUnreachable, { ...options, @@ -1008,7 +986,6 @@ test("legacy Glow validates correlated provenance before adopting external resul ["bit-exact legacy decision", (role) => (role.decisionGuarantee = { kind: "bit-exact" })], ["null legacy selection", (role) => (role.selectionDiagnosticProfile = null)], ["generic exact status", (role) => (role.targetStatus = "exact-noop-unreachable")], - ["reached marked degraded", (role) => (role.degraded = true)], ["unknown recipe", (role) => (role.layerRecipeProfile = "future-recipe-v2")], ["unknown composite", (role) => (role.compositeProfile = "browser-screen-v1")], ]; @@ -1072,7 +1049,6 @@ test("stable Glow invalidation does not snap an in-flight color ease", () => { selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, } : { kind: "glow-indeterminate", @@ -1167,7 +1143,6 @@ test("a color re-solve cannot reintroduce stable Glow vars unsafe for another sa selectionDiagnosticProfile: null, constraintLayer: "halo", targetStatus: "exact-noop-unreachable", - degraded: true, } : { kind: "glow-indeterminate", diff --git a/packages/colors/test/chain-invariants.test.mjs b/packages/colors/test/chain-invariants.test.mjs index 0b948ea2..b2325bc1 100644 --- a/packages/colors/test/chain-invariants.test.mjs +++ b/packages/colors/test/chain-invariants.test.mjs @@ -245,8 +245,6 @@ test("glow roles emit halo primary + -core/-alpha satellites, all well-formed", kind: "legacy-platform-dependent-v1", }); assert.ok(["legacy-reached", "legacy-unreachable"].includes(role.targetStatus)); - assert.equal(role.degraded, role.targetStatus === "legacy-unreachable"); - assert.ok(Object.is(role.achievedDj, role.haloAchievedDj)); if (role.targetStatus === "legacy-reached") { assert.ok(role.haloAchievedDj >= role.targetDj); } else { diff --git a/packages/colors/test/public-claims.test.mjs b/packages/colors/test/public-claims.test.mjs index d392794c..6e0835d5 100644 --- a/packages/colors/test/public-claims.test.mjs +++ b/packages/colors/test/public-claims.test.mjs @@ -30,6 +30,10 @@ const WHOLE_GLOW_CLAIM = /(?:glow[^.!?\n]*полного результата|полного результата[^.!?\n]*glow)/iu; const FULL_SOLVE_EXACT_INVERSION_CLAIM = /(?:точн[а-яё]*\s+инверси[а-яё]*\s+прямого\s+пути|exact(?:ly)?\s+(?:inverts?|inversion\s+of)\s+the\s+(?:complete\s+)?forward\s+path)/iu; +const RETIRED_SENTIMENT_MODEL = + /sentiment\.rs|`sentiments`|Sentiment(?:Curve|sConfig|Resolution)|LadderSource(?:::|Dto::)Sentiment|UnknownSentiment|resolve_config_sentiment_solid|WARNING_HUE_FLOOR_DEG|S_PERC_MIN|NeighborZone|Sticky Potential|Warning[- ]zone|brand[- ]displacement|achromatic sentiment/iu; +const RETIRED_SENTIMENT_SYMBOL = + /\b(?:SentimentCategory(?:Dto)?|SentimentCurve|SentimentsConfig|SentimentsDto|SentimentResolution|UnknownSentiment|NeighborZone)\b|\b(?:LadderSource|LadderSourceDto)::Sentiment\b|\bSentiment\s*(?:\(|\{)|\b(?:pub\s+)?mod\s+sentiment\s*;|\b(?:compile_sentiment_tint|sentiment_solid_for_mode|sentiment_s_perc_min|resolve_sentiment_hue_among|resolve_config_sentiment_solid(?:_among)?|s_perc_min_(?:from_chromas|frozen))\b|\b(?:WARNING_HUE_FLOOR_DEG|S_PERC_MIN)\b|["'`]sentiments["'`]|\bsentiments\s*:|(?:\bkind|["'`]kind["'`])\s*:\s*["'`]sentiment["'`]/iu; function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); @@ -197,6 +201,46 @@ const LCS_LPC_DRIFT = [ /generic perceptual-contrast math/iu, /метрика\s+называется\s+LPC/iu, ]; +const YS_SCORE_OVERCLAIMS = [ + /signed\s+(?:perceptual\s+contrast|LPC)\b/iu, + /знаков(?:ый|ая|ое|ого)\s+перцептивн[а-яё]*[^.!?\n]{0,48}\bLc\b/iu, + /(?:perceptual\s+LPC\s+target|LPC\s+solution)/iu, + /LPC[- ]перцептивн[а-яё]*\s+цел/iu, + /(?:readability[- ](?:контраст|оценк)|ось\s+читаемости)/iu, +]; +const YS_SCORE_CANONICAL_SURFACES = [ + { + path: "crates/labcolors-core/src/solve.rs", + patterns: [/signed Ys candidate score `Lc`/u, /not an admitted LPC\/readability certificate/iu], + }, + { + path: "crates/labcolors-core/src/semantic.rs", + patterns: [/signed Ys candidate score `Lc`/u, /not[\s\S]{0,40}LPC\/readability verdict/iu], + }, + { + path: "crates/labcolors-wasm/src/lib.rs", + patterns: [ + /Знаковая candidate-координата Ys \(`lc`\)/u, + /не доказательство LPC или читаемости/iu, + ], + }, + { + path: "crates/labcolors-wasm/src/dto.rs", + patterns: [/signed Ys candidate score/iu, /not LPC\/readability evidence/iu], + }, + { + path: "crates/labcolors-ffi/src/lib.rs", + patterns: [/кандидатная оценка `Lc` по `Ys`/u, /не LPC\/readability evidence/iu], + }, + { + path: "crates/labcolors-conformance/src/lib.rs", + patterns: [/кандидатная оценка `Lc` по `Ys`/u, /не LPC\/readability evidence/iu], + }, + { + path: "packages/colors/README.md", + patterns: [/кандидатная оценка по `Ys`/u, /не является LPC\/readability verdict/iu], + }, +]; const DISCARDED_HONEST_RESULT_CLAIMS = [ /ADR[- ]?0002/iu, /honest-result-policy/iu, @@ -229,6 +273,92 @@ function claimFiles(path, files = [], extensions = CLAIM_EXT) { return files; } +function maskRustNonCode(source) { + // Все позиции ниже приходят из String API и потому измерены в UTF-16 code + // units. split("") сохраняет ту же систему координат даже при astral chars. + const masked = source.split(""); + const blank = (start, end) => { + for (let index = start; index < end; index += 1) { + if (masked[index] !== "\n" && masked[index] !== "\r") masked[index] = " "; + } + }; + let cursor = 0; + while (cursor < source.length) { + if (source.startsWith("//", cursor)) { + const end = source.indexOf("\n", cursor + 2); + const stop = end < 0 ? source.length : end; + blank(cursor, stop); + cursor = stop; + continue; + } + if (source.startsWith("/*", cursor)) { + let depth = 1; + let end = cursor + 2; + while (end < source.length && depth > 0) { + if (source.startsWith("/*", end)) { + depth += 1; + end += 2; + } else if (source.startsWith("*/", end)) { + depth -= 1; + end += 2; + } else { + end += 1; + } + } + blank(cursor, end); + cursor = end; + continue; + } + const raw = /^(?:br|r)(#*)"/u.exec(source.slice(cursor)); + if (raw) { + const close = `"${raw[1]}`; + const contentStart = cursor + raw[0].length; + const found = source.indexOf(close, contentStart); + const end = found < 0 ? source.length : found + close.length; + blank(cursor, end); + cursor = end; + continue; + } + const stringPrefix = source.startsWith('b"', cursor) ? 2 : source[cursor] === '"' ? 1 : 0; + if (stringPrefix > 0) { + let end = cursor + stringPrefix; + while (end < source.length) { + if (source[end] === "\\") end += 2; + else if (source[end] === '"') { + end += 1; + break; + } else end += 1; + } + blank(cursor, end); + cursor = end; + continue; + } + const character = /^(?:b)?'(?:\\.|[^'\\\r\n])'/u.exec(source.slice(cursor)); + if (character) { + blank(cursor, cursor + character[0].length); + cursor += character[0].length; + continue; + } + cursor += 1; + } + return masked.join(""); +} + +function isProductionRustPath(file) { + return /[\\/]src[\\/]/u.test(file); +} + +function productionRustFiles() { + return claimFiles(join(ROOT, "crates"), [], /\.rs$/u).filter(isProductionRustPath); +} + +function productionPackageFiles() { + return PACKAGE_MANIFEST.files + .filter((path) => /\.(?:js|mjs|ts)$/u.test(path) && !path.startsWith("pkg/")) + .map((path) => join(PACKAGE_ROOT, path)) + .filter(existsSync); +} + function knownFalseClaims(path, source) { const failures = []; // Regression laws from the false-claim cleanup: these are exact known lies, @@ -290,6 +420,12 @@ function lcsLpcDrift(path, source) { ); } +function ysScoreOverclaim(path, source) { + return YS_SCORE_OVERCLAIMS.filter((pattern) => pattern.test(source)).map( + () => `${path}: frozen Ys candidate score was promoted to LPC/readability evidence`, + ); +} + function discardedHonestResultClaims(path, source) { return DISCARDED_HONEST_RESULT_CLAIMS .filter((pattern) => pattern.test(source)) @@ -305,6 +441,12 @@ function discardedFailureWire(path, source) { ); } +test("production Rust path filter is separator-agnostic", () => { + assert.equal(isProductionRustPath("/repo/crates/core/src/lib.rs"), true); + assert.equal(isProductionRustPath(String.raw`C:\repo\crates\core\src\lib.rs`), true); + assert.equal(isProductionRustPath("/repo/crates/core/tests/src_like.rs"), false); +}); + test("false-claim detector bites without treating hex colours as Issue links", () => { assert.equal(knownFalseClaims("x.md", "см. #89").length, 1); assert.equal(knownFalseClaims("x.md", "цвета #89CFF0 и #8944AB").length, 0); @@ -490,6 +632,43 @@ test("LCS/LPC drift detector bites on every rejected expansion or reduction", () } }); +test("Ys candidate-score detector bites on known evidence promotions", () => { + for (const sample of [ + "signed perceptual contrast Lc", + "знаковый перцептивный контраст Lc", + "perceptual LPC target", + "LPC solution", + "LPC-перцептивная цель", + "readability-контраст", + "ось читаемости", + ]) { + assert.ok(ysScoreOverclaim("x.md", sample).length >= 1, `detector did not bite: ${sample}`); + } + for (const scopedTruth of [ + "signed Ys candidate score (`lc`) from the frozen SAPC-shaped curve", + "`lc` is not LPC/readability evidence", + "WCAG ratio is reported independently", + ]) { + assert.deepEqual(ysScoreOverclaim("x.md", scopedTruth), []); + } +}); + +test("live public surfaces do not promote the frozen Ys score", () => { + const failures = publicClaimFiles().flatMap((file) => + ysScoreOverclaim(relative(ROOT, file), readFileSync(file, "utf8")), + ); + assert.deepEqual(failures, []); +}); + +test("every shipped lc surface carries the canonical scope marker", () => { + for (const { path, patterns } of YS_SCORE_CANONICAL_SURFACES) { + const source = readFileSync(join(ROOT, path), "utf8"); + for (const pattern of patterns) { + assert.match(source, pattern, `${path} lost the Ys candidate-score scope marker`); + } + } +}); + test("live repository keeps canonical LCS/LPC names and evidence boundaries", () => { const files = claimFiles(ROOT, [], REPOSITORY_TEXT_EXT).filter( (file) => file !== SELF, @@ -594,7 +773,6 @@ test("public claim inventory includes the shipped Swift README", () => { assert.ok(publicClaimFiles().includes(join(ROOT, "bindings", "swift", "README.md"))); }); - test("legacy cleanliness proxy is excised, not quarantined", () => { // Инвариант: карантин заменён вырезом. Ни модуль, ни векторное семейство не // существуют; публичные поверхности не несут ни API, ни человеческих @@ -640,3 +818,169 @@ test("legacy cleanliness proxy is excised, not quarantined", () => { assert.doesNotMatch(publicText, forbidden); } }); + +test("legacy sentiment curve is excised instead of preserved as schema", () => { + for (const sample of [ + "sentiment.rs", + "`sentiments`", + "SentimentCurve", + "LadderSource::Sentiment", + "WARNING_HUE_FLOOR_DEG", + "Warning-zone", + "brand-displacement", + ]) { + assert.match(sample, RETIRED_SENTIMENT_MODEL, `detector did not bite: ${sample}`); + } + for (const sample of [ + "SentimentCategoryDto", + "SentimentCurve", + "SentimentsConfig", + "LadderSource::Sentiment", + "enum LadderSource { Sentiment(String) }", + "enum LadderSourceDto { Sentiment { name: String } }", + "pub mod sentiment;", + "resolve_sentiment_hue_among", + "WARNING_HUE_FLOOR_DEG", + '"sentiments"', + 'kind: "sentiment"', + ]) { + assert.match( + sample, + RETIRED_SENTIMENT_SYMBOL, + `production-symbol detector did not bite: ${sample}`, + ); + } + + assert.equal( + existsSync(join(ROOT, "crates/labcolors-core/src/sentiment.rs")), + false, + "the legacy sentiment solver module must stay deleted", + ); + + const surfaces = [ + [ + "crates/labcolors-core/src/config.rs", + /\b(?:SentimentCategory|SentimentsConfig|UnknownSentiment|SentimentResolution)\b|LadderSource::Sentiment|resolve_config_sentiment_solid/gu, + ], + [ + "crates/labcolors-core/src/lib.rs", + /pub mod sentiment|\b(?:SentimentCategory|SentimentsConfig)\b/gu, + ], + [ + "crates/labcolors-wasm/src/config_dto.rs", + /\b(?:SentimentCategoryDto|SentimentsDto)\b|LadderSourceDto::Sentiment/gu, + ], + [ + "crates/labcolors-wasm/src/lib.rs", + /\{ kind: "sentiment"|readonly sentiments\s*:/gu, + ], + ]; + + for (const [path, forbidden] of surfaces) { + assert.doesNotMatch( + readFileSync(join(ROOT, path), "utf8"), + forbidden, + `${path}: retired sentiment-specific API must not resurface`, + ); + } + + const shippedSourceFiles = [...productionRustFiles(), ...productionPackageFiles()]; + const shippedSourcePaths = shippedSourceFiles.map((path) => relative(ROOT, path)); + for (const required of [ + "crates/labcolors-core/src/semantic.rs", + "crates/labcolors-wasm/src/projection.rs", + "crates/labcolors-ffi/src/lib.rs", + "crates/labcolors-conformance/src/lib.rs", + "packages/colors/index.js", + "packages/colors/index.d.ts", + ]) { + assert.ok(shippedSourcePaths.includes(required), `shipped-source scan omitted ${required}`); + } + assert.ok( + shippedSourcePaths.every((path) => + path.endsWith(".rs") || !/(?:^|\/)pkg(?:\/|$)/u.test(path), + ), + "shipped-source scan admitted a generated npm binding", + ); + for (const path of shippedSourceFiles) { + const source = readFileSync(path, "utf8"); + const code = path.endsWith(".rs") ? maskRustNonCode(source) : source; + assert.doesNotMatch( + code, + RETIRED_SENTIMENT_SYMBOL, + `${relative(ROOT, path)}: retired sentiment symbol resurfaced in shipped source`, + ); + } + + const maskedNegativeFixtures = [ + "// SentimentCurve and LadderSource::Sentiment are retired", + "/* nested /* SentimentsConfig */ comment */", + 'const OLD: &str = "SentimentCurve 😀 { };";', + 'const RAW: &str = r#"LadderSource::Sentiment"#;', + "const LETTER: char = 'S';", + "struct CurrentProductionType;", + ].join("\n"); + assert.doesNotMatch( + maskRustNonCode(maskedNegativeFixtures), + RETIRED_SENTIMENT_SYMBOL, + "comments and literal negative fixtures must not poison the source scan", + ); + const retiredTestType = [ + "#[cfg(test)]", + "struct SentimentCurve;", + ].join("\n"); + assert.match( + maskRustNonCode(retiredTestType), + RETIRED_SENTIMENT_SYMBOL, + "retired model code is forbidden even when cfg(test)-gated", + ); + + const documentation = [ + ...publicClaimFiles().filter((path) => /(?:\.md|\.d\.ts)$/u.test(path)), + ...claimFiles(join(ROOT, "docs"), [], /\.md$/u), + ...claimFiles(join(ROOT, "reference"), [], /\.md$/u), + ].filter((path, index, all) => all.indexOf(path) === index); + for (const path of documentation) { + assert.doesNotMatch( + readFileSync(path, "utf8"), + RETIRED_SENTIMENT_MODEL, + `${relative(ROOT, path)}: retired sentiment model must not survive as live documentation`, + ); + } + + const canonicalConfigSurfaces = [ + "crates/labcolors-wasm/tests/data/labui.config.json", + "crates/labcolors-wasm/tests/data/labui.config.prod.json", + "crates/labcolors-wasm/tests/chain_invariants.rs", + "crates/labcolors-wasm/tests/wasm_parity.rs", + "scripts/verify-package-release.mjs", + ]; + for (const path of canonicalConfigSurfaces) { + assert.doesNotMatch( + readFileSync(join(ROOT, path), "utf8"), + /["']sentiments["']|["']kind["']\s*:\s*["']sentiment["']/iu, + `${path}: canonical configs must not preserve the retired wire schema`, + ); + } +}); + +test("the retired Lab UI accent catalogue stays absent from generic Core", () => { + assert.equal( + existsSync(join(ROOT, "crates/labcolors-core/src/accent.rs")), + false, + "the closed Lab UI catalogue must not survive as a self-testing fixture", + ); + const lib = readFileSync(join(ROOT, "crates/labcolors-core/src/lib.rs"), "utf8"); + assert.doesNotMatch(lib, /\bmod accent\s*;/u); + + for (const path of [ + "crates/labcolors-core/src/config.rs", + "crates/labcolors-core/src/semantic.rs", + ]) { + assert.doesNotMatch( + readFileSync(join(ROOT, path), "utf8"), + /crate::accent::/u, + `${path}: generic colour math must not depend on the Lab UI test catalogue`, + ); + } +}); diff --git a/packages/colors/test/release-contract.test.mjs b/packages/colors/test/release-contract.test.mjs index 360b6495..5f12f1a9 100644 --- a/packages/colors/test/release-contract.test.mjs +++ b/packages/colors/test/release-contract.test.mjs @@ -23,6 +23,7 @@ import { validateSolveFamily, validateWcag22EvidenceArtifacts, } from "../../../scripts/verify-package-release.mjs"; +import { workspacePackageTable } from "../../../scripts/cargo-workspace.mjs"; const here = dirname(fileURLToPath(import.meta.url)); const root = resolve(here, "../../.."); @@ -83,21 +84,37 @@ function assertCheckoutCredentialsAreEphemeral(workflow, name) { } } -test("breaking release metadata is one explicit 0.2.0/0.10.0 contract", () => { - const workspace = read("Cargo.toml"); - assert.match(workspace, /\[workspace\.package\][\s\S]*\nversion = "0\.2\.0"/); - assert.match(workspace, /\nrust-version = "1\.85"/); - assert.match( - workspace, - /repository = "https:\/\/github\.com\/Labpics-Team\/lab-colors"/, +function tomlString(table, key) { + const escaped = key.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const matches = [ + ...table.matchAll( + new RegExp(`^[ \\t]*${escaped}[ \\t]*=[ \\t]*\"([^\"\\r\\n]+)\"[ \\t]*(?:#.*)?$`, "gmu"), + ), + ]; + assert.equal(matches.length, 1, `expected exactly one ${key} in [workspace.package]`); + return matches[0][1]; +} + +function assertWorkspaceReleaseMetadata(source) { + const workspacePackage = workspacePackageTable(source); + assert.equal(tomlString(workspacePackage, "version"), "0.3.0"); + assert.equal(tomlString(workspacePackage, "rust-version"), "1.85"); + assert.equal( + tomlString(workspacePackage, "repository"), + "https://github.com/Labpics-Team/lab-colors", ); +} + +test("breaking release metadata is one explicit 0.3.0/0.11.0 contract", () => { + const workspace = read("Cargo.toml"); + assertWorkspaceReleaseMetadata(workspace); const packageJson = JSON.parse(read("packages", "colors", "package.json")); const packageLock = JSON.parse(read("packages", "colors", "package-lock.json")); - assert.equal(packageJson.version, "0.10.0"); + assert.equal(packageJson.version, "0.11.0"); assert.equal(packageJson.packageManager, "npm@11.9.0"); - assert.equal(packageLock.version, "0.10.0"); - assert.equal(packageLock.packages[""].version, "0.10.0"); + assert.equal(packageLock.version, "0.11.0"); + assert.equal(packageLock.packages[""].version, "0.11.0"); assert.equal(packageJson.engines.node, ">=22.11.0"); assert.equal(packageLock.packages[""].engines.node, ">=22.11.0"); assert.equal( @@ -107,6 +124,28 @@ test("breaking release metadata is one explicit 0.2.0/0.10.0 contract", () => { assert.match(packageJson.scripts.build, /wasm-pack build .* --locked$/); }); +test("workspace release metadata cannot be rescued by a later TOML table", () => { + const expected = { + version: "0.3.0", + "rust-version": "1.85", + repository: "https://github.com/Labpics-Team/lab-colors", + }; + for (const poisoned of Object.keys(expected)) { + const actual = { ...expected, [poisoned]: "wrong" }; + assert.throws(() => assertWorkspaceReleaseMetadata(` +[workspace.package] +version = "${actual.version}" +rust-version = "${actual["rust-version"]}" +repository = "${actual.repository}" + +[workspace.metadata.release] +version = "0.3.0" +rust-version = "1.85" +repository = "https://github.com/Labpics-Team/lab-colors" +`), `later table rescued poisoned ${poisoned}`); + } +}); + test("every workspace package inherits the declared MSRV", () => { const manifests = readdirSync(join(root, "crates"), { withFileTypes: true }) .filter((entry) => entry.isDirectory()) @@ -1148,7 +1187,7 @@ test("release evidence carries no trace of the excised offline line", () => { test("WASM role size budgets are exact, append-only, and acyclic", async () => { const bench = join(root, "packages", "colors", "bench"); const paths = Object.fromEntries( - [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13].map((version) => [ + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].map((version) => [ `v${version}`, join(bench, `wasm-size-budget-v${version}.json`), ]), @@ -1170,6 +1209,8 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { v11: "fa11531ee390dd6dfdfadfadab99bbe8277f2b152b567951b17ef6093d42b1e4", v12: "925452113b18b63137b9dae4786e3a8f7ba098eb47a2631a97107fbd52aa9a95", v13: "3cc88303a0f43e8ca33ae70d723a3179c68b0cc2744310a791e8f43885482f34", + v14: "20c5886e3edaa6eaf3e37b915d81982a3a13e30064fc7fa8eb702eda38a20fb6", + v15: "979dc7990742cc2daf163c64051ed6a22c939745a4b1767bc010d6faaaba5161", }; const documents = {}; for (const version of Object.keys(paths)) { @@ -1180,7 +1221,8 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { if (version !== "v1") assert.equal(bytes.toString("utf8"), canonicalJson(value)); } - const { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13 } = documents; + const { v1, v2, v3, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14, v15 } = + documents; assert.equal(v1.budgetId, "labcolors-wasm-raw-issue-284-v1"); assert.equal(v2.budgetId, "labcolors-wasm-raw-issue-295-v2"); assert.equal(v3.budgetId, "labcolors-wasm-raw-issue-296-v3"); @@ -1445,11 +1487,63 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { "C5.2 must shrink the runtime by the exact excision delta", ); + // V14 (C6): legacy sentiment/recipe API вырезан из Core и WASM — канонический + // Linux runtime уменьшился ещё на 26534B (run 29647236232). + assert.equal(v14.schemaVersion, 8); + assert.equal(v14.budgetId, "labcolors-wasm-runtime-c6-legacy-excision-v14"); + assert.deepEqual(v14.predecessor, { + path: "packages/colors/bench/wasm-size-budget-v13.json", + fileSha256: expectedHashes.v13, + }); + assert.deepEqual(v14.toolchainSource, v13.toolchainSource); + assert.deepEqual(v14.buildRecipes, v13.buildRecipes); + assert.deepEqual(v14.roles.runtime.measurement, { + source: "github-actions-run-29647236232", + measurementPlatform: "linux-x64", + rawBytes: 428540, + }); + assert.deepEqual(v14.roles.runtime.policy, { + maxRawBytes: 428540, + basis: "accepted-c6-legacy-excision-snapshot", + gzip: "diagnostic-only", + }); + assert.equal( + v13.roles.runtime.policy.maxRawBytes - v14.roles.runtime.policy.maxRawBytes, + 26534, + "C6 must shrink the runtime by the exact legacy-excision delta", + ); + + // V15 (C6): validation больше не повторяется, а encoded-sRGB boundary сведена + // к SSOT; Linux-ратчет ниже на 137B (run 29654779091). + assert.equal(v15.schemaVersion, 8); + assert.equal(v15.budgetId, "labcolors-wasm-runtime-c6-validation-srgb-ssot-v15"); + assert.deepEqual(v15.predecessor, { + path: "packages/colors/bench/wasm-size-budget-v14.json", + fileSha256: expectedHashes.v14, + }); + assert.deepEqual(v15.toolchainSource, v14.toolchainSource); + assert.deepEqual(v15.buildRecipes, v14.buildRecipes); + assert.deepEqual(v15.roles.runtime.measurement, { + source: "github-actions-run-29654779091", + measurementPlatform: "linux-x64", + rawBytes: 428403, + }); + assert.deepEqual(v15.roles.runtime.policy, { + maxRawBytes: 428403, + basis: "accepted-c6-validation-srgb-ssot-snapshot", + gzip: "diagnostic-only", + }); + assert.equal( + v14.roles.runtime.policy.maxRawBytes - v15.roles.runtime.policy.maxRawBytes, + 137, + "validation and encoded-sRGB SSOT must lower the exact runtime ratchet", + ); + const checker = await import( new URL("../../../scripts/check-wasm-size-budget.mjs", import.meta.url) ); - assert.equal(checker.DEFAULT_BUDGET, paths.v13); - for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]) { + assert.equal(checker.DEFAULT_BUDGET, paths.v15); + for (const version of [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) { assert.equal(checker[`V${version}_FILE_SHA256`], expectedHashes[`v${version}`]); } assert.equal(checker.V1_RECIPE_SHA256, v5.buildRecipes.runtime.recipeSha256); @@ -1475,7 +1569,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { return `"--remap-path-prefix=\$${mapping.slice(0, separator)}=${mapping.slice(separator + 1)}"`; }) .join("$'\\x1f'")}`; - const runtimeCommand = v13.buildRecipes.runtime.command; + const runtimeCommand = v15.buildRecipes.runtime.command; assert.ok(runtimeCommand.startsWith(recipePrefix)); const expectedBuild = runtimeCommand.slice(recipePrefix.length); const expectedDiffBlock = [ @@ -1552,13 +1646,13 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { assert.notEqual(pathBypass, repetition, "path mutation must bite the live guard"); assert.throws(() => assertRepeatabilityContract(pathBypass)); - const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-runtime-budget-v11-")); + const temporary = mkdtempSync(join(tmpdir(), "labcolors-wasm-runtime-budget-v15-")); try { const runtimePath = join(temporary, "runtime.wasm"); const fixtureBudgetPath = join(temporary, "budget.json"); const runtimeBytes = Buffer.alloc(16); runtimeBytes.set([0x00, 0x61, 0x73, 0x6d]); - const fixture = structuredClone(v13); + const fixture = structuredClone(v15); fixture.roles.runtime.measurement.rawBytes = runtimeBytes.length; fixture.roles.runtime.policy.maxRawBytes = runtimeBytes.length; writeFileSync(runtimePath, runtimeBytes); @@ -1567,17 +1661,18 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { checker.parseBudgetDocument(readFileSync(fixtureBudgetPath), fixtureBudgetPath) ); - const run = () => execFileSync( + const runWith = (budgetPath, wasmPath) => execFileSync( process.execPath, [ checkerPath, "--budget", - fixtureBudgetPath, + budgetPath, "--runtime-wasm", - runtimePath, + wasmPath, ], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, ); + const run = () => runWith(fixtureBudgetPath, runtimePath); const output = run(); assert.match( output, @@ -1630,7 +1725,7 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { // acceptedCeiling-закона. Чекер обязан отклонить его всё равно: // рост сверх принятого снапшота требует НОВОЙ версии бюджета, // а не правки текущей. - const ceiling = v13.roles.runtime.policy.maxRawBytes; + const ceiling = v15.roles.runtime.policy.maxRawBytes; value.roles.runtime.measurement.rawBytes = ceiling + 1; value.roles.runtime.policy.maxRawBytes = ceiling + 1; }], @@ -1644,14 +1739,29 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { roles: value.roles, })], ]; - assert.equal(schemaMutations.length, 24, "v11 schema mutation set changed"); + assert.equal(schemaMutations.length, 24, "v15 schema mutation set changed"); for (const [name, mutate] of schemaMutations) { const invalid = structuredClone(fixture); const result = mutate(invalid) ?? invalid; - writeFileSync(fixtureBudgetPath, canonicalJson(result)); - assert.throws(run, undefined, `${name} must fail the checker`); + assert.throws( + () => checker.parseBudgetDocument( + Buffer.from(canonicalJson(result)), + fixtureBudgetPath, + ), + /WASM size budget:/u, + `${name} must fail schema validation before artifact evaluation`, + ); } + const schemaFirst = structuredClone(fixture); + schemaFirst.budgetId = "other"; + writeFileSync(fixtureBudgetPath, canonicalJson(schemaFirst)); + assert.throws( + () => runWith(fixtureBudgetPath, join(temporary, "missing-runtime.wasm")), + /budgetId must be labcolors-wasm-runtime-c6-validation-srgb-ssot-v15/u, + "CLI must reject the budget schema before reading a missing artifact", + ); + writeFileSync(fixtureBudgetPath, `${JSON.stringify(fixture)}\n`); assert.throws(run, /canonical JSON/u, "non-canonical JSON must fail"); writeFileSync( @@ -1704,8 +1814,8 @@ test("WASM role size budgets are exact, append-only, and acyclic", async () => { coordinatedMutation.roles.runtime.measurement.rawBytes -= 1; coordinatedMutation.roles.runtime.policy.maxRawBytes -= 1; assert.throws( - () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v13), - /current v13 file SHA-256 mismatch/u, + () => checker.parseBudgetDocument(Buffer.from(canonicalJson(coordinatedMutation)), paths.v15), + /current v15 file SHA-256 mismatch/u, "coordinated artifact and document drift must still fail the default identity", ); } finally { diff --git a/packages/colors/test/release-provenance.test.mjs b/packages/colors/test/release-provenance.test.mjs index db689ff8..451b2fd2 100644 --- a/packages/colors/test/release-provenance.test.mjs +++ b/packages/colors/test/release-provenance.test.mjs @@ -103,7 +103,36 @@ edition = "2024" [workspace.metadata.release] version = "8.8.8" `), - /workspace core version is absent/u, + /\[workspace\.package\]\.version is absent/u, + ); + assert.throws( + () => workspaceVersion(` +[workspace.package] +description = """ +[not.a.table] +""" +version = "0.3.0" +`), + /multiline TOML strings are unsupported/u, + ); + assert.throws( + () => workspaceVersion(` +[workspace.package] +description = ''' +[not.a.table] +''' +version = "0.3.0" +`), + /multiline TOML strings are unsupported/u, + ); + assert.equal( + workspaceVersion(` +# documentation mentions """ but does not open a TOML string +[workspace.package] +version = "0.3.0" # the retired parser rejected a comment containing ''' +description = '"""' +`), + "0.3.0", ); assert.throws( () => workspaceVersion(` @@ -113,7 +142,7 @@ edition = "2024" [[example]] version = "7.7.7" `), - /workspace core version is absent/u, + /\[workspace\.package\]\.version is absent/u, ); }); diff --git a/packages/colors/test/wasm-boundary.golden.json b/packages/colors/test/wasm-boundary.golden.json index cc38ee0b..ec0f7408 100644 --- a/packages/colors/test/wasm-boundary.golden.json +++ b/packages/colors/test/wasm-boundary.golden.json @@ -1 +1 @@ -{"_meta":{"purpose":"Байт-точный golden границы JS↔WASM: recheckContrast/_recheckContrastMulti и полный snapshot resolveTheme.vars.","regenerated":"2026-07-10","regen_reason":"ADR-0004: point-glow решается по конечным encoded-sRGB8 screen-state; alpha эмитируется кратчайшей decimal-записью без повторного округления.","drift_scope":"Относительно origin/main изменены ровно 24 листа resolveVars: только --lab-fx-glow-{brand,danger,warning,neutral}-alpha на 6 фонах. Ни один base/core, label, border, fill, background или другой var не изменён.","recheck_unchanged":"Секция recheck: 2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), байт-идентичны origin/main; ни одно значение recheckContrast не изменено.","numeric_provenance":{"glow_alpha":"Старый fixed-48 bisection + 4-decimal alpha заменён перебором конечных sRGB8-state, внутренней alpha выбранного интервала и shortest-roundtrip binary64 CSS.","scope_check":"Фактический структурный diff вычислен по каждому листу resolveVars; 4 alpha-ключа × 6 записей = 24."},"wcag_floors_not_weakened":"Glow — декоративная роль без WCAG-пола. Отдельно подтверждено: все 1376 пар (2752 числовых значения) recheck неизменны, поэтому измеренный контраст ни одной проверяемой роли не ослаблен.","isolated_verification_required":"Регенерация принимается только после независимой проверки scope, recheck-инварианта и полного Rust/npm/browser gate.","partition_correction":{"regenerated":"2026-07-11","reason":"Коррекция канонической binary64 alpha по фактическому midpoint encoded-sRGB8 partition без дополнительного округления.","drift_scope":"Ровно 17 alpha leaves: light/#000000 danger и четыре Glow alpha на каждом из #808080, #3A3A3C, #007AFF, #FF3B30; остальные resolveVars-листья неизменны.","recheck_unchanged":"2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), побитно неизменны.","numeric_provenance":{"halo":"#FF3B30","background":"#000000","state":"#030101","lower_bits":"0x3f85555555555555","upper_bits":"0x3f8c1c1c1c1c1c1c","midpoint_bits":"0x3f88b8b8b8b8b8b8","midpoint":"0.012071078431372548","previous_bits":"0x3f88b8b8b8b8b8b9","previous_relation":"1 ULP выше, внутри той же partition"},"exhaustive_proof":"65 536 channel partitions и seam: state/composite outputs неизменны.","performance":"Full-range → bracketed exact search: base 19.7→4.37 µs, bloom 74.1→16.2 µs, long 656→139 µs."},"history":{"previous_regenerated":"2026-07-10","previous_regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","previous_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.","previous_recheck_unchanged":"Исторический regen также не менял recheck; прежний текст ошибочно называл 2752 числа парами. Фактический объём: 1376 пар (lc, wcag).","previous_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."},"previous_wave1_scope":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"endpoint_recovery":{"regenerated":"2026-07-17","reason":"Acceptance is checked before low-contrast dead-zone and physical-endpoint rejection, so previously false-failed opaque border endpoints now resolve.","drift_scope":"Exactly 9 resolveVars leaves added: border-{brand,danger,info}-strong on light #808080, #007AFF and #FF3B30. No existing leaf changed or disappeared.","recheck_unchanged":"Every committed recheck value remains bit-identical; the parity test compares all 2752 numbers before resolveVars.","proof":"agnostic_gates::accepted_endpoint_recovery_keeps_previously_false_failed_borders_legal plus full Rust workspace and WASM boundary parity."}},"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":"0.5","--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.5","--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.5","--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.5","--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.013010286081645773","--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.012071078431372548","--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.009560345877481427","--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.00784313725490196","--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.11057506131405687","--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.11023622047244083","--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.07169755954418727","--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.04724409448818886","--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)","--lab-border-brand-strong":"oklch(34.17043% 0.122830 257.309 / 1)","--lab-border-danger-strong":"oklch(35.54757% 0.145582 28.627 / 1)","--lab-border-info-strong":"oklch(34.39884% 0.140838 260.069 / 1)"}},{"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.06909778454881221","--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.060913705583756306","--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.040609137055837526","--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.028061954965508236","--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.1414396647356093","--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.1805418331471749","--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.09073588245366788","--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.06430782839451565","--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)","--lab-border-brand-strong":"oklch(33.52307% 0.122654 257.729 / 1)","--lab-border-danger-strong":"oklch(34.90075% 0.142917 28.593 / 1)","--lab-border-info-strong":"oklch(33.98919% 0.139378 260.097 / 1)"}},{"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.1835748792270531","--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.4395613333149519","--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.1777791862086449","--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.1016772651089421","--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)","--lab-border-brand-strong":"oklch(36.69778% 0.133197 257.540 / 1)","--lab-border-danger-strong":"oklch(38.31782% 0.156989 28.748 / 1)","--lab-border-info-strong":"oklch(37.14168% 0.149591 259.762 / 1)"}}]} +{"_meta":{"purpose":"Байт-точный golden границы JS↔WASM: recheckContrast/_recheckContrastMulti и полный snapshot resolveTheme.vars.","regenerated":"2026-07-10","regen_reason":"ADR-0004: point-glow решается по конечным encoded-sRGB8 screen-state; alpha эмитируется кратчайшей decimal-записью без повторного округления.","drift_scope":"Относительно origin/main изменены ровно 24 листа resolveVars: только --lab-fx-glow-{brand,danger,warning,neutral}-alpha на 6 фонах. Ни один base/core, label, border, fill, background или другой var не изменён.","recheck_unchanged":"Секция recheck: 2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), байт-идентичны origin/main; ни одно значение recheckContrast не изменено.","numeric_provenance":{"glow_alpha":"Старый fixed-48 bisection + 4-decimal alpha заменён перебором конечных sRGB8-state, внутренней alpha выбранного интервала и shortest-roundtrip binary64 CSS.","scope_check":"Фактический структурный diff вычислен по каждому листу resolveVars; 4 alpha-ключа × 6 записей = 24."},"wcag_floors_not_weakened":"Glow — декоративная роль без WCAG-пола. Отдельно подтверждено: все 1376 пар (2752 числовых значения) recheck неизменны, поэтому измеренный контраст ни одной проверяемой роли не ослаблен.","isolated_verification_required":"Регенерация принимается только после независимой проверки scope, recheck-инварианта и полного Rust/npm/browser gate.","partition_correction":{"regenerated":"2026-07-11","reason":"Коррекция канонической binary64 alpha по фактическому midpoint encoded-sRGB8 partition без дополнительного округления.","drift_scope":"Ровно 17 alpha leaves: light/#000000 danger и четыре Glow alpha на каждом из #808080, #3A3A3C, #007AFF, #FF3B30; остальные resolveVars-листья неизменны.","recheck_unchanged":"2752/2752 числовых значений, то есть 1376/1376 пар (lc, wcag), побитно неизменны.","numeric_provenance":{"halo":"#FF3B30","background":"#000000","state":"#030101","lower_bits":"0x3f85555555555555","upper_bits":"0x3f8c1c1c1c1c1c1c","midpoint_bits":"0x3f88b8b8b8b8b8b8","midpoint":"0.012071078431372548","previous_bits":"0x3f88b8b8b8b8b8b9","previous_relation":"1 ULP выше, внутри той же partition"},"exhaustive_proof":"65 536 channel partitions и seam: state/composite outputs неизменны.","performance":"Full-range → bracketed exact search: base 19.7→4.37 µs, bloom 74.1→16.2 µs, long 656→139 µs."},"history":{"previous_regenerated":"2026-07-10","previous_regen_reason":"Глава #64 (ADR-0003): ось читаемости активирована в домене Ys — реактивный recheck (measure_contrast/recheck_against) и solver-вывод считаются в Ys. recheck flat и resolveVars регенерированы против пересобранного движка; inputs (theme/bg/fgs) сохранены. semver-major по ADR.","previous_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.","previous_recheck_unchanged":"Исторический regen также не менял recheck; прежний текст ошибочно называл 2752 числа парами. Фактический объём: 1376 пар (lc, wcag).","previous_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); exact achromatic source stays on the neutral ray."},"previous_wave1_scope":"resolveVars: 69 info-* leaves changed across 6 theme×bg entries. Zero non-info drift (scope-checked). recheck untouched."},"endpoint_recovery":{"regenerated":"2026-07-17","reason":"Acceptance is checked before low-contrast dead-zone and physical-endpoint rejection, so previously false-failed opaque border endpoints now resolve.","drift_scope":"Exactly 9 resolveVars leaves added: border-{brand,danger,info}-strong on light #808080, #007AFF and #FF3B30. No existing leaf changed or disappeared.","recheck_unchanged":"Every committed recheck value remains bit-identical; the parity test compares all 2752 numbers before resolveVars.","proof":"agnostic_gates::accepted_endpoint_recovery_keeps_previously_false_failed_borders_legal plus full Rust workspace and WASM boundary parity."}},"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":"0.5","--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.5","--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.5","--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.5","--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.013010286081645773","--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.012071078431372548","--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.009560345877481427","--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.00784313725490196","--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.11057506131405687","--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.11023622047244083","--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.07169755954418727","--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.04724409448818886","--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)","--lab-border-brand-strong":"oklch(34.17043% 0.122830 257.309 / 1)","--lab-border-danger-strong":"oklch(35.54757% 0.145582 28.627 / 1)","--lab-border-info-strong":"oklch(34.39884% 0.140838 260.069 / 1)"}},{"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.06909778454881221","--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.060913705583756306","--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.040609137055837526","--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.028061954965508236","--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.1414396647356093","--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.1805418331471749","--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.09073588245366788","--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.06430782839451565","--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)","--lab-border-brand-strong":"oklch(33.52307% 0.122654 257.729 / 1)","--lab-border-danger-strong":"oklch(34.90075% 0.142917 28.593 / 1)","--lab-border-info-strong":"oklch(33.98919% 0.139378 260.097 / 1)"}},{"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.1835748792270531","--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.4395613333149519","--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.1777791862086449","--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.1016772651089421","--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)","--lab-border-brand-strong":"oklch(36.69778% 0.133197 257.540 / 1)","--lab-border-danger-strong":"oklch(38.31782% 0.156989 28.748 / 1)","--lab-border-info-strong":"oklch(37.14168% 0.149591 259.762 / 1)"}}]} diff --git a/reference/labui-accent-primitives.md b/reference/labui-accent-primitives.md index 0e5f0870..d541f425 100644 --- a/reference/labui-accent-primitives.md +++ b/reference/labui-accent-primitives.md @@ -1,28 +1,21 @@ -# Палитра акцентов: протокол замера примитивов Figma +# Lab UI: исходные якоря цветовых семейств -Документ-источник якорных hex модуля `accent.rs` ([`Accent::anchor_hex`]). -Значения сняты с живого Figma-файла 2026-07-02 и служат SSOT для 10 семейств -палитры; четыре из них дополнительно являются прототипами сентиментов -(`sentiment.rs`). +Этот reference фиксирует измеренные данные клиентской палитры Lab UI. Они +являются fixture клиента и не задают встроенную семантику Lab Colors. -## 1. Протокол замера +## Провенанс | параметр | значение | |---|---| -| Файл | 🧪Lab UI (v.1), fileKey `LuaiBd4anRi4DMZayKAnY2` | -| Коллекция переменных | «🔵 4.1 Primitives» (VariableCollectionId `7644:352`) | -| Режимы | Light-mode · Dark-mode · Light-mode/IC · Dark-mode/IC | -| Инструмент | figma-console MCP (Desktop Bridge, WebSocket), `figma_get_variables` (namePattern `Accent`, `resolveAliases: true`) | -| Дата замера | 2026-07-02 (MSK) | -| Вычисление Oklab-оттенка | `#[cfg(test)]`-путь `accent::Accent::prototype_hue` (srgb→Oklab→atan2, тот же путь, что рампа акцента); значения пинит тест `accent_anchor_hex_matches_figma_primitives_light_mode` | +| Figma-файл | 🧪Lab UI (v.1), `LuaiBd4anRi4DMZayKAnY2` | +| Коллекция | `🔵 4.1 Primitives`, `7644:352` | +| Переменные | `Accent/` | +| Режимы | Light · Dark · Light/IC · Dark/IC | +| Дата снимка | 2026-07-02 | -## 2. Сырые значения (живой Figma, все режимы) +## Якоря -Базовые семейства палитры (переменные `Accent/`). `Accent/Brand` -приведён для полноты, но НЕ входит в перечисление [`Accent`]: бренд — это вход -(настраиваемый оттенок, дефолт `#007AFF`), а не фиксированное семейство. - -| переменная | Light-mode | Dark-mode | Light-mode/IC | Dark-mode/IC | +| переменная | Light | Dark | Light/IC | Dark/IC | |---|---|---|---|---| | `Accent/Brand` | `#007AFF` | `#4A8FFF` | `#0040DD` | `#409CFF` | | `Accent/Red` | `#FF3B30` | `#FF3A3A` | `#D70015` | `#FF6161` | @@ -36,63 +29,16 @@ | `Accent/Purple` | `#AF52DE` | `#BF5AF2` | `#8944AB` | `#DA8FFF` | | `Accent/Pink` | `#FF2D55` | `#FF2D55` | `#D30F45` | `#FF6482` | -Примечания. -* Коллекция несёт также рампу альфа-производных (`Accent/Derivable//@NN`, - где `NN` — процент прозрачности): `@2 @4 @8 @12 @20 @32 @52 @72 @80`. Это - вход для **альфа-слайса** (композит-инверсия из солида), НЕ входящего в - текущую задачу; здесь фиксируется факт их существования, значения не - переносятся. -* `accent.rs` анкорится на **Light-mode**: из якоря берётся только Oklab-оттенок - (прототип), а рампа строится законом [`AccentCurve`] на нейтральной лестнице. - Тёмный/IC-вариант — часть замера (эта таблица), но не участвует в построении - рампы (см. модульную документацию `accent.rs`). - -## 3. Вычисленные Oklab-оттенки якорей (Light-mode) - -Прототипный оттенок = Oklab-оттенок светлого якоря, `Accent::prototype_hue`. -Числа воспроизводятся скриптом §1. Порядок строк — порядок вариантов `Accent` -(= порядок коллекции Figma / семейств HIG), а НЕ сортировка по оттенку: оттенок -не монотонен (Teal 230.8° раньше Mint 189.0°, Pink заворачивается к 17.9°). - -| семейство | якорь (Light) | ключ | Oklab h° | сентимент | -|---|---|---|---|---| -| Red | `#FF3B30` | `red` | 28.6592 | Danger | -| Orange | `#FFA100` | `orange` | 68.6070 | Warning | -| Yellow | `#FFD000` | `yellow` | 92.2265 | — | -| Green | `#34C759` | `green` | 147.4439 | Success | -| Teal | `#5AC8FA` | `teal` | 230.8271 | — | -| Mint | `#00C7BE` | `mint` | 189.0284 | — | -| Blue | `#3E87FF` | `blue` | 259.8918 | Info | -| Indigo | `#5856D6` | `indigo` | 278.3368 | — | -| Purple | `#AF52DE` | `purple` | 312.4106 | — | -| Pink | `#FF2D55` | `pink` | 17.8982 | — | - -## 4. Кросс-проверка: сентименты ссылаются на палитру, не дублируют - -Четыре сентимента (`sentiment.rs`) более не хранят собственные копии hex — -[`Sentiment::accent`] отображает их на семейство палитры, а `anchor_hex` -делегирует в [`Accent::anchor_hex`] (устранение дублирования). Отображение и -совпадение оттенков заземлены Figma: - -| сентимент | семейство | якорь | Oklab h° | -|---|---|---|---| -| Danger | Red | `#FF3B30` | 28.6592 | -| Warning | Orange | `#FFA100` | 68.6070 | -| Success | Green | `#34C759` | 147.4439 | -| Info | Blue | `#3E87FF` | 259.8918 | +`Accent/Brand` — отдельный клиентский вход. Остальные строки образуют обычные +семейства `palette`; их ключи не имеют специального поведения в Core. -Эти четыре значения совпадают с прежними константами `sentiment.rs` -(и с ранее заземлёнными в `reference/labui-figma-structure.md` / -`s_perc_min_derivation_identity`), поэтому делегирование ничего не сдвинуло: -существующие тесты сентиментов остаются зелёными. +Коллекция также содержит `Accent/Derivable//@NN`. Эти значения +принадлежат клиентскому ladder-контракту прозрачности, а не физическому смыслу +семейства. -## 5. Статус провенанса +## Контроль дрейфа -* Якорные hex `accent.rs` — измеренные (не выведенные): подтверждены живым - Figma на 2026-07-02. -* Якорные hex НЕ являются числовыми POLICY-константами перцептивных модулей - (это строковые данные палитры, как `Sentiment::anchor_hex` до рефакторинга), - поэтому в `docs/empirical-inventory.md` строк не заводят — грунт держится - этим документом + тестом `accent_anchor_hex_matches_figma_primitives_light_mode`. -* При изменении переменных `Accent/*` в Figma документ устаревает — повторить §1 - и пересчитать §3 (одна команда, §1). +При изменении Figma-переменных обновляются одновременно этот снимок и обе +Lab UI fixture. Тест конфиг-границы проверяет, что каждый family-source во всех +четырёх контекстах возвращает ровно объявленный якорь, включая +`Accent/Orange` Light/IC = `#C93400`. diff --git a/scripts/cargo-workspace.mjs b/scripts/cargo-workspace.mjs index 5f99ceaa..1f86dfec 100644 --- a/scripts/cargo-workspace.mjs +++ b/scripts/cargo-workspace.mjs @@ -4,15 +4,58 @@ const ANY_TABLE_HEADER = /^[ \t]*(?:\[[^\[\]\r\n]+\]|\[\[[^\[\]\r\n]+\]\])[ \t]*(?:#.*)?\r?$/mu; const VERSION_ENTRY = /^[ \t]*version[ \t]*=[ \t]*"([^"\r\n]+)"[ \t]*(?:#.*)?\r?$/mu; -/** Читает root version, не пересекая границу TOML-таблицы `[workspace.package]`. */ -export function workspaceVersion(cargoSource) { +function assertNoMultilineStrings(source) { + let state = "code"; + for (let index = 0; index < source.length; index += 1) { + const char = source[index]; + if (state === "comment") { + if (char === "\n") state = "code"; + continue; + } + if (state === "basic") { + if (char === "\\") index += 1; + else if (char === '"') state = "code"; + else if (char === "\n") state = "code"; + continue; + } + if (state === "literal") { + if (char === "'") state = "code"; + else if (char === "\n") state = "code"; + continue; + } + if (char === "#") { + state = "comment"; + } else if (char === '"') { + if (source.startsWith('"""', index)) { + throw new Error("multiline TOML strings are unsupported by the workspace parser"); + } + state = "basic"; + } else if (char === "'") { + if (source.startsWith("'''", index)) { + throw new Error("multiline TOML strings are unsupported by the workspace parser"); + } + state = "literal"; + } + } +} + +/** Изолирует workspace metadata от последующих TOML-таблиц. + * Инвариант: возвращённый диапазон не содержит другую таблицу. */ +export function workspacePackageTable(cargoSource) { + assertNoMultilineStrings(cargoSource); const header = WORKSPACE_PACKAGE_HEADER.exec(cargoSource); if (header) { const remainder = cargoSource.slice(header.index + header[0].length); const nextTable = remainder.search(ANY_TABLE_HEADER); - const workspacePackage = nextTable < 0 ? remainder : remainder.slice(0, nextTable); - const version = VERSION_ENTRY.exec(workspacePackage)?.[1]; - if (version) return version; + return nextTable < 0 ? remainder : remainder.slice(0, nextTable); } - throw new Error("workspace core version is absent"); + throw new Error("workspace.package table is absent"); +} + +/** Читает release-версию только из `[workspace.package]`. + * Инвариант: одноимённые ключи последующих таблиц не влияют на результат. */ +export function workspaceVersion(cargoSource) { + const version = VERSION_ENTRY.exec(workspacePackageTable(cargoSource))?.[1]; + if (version) return version; + throw new Error("[workspace.package].version is absent"); } diff --git a/scripts/check-wasm-size-budget.mjs b/scripts/check-wasm-size-budget.mjs index 0fe23953..481c4a72 100644 --- a/scripts/check-wasm-size-budget.mjs +++ b/scripts/check-wasm-size-budget.mjs @@ -20,10 +20,12 @@ const V9_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v9.js const V10_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v10.json"); const V11_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v11.json"); const V12_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v12.json"); +const V13_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v13.json"); +const V14_PATH = resolve(REPO_ROOT, "packages/colors/bench/wasm-size-budget-v14.json"); export const DEFAULT_BUDGET = resolve( REPO_ROOT, - "packages/colors/bench/wasm-size-budget-v13.json", + "packages/colors/bench/wasm-size-budget-v15.json", ); export const V1_FILE_SHA256 = "4f7340fc8cfd0ccb97377c385f2f8d8e7a9ef2c5ba96177f518c5d07de2825e1"; @@ -53,14 +55,18 @@ export const V12_FILE_SHA256 = "925452113b18b63137b9dae4786e3a8f7ba098eb47a2631a97107fbd52aa9a95"; // V*_FILE_SHA256 — SHA-256 канонических байтов версионных budget-файлов в // packages/colors/bench/. Версии v1..v(N−1) неизменяемы навсегда (история -// ратчета); константа текущей версии (сейчас v13) ротируется ТОЛЬКО вместе с +// ратчета); константа текущей версии (сейчас v15) ротируется ТОЛЬКО вместе с // принятием нового снапшота: следующий bump добавляет v(N+1)-пару, а v(N) // уходит в неизменяемый список. Дрейф любого файла к его пину = отказ чекера. export const V13_FILE_SHA256 = "3cc88303a0f43e8ca33ae70d723a3179c68b0cc2744310a791e8f43885482f34"; +export const V14_FILE_SHA256 = + "20c5886e3edaa6eaf3e37b915d81982a3a13e30064fc7fa8eb702eda38a20fb6"; +export const V15_FILE_SHA256 = + "979dc7990742cc2daf163c64051ed6a22c939745a4b1767bc010d6faaaba5161"; const V1_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v1.json"; -const V12_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v12.json"; +const V14_REPOSITORY_PATH = "packages/colors/bench/wasm-size-budget-v14.json"; const V5_BUDGET_ID = "labcolors-wasm-roles-issue-296-c1-v5"; const V6_BUDGET_ID = "labcolors-wasm-roles-issue-296-c3-v6"; const V7_BUDGET_ID = "labcolors-wasm-roles-issue-307-c7a-v7"; @@ -70,6 +76,8 @@ const V10_BUDGET_ID = "labcolors-wasm-roles-failure-admissibility-v10"; const V11_BUDGET_ID = "labcolors-wasm-runtime-c4cd-v11"; const V12_BUDGET_ID = "labcolors-wasm-runtime-c5-theme-keys-v12"; const V13_BUDGET_ID = "labcolors-wasm-runtime-c5-2-proxy-excision-v13"; +const V14_BUDGET_ID = "labcolors-wasm-runtime-c6-legacy-excision-v14"; +const V15_BUDGET_ID = "labcolors-wasm-runtime-c6-validation-srgb-ssot-v15"; const ROLE_ORDER = ["runtime"]; const ROLE_SPECS = { runtime: { @@ -77,12 +85,12 @@ const ROLE_SPECS = { command: "CARGO_ENCODED_RUSTFLAGS= wasm-pack build crates/labcolors-wasm --release --target web --out-dir ../../packages/colors/pkg --out-name labcolors --locked", recipeSha256: V1_RECIPE_SHA256, - // Pinned Linux run 29613131229 измерил C5.2 head точно (−4691B от v12: вырез - // muddiness-метода и legacy-прокси). Любой дальнейший рост требует НОВОЙ - // версии снапшота, не headroom здесь. - basis: "accepted-c5-2-proxy-excision-snapshot", - measurementSource: "github-actions-run-29613131229", - acceptedCeiling: 455074, + // Pinned Linux run 29654779091 измерил validation + encoded-sRGB SSOT C6 + // точно (−137B от v14). Любой дальнейший рост требует НОВОЙ версии + // снапшота, не headroom здесь. + basis: "accepted-c6-validation-srgb-ssot-snapshot", + measurementSource: "github-actions-run-29654779091", + acceptedCeiling: 428403, }, }; @@ -211,7 +219,15 @@ function verifyImmutableHistory() { if (v12?.schemaVersion !== 8 || v12?.budgetId !== V12_BUDGET_ID) { fail("immutable v12 budget identity drifted"); } - return { v1, v4, v5, v6, v7, v8, v9, v10, v11, v12 }; + const v13 = readImmutableJson(V13_PATH, V13_FILE_SHA256, "v13"); + if (v13?.schemaVersion !== 8 || v13?.budgetId !== V13_BUDGET_ID) { + fail("immutable v13 budget identity drifted"); + } + const v14 = readImmutableJson(V14_PATH, V14_FILE_SHA256, "v14"); + if (v14?.schemaVersion !== 8 || v14?.budgetId !== V14_BUDGET_ID) { + fail("immutable v14 budget identity drifted"); + } + return { v1, v4, v5, v6, v7, v8, v9, v10, v11, v12, v13, v14 }; } function validateBudgetValue(budget) { @@ -228,14 +244,14 @@ function validateBudgetValue(budget) { "budget", ); if (budget.schemaVersion !== 8) fail("supported schemaVersion is exactly 8"); - if (budget.budgetId !== V13_BUDGET_ID) fail(`budgetId must be ${V13_BUDGET_ID}`); + if (budget.budgetId !== V15_BUDGET_ID) fail(`budgetId must be ${V15_BUDGET_ID}`); exactKeys(budget.predecessor, ["path", "fileSha256"], "predecessor"); if ( - budget.predecessor.path !== V12_REPOSITORY_PATH || - budget.predecessor.fileSha256 !== V12_FILE_SHA256 + budget.predecessor.path !== V14_REPOSITORY_PATH || + budget.predecessor.fileSha256 !== V14_FILE_SHA256 ) { - fail("predecessor must bind the immutable v12 document"); + fail("predecessor must bind the immutable v14 document"); } exactKeys(budget.toolchainSource, ["path", "fileSha256"], "toolchainSource"); @@ -318,10 +334,10 @@ export function parseBudgetDocument(bytes, budgetPath) { validateBudgetValue(budget); if (resolve(budgetPath) === DEFAULT_BUDGET) { const actualFileSha256 = sha256(document); - if (actualFileSha256 !== V13_FILE_SHA256) { + if (actualFileSha256 !== V15_FILE_SHA256) { fail( - `current v13 file SHA-256 mismatch: ` + - `expected=${V13_FILE_SHA256} actual=${actualFileSha256}`, + `current v15 file SHA-256 mismatch: ` + + `expected=${V15_FILE_SHA256} actual=${actualFileSha256}`, ); } } diff --git a/scripts/verify-package-release.mjs b/scripts/verify-package-release.mjs index ec4eb398..c9869cb2 100644 --- a/scripts/verify-package-release.mjs +++ b/scripts/verify-package-release.mjs @@ -762,7 +762,7 @@ const config = { }, neutral: { anchors: { light: "#FFFFFF", mid: "#7A7A82", dark: "#17171A" }, - tint: { ratio: 0.1, target_mp: 6.1, hue_stiffness: 9.0 }, + tint: { target_mp: 6.1, hue_stiffness: 9.0 }, }, palette: [{ key: "family-4d", @@ -773,7 +773,6 @@ const config = { dark_ic: "#A78BFA", }, }], - sentiments: { categories: [], hardness: 5.0, chroma_fraction: 0.88 }, themes: [{ name: "light", preset: "srgb" }], roles: [ { @@ -843,17 +842,15 @@ assert.equal( if (material.alphaGuarantee.kind === "transparent-endpoint-characterized-v1") { assert.equal(material.alpha, 0); assert.equal(material.alphaStatus, "satisfied"); - assert.equal(material.guaranteed, true); } else if (material.alphaGuarantee.kind === "bisection-bracket-characterized-v1") { assert.equal(material.alpha, material.alphaGuarantee.upperAlpha); assert.equal(material.alphaStatus, "satisfied"); - assert.equal(material.guaranteed, true); } else { assert.equal(material.alphaGuarantee.kind, "opaque-endpoint-characterized-v1"); assert.equal(material.alpha, 1); assert.equal(material.alphaStatus, "degraded"); - assert.equal(material.guaranteed, false); } +assert.equal(Object.hasOwn(material, "guaranteed"), false); assert.ok(Number.isFinite(material.floor)); const glow = resolved.roles["token-92be"]; @@ -869,8 +866,8 @@ assert.equal(glow.constraintLayer, "halo"); assert.ok(glow.targetStatus === "legacy-reached" || glow.targetStatus === "legacy-unreachable"); assert.ok(Number.isFinite(glow.alpha) && glow.alpha > 0 && glow.alpha <= 1); assert.equal(Number(glow.alphaCss), glow.alpha); -assert.equal(glow.achievedDj, glow.haloAchievedDj); -assert.equal(glow.degraded, glow.targetStatus === "legacy-unreachable"); +assert.equal(Object.hasOwn(glow, "achievedDj"), false); +assert.equal(Object.hasOwn(glow, "degraded"), false); assert.equal(screen(glow.haloHex, glow.alpha, background), glow.haloCompositeHex); assert.equal(screen(glow.coreHex, glow.alpha, background), glow.coreCompositeHex); for (const value of [glow.targetDj, glow.haloAchievedDj, glow.coreAchievedDj]) { @@ -906,7 +903,7 @@ assert.equal(stableNoop.layerRecipeProfile, "cam16-jprime-oklab-cusp-v1"); assert.equal(stableNoop.appearanceDiagnosticProfile, "cam16-ucs-jprime-li2017-v1"); assert.equal(stableNoop.selectionDiagnosticProfile, null); assert.equal(stableNoop.targetStatus, "exact-noop-unreachable"); -assert.equal(stableNoop.degraded, true); +assert.equal(Object.hasOwn(stableNoop, "degraded"), false); assert.equal(stableNoop.haloCompositeHex, "#FFFFFF"); for (const key of [ stableNoop.cssVar, @@ -931,7 +928,6 @@ import init, { type GlowTargetStatusV1, type LadderPositionV1, type MaterialRole, - type MaterialRoleBase, type NumericalCapabilityManifestV2, type NumericalIndeterminacyV1, type ResolvedTheme, @@ -994,10 +990,9 @@ const config: ThemeConfig = { }, neutral: { anchors: { light: "#FFFFFF", mid: "#7A7A82", dark: "#17171A" }, - tint: { ratio: 0.1, target_mp: 6.1, hue_stiffness: 9.0 }, + tint: { target_mp: 6.1, hue_stiffness: 9.0 }, }, palette: [], - sentiments: { categories: [], hardness: 5.0, chroma_fraction: 0.88 }, themes: [{ name: "light", preset: "srgb" }], roles: [ { @@ -1078,34 +1073,26 @@ function determinateGlowEvidence(role: GlowDeterminateRole): string { if (role.decisionProfile === "stable-v1") { const status: "exact-noop-unreachable" = role.targetStatus; const selection: null = role.selectionDiagnosticProfile; - const degraded: true = role.degraded; void selection; - void degraded; return status; } if (role.targetStatus === "legacy-reached") { - const degraded: false = role.degraded; - void degraded; return role.targetStatus; } const status: "legacy-unreachable" = role.targetStatus; - const degraded: true = role.degraded; - void degraded; return status; } -function materialEvidence(role: MaterialRole): number | boolean { +function materialEvidence(role: MaterialRole): number | string { if (role.alphaStatus === "degraded") { const alpha: 1 = role.alpha; - const guaranteed: false = role.guaranteed; void alpha; - return guaranteed; + return role.alphaStatus; } - const guaranteed: true = role.guaranteed; if (role.alphaGuarantee.kind === "bisection-bracket-characterized-v1") { return role.alphaGuarantee.upperAlpha; } - return guaranteed; + return role.alphaStatus; } declare const glowBase: GlowDeterminateRoleBase; @@ -1116,21 +1103,16 @@ const impossibleGlow: GlowDeterminateRole = { decisionGuarantee: { kind: "bit-exact" }, selectionDiagnosticProfile: null, targetStatus: "legacy-reached", - degraded: false, }; -declare const materialBase: MaterialRoleBase; -// @ts-expect-error compatibility boolean выводится из material status/guarantee. -const impossibleMaterial: MaterialRole = { - ...materialBase, - alpha: 1, - alphaGuarantee: { - kind: "opaque-endpoint-characterized-v1", - numericalProfile: "encoded-srgb-byte-scale-affine-platform-binary64-powf-v1", - }, - alphaStatus: "degraded", - guaranteed: true, -}; +declare const noAliasGlow: GlowDeterminateRole; +// @ts-expect-error ambiguous measurement alias was removed. +noAliasGlow.achievedDj; +// @ts-expect-error boolean duplicate of targetStatus was removed. +noAliasGlow.degraded; +declare const noAliasMaterial: MaterialRole; +// @ts-expect-error boolean duplicate of alphaStatus was removed. +noAliasMaterial.guaranteed; void [ initialise, @@ -1146,7 +1128,8 @@ void [ determinateGlowEvidence, materialEvidence, impossibleGlow, - impossibleMaterial, + noAliasGlow, + noAliasMaterial, ]; `; }