Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
4 changes: 4 additions & 0 deletions crates/labcolors-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ harness = false
[[bench]]
name = "forward"
harness = false

[[bench]]
name = "glow_alpha"
harness = false
36 changes: 36 additions & 0 deletions crates/labcolors-core/benches/glow_alpha.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use labcolors_core::ViewingConditions;
use labcolors_core::glow::solve_screen_alpha_for_dj;
use std::hint::black_box;

fn bench_glow_alpha(c: &mut Criterion) {
let vc = ViewingConditions::dim_surround();
let mut group = c.benchmark_group("glow_alpha");
for (name, tint, background, target) in [
("base", "#4A8FFF", "#101012", 2.3006),
("bloom", "#FF3B30", "#101012", 13.3251),
("unreachable", "#3E87FF", "#FFFFFF", 2.3006),
] {
group.bench_with_input(
BenchmarkId::new("solve", name),
&(tint, background, target),
|b, &(tint, background, target)| {
b.iter(|| {
black_box(
solve_screen_alpha_for_dj(
black_box(tint),
black_box(background),
black_box(target),
black_box(&vc),
)
.expect("benchmark inputs are valid"),
)
});
},
);
}
group.finish();
}

criterion_group!(benches, bench_glow_alpha);
criterion_main!(benches);
1 change: 1 addition & 0 deletions crates/labcolors-core/examples/figma_anchor_provenance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ fn srgb_gamma_encode(v: f64) -> f64 {
/// потребляет её, а не держит вторую копию формулы.
fn composite_gamma(fg: [f64; 3], alpha: f64, bg: [f64; 3]) -> [f64; 3] {
labcolors_core::alpha::composite_over_encoded(fg, alpha, bg)
.expect("данные provenance-примера лежат в encoded-sRGB домене")
}

/// Композит в линейном свете: каналы декодируются из gamma, смешиваются,
Expand Down
956 changes: 818 additions & 138 deletions crates/labcolors-core/src/alpha.rs

Large diffs are not rendered by default.

70 changes: 68 additions & 2 deletions crates/labcolors-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,9 @@ pub enum ConfigError {
},
/// Ссылка (алиас/alpha_analog) на роль, которой нет в `roles`.
UnknownRole { referenced_by: String, role: String },
/// Дубликат ключа в словаре конфига: повтор имени сделал бы lookup и
/// эмиссию неоднозначными (какая запись выиграла — вопрос порядка, тихо).
/// Дубликат ключа в словаре конфига или итоговом CSS-namespace: повтор имени
/// сделал бы lookup/эмиссию неоднозначными (какая запись выиграла — вопрос
/// порядка, тихо).
DuplicateKey {
dictionary: &'static str,
key: String,
Expand Down Expand Up @@ -598,6 +599,47 @@ pub enum RoleRecipe {
Zero,
}

/// Суффиксы CSS-переменных, которые один успешно решённый рецепт резервирует.
///
/// Namespace выводится из типа outcome до резолва: иначе коллизия могла бы
/// зависеть от фона (на одном роль unreachable и «всё работает», на другом два
/// писателя молча делят один `--lab-*`). `Zero` переменных не эмитит никогда.
fn emitted_css_suffixes(recipe: &RoleRecipe) -> &'static [&'static str] {
const PRIMARY: &[&str] = &[""];
const GLOW: &[&str] = &["", "-core", "-alpha"];
const MATERIAL: &[&str] = &["", "-01", "-02"];
const NONE: &[&str] = &[];

match recipe {
RoleRecipe::Glow { .. } => GLOW,
RoleRecipe::Material { .. } => MATERIAL,
RoleRecipe::Zero => NONE,
_ => PRIMARY,
}
}

/// Зарезервировать один shape эмиссии в общем namespace.
///
/// Отдельный примитив не знает сегодняшних суффиксов и потому не опирается на
/// случайное свойство, что `-core/-alpha/-01/-02` пока не пересекаются друг с
/// другом: будущая derived↔derived коллизия попадёт в тот же гард.
fn reserve_emitted_css_names(
emitted: &mut std::collections::BTreeSet<String>,
name: &str,
suffixes: &[&str],
) -> Result<(), ConfigError> {
for suffix in suffixes {
let key = format!("--lab-{name}{suffix}");
if !emitted.insert(key.clone()) {
return Err(ConfigError::DuplicateKey {
dictionary: "emitted CSS variables",
key,
});
}
}
Ok(())
}

/// Источник тинта лестницы/альфа-аналога: откуда берётся якорный цвет.
///
/// Тинт bg-независим (это якорь источника), только пер-темен. Для [`Family`](Self::Family)
Expand Down Expand Up @@ -1019,6 +1061,30 @@ impl ThemeConfig {
}
}

// Роль резервирует не только собственный `--lab-{name}`: Glow и Material
// создают сателлиты. Алиас клонирует outcome цели и потому создаёт тот же
// набор уже под СВОИМ именем. Проверяем итоговый namespace целиком до
// резолва/JSON, чтобы порядок писателей никогда не решал, чьё значение
// молча победит. Один общий set ловит role↔satellite, alias↔satellite и
// любые будущие satellite↔satellite пересечения без списка частных пар.
let mut emitted = std::collections::BTreeSet::new();

for (name, recipe) in &self.roles {
reserve_emitted_css_names(&mut emitted, name, emitted_css_suffixes(recipe))?;
}
for (alias, target) in &self.aliases {
let target_recipe = self
.roles
.iter()
.find(|(name, _)| name == target)
.map(|(_, recipe)| recipe)
.ok_or_else(|| ConfigError::UnknownRole {
referenced_by: format!("aliases.{alias}"),
role: target.clone(),
})?;
reserve_emitted_css_names(&mut emitted, alias, emitted_css_suffixes(target_recipe))?;
}

Ok(())
}

Expand Down
161 changes: 156 additions & 5 deletions crates/labcolors-core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,154 @@ fn validator_rejects_duplicate_dictionary_keys() {
));
}

/// Имена конфига — не весь namespace эмиссии: Glow добавляет `-core/-alpha`,
/// Material — `-01/-02`. Роль или алиас с таким именем раньше проходили
/// preflight, а JSON-проекция молча записывала один `--lab-*` дважды; последний
/// писатель менял тип значения (например, alpha-число превращалось в цвет).
#[test]
fn validator_rejects_role_and_alias_collisions_with_emitted_satellites() {
let assert_collision = |cfg: ThemeConfig, expected_stem: &str| {
let expected = format!("--lab-{expected_stem}");
assert!(
matches!(
cfg.validate(),
Err(ConfigError::DuplicateKey {
dictionary: "emitted CSS variables",
key,
}) if key == expected
),
"коллизия эмитируемого ключа {expected} обязана быть отвергнута"
);
};

// Реальный класс регрессии: существующий glow владеет двумя сателлитами.
for suffix in ["-core", "-alpha"] {
let colliding = format!("fx-glow-brand{suffix}");

let mut role_cfg = labui_reference();
let ordinary_recipe = role_cfg
.roles
.iter()
.find(|(name, _)| name == "label-primary")
.expect("фикстура несёт label-primary")
.1
.clone();
role_cfg.roles.push((colliding.clone(), ordinary_recipe));
assert_collision(role_cfg, &colliding);

let mut alias_cfg = labui_reference();
alias_cfg
.aliases
.push((colliding.clone(), "label-primary".to_string()));
assert_collision(alias_cfg, &colliding);
}

// Тот же закон обязан закрывать второй многоключевой outcome, а не только
// конкретный найденный суффикс glow.
for suffix in ["-01", "-02"] {
let colliding = format!("probe-material{suffix}");

let mut role_cfg = labui_reference();
role_cfg.roles.push((
"probe-material".to_string(),
neutral_material(10.0, Floor::AaText),
));
let ordinary_recipe = role_cfg
.roles
.iter()
.find(|(name, _)| name == "label-primary")
.expect("фикстура несёт label-primary")
.1
.clone();
role_cfg.roles.push((colliding.clone(), ordinary_recipe));
assert_collision(role_cfg, &colliding);

let mut alias_cfg = labui_reference();
alias_cfg.roles.push((
"probe-material".to_string(),
neutral_material(10.0, Floor::AaText),
));
alias_cfg
.aliases
.push((colliding.clone(), "label-primary".to_string()));
assert_collision(alias_cfg, &colliding);
}

// Алиас многоключевой цели сам становится владельцем полного shape. Это
// отдельная ветвь: проверка только рецептов ролей пропустила бы её.
for suffix in ["-core", "-alpha"] {
let owner = "probe-glow-alias";
let colliding = format!("{owner}{suffix}");
let mut cfg = labui_reference();
cfg.aliases
.push((owner.to_string(), "fx-glow-brand".to_string()));
let ordinary_recipe = cfg
.roles
.iter()
.find(|(name, _)| name == "label-primary")
.expect("фикстура несёт label-primary")
.1
.clone();
cfg.roles.push((colliding.clone(), ordinary_recipe));
assert_collision(cfg, &colliding);
}

for suffix in ["-01", "-02"] {
let owner = "probe-material-alias";
let colliding = format!("{owner}{suffix}");
let mut cfg = labui_reference();
cfg.roles.push((
"probe-material".to_string(),
neutral_material(10.0, Floor::AaText),
));
cfg.aliases
.push((owner.to_string(), "probe-material".to_string()));
cfg.aliases
.push((colliding.clone(), "label-primary".to_string()));
assert_collision(cfg, &colliding);
}
}

/// Сегодняшние суффиксы не пересекаются друг с другом, но сам примитив
/// namespace не должен полагаться на это случайное свойство. Синтетические
/// shapes доказывают derived↔derived ветвь: два разных владельца строят один
/// итоговый ключ, и второй резерв немедленно падает.
#[test]
fn emitted_namespace_primitive_rejects_satellite_to_satellite_collision() {
let mut emitted = std::collections::BTreeSet::new();
reserve_emitted_css_names(&mut emitted, "probe", &["-outer-inner"])
.expect("первый сателлит свободен");
let error = reserve_emitted_css_names(&mut emitted, "probe-outer", &["-inner"])
.expect_err("derived↔derived коллизия обязана быть отвергнута");

assert!(matches!(
error,
ConfigError::DuplicateKey {
dictionary: "emitted CSS variables",
key,
} if key == "--lab-probe-outer-inner"
));
}

/// Гард не должен превращаться в запрет похожих префиксов: резервируются ровно
/// фактически эмитируемые имена, а не все строки, начинающиеся с имени роли.
#[test]
fn emitted_namespace_allows_non_colliding_near_misses() {
let mut cfg = labui_reference();
cfg.aliases.push((
"fx-glow-brand-alpha-extra".to_string(),
"label-primary".to_string(),
));
cfg.roles.push((
"probe-material".to_string(),
neutral_material(10.0, Floor::AaText),
));
cfg.aliases
.push(("probe-material-03".to_string(), "label-primary".to_string()));

assert_eq!(cfg.validate(), Ok(()));
}

/// preferred_side — закрытое меню {-1, +1}: 0 и 2 отвергаются.
#[test]
fn validator_rejects_preferred_side_outside_closed_menu() {
Expand Down Expand Up @@ -1965,7 +2113,8 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() {
.unwrap_or_else(|| panic!("{name} должен быть Translucent"));
let tint = srgb_encoded_from_hex(t.tint_hex()).unwrap();
let prev_hex = hex_from_srgb_encoded(state);
state = composite_over_encoded(tint, t.alpha(), state);
state = composite_over_encoded(tint, t.alpha(), state)
.expect("эмитированные tint/alpha и предыдущий композит лежат в домене");
let state_hex = hex_from_srgb_encoded(state);
// (1) слой меняет пиксели поверх уже наслоённого стека.
assert_ne!(
Expand All @@ -1989,8 +2138,8 @@ fn fx_shadow_stack_composition_is_strictly_progressive_on_light() {
/// Ladder@52-строк): (а) на тёмной базе паспорта свечение решается БЕЗ
/// деградации, halo = пер-темный якорь источника, α ∈ (0, 1], фактический шаг
/// в допуске квантования от контрактной ступени Base; (б) на белом фоне
/// белое нейтральное свечение деградирует ЧЕСТНО (screen гаснет физически) —
/// флаг degraded, не молчание и не ошибка (ADR-0002, закон 2).
/// белое нейтральное свечение деградирует ЧЕСТНО (на белом screen — point-no-op
/// reference-профиля) — флаг degraded, не молчание и не ошибка.
#[test]
fn glow_roles_resolve_screen_layers() {
let cfg = labui_reference();
Expand Down Expand Up @@ -2244,8 +2393,10 @@ fn material_guarantee_recomputable_over_worst_backdrop() {
let tint = crate::spaces::srgb::srgb_encoded_from_hex(m.tint_hex()).unwrap();
// ЭКСАКТНЫЙ композит квантованного тинта (как ядро и потребитель), без
// переквантования — над двумя углами полного коридора.
let over_black = crate::alpha::composite_over_encoded(tint, m.alpha(), [0.0; 3]);
let over_white = crate::alpha::composite_over_encoded(tint, m.alpha(), [1.0; 3]);
let over_black = crate::alpha::composite_over_encoded(tint, m.alpha(), [0.0; 3])
.expect("эмитированный материал лежит в домене композитора");
let over_white = crate::alpha::composite_over_encoded(tint, m.alpha(), [1.0; 3])
.expect("эмитированный материал лежит в домене композитора");
let pole_lum = if matches!(m.pole(), crate::material::Pole::White) {
1.0
} else {
Expand Down
Loading