Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c355fb6
feat(core): ThemeConfig-типы + NamedRoleTable::from_config + валидато…
Jul 2, 2026
7f4430c
feat(core): рецепт ladder (rgba-эмиссия) + alpha_analog + пересчёт S_…
Jul 2, 2026
7155271
review(t2): арх-ноты (r3 panic, сухие комментарии, долг t3) + CoVe do…
Jul 2, 2026
5878014
fix(t2): заземление нейтральных ролей + пер-темные альфы + значенческ…
Jul 2, 2026
8cd7e9b
fix(config): правки гейта PR-a — wasm-арм, строгий валидатор, различи…
Jul 2, 2026
73576a2
fix(config): правки CodeRabbit р-2 — алиасы в таблице, честный порог,…
Jul 2, 2026
79ea1cf
fix(config): правки CodeRabbit р-3 — агностичный подтон, честный заме…
Jul 2, 2026
8e2cb37
fix(config): CodeRabbit р-4 — тинт квантуется до композита, нетавтоло…
Jul 2, 2026
839df8a
fix(config): CodeRabbit р-5 — пер-темные края нейтрали, чистый закон …
Jul 2, 2026
efb355c
fix(config): CodeRabbit р-6 — validate() = полный preflight по постро…
Jul 2, 2026
c969019
fix(config): CodeRabbit р-7 — граница сатурации по максимуму разведен…
Jul 2, 2026
adecf6b
docs(s2b): семантика анкора baseline-гарда — решение по ре-анкору пос…
Jul 2, 2026
9ff398d
docs(comments): комментарии — только «почему» о коде, без процессной …
Jul 2, 2026
401cfe1
docs(comments): дочистка наррации — сообщение об ошибке WASM, атрибуц…
Jul 2, 2026
a4a84a7
fix(config): честная таксономия ошибок сентимента + броня публичных в…
Jul 2, 2026
b9344cb
fix(config): единый домен оттенка, IC-регрессия альф, честная дока кл…
Jul 2, 2026
54c844c
fix(registry): маркеры и строки реестра для границ домена оттенка
Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,541 changes: 1,541 additions & 0 deletions crates/labcolors-core/src/config.rs

Large diffs are not rendered by default.

1,567 changes: 1,567 additions & 0 deletions crates/labcolors-core/src/config/tests.rs

Large diffs are not rendered by default.

402 changes: 402 additions & 0 deletions crates/labcolors-core/src/ladder.rs

Large diffs are not rendered by default.

12 changes: 10 additions & 2 deletions crates/labcolors-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ pub(crate) mod spaces;
pub mod accent;
pub mod alpha;
pub mod cleanliness;
pub mod config;
pub mod ladder;
pub mod lcs;
pub mod lpc;
pub(crate) mod lut;
Expand All @@ -28,11 +30,17 @@ pub use cleanliness::{
confidence_from_hex as cleanliness_confidence_from_hex, drab, drab_in_context,
muddiness_from_hex, muddiness_from_linear_srgb, muddiness_in_context, muddiness_oklch, n_pure,
};
pub use config::{
Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint,
PaletteFamily, RoleRecipe, SentimentCategory, SentimentsConfig, ThemeConfig, ThemesConfig,
VcPreset, labui_reference,
};
pub use curve::ColorCurve;
pub use ladder::{LadderPosition, LadderTint, ThemeAnchors};
pub use lcs::LcsColor;
pub use semantic::{
Resolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor, measure_contrast, recheck_against,
resolve, resolve_set,
NamedRoleTable, Resolved, RgbaResolved, Role, RoleChroma, RoleSpec, RoleTable, TextAnchor,
measure_contrast, recheck_against, resolve, resolve_named_set, resolve_set,
};
pub use solve::{
BgInput, ChromaPolicy, Contract, Floor, Gamut, Hue, SolveJob, Solved, TypographicContext,
Expand Down
429 changes: 405 additions & 24 deletions crates/labcolors-core/src/semantic.rs

Large diffs are not rendered by default.

264 changes: 259 additions & 5 deletions crates/labcolors-core/src/sentiment.rs

Large diffs are not rendered by default.

16 changes: 16 additions & 0 deletions crates/labcolors-core/src/solve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,20 @@ impl BgInput {
BgInput::Solid(rgb) => quantised_display(*rgb),
}
}

/// Гамма-кодированный 8-битный sRGB фона (`[0,1]³`, byte/255) — то самое
/// device-пространство, в котором Figma/браузер композитят straight-alpha
/// ([`crate::alpha`]). Альфа-роль ([`crate::semantic::RoleSpec::Ladder`] /
/// [`AlphaAnalog`](crate::semantic::RoleSpec::AlphaAnalog)) композитит свой
/// тинт на этом фоне для честного замера контраста солид-эквивалента. Для
/// [`Solid`](BgInput::Solid) это квантованный дисплей-цвет фона; будущие
/// интервальные фоны выберут здесь свой представительный край, оставляя
/// физику резолва свободной от матчинга вариантов (SEAM a).
pub(crate) fn encoded_display(&self) -> [f64; 3] {
match self {
BgInput::Solid(rgb) => quantised_display(*rgb),
}
}
}

/// A background luminance interval in `Y_hk` space (H-K-corrected luminance).
Expand Down Expand Up @@ -2566,6 +2580,8 @@ mod tests {
.map(|(role, res)| {
let v = match res {
Resolved::Color { solved, .. } => solved.hex().to_string(),
// Дефолтная таблица не несёт Ladder/AlphaAnalog — недостижимо здесь.
Resolved::Rgba(r) => format!("rgba({},{})", r.tint_hex(), r.alpha()),
Resolved::None => "none".to_string(),
Resolved::Unreachable(_) => "unreach".to_string(),
};
Expand Down
7 changes: 7 additions & 0 deletions crates/labcolors-core/tests/r3_byte_identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,13 @@ fn r3_resolve_set_240_cell_representative_byte_identity() {
Resolved::Color { solved, .. } => solved.hex().to_string(),
Resolved::None => "none".to_string(),
Resolved::Unreachable(_) => "UNREACHABLE".to_string(),
// Дефолтная `RoleTable` (Role-путь) не несёт Ladder/AlphaAnalog-
// рецептов, поэтому rgba-роль здесь недостижима; арм обязателен
// из-за `#[non_exhaustive] Resolved` (t2 добавил вариант Rgba).
Resolved::Rgba(_) => "RGBA".to_string(),
// Будущий вариант Resolved не должен молча пройти golden: паника
// делает его видимым (обязан быть переучтён вместе с golden).
other => panic!("неучтённый Resolved-вариант в r3 golden: {other:?}"),
})
.unwrap_or_else(|| {
panic!(
Expand Down
20 changes: 20 additions & 0 deletions crates/labcolors-wasm/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,26 @@ fn map_resolved(resolved: Resolved, legal_floor: Option<f64>) -> RoleOutcome {
code: unreachable_code(&reason),
message: reason.to_string(),
},
// Полупрозрачные роли лестницы/альфа-аналога появляются только на
// конфиг-пути (`resolve_named_set`), который ЭТА поверхность ещё не
// экспортирует: `resolve_theme` идёт по встроенной `RoleTable`, где
// Ladder/AlphaAnalog-рецептов нет, поэтому вариант здесь недостижим.
// rgba-форма границы WASM — задача t3; до неё маппим в стабильный код,
// а не молчаливо роняем неверный цвет (`Resolved` теперь non_exhaustive).
Resolved::Rgba(_) => RoleOutcome::Unreachable {
code: "rgba_boundary_not_yet_exported",
message: "semi-transparent ladder/alpha-analog role is not exported by resolve_theme \
(config path, task t3)"
.to_string(),
},
// ОСОЗНАННЫЙ ДОЛГ t3: `Resolved` — `#[non_exhaustive]`, поэтому catch-all
// обязателен для будущих вариантов ядра. Пока маппит в стабильный код,
// а не молча роняет неверный цвет; при экспорте rgba-границы (t3) каждый
// новый вариант должен получить явный арм выше, а не оседать сюда.
_ => RoleOutcome::Unreachable {
code: "unreachable",
message: "unmapped resolved variant".to_string(),
},
}
}

Expand Down
6 changes: 6 additions & 0 deletions crates/labcolors-wasm/tests/wasm_parity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,12 @@ fn resolve_theme_matches_native_resolve_set() {
Resolved::Unreachable(_) => {
assert_eq!(kind, "unreachable", "{} should be unreachable", role.key());
}
// Rgba в дефолт-таблице не встречается (rgba-граница — долг t3);
// будущий вариант обязан быть переучтён здесь шумно, не замаскирован.
other => panic!(
"неучтённый Resolved-вариант в wasm-парити ({}): {other:?}",
role.key()
),
}
}
}
Expand Down
103 changes: 103 additions & 0 deletions docs/decisions/0001-config-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,106 @@ load-bearing продакшн-hex движка — 10 якорей `Accent::anch
- Нейминг теней канонизирует конфиг labui (`fx-shadow-*`); переход теней на
альфа-якоря (@1/@2/@4/@12 через alpha_analog) — отдельное научное решение,
вне поезда.

## Приложение A. Закрытое меню позиций лестницы (t2)

Фиксирует `position` рецепта `ladder(source, position)` (объявлено в разделе
API). Меню закрыто: движок эмитит `rgba(тинт, α)` НАПРЯМУЮ (закон лестницы
labui — композитит браузер), где тинт = якорь источника ПО ТЕМЕ, а α — данные
позиции ниже. Резолв несёт rgba + солид-композит на фоне резолва для честного
замера контраста (фаза 1 AA меряет композит). Реализация — `crate::ladder`
(`LadderPosition`), `crate::semantic` (`RoleSpec::Ladder` / `Resolved::Rgba`).

### Заземление меню

Снято 2026-07-02 из живого потребления labui: стаб `packages/colors-stub/
contract.css` (несёт Figma-значения дословно) + `reference/labui-accent-
primitives.md` §2 (пер-темные якоря) + grounding-документ главы
(`chapters/ch02-engine-config-input/grounding-accent-roles-2026-07-02.md`).
Акцентная лестница устроена КАК нейтральная: один тинт-якорь × закрытая рампа
альф Figma `Accent/Derivable/<Family>/<Family>@NN`. Единый паттерн проверен на
brand/danger/info/success.

### Позиции и ПЕР-ТЕМНЫЕ альфы (провенанс — стаб labui `contract.css`)

Альфы — данные, снятые ПОСТРОЧНО из стаба labui `packages/colors-stub/contract.css`
(light-scope `[data-theme="light"]` и dark-scope `[data-theme="dark"]`, 2026-07-02),
НЕ POLICY-константы: провенанс держит doc-строка `crate::ladder` + тест
`position_alpha_pairs_match_grounded_stub` (нетавтологичный пин из стаба).

Альфа — ПЕР-ТЕМНАЯ пара `(light, dark)`. У акцентов пара равна (меняется только
тинт по теме — у Figma ещё нет отдельных dark-рампов альф акцентов, стаб держит
пары равными). Скелетон-база — ЕДИНСТВЕННАЯ пер-темная альфа. IC-темы наследуют
альфу базовой темы (стаб не несёт ic-скоупов; IC отличается только тинтом).

| позиция | α (light) | α (dark) | стаб @NN |
|---------------------|-----------|----------|--------------|
| `LabelPrimary` | 1.0 | 1.0 | солид |
| `LabelSecondary` | 0.722 | 0.722 | @72 |
| `LabelTertiary` | 0.522 | 0.522 | @52 |
| `LabelQuaternary` | 0.322 | 0.322 | @32 |
| `FillPrimary` | 0.122 | 0.122 | @12 |
| `FillSecondary` | 0.078 | 0.078 | @8 |
| `FillTertiary` | 0.039 | 0.039 | @4 |
| `FillQuaternary` | 0.02 | 0.02 | @2 |
| `BorderBase` | 0.2 | 0.2 | @20 |
| `BorderSoft` | 0.122 | 0.122 | @12 |
| `BorderStrong` | 1.0 | 1.0 | солид |
| `FocusRing` | 1.0 | 1.0 | солид |
| `Glow` | 0.522 | 0.522 | @52 |
| `SkeletonBase` | 0.078 | 0.122 | @8 / @12 ⚠ |
| `SkeletonHighlight` | 0.039 | 0.039 | @4 |

⚠ `SkeletonBase` — единственная пер-темная альфа (стаб: light @8, dark @12).

### Источник тинта (`source`)

- `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`: `Mid` (`#787880`, стаб `rgb(120 120 128 / …)`) — скелетон/нейтральные
тинты; `Light` (`#FFFFFF`) — нейтральное свечение (стаб `rgb(255 255 255 / 0.522)`);
`Dark` (`#101012`) — нейтральный фокус (стаб `rgb(16 16 18)` на светлой теме).

Пер-темность якорей (а не «светлый + вывод») — из reference §2: тёмный/IC-варианты
Figma-примитивов замерены, не выведены.

### Значенческая сверка (класс «имя без значения»)

`representative_roles_match_stub_values_light_and_dark` сверяет эмиссию
`rgba(тинт, α)` представителя КАЖДОЙ группы со строкой стаба ПОБАЙТНО в light И
dark (нормализованный формат). Закрывает класс дефекта «роль в diff по имени, но
эмитит не то значение». Исключены намеренно расходящиеся роли (с ссылкой):
`*-info-*` (смещение солвером); `fx-focus-ring-neutral` (dark), `fx-glow-inverted`,
`fill-neutral` — задокументированные пробелы (gap) (пер-темный нейтральный край /
якоря инвертированных поверхностей / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`).

Comment on lines +201 to +210

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Опечатка: «с ссылкой» → «со ссылкой».

✏️ Исправление
-эмитит не то значение». Исключены намеренно расходящиеся роли (с ссылкой):
+эмитит не то значение». Исключены намеренно расходящиеся роли (со ссылкой):
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
### Значенческая сверка (класс «имя без значения»)
`representative_roles_match_stub_values_light_and_dark` сверяет эмиссию
`rgba(тинт, α)` представителя КАЖДОЙ группы со строкой стаба ПОБАЙТНО в light И
dark (нормализованный формат). Закрывает класс дефекта «роль в diff по имени, но
эмитит не то значение». Исключены намеренно расходящиеся роли (с ссылкой):
`*-info-*` (смещение солвером); `fx-focus-ring-neutral` (dark), `fx-glow-inverted`,
`fill-neutral` — задокументированные gap-и (пер-темный нейтральный край /
inverted-якоря / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`).
### Значенческая сверка (класс «имя без значения»)
`representative_roles_match_stub_values_light_and_dark` сверяет эмиссию
`rgba(тинт, α)` представителя КАЖДОЙ группы со строкой стаба ПОБАЙТНО в light И
dark (нормализованный формат). Закрывает класс дефекта «роль в diff по имени, но
эмитит не то значение». Исключены намеренно расходящиеся роли (со ссылкой):
`*-info-*` (смещение солвером); `fx-focus-ring-neutral` (dark), `fx-glow-inverted`,
`fill-neutral` — задокументированные gap-и (пер-темный нейтральный край /
inverted-якоря / PROVISIONAL-литерал не выводятся из тройки `neutral.anchors`).
🧰 Tools
🪛 LanguageTool

[uncategorized] ~206-~206: Неправильная форма предлога. Исправление: «со ссылкой».
Context: ... Исключены намеренно расходящиеся роли (с ссылкой): *-info-* (смещение солвером); `fx-f...

(S_SO)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/decisions/0001-config-boundary.md` around lines 201 - 210, In the
decision document, fix the typo in the sentence about intentionally divergent
roles by changing “с ссылкой” to “со ссылкой”; update the prose in the section
containing representative_roles_match_stub_values_light_and_dark so the wording
is grammatically correct while keeping the rest of the explanation unchanged.

Source: Linters/SAST tools

### Деривационная идентичность сентимента (честная находка t2)

При бренде labui (`#007AFF`, Oklab h≈257.4°) сентимент-тинт совпадает с СЫРЫМ
якорем семейства для сентиментов, отстоящих от бренда дальше порога разделения:
Danger (red), Success (green), Warning (orange) — держится (тест
`sentiment_tint_is_raw_family_anchor_when_brand_is_hue_distant`). Для **Info**
(→blue, h≈259.9°) НЕ держится: Info отстоит от синего бренда лишь на ≈2.5° —
ниже порога `S_PERC_MIN`, поэтому солвер КОРРЕКТНО смещает Info прочь от бренда
(иначе информационный и брендовый синий слились бы). Это заземлённое поведение
солвера (#20/#55/#65), задокументировано тестом
`info_is_displaced_from_blue_brand_by_design`, не спрятано и не подогнано.

### Альфа-аналог (`alpha_analog(of, alpha)`)

Отдельный рецепт (#119): солид-цель `of` (по теме) ФИКСИРОВАНА, тинт выводится
композит-инверсией (`crate::alpha::resolve_alpha_analog`) над фоном резолва.
Композит фактической пары равен солиду по построению (наследует контраст).
Фактическая α поднимается до `α_min`, если запрошенная неразрешима в гамуте
(кламп тинта запрещён — двигается прозрачность, не цвет). Применяется для ролей,
где нужен полупрозрачный аналог УЖЕ РЕШЁННОГО контраст-солида; прямые `-tinted`
заливки labui эмитятся как `ladder(..., FillPrimary)` (тинт-якорь напрямую),
т.к. насыщенный солид над белым дал бы `α_min≈1` и перестал быть полупрозрачным.
1 change: 1 addition & 0 deletions docs/empirical-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Marker column: `SSOT-TRACKED` = has a paper trail in this table but no citation-
| 35 | `IC_DECORATIVE_FLOOR_MIN` | `15.0` | `semantic.rs` | SSOT-TRACKED | Increased-contrast decorative floor (Lc), raised above `DECORATIVE_FLOOR_MIN` (7.5) to match the stronger perceptual requirements of the `-ic` themes. |
| 36 | `HUE_SEARCH_HALF_WINDOW` | `30.0` | `scale.rs` | SSOT-TRACKED | Half-width (degrees) of the hue search window in `find_optimal_hue`; 30° spans the typical sRGB gamut ridge width around the canonical hue. |
| 37 | `HUE_DRIFT_PENALTY_SLOPE` | `0.15` | `scale.rs` | SSOT-TRACKED | Наклон штрафа дрейфа оттенка в `find_optimal_hue` (`penalty_scale = slope/half_window`, `score = c − penalty_scale·drift`): баланс «максимум хромы против ухода от канонического оттенка». Калибровочный; ранее жил незадекларированным поле-литералом (найден инвентарём конфиг-границы 2026-07-02). Кандидат науки: вывести или обосновать датасетом. |
| 38 | `ACHROMATIC_CHROMA_EPS` | `1e-7` | `sentiment.rs` | GROUNDED | Технический ε числовой определённости оттенка (не перцептивная политика): ниже него `atan2(b,a)` не определён — гард против произвольного 0°. Вывод из арифметики представимости, а не подгонка: минимум ненулевой Oklab-хромы 8-битного sRGB-цвета ≈ 1.06e-3 (`#FEFFFF`; `#808081` ≈ 1.49e-3; расчёт конвейером sRGB→Oklab, Ottosson 2020), накопленный f64-шум того же конвейера ≲ 1e-12; 1e-7 лежит между границами с запасом ≥4 порядка в обе стороны, поэтому не может переклассифицировать ни один представимый цвет. |

## Muddiness Law constants — `cleanliness.rs`

Expand Down