Files
Unlucky-Launcher/crates/design/tests/contrast_aa.rs
T
AskaEth 9519a89472 feat(design): M70 r1 —— 补第 4 档「天蓝」候选 #3C8DBC(四档对比)
③ 的 primary `#0C6780` 偏青(teal),不是「天蓝」。新增 ④ 天蓝:种子 `#3C8DBC`
(CAM16 hue 239.5°,夹 ③ 与 ①② 之间)+ 中性彩度 12 → primary `#236488`。

- `tests/skyseed_candidates.rs`:候选表 3 → 4 档,④ 的 9 角色 × light/dark 全部进 pin;
  `the_three_tiers_are_actually_distinguishable` 改为 `the_candidates_are_actually_distinguishable`,
  增加「③ 与 ④ 中性彩度相同、主色/容器必须靠色相区分」的断言
- `tests/contrast_aa.rs`:候选 WCAG 门禁 3 → 4 档(26 × 4 × 2 = 208 项)
- 默认值**不动**:仍为 ③ `#87CEEB`(临时默认,待用户看完四档再拍);
  `product_default_is_the_recommended_candidate` 保持原样,专门在默认与推荐不一致时报警
- 文档:`color/mod.rs` 与 `palettes.rs` 的候选清单补上 ④ 与「改一行即可切 ④」的说明
2026-11-01 18:09:13 +08:00

256 lines
9.9 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.
//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。
//!
//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立:
//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1);
//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射);
//! 3. 角色 → tone 的映射与色相/彩度无关。
//!
//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子
//! 逐一验证,就是在钉这个性质。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。
const AA_TEXT: f32 = 4.5;
/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。
const AA_NON_TEXT: f32 = 3.0;
/// 文字/图标对(前景, 承载色)。两个模式都要过。
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),
// 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达)
(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),
];
/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。
///
/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。
const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
(
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
),
(
Role::SurfaceVariant,
Role::Surface,
1.0,
"surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)",
),
];
fn seeds() -> Vec<Color> {
vec![
// 出厂基线(M3 baseline 紫)
Color::from_hex("#6750A4").expect("基线种子"),
// 冷蓝(原 Fluent 强调色,用户大概率会选这一族)
Color::from_hex("#0078D4").expect("蓝"),
// 青绿
Color::from_hex("#006A5C").expect("青"),
// 暖橙(高彩度,gamut 压力大)
Color::from_hex("#E8710A").expect("橙"),
// 亮黄(最容易被白字吃掉的极端)
Color::from_hex("#FFD400").expect("黄"),
// 近灰(低彩度种子,检验 neutral 不塌)
Color::from_hex("#6E6E6E").expect("灰"),
]
}
#[test]
fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() {
let mut failures = Vec::new();
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
if ratio < AA_TEXT {
failures.push(format!(
"seed {} / {} / {} on {} = {ratio:.2}",
seed.to_hex(),
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
assert!(
failures.is_empty(),
"以下角色对未达 WCAG AA(4.5:1):\n{}",
failures.join("\n")
);
}
#[test]
fn exception_list_is_honest_about_its_thresholds() {
// 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。
for seed in seeds() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg, threshold, reason) in EXCEPTIONS {
assert!(
!fg.is_foreground(),
"例外 {} 不该是文字前景色({reason})",
fg.name()
);
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= threshold,
"例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})",
fg.name(),
bg.name(),
seed.to_hex(),
flavor.name()
);
}
}
}
}
#[test]
fn baseline_light_and_dark_both_pass() {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"baseline {} 的 {} on {} 只有 {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
);
}
}
}
#[test]
fn sky_seed_candidates_meet_wcag_aa() {
// M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。
// 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过,
// 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。
let candidates = [
("① 保守蓝", "#2196F3", 6.0_f64),
("② 冷调蓝", "#2196F3", 12.0),
("③ 浅冷蓝", "#87CEEB", 12.0),
("④ 天蓝", "#3C8DBC", 12.0),
];
let mut failures = Vec::new();
let mut worst = (f32::MAX, String::new());
let mut checked = 0;
for (title, seed_hex, neutral_chroma) in candidates {
let seed = Color::from_hex(seed_hex).expect("候选种子");
let palettes =
unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::from_palettes(&palettes, flavor);
for (fg, bg) in TEXT_PAIRS {
let ratio = scheme.contrast(fg, bg);
checked += 1;
if ratio < worst.0 {
worst = (
ratio,
format!(
"{title}/{} 的 {} on {}",
flavor.name(),
fg.name(),
bg.name()
),
);
}
if ratio < AA_TEXT {
failures.push(format!(
"{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}",
flavor.name(),
fg.name(),
bg.name()
));
}
}
}
}
println!(
"候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})",
worst.1, worst.0
);
assert!(
failures.is_empty(),
"以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}",
failures.join("\n")
);
}
#[test]
fn accent_override_cannot_break_contrast() {
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
let base = Scheme::baseline(SchemeFlavor::Light);
for seed in seeds() {
let custom = Scheme::from_accent(SchemeFlavor::Light, seed);
for (fg, bg) in [
(Role::OnPrimary, Role::Primary),
(Role::OnPrimaryContainer, Role::PrimaryContainer),
(Role::OnSecondary, Role::Secondary),
(Role::OnTertiary, Role::Tertiary),
] {
let ratio = custom.contrast(fg, bg);
assert!(
ratio >= AA_TEXT,
"强调色 {} 下 {} on {} 只有 {ratio:.2}",
seed.to_hex(),
fg.name(),
bg.name()
);
}
// 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
}
}
#[test]
fn outline_is_registered_as_non_text_exception() {
// 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。
let light = Scheme::baseline(SchemeFlavor::Light);
let ratio = light.contrast(Role::Outline, Role::Surface);
assert!(
ratio < AA_TEXT,
"light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}"
);
assert!(
ratio >= AA_NON_TEXT,
"light 的 outline 低于非文字 3:1:{ratio:.2}"
);
}