③ 的 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` 的候选清单补上 ④ 与「改一行即可切 ④」的说明
256 lines
9.9 KiB
Rust
256 lines
9.9 KiB
Rust
//! 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}"
|
||
);
|
||
}
|