Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ adjacent bytes или нормативного отношения пересчи
наблюдателях человеческий вердикт clean/dirty и не пригодность для production
decision. Legacy-идентификаторы сохранены только для совместимости.

- `theme` — kebab-ключ: `light` \| `dark` \| `light-ic` \| `dark-ic`.
- `theme` — kebab-ключ ЛОКАЛЬНОГО fixture-словаря пака (совпадает со словарём
labui-паспорта): `light` \| `dark` \| `light-ic` \| `dark-ic`. Канонический
словарь тем принадлежит клиентскому конфигу (C5.1); ядро встроенных имён
не несёт.
- `contract` (в `solve`): `{kind:"text", lc}` \| `{kind:"ui", lc}` \|
`{kind:"range", floor, ceiling}`.
- `outcome` (в `solve`): успех `{kind:"solved", hex, lc, wcagRatio, floorOverride}`
Expand Down
23 changes: 16 additions & 7 deletions crates/labcolors-conformance/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ use serde::{Deserialize, Serialize};
use labcolors_core::alpha::{composite_hex, min_alpha_hex};
use labcolors_core::cleanliness::muddiness_from_hex;
use labcolors_core::{
BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, Theme, ViewingConditions,
fnv1a_32, recheck_against, solve,
BgInput, ChromaPolicy, Contract, Gamut, Hue, LadderPosition, ViewingConditions, fnv1a_32,
recheck_against, solve,
};

/// Семантическая версия conformance-пака. Меняется при изменении СХЕМЫ или
Expand All @@ -71,12 +71,21 @@ pub fn core_version() -> &'static str {
// словарём тем ("light" | "dark" | "light-ic" | "dark-ic").
// ─────────────────────────────────────────────────────────────────────────────

/// Условия просмотра для kebab-ключа темы. Паникует на неизвестной теме —
/// ключи в паке контролируются генератором, внешний вход сюда не попадает.
/// Условия просмотра для kebab-ключа темы — ЛОКАЛЬНЫЙ fixture-словарь пака
/// (C5.1: канонический словарь тем принадлежит клиентскому конфигу; ядро
/// встроенных имён не несёт). Ключи совпадают со словарём labui-паспорта.
/// Паникует на неизвестной теме — ключи в паке контролируются генератором,
/// внешний вход сюда не попадает.
fn vc_for_theme(theme_key: &str) -> ViewingConditions {
Theme::parse(theme_key)
.expect("ключ темы в паке всегда канонический")
.viewing_conditions()
use labcolors_core::VcPreset;
let preset = match theme_key {
"light" => VcPreset::Srgb,
"dark" => VcPreset::Dim,
"light-ic" => VcPreset::SrgbIc,
"dark-ic" => VcPreset::DimIc,
other => panic!("ключ темы в паке всегда канонический, получено: {other}"),
};
preset.viewing_conditions()
}

/// Все четыре канонические темы в стабильном порядке.
Expand Down
374 changes: 0 additions & 374 deletions crates/labcolors-core/src/cleanliness.rs

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions crates/labcolors-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ pub enum ConfigError {
/// ЗАГРУЗКЕ (`#[serde(default)]` на `roles` на границе WASM разрешает ОПУСТИТЬ
/// словарь синтаксически, но не остаться совсем без контракта).
EmptyContract,
/// Словарь тем пуст. Симметрия с [`EmptyContract`](Self::EmptyContract):
/// без единой темы `resolve`/`recheck` тотально неработоспособны (любой
/// ключ — unknown), и дефект уехал бы на использование неотличимым от
/// опечатки. Отказ на загрузке.
EmptyThemes,
/// `material`-рецепту передан `floor: zero` — у материала нет цели для вывода
/// альфы без пола читаемости. Отказ на загрузке (а не молчаливая невидимая
/// роль): материал обязан нести `aa-text` или `aa-ui`.
Expand Down Expand Up @@ -303,6 +308,7 @@ impl std::fmt::Display for ConfigError {
reason,
} => write!(f, "сентимент `{sentiment}` (роль `{role}`): {reason}"),
ConfigError::EmptyContract => write!(f, "контракт пуст: передайте roles"),
ConfigError::EmptyThemes => write!(f, "словарь тем пуст: передайте themes"),
ConfigError::MaterialFloorRequired { role } => write!(
f,
"material-роль `{role}` требует пол читаемости (aa-text/aa-ui), получен zero-floor"
Expand Down Expand Up @@ -1261,8 +1267,9 @@ impl ThemeConfig {
///
/// # Errors
///
/// [`ConfigError`] структурной/деривационной фазы либо
/// [`ConfigError::EmptyContract`] на голом контракте (без ролей и алиасов).
/// [`ConfigError`] структурной/деривационной фазы,
/// [`ConfigError::EmptyContract`] на голом контракте (без ролей и алиасов)
/// либо [`ConfigError::EmptyThemes`] на пустом словаре тем.
pub fn compile_named_role_table(&self) -> Result<NamedRoleTable, ConfigError> {
self.validate_syntactic()?;

Expand All @@ -1273,6 +1280,12 @@ impl ThemeConfig {
if self.roles.is_empty() && self.aliases.is_empty() {
return Err(ConfigError::EmptyContract);
}
// Пустой словарь тем — тот же класс дефекта, что и пустой контракт
// ролей: без темы resolve/recheck невозможны, отказ обязан быть на
// загрузке, а не поздним unknown_theme на использовании.
if self.themes.entries.is_empty() {
return Err(ConfigError::EmptyThemes);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let mut entries: Vec<(String, RoleSpec)> = Vec::with_capacity(self.roles.len());
for (name, recipe) in &self.roles {
Expand Down
12 changes: 12 additions & 0 deletions crates/labcolors-core/src/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1613,6 +1613,18 @@ fn validator_rejects_duplicate_dictionary_keys() {
})
));

// C5.1: имя темы — ключ клиентского словаря; дубликат делал бы lookup
// неоднозначным (first-wins тихо хоронит вторую декларацию).
let mut c = labui_reference();
c.themes.entries.push(c.themes.entries[0].clone());
assert!(matches!(
c.validate(),
Err(ConfigError::DuplicateKey {
dictionary: "themes",
..
})
));

let mut c = labui_reference();
c.palette.push(c.palette[0].clone());
assert!(matches!(
Expand Down
3 changes: 1 addition & 2 deletions crates/labcolors-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,7 @@ pub use accent_surface::{
};
pub use alpha::composite_over_encoded;
pub use cleanliness::{
DefectContext, Theme, drab, drab_in_context, muddiness_from_hex, muddiness_from_linear_srgb,
muddiness_in_context, muddiness_oklch, n_pure,
drab, muddiness_from_hex, muddiness_from_linear_srgb, muddiness_oklch, n_pure,
};
pub use config::{
Brand, ConfigError, LadderSource, NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint,
Expand Down
32 changes: 5 additions & 27 deletions crates/labcolors-core/tests/property_invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@
//! как characterization реального поведения (не одобрение — фиксация факта для владельца).

use labcolors_core::{
BgInput, Brand, DefectContext, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource,
NeutralAnchors, NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure,
RoleRecipe, SentimentCategory, SentimentsConfig, Theme, ThemeAnchors, ThemeConfig,
ThemesConfig, VcPreset, ViewingConditions, muddiness_in_context, muddiness_oklch,
oklch_from_hex, p3_from_hex, resolve_named_set, srgb_encoded_from_hex,
BgInput, Brand, Floor, GlowDecisionProfileV1, LadderPosition, LadderSource, NeutralAnchors,
NeutralConfig, NeutralPick, NeutralTint, PaletteFamily, Resolved, RoleFailure, RoleRecipe,
SentimentCategory, SentimentsConfig, ThemeAnchors, ThemeConfig, ThemesConfig, VcPreset,
ViewingConditions, muddiness_oklch, oklch_from_hex, p3_from_hex, resolve_named_set,
srgb_encoded_from_hex,
};
use proptest::prelude::*;
use proptest::test_runner::{Config, RngAlgorithm, TestRng, TestRunner};
Expand Down Expand Up @@ -200,28 +200,6 @@ fn muddiness_strictly_increases_in_chroma_in_the_steep_warm_band() {
);
}

/// Закрытие ДЫРЫ, найденной mutation-прогоном (survivor `cleanliness.rs:541:35
/// replace + with * in muddiness_in_context`): local-context path must calculate
/// Euclidean Oklab chroma `sqrt(a²+b²)`. The `a²*b²` mutant collapses these
/// fixtures below C0 and moves the frozen proxy from roughly 0.5-0.7 to ~0.03.
/// The `> 0.3` boundary is mutation characterization, not a human verdict.
/// Заземлено замером: #6B6B2E=0.651, #8A7A2E=0.622, #7A5A20=0.666, #B8860B=0.506.
#[test]
fn muddiness_in_context_uses_euclidean_oklab_chroma() {
let ctx = DefectContext {
bg_hex: "#FFFFFF",
theme: Theme::Light,
};
for hex in ["#6B6B2E", "#8A7A2E", "#7A5A20", "#B8860B"] {
let mud = muddiness_in_context(hex, ctx).unwrap();
assert!(
mud > 0.3,
"fixture {hex} proxy must remain > 0.3 with Euclidean sqrt(a²+b²); \
collapsed chroma would approach zero. Got {mud}"
);
}
}

/// Characterization: for a cool hue (h=270°, sin=-1), the compatibility
/// coordinate is not monotone in C: it first rises, then falls. This records
/// current mathematics without assigning perceptual meaning.
Expand Down
18 changes: 10 additions & 8 deletions crates/labcolors-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,7 @@ use labcolors_core::{
GlowDecisionProfileV1, GlowDiagnosticProfileV1, GlowTargetStatus as CoreGlowTargetStatus, Hue,
LadderPosition, LegacyPlatformDependentV1, NumericalCompatibilityReleaseIdV1,
NumericalDecisionEvidenceV1, NumericalDecisionV1, NumericalIndeterminacyV1, NumericalSiteIdV1,
Theme as CoreTheme, ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj,
srgb_encoded_from_hex,
ViewingConditions, recheck_against, solve, solve_screen_alpha_for_dj, srgb_encoded_from_hex,
};

// Регистрирует UniFFI-scaffolding под namespace = имя крейта (`labcolors`).
Expand All @@ -78,17 +77,20 @@ pub enum Theme {
}

impl Theme {
fn to_core(self) -> CoreTheme {
/// ЛОКАЛЬНЫЙ adapter-словарь FFI → физический VC-пресет ядра (C5.1:
/// канонический словарь тем клиентский; ядро встроенных имён не несёт).
fn preset(self) -> labcolors_core::VcPreset {
use labcolors_core::VcPreset;
match self {
Theme::Light => CoreTheme::Light,
Theme::Dark => CoreTheme::Dark,
Theme::LightIc => CoreTheme::LightIc,
Theme::DarkIc => CoreTheme::DarkIc,
Theme::Light => VcPreset::Srgb,
Theme::Dark => VcPreset::Dim,
Theme::LightIc => VcPreset::SrgbIc,
Theme::DarkIc => VcPreset::DimIc,
}
}

fn vc(self) -> ViewingConditions {
self.to_core().viewing_conditions()
self.preset().viewing_conditions()
}
}

Expand Down
70 changes: 27 additions & 43 deletions crates/labcolors-wasm/src/cache.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//! A contract cache for resolved theme sets, keyed by `(bgHex, theme, table
//! fingerprint)`.
//! A contract cache for resolved theme sets, keyed by `(bgHex, theme-binding
//! slot, table fingerprint)`.
//!
//! Re-solving the same background under the same theme is the common case while
//! a tool tweaks a colour, and a resolve sweep is real work. The cache returns
Expand All @@ -20,8 +20,6 @@
use std::cell::RefCell;
use std::collections::{HashMap, HashSet};

use crate::theme::Theme;

/// A stable, arbitrary fingerprint used by the cache's own unit tests as a
/// single key namespace. Production keys always carry a real config fingerprint
/// (an FNV-1a over the canonical DTO, computed in the engine); this constant
Expand All @@ -30,21 +28,27 @@ use crate::theme::Theme;
pub(crate) const DEFAULT_TABLE_FINGERPRINT: u64 = 0;

/// The full key of a cached resolve: every input that can change the output.
///
/// Тема входит НОМЕРОМ слота в словаре тем загруженного конфига (порядок
/// объявления), не строкой: ключ не аллоцирует и не зависит от длины
/// клиентского имени. Отпечаток конфига разводит пространства словарей, так
/// что слот всегда читается в правильном словаре.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
bg_hex: String,
theme: &'static str,
theme_slot: u32,
table_fingerprint: u64,
}

impl CacheKey {
/// Build a key from a normalised background hex, a theme, and a table
/// fingerprint. The hex is normalised by the caller (uppercased, `#`-led)
/// so `#fff` and `#FFFFFF` never split the cache once expanded upstream.
pub fn new(bg_hex: String, theme: Theme, table_fingerprint: u64) -> Self {
/// Build a key from a normalised background hex, a theme-binding slot, and
/// a table fingerprint. The hex is normalised by the caller (uppercased,
/// `#`-led) so `#fff` and `#FFFFFF` never split the cache once expanded
/// upstream.
pub fn new(bg_hex: String, theme_slot: u32, table_fingerprint: u64) -> Self {
Self {
bg_hex,
theme: theme.key(),
theme_slot,
table_fingerprint,
}
}
Expand Down Expand Up @@ -152,7 +156,7 @@ mod tests {
fn failed_build_is_not_cached_and_a_later_success_is_shared() {
let cache: ContractCache<Rc<u32>> = ContractCache::new(8);
let calls = Cell::new(0);
let key = || CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT);
let key = || CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT);

let failed: Result<Rc<u32>, &'static str> = cache.get_or_try_insert_with(key(), || {
calls.set(calls.get() + 1);
Expand All @@ -179,8 +183,8 @@ mod tests {
#[test]
fn failed_miss_at_capacity_preserves_every_successful_entry() {
let cache: ContractCache<Rc<u32>> = ContractCache::new(2);
let first_key = CacheKey::new("#000000".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT);
let second_key = CacheKey::new("#111111".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT);
let first_key = CacheKey::new("#000000".into(), 0, DEFAULT_TABLE_FINGERPRINT);
let second_key = CacheKey::new("#111111".into(), 0, DEFAULT_TABLE_FINGERPRINT);
let first = cache
.get_or_try_insert_with(first_key.clone(), || Ok::<_, &'static str>(Rc::new(1)))
.unwrap();
Expand All @@ -190,7 +194,7 @@ mod tests {
assert_eq!(cache.len(), 2);

let failed: Result<Rc<u32>, &'static str> = cache.get_or_try_insert_with(
CacheKey::new("#222222".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT),
CacheKey::new("#222222".into(), 0, DEFAULT_TABLE_FINGERPRINT),
|| Err("injected failure"),
);
assert_eq!(failed, Err("injected failure"));
Expand All @@ -208,7 +212,7 @@ mod tests {
fn builds_once_then_serves_from_cache() {
let cache: ContractCache<u32> = ContractCache::new(8);
let calls = Cell::new(0);
let key = || CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT);
let key = || CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT);

let first = cache
.get_or_try_insert_with(key(), || {
Expand All @@ -233,13 +237,13 @@ mod tests {
let cache: ContractCache<&str> = ContractCache::new(8);
let light = cache
.get_or_try_insert_with(
CacheKey::new("#FFFFFF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT),
CacheKey::new("#FFFFFF".into(), 0, DEFAULT_TABLE_FINGERPRINT),
|| Ok::<_, ()>("light"),
)
.unwrap();
let dark = cache
.get_or_try_insert_with(
CacheKey::new("#FFFFFF".into(), Theme::Dark, DEFAULT_TABLE_FINGERPRINT),
CacheKey::new("#FFFFFF".into(), 1, DEFAULT_TABLE_FINGERPRINT),
|| Ok::<_, ()>("dark"),
)
.unwrap();
Expand All @@ -254,11 +258,7 @@ mod tests {
for i in 0..2 {
cache
.get_or_try_insert_with(
CacheKey::new(
format!("#00000{i}"),
Theme::Light,
DEFAULT_TABLE_FINGERPRINT,
),
CacheKey::new(format!("#00000{i}"), 0, DEFAULT_TABLE_FINGERPRINT),
|| Ok::<_, ()>(i),
)
.unwrap();
Expand All @@ -267,7 +267,7 @@ mod tests {
// The third distinct key trips the cap → wholesale clear, then insert.
cache
.get_or_try_insert_with(
CacheKey::new("#0000FF".into(), Theme::Light, DEFAULT_TABLE_FINGERPRINT),
CacheKey::new("#0000FF".into(), 0, DEFAULT_TABLE_FINGERPRINT),
|| Ok::<_, ()>(3),
)
.unwrap();
Expand All @@ -289,11 +289,7 @@ mod reentrancy_tests {
#[should_panic(expected = "реентерабельный build")]
fn same_key_reentrant_build_panics_deterministically() {
let cache: ContractCache<u32> = ContractCache::new(4);
let key = CacheKey::new(
"#FFFFFF".to_string(),
Theme::Light,
DEFAULT_TABLE_FINGERPRINT,
);
let key = CacheKey::new("#FFFFFF".to_string(), 0, DEFAULT_TABLE_FINGERPRINT);
let key_inner = key.clone();
let _ = cache.get_or_try_insert_with::<()>(key, || {
// Тот же ключ изнутри build — обязан паниковать, не рекурсировать.
Expand All @@ -308,16 +304,8 @@ mod reentrancy_tests {
#[test]
fn different_key_nested_build_is_safe_and_guard_lifts_on_error() {
let cache: ContractCache<u32> = ContractCache::new(4);
let a = CacheKey::new(
"#FFFFFF".to_string(),
Theme::Light,
DEFAULT_TABLE_FINGERPRINT,
);
let b = CacheKey::new(
"#000000".to_string(),
Theme::Light,
DEFAULT_TABLE_FINGERPRINT,
);
let a = CacheKey::new("#FFFFFF".to_string(), 0, DEFAULT_TABLE_FINGERPRINT);
let b = CacheKey::new("#000000".to_string(), 0, DEFAULT_TABLE_FINGERPRINT);
let b_inner = b.clone();
let nested = cache
.get_or_try_insert_with::<()>(a.clone(), || {
Expand All @@ -328,11 +316,7 @@ mod reentrancy_tests {
.unwrap();
assert_eq!(nested, 8);

let c = CacheKey::new(
"#123456".to_string(),
Theme::Dark,
DEFAULT_TABLE_FINGERPRINT,
);
let c = CacheKey::new("#123456".to_string(), 1, DEFAULT_TABLE_FINGERPRINT);
assert!(
cache
.get_or_try_insert_with(c.clone(), || Err::<u32, _>("boom"))
Expand Down
Loading
Loading