Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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,358 changes: 1,358 additions & 0 deletions crates/labcolors-core/src/config.rs

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

384 changes: 384 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
362 changes: 338 additions & 24 deletions crates/labcolors-core/src/semantic.rs

Large diffs are not rendered by default.

124 changes: 120 additions & 4 deletions crates/labcolors-core/src/sentiment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,37 @@ fn resolve_smooth_hue(
brand_hue: f64,
params: SentimentParams,
s_min: f64,
) -> Result<f64, String> {
resolve_smooth_hue_explicit(
sentiment.preferred_side(),
sentiment.hue_floor(),
prototype,
brand_hue,
params,
s_min,
)
}

/// Config-facing sibling of [`resolve_smooth_hue`] that takes the categorical
/// policy (`preferred_side`, `hue_floor`) explicitly instead of reading it off the
/// fixed [`Sentiment`] enum — so an arbitrary consumer sentiment category
/// ([`crate::config::SentimentCategory`]) resolves through the identical smooth
/// p-norm displacement + legality guard, no second copy of the physics.
///
/// `prototype`, `brand_hue` and the result are **Oklab hue degrees**. See
/// [`resolve_smooth_hue`] / [`SentimentCurve::with_params`] for the model.
///
/// # Errors
///
/// `Err` if no hue satisfies both the floor and the separation invariant
/// (empty legal arc) — never a silent breach.
pub fn resolve_smooth_hue_explicit(
preferred_side: f64,
hue_floor: Option<f64>,
prototype: f64,
brand_hue: f64,
params: SentimentParams,
s_min: f64,
) -> Result<f64, String> {
// Signed shortest delta from prototype to brand. Its sign tells us which side
// of the brand the prototype sits on; we push the resolved hue out along that
Expand All @@ -461,17 +492,16 @@ fn resolve_smooth_hue(
(1.0, params.p_high)
} else {
// Degenerate seam: brand exactly on the prototype. Pick the preferred side.
let pref = sentiment.preferred_side();
let p = if pref >= 0.0 {
let p = if preferred_side >= 0.0 {
params.p_high
} else {
params.p_low
};
(pref, p)
(preferred_side, p)
};

let s = smooth_separation(d, s_min, p);
let floor = sentiment.hue_floor();
let floor = hue_floor;

// The prototype-ward displacement is the natural target (it decays to the
// prototype as the brand recedes).
Expand Down Expand Up @@ -557,6 +587,92 @@ fn angular_distance(a: f64, b: f64) -> f64 {
if diff > 180.0 { 360.0 - diff } else { diff }
}

/// Категориальный порог оттенка `S_PERC_MIN` (длина хорды Oklab a/b),
/// пересчитанный из хром сентимент-якорей конфига по закону
/// `2·C_rep·sin(20°/2)`, где `C_rep` — среднее хром (поправка t2 №д).
///
/// `20°` — нижний предел категориального восприятия (Witzel & Gegenfurtner 2013,
/// JOSA A 30(7):1501). При labui-якорях (хромы Red/Orange/Green/Blue) результат
/// совпадает с замороженной константой [`S_PERC_MIN`] (`0.068_703_9`,
/// деривационная идентичность — тестом, допуск 1e-4): формула остаётся законом
/// при произвольных якорях клиента, а сегодняшнее значение — её частный случай.
///
/// Пустой срез хром даёт `0.0` (нет сентиментов — нет порога разделения).
pub fn s_perc_min_from_chromas(chromas: &[f64]) -> f64 {
if chromas.is_empty() {
return 0.0;
}
let c_rep = chromas.iter().sum::<f64>() / chromas.len() as f64;
// Хорда длины 2·C·sin(Δh/2) при Δh = 20° — тот же категориальный порог
// (Witzel & Gegenfurtner 2013), что в деривации [`S_PERC_MIN`]; инлайн
// (не именованная const), т.к. это derivation-identity вход, не новый
// POLICY-литерал — provenance держит doc [`S_PERC_MIN`].
2.0 * c_rep * (20.0_f64.to_radians() / 2.0).sin()
}

/// Замороженное значение `S_PERC_MIN` (для деривационной идентичности теста t2).
/// Возвращается функцией (не `const`), чтобы не заводить второй POLICY-литерал в
/// аудите реестра — это тот же derivation-identity, что [`S_PERC_MIN`].
pub fn s_perc_min_frozen() -> f64 {
S_PERC_MIN
}

/// Config-facing сентимент-солид: якорь семейства, чей оттенок разведён с брендом
/// сентимент-солвером, при СОХРАНЁННЫХ светлоте и хроме якоря.
///
/// Тинт лестницы сентимента (поправка t2 №г): берётся оттенок семейства,
/// смещённый от бренда через [`resolve_smooth_hue_explicit`] (тот же C¹-солвер,
/// что у [`SentimentCurve`]), но светлота/хрома — исходного якоря. Когда
/// смещение не нужно (`resolved_hue == prototype`, случай labui-бренда), солид
/// воспроизводит СЫРОЙ якорь семейства — это и есть деривационная идентичность,
/// которую фиксирует тест. `brand_hue` — Oklab-оттенок бренда (градусы).
///
/// # Errors
///
/// `Err`, если якорь невалиден или легальный оттенок геометрически пуст
/// (см. [`resolve_smooth_hue_explicit`]).
pub fn resolve_config_sentiment_solid(
family_anchor_hex: &str,
brand_hue: f64,
hardness: f64,
chroma_fraction: f64,
hue_floor: Option<f64>,
preferred_side: f64,
s_perc_min: f64,
) -> Result<String, String> {
let _ = chroma_fraction; // хрома тинта = хрома якоря (сохраняем солид якоря);
// chroma_fraction — ручка рампы SentimentCurve, не тинта; принимается для
// единообразия сигнатуры конфига, но тинт держит фактическую хрому якоря.
let anchor_lab = srgb_linear_to_oklab(srgb_from_hex(family_anchor_hex)?);
let prototype = oklab_hue_of(family_anchor_hex);
let l_anchor = anchor_lab[0];
let c_anchor = (anchor_lab[1].powi(2) + anchor_lab[2].powi(2)).sqrt();
let s_min = s_min_deg(c_anchor);
// Порог разделения — max из перцептивного (от хромы якоря) и конфиг-порога:
// конфиг S_PERC_MIN задаёт минимум для КАТЕГОРИИ, s_min_deg — для этой хромы.
let params = SentimentParams::uniform(hardness)?;
let effective_s_min = s_min.max(s_min_deg_from_chord(s_perc_min, c_anchor));
let resolved_hue = resolve_smooth_hue_explicit(
preferred_side,
hue_floor,
prototype,
brand_hue,
params,
effective_s_min,
)?;
// Солид на исходных L/C якоря, смещённый оттенок.
Ok(oklab_lc_to_hex(l_anchor, c_anchor, resolved_hue))
}

/// Перевести целевую хорду разделения `chord` в угол оттенка (градусы) при
/// хроме `zone_chroma` — та же инверсия `2·C·sin(Δh/2)`, что [`s_min_deg`], но с
/// произвольной хордой (для конфиг-`S_PERC_MIN`).
fn s_min_deg_from_chord(chord: f64, zone_chroma: f64) -> f64 {
let safe_chroma = zone_chroma.max(1e-6);
let ratio = (chord / (2.0 * safe_chroma)).clamp(0.0, 1.0);
2.0 * ratio.asin().to_degrees()
}

/// The in-gamut sRGB hex at Oklab `(L, C, h)`, channels clamped to `[0, 1]`.
fn oklab_lc_to_hex(l_ok: f64, c: f64, h_ok: f64) -> String {
let a = c * h_ok.to_radians().cos();
Expand Down
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
Loading