Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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.

12 changes: 12 additions & 0 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 @@ -1273,6 +1279,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
5 changes: 3 additions & 2 deletions crates/labcolors-wasm/src/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
/// после того, как ВЕСЬ именованный набор атомарно прошёл допуск.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedTheme {
/// The theme key this was resolved under (`"light"`, `"dark"`, …).
pub theme: &'static str,
/// ИСХОДНЫЙ клиентский ключ темы из словаря конфига, под которым решён
/// набор (результат сохраняет имя клиента, не физический пресет).
pub theme: String,
/// The normalised background hex the set was resolved against.
pub background: String,
/// One entry per role the core returned, in the core's deterministic order.
Expand Down
Loading
Loading