feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)

D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
This commit is contained in:
AskaEth committed 2026-11-01 15:34:04 +08:00
1 parent fef0922073
commit 65ca0ed29a
25 files changed
+4622 -1

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
//! 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() || fg == Role::Outline,
"例外 {} 不该是文字前景色({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}"
);
}