Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
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
18 changes: 10 additions & 8 deletions crates/labcolors-core/src/agnostic_gates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,9 @@ fn acme_config() -> ThemeConfig {
),
(
"brand-fill".to_string(),
brand_ladder(LadderPosition::FillPrimary),
RoleRecipe::PairFill {
source: LadderSource::Brand,
},
),
(
"brand-label".to_string(),
Expand All @@ -331,8 +333,8 @@ fn acme_config() -> ThemeConfig {
hue: Some(LadderSource::Brand),
},
),
// Лейбл тинт-бейджа: любой клиент получает жёсткий контраст
// label↔tinted-fill через тот же движок, без правок ядра.
// Лейбл пары: любой клиент получает жёсткий контраст против
// фактически emitted PairFill Surface через общий joint engine.
(
"badge-label".to_string(),
RoleRecipe::PairLabel {
Expand Down Expand Up @@ -418,9 +420,9 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() {
"hued brand-label must resolve to a solved colour on {bg_hex}"
);

// Лейбл тинт-бейджа — агностичный жёсткий контраст: решается цветом и
// держит свой UI-пол (3:1) ПРОТИВ тинт-поверхности бренда (композит
// brand-fill), а не против фона страницы. Тот же движок, чужой конфиг.
// PairLabel — агностичный жёсткий контраст: решается цветом и держит
// UI-пол (3:1) против фактически emitted PairFill Surface, а не
// страницы или скрытой синтетической подложки.
let badge_label = set.iter().find(|(n, _)| n == "badge-label").unwrap();
let Resolved::Color { solved, .. } = &badge_label.1 else {
panic!("badge-label must resolve to a solved colour on {bg_hex}");
Expand All @@ -429,13 +431,13 @@ fn a_second_company_config_compiles_and_emits_a_valid_system() {
let surface_hex = brand_fill
.1
.translucent()
.expect("brand-fill is a translucent tinted surface")
.expect("brand-fill is the emitted PairFill surface")
.composite_hex();
let enc = |h: &str| crate::spaces::srgb::srgb_encoded_from_hex(h).unwrap();
let ratio = crate::wcag::contrast_ratio(enc(solved.hex()), enc(surface_hex));
assert!(
ratio >= 3.0 - 1e-9,
"badge-label must clear 3:1 against its tinted surface on {bg_hex}: got {ratio:.2}:1"
"badge-label must clear 3:1 against emitted PairFill on {bg_hex}: got {ratio:.2}:1"
);
}
}
Expand Down
7 changes: 0 additions & 7 deletions crates/labcolors-core/src/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1162,13 +1162,6 @@ impl ModeledSrgb8PointOccurrence {
self.visible
}

#[cfg_attr(
not(test),
expect(
dead_code,
reason = "shipped exact evaluator reads visible; backdrop is consumed by the test-private WCAG adapter"
)
)]
pub(crate) fn backdrop(self) -> [u8; 3] {
self.backdrop
}
Expand Down
32 changes: 12 additions & 20 deletions crates/labcolors-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -435,28 +435,24 @@ pub enum RoleRecipe {
/// Обязательный numerical-decision profile; implicit legacy запрещён.
decision_profile: crate::glow::GlowDecisionProfileV1,
},
/// Переходная solid-эмиссия пары (внутренний модуль `pair`). Текущий heuristic
/// выбирает сторону и при необходимости сдвигает светлоту источника; это не
/// валидированный перцептивный закон. Результат не является поверхностью
/// [`PairLabel`](Self::PairLabel) и удаляется вместе с pair façade.
/// Frozen PairFill frontend до C7c. Источник эмитится opaque Paint через
/// общий point occurrence; отдельной Pair-эвристики и скрытой роли нет.
PairFill {
/// Источник якоря: бренд, семейство или нейтраль.
source: LadderSource,
},
/// Переходный foreground пары. Он решается против внутренне синтезированной
/// tint-поверхности с alpha закрытой позиции `FillPrimary`, а не против
/// страницы и не против эмитированного [`PairFill`](Self::PairFill).
/// Наличие двух несвязанных поверхностей является известным разрывом SSOT;
/// target occurrence-граф заменяет оба варианта одной композицией.
/// Frozen PairLabel frontend до C7c. Label-кандидаты проверяются против
/// фактически emitted opaque [`PairFill`](Self::PairFill) Surface общим
/// joint hard-report и fresh recheck.
PairLabel {
/// Источник физической цветовой идентичности: бренд, семейство или нейтраль.
source: LadderSource,
/// Доля максимума контраста тинт-поверхности `(0, 1]` (как у
/// Доля максимума контраста PairFill Surface `(0, 1]` (как у
/// [`TextAnchor`](Self::TextAnchor)): низкая доля оставляет больше места
/// для хромы источника у пола, высокая тянет к контрастному пределу.
/// Точный серый source при любой доле остаётся нейтральным.
fraction: f64,
/// WCAG-пол, энфорсимый ПРОТИВ тинт-поверхности (а не фона страницы).
/// WCAG-пол против emitted PairFill Surface, не страницы.
floor: Floor,
},
/// Альфа-аналог solid-источника через точечную композит-инверсию
Expand Down Expand Up @@ -952,7 +948,7 @@ impl ThemeConfig {
*fraction,
FRACTION_MIN_EXCLUSIVE,
FRACTION_MAX_INCLUSIVE,
"0 < fraction ≤ 1 (доля максимального контраста тинт-поверхности бейджа)",
"0 < fraction ≤ 1 (доля максимального контраста PairFill Surface)",
)
}
RoleRecipe::AlphaAnalog { of, alpha } => {
Expand Down Expand Up @@ -1186,14 +1182,10 @@ impl ThemeConfig {
fraction,
floor,
} => {
// Поверхность бейджа = семейный тинт при альфе `fill-*-primary`
// (@12) над фоном резолва. Альфа берётся из ЗАКРЫТОГО меню позиции
// (не литерал), поэтому tinted-badge лейбл и `fill-*-tinted`
// заливка всегда садятся на одну и ту же подложку по построению.
// `FillPrimary` здесь — ТОЛЬКО источник client-calibrated alpha
// на этапе lowering; физика и appearance-граф это имя не знают.
let (surface_alpha_light, surface_alpha_dark) =
crate::ladder::LadderPosition::FillPrimary.alpha_pair();
// P1 унифицирует PairFill/PairLabel на единственной поверхности,
// которую публичный PairFill уже эмитил: opaque source Paint.
// Representation не выводится из клиентского имени позиции.
let (surface_alpha_light, surface_alpha_dark) = (1.0, 1.0);
Ok(RoleSpec::PairLabel {
tint: self.compile_ladder_tint(role, source)?,
fraction: *fraction,
Expand Down
7 changes: 3 additions & 4 deletions crates/labcolors-core/src/config/preset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -341,10 +341,9 @@ pub fn labui_preset_roles() -> Vec<(String, RoleRecipe)> {
brand_pos(LadderPosition::FocusRing),
));

// Пары «заливка × лейбл» бейджа (crate::pair): якорь источника, минимально
// сдвинутый до выбранной ветви переходной pair-эвристики;
// лейбл на такой заливке — обычный nested resolve потребителя. Статики
// покрываются тем же законом (белый/чёрный якоря нейтрали).
// Frozen Pair frontend: PairFill эмитит exact source как opaque occurrence;
// PairLabel строит конечный candidate domain на фактически emitted Surface
// и проверяет его общим joint evaluator/recheck. Статики проходят тот же путь.
let pair = |source| RoleRecipe::PairFill { source };
roles.push(("badge-fill-brand".to_string(), pair(LadderSource::Brand)));
for (client_name, family_key) in [
Expand Down
75 changes: 36 additions & 39 deletions crates/labcolors-core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,7 @@ const LABUI_CONSUMED_ROLES: &[&str] = &[
"border-neutral",
"border-danger",
"border-focus",
// Пары бейджа: заливка законом пары, лейбл — nested resolve потребителя.
// Пары бейджа: exact opaque fill и joint-verified label на emitted Surface.
"badge-fill-brand",
"badge-fill-danger",
"badge-fill-warning",
Expand Down Expand Up @@ -717,12 +717,12 @@ const COLLAPSED_ROLES: &[(&str, &str)] = &[
"оверлеи → alpha.rs-роли (вне поглощаемого GAP)",
),
// Компонентные алиасы — конфиг-алиасы, не рецепты. Бейдж сузился законом
// пары: badge-fill-* стали первоклассной эмиссией (RoleRecipe::PairFill,
// crate::pair), коллапс остаётся только за лейблами бейджа — те решаются
// nested resolve потребителя от выведенной заливки.
// пары: badge-fill-* — первоклассная эмиссия RoleRecipe::PairFill;
// label frontend использует фактически emitted fill Surface без зависимости
// по имени токена.
(
"badge-label-*",
"лейбл бейджа — nested resolve от заливки пары",
"лейбл бейджа — joint-verified foreground на emitted PairFill Surface",
),
("control-bg", "компонентный алиас, не рецепт эмиссии"),
];
Expand Down Expand Up @@ -1312,43 +1312,40 @@ fn value_test_bites_on_alpha_mutation() {
);
}

/// Сторона пары — идентичность семьи НА РЕЗОЛВ-УРОВНЕ. Носитель класса —
/// БРЕНД под dark-IC: источник Brand несёт сырые якоря, и его dark-ic
/// (#409CFF, Y = 0.321) пересекает кроссовер 0.30. Семейные якоря (включая
/// info) разведены солвером и порог не straddle-ят — на них мутация
/// «сторона от vc» поведенчески неразличима (выживший мутант M3
/// верификатора). Мутация semantic.rs srgb→vc обязана уронить ЭТОТ тест.
/// P1 не выводит representation из client-owned имени позиции и не двигает
/// authored source скрытой Pair-эвристикой. Во всех VC PairFill эмитит точный
/// выбранный source как opaque Paint; смена темы меняет только authored anchor.
#[test]
fn pair_side_is_family_stable_across_themes_at_resolve_level() {
fn pair_fill_is_exact_opaque_source_across_viewing_conditions() {
let table = labui_reference().compile_named_role_table().unwrap();
let bg_dark = BgInput::solid("#101012").unwrap();
let set = resolve_named_set(
&bg_dark,
&table,
&ViewingConditions::dim_surround_high_contrast(),
)
.expect("валидная pair-side fixture обязана резолвиться");
let (_, res) = set
.iter()
.find(|(n, _)| n == "badge-fill-brand")
.expect("паспорт несёт badge-fill-brand");
let fill = res
.translucent()
.expect("заливка пары эмитится лестничной сантехникой");
// Светлая сторона семьи: тёмная заливка (белый строго выигрывает
// штатную полярность — Y ниже выведенной границы WCAG).
let enc =
crate::spaces::srgb::srgb_encoded_from_hex(fill.tint_hex()).expect("эмиссия валидный hex");
let lin = [
crate::spaces::srgb::srgb_gamma_inv(enc[0]),
crate::spaces::srgb::srgb_gamma_inv(enc[1]),
crate::spaces::srgb::srgb_gamma_inv(enc[2]),
let cases = [
(ViewingConditions::srgb(), "#FFFFFF", "#007AFF"),
(ViewingConditions::dim_surround(), "#101012", "#4A8FFF"),
(
ViewingConditions::srgb_high_contrast(),
"#FFFFFF",
"#0040DD",
),
(
ViewingConditions::dim_surround_high_contrast(),
"#101012",
"#409CFF",
),
];
let y = 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
assert!(
y < 0.17913,
"badge-fill-brand в dark-IC обязан быть утемнён под светлую сторону семьи (Y={y:.4})"
);

for (vc, background, expected_source) in cases {
let set = resolve_named_set(&BgInput::solid(background).unwrap(), &table, &vc)
.expect("валидная PairFill fixture обязана резолвиться");
let (_, resolved) = set
.iter()
.find(|(name, _)| name == "badge-fill-brand")
.expect("паспорт несёт badge-fill-brand");
let fill = resolved
.translucent()
.expect("PairFill эмитится общей rgba-формой");
assert_eq!(fill.tint_hex(), expected_source);
assert_eq!(fill.alpha().to_bits(), 1.0_f64.to_bits());
}
}

/// Дубликаты ключей всех словарей отвергаются (повтор имени = неоднозначный
Expand Down
1 change: 1 addition & 0 deletions crates/labcolors-core/src/constraints/exact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub(crate) struct ExactIdentityViolationV1(());
pub(crate) type ExactPassEvidenceV1 = VisiblePointPassEvidence<ExactSrgb8IdentityV1>;
pub(crate) type ExactViolationEvidenceV1 = VisiblePointViolationEvidence<ExactSrgb8IdentityV1>;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExactSrgb8IdentityV1;

impl ExactSrgb8IdentityV1 {
Expand Down
13 changes: 7 additions & 6 deletions crates/labcolors-core/src/constraints/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,13 @@ pub(crate) use exact::{
#[cfg(test)]
pub(crate) use exact::ExactIdentityPassV1;

#[cfg(test)]
mod wcag22;

pub(crate) use wcag22::Wcag22Srgb8V1;

#[cfg(test)]
pub(crate) use wcag22::{
ApplicableWcag22EvaluationErrorV1, ApplicableWcag22MeasurementV1, Wcag22PassV1, Wcag22Srgb8V1,
ApplicableWcag22EvaluationErrorV1, ApplicableWcag22MeasurementV1, Wcag22PassV1,
Wcag22ViolationV1,
};

Expand Down Expand Up @@ -142,13 +143,13 @@ pub(crate) trait HardClassifier<Invocation, Measurement>:
) -> HardDecision<Self::Pass, Self::Violation>;
}

type PointInvocation<Evaluation> =
pub(crate) type PointInvocation<Evaluation> =
<Evaluation as Evaluator<ModeledSrgb8PointOccurrence>>::Invocation;
type PointMeasurement<Evaluation> =
pub(crate) type PointMeasurement<Evaluation> =
<Evaluation as Evaluator<ModeledSrgb8PointOccurrence>>::Measurement;
type PointPass<Evaluation> =
pub(crate) type PointPass<Evaluation> =
<Evaluation as HardClassifier<PointInvocation<Evaluation>, PointMeasurement<Evaluation>>>::Pass;
type PointViolation<Evaluation> = <Evaluation as HardClassifier<
pub(crate) type PointViolation<Evaluation> = <Evaluation as HardClassifier<
PointInvocation<Evaluation>,
PointMeasurement<Evaluation>,
>>::Violation;
Expand Down
2 changes: 2 additions & 0 deletions crates/labcolors-core/src/constraints/wcag22.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use crate::wcag22::{
evaluate_wcag22_srgb8, wcag22_profile_v1,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Wcag22Srgb8V1;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -26,6 +27,7 @@ pub(crate) struct ApplicableWcag22MeasurementV1 {
evidence: NumericalDecisionEvidenceV1,
}

#[cfg(test)]
impl ApplicableWcag22MeasurementV1 {
pub(crate) const fn profile_id(&self) -> Wcag22ProfileIdV1 {
self.profile_id
Expand Down
15 changes: 0 additions & 15 deletions crates/labcolors-core/src/exposure_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,6 @@ pub(crate) fn grid_size() -> usize {
n * n * n
}

/// WCAG-люминанс (Rec.709) кодированного 8-битного цвета.
pub(crate) fn wcag_y(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]),
];
0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2]
}

/// 8-битные каналы hex-якоря.
pub(crate) fn enc_of(hex: &str) -> [u8; 3] {
let s = srgb_encoded_from_hex(hex).expect("passport hex valid");
Expand Down
Loading
Loading