Files
Unlucky-Launcher/crates/design/src/palette/wcag.rs
T
AskaEth 18327fb038 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(原因码/提示键)
2026-11-02 11:04:10 +08:00

282 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 对比度校验与**收敛**:任意一套方案(不管种子来自图片还是色卡)都要过同一道门。
//!
//! 为什么需要这一层:`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}°");
}
}