feat(design): 配色来源纯数学层(Monet 式量化取色 + 色卡 + 三级回落 + 对比度收敛)

D40 分层:design 保持零第三方依赖,只吃已解码的 RGBA 像素。
- palette/quantize:5bit 直方图 + CIELAB k-means(QuantizerCelebi/Score 的形状)
- palette/score:官方 Score 口径(CUTOFF_CHROMA 5 / TARGET 48 / 权重 0.7+0.3)
- palette/seed:seed_from_rgba + D40 彩度门槛 8,灰白图回落而非凭空上色
- palette/card:8 张内置色卡,key 为持久化键
- palette/wcag:26 对文字对比度门禁 + 沿 tone 轴收敛并记录修正
- palette/resolve:纯函数三级来源编排 + Provenance(原因码/提示键)
This commit is contained in:
AskaEth committed 2026-11-02 11:04:10 +08:00
1 parent c1fc5b8086
commit 18327fb038
11 files changed
+2098 -2

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
//! 对比度校验与**收敛**:任意一套方案(不管种子来自图片还是色卡)都要过同一道门。
//!
//! 为什么需要这一层:`tests/contrast_aa.rs` 的 208 项门禁只覆盖**候选色卡**(它拿 6 个手挑的
//! 种子逐对断言)。D38/D40 之后「方案生成」还可能吃图片来的种子,而远程证据不能只靠
//! 「tone 轴固定所以一定达标」这句话——那条性质对本 crate 的生成器成立,但一旦有人手动改角色、
//! 或者将来接别的生成器(比如非 TonalSpot 变体),门禁必须当场发现。所以:
//!
//! 1. [`check`] 对**任何** [`Scheme`] 跑全表(26 对文字/图标对,AA ≥ 4.5:1);
//! 2. [`converge`] 不达标就**沿 tone 轴修正前景色**(保住色相,只动明度),并把每一处修正
//! 连同「改前/改后的实测对比度」记进 [`Adjustment`],让 UI 能说「为了可读性微调了强调色」;
//! 3. 修正后仍然不达标的方案,由 [`super::resolve`] 回落到色卡并在 `Provenance` 里登记原因。
//!
//! ## 为什么只改前景、不改背景
//!
//! 背景(`surface` / `*-container`)决定整个界面的骨架与层级,改它会让「同一套方案的两个页面
//! 底色不一样」;前景(`on-*`)本来就是为可读性服务的,动它代价最小。方向也不是猜的:
//! 背景 tone ≥ 50(浅底)就压暗前景,否则提亮前景——修正方向永远朝着「离背景更远」的那一侧。
//!
//! ## 只做 4.5:1 这一档,`outline` 不在门禁里
//!
//! M3 里 `outline` 是非文字元素(输入框描边),规范目标是 3:1,且它按设计就贴着 surface;
//! `outline-variant` 是纯装饰分隔线。这两个的例外登记在 `crates/design/src/color/mod.rs` 的
//! 「对比度纪律」小节,本模块不重复断言(重复断言只会让两边口径打架)。
use crate::color::{Color, Hct, Role, Scheme};
/// 正文/图标对的 AA 门槛(WCAG 2.x:普通文字 4.5:1)。
pub const AA_TEXT: f32 = 4.5;
/// 非文字元素的门槛(描边/图标块 3:1)——本模块不自动收敛这一档,只提供常量给调用方参考。
pub const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。与 `tests/contrast_aa.rs` 的 `TEXT_PAIRS` 同表。
pub const TEXT_PAIRS: [(Role, Role); 26] = [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnSecondaryContainer, Role::SecondaryContainer),
(Role::OnTertiary, Role::Tertiary),
(Role::OnTertiaryContainer, Role::TertiaryContainer),
(Role::OnError, Role::Error),
(Role::OnErrorContainer, Role::ErrorContainer),
(Role::OnSurface, Role::Surface),
(Role::OnSurfaceVariant, Role::SurfaceVariant),
(Role::OnBackground, Role::Background),
(Role::InverseOnSurface, Role::InverseSurface),
(Role::InversePrimary, Role::InverseSurface),
(Role::OnPrimaryFixed, Role::PrimaryFixed),
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
(Role::OnSecondaryFixed, Role::SecondaryFixed),
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
(Role::OnTertiaryFixed, Role::TertiaryFixed),
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
(Role::OnSurface, Role::SurfaceContainerLowest),
(Role::OnSurface, Role::SurfaceContainerLow),
(Role::OnSurface, Role::SurfaceContainer),
(Role::OnSurface, Role::SurfaceContainerHigh),
(Role::OnSurface, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
];
/// 一处不达标。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Failure {
/// 前景角色。
pub fg: Role,
/// 承载色角色。
pub bg: Role,
/// 实测对比度。
pub ratio: f32,
/// 要求的下限(目前恒为 [`AA_TEXT`])。
pub required: f32,
}
/// 一套方案的对比度体检结果。
#[derive(Debug, Clone, PartialEq)]
pub struct WcagReport {
/// 全部不达标项(空 = 通过)。
pub failures: Vec<Failure>,
/// 全表里最低的对比度(用来观察「刚好卡线」的方案)。
pub worst: f32,
}
impl WcagReport {
/// 是否全部达标。
pub fn is_clean(&self) -> bool {
self.failures.is_empty()
}
}
/// 对任意方案跑全表断言。
pub fn check(scheme: &Scheme) -> WcagReport {
let mut failures = Vec::new();
let mut worst = f32::INFINITY;
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
worst = worst.min(ratio);
if ratio < AA_TEXT {
failures.push(Failure {
fg,
bg,
ratio,
required: AA_TEXT,
});
}
}
WcagReport { failures, worst }
}
/// 一处自动修正的记录(UI 可以拿去写「为可读性微调了强调色」)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Adjustment {
/// 被改的角色。
pub role: Role,
/// 为了跟谁达标而改的(承载色角色)。
pub against: Role,
/// 改前的色值。
pub before: Color,
/// 改后的色值。
pub after: Color,
/// 改前的对比度。
pub before_ratio: f32,
/// 改后的对比度。
pub after_ratio: f32,
}
/// 不达标就沿 tone 轴修正,返回(修正后的方案, 修正记录)。
///
/// 最多三遍:一遍修正后可能让「同一个前景对另一个承载色」变得不达标(例如 `on-surface`
/// 同时压在 6 个 surface-container 上),多遍保证收敛。修不动的情况(理论上不存在:纯黑/纯白
/// 对任意背景都 ≥ 4.58:1)会保留原值,由调用方用 [`check`] 复核后决定是否回落。
pub fn converge(scheme: Scheme) -> (Scheme, Vec<Adjustment>) {
let mut colors = [Color::BLACK; 49];
for (role, color) in scheme.iter() {
colors[role as usize] = color;
}
let mut adjustments = Vec::new();
for _ in 0..3 {
let mut changed = false;
for (fg, bg) in TEXT_PAIRS {
let before = colors[fg as usize];
let background = colors[bg as usize];
let before_ratio = before.contrast_ratio(background);
if before_ratio >= AA_TEXT {
continue;
}
let Some(after) = shift_away_from(before, background, AA_TEXT) else {
continue;
};
colors[fg as usize] = after;
adjustments.push(Adjustment {
role: fg,
against: bg,
before,
after,
before_ratio,
after_ratio: after.contrast_ratio(background),
});
changed = true;
}
if !changed {
break;
}
}
(Scheme::from_colors(scheme.flavor(), colors), adjustments)
}
/// 保住色相、压掉彩度地往「离背景更远」的 tone 走,直到达标或到尽头。
fn shift_away_from(foreground: Color, background: Color, min_ratio: f32) -> Option<Color> {
let hct = Hct::from_color(foreground);
let darken = background.lstar() >= 50.0;
let step = if darken { -4.0 } else { 4.0 };
let mut tone = hct.tone.round();
for _ in 0..=25 {
tone += step;
if !(0.0..=100.0).contains(&tone) {
break;
}
// 修正是「为了可读性」而不是「换个品牌色」:色相不动,彩度让求解器自己压到色域内。
let candidate = Hct::from_components(hct.hue, hct.chroma, tone).to_color();
if candidate.contrast_ratio(background) >= min_ratio {
return Some(candidate);
}
}
// 尽头兜底:纯黑/纯白对任何背景都在 4.58:1 以上(中灰处的理论最低点)。
let extreme = if darken { Color::BLACK } else { Color::WHITE };
(extreme.contrast_ratio(background) >= min_ratio).then_some(extreme)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::SchemeFlavor;
#[test]
fn baseline_is_clean() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let report = check(&Scheme::baseline(flavor));
assert!(report.is_clean(), "{flavor:?}: {:?}", report.failures);
assert!(report.worst >= AA_TEXT, "{flavor:?} worst {}", report.worst);
}
}
#[test]
fn hostile_scheme_is_detected() {
// 反证:把 on-surface 换成 surface(对比度 1:1),校验器必须报出来。
let base = Scheme::baseline(SchemeFlavor::Light);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::OnSurface as usize] = base.get(Role::Surface);
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
let report = check(&hostile);
assert!(!report.is_clean());
assert!(report
.failures
.iter()
.any(|failure| failure.fg == Role::OnSurface && failure.bg == Role::Surface));
}
#[test]
fn converge_repairs_a_hostile_scheme() {
let base = Scheme::baseline(SchemeFlavor::Light);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::OnSurface as usize] = base.get(Role::Surface);
colors[Role::OnPrimary as usize] = base.get(Role::Primary);
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
let (fixed, adjustments) = converge(hostile);
assert!(!adjustments.is_empty(), "应当记录了修正");
assert!(check(&fixed).is_clean(), "{:?}", check(&fixed).failures);
for adjustment in &adjustments {
assert!(adjustment.after_ratio >= AA_TEXT);
assert!(adjustment.after_ratio > adjustment.before_ratio);
}
}
#[test]
fn converge_is_a_no_op_on_a_healthy_scheme() {
let clean = Scheme::baseline(SchemeFlavor::Dark);
let (fixed, adjustments) = converge(clean);
assert!(adjustments.is_empty());
assert_eq!(fixed, clean);
}
#[test]
fn shift_darkens_on_light_background_and_lightens_on_dark() {
let light_bg = Scheme::baseline(SchemeFlavor::Light);
let surface = light_bg.get(Role::Surface);
let fixed = shift_away_from(surface, surface, 4.5).expect("浅底上前景可以压暗");
assert!(
fixed.lstar() < surface.lstar() - 20.0,
"压暗后 tone = {}",
fixed.lstar()
);
assert!(fixed.contrast_ratio(surface) >= 4.5);
let dark_bg = Scheme::baseline(SchemeFlavor::Dark);
let surface = dark_bg.get(Role::Surface);
let fixed = shift_away_from(surface, surface, 4.5).expect("深底上前景可以提亮");
assert!(
fixed.lstar() > surface.lstar() + 20.0,
"提亮后 tone = {}",
fixed.lstar()
);
assert!(fixed.contrast_ratio(surface) >= 4.5);
// 色相不跟着动:修正只动明度(改前改后的 CAM16 色相差 < 2°)。
let before = Hct::from_color(surface);
let after = Hct::from_color(fixed);
let delta = (before.hue - after.hue)
.abs()
.min(360.0 - (before.hue - after.hue).abs());
assert!(delta < 2.0, "色相被改了 {delta}°");
}
}