Files
Unlucky-Launcher/crates/design/tests/contrast_aa.rs
T
AskaEth 83e19e58f0 fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试
reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
2026-11-01 15:58:19 +08:00

200 lines
7.7 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.29:1、dark 6.5:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;1.6: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 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}"
);
}