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:
1 parent
fef0922073
commit
65ca0ed29a
25 files changed
+4622
-1
No files matched your search
@@ -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}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。
|
||||
//!
|
||||
//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、
|
||||
//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。
|
||||
|
||||
use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette};
|
||||
use unlucky_design::component_map::Component;
|
||||
use unlucky_design::elevation::{Elevation, MaterialLayer};
|
||||
use unlucky_design::icons::{
|
||||
stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX,
|
||||
};
|
||||
use unlucky_design::motion::{DurationToken, Easing, MotionScale, SPRING_SPATIAL_DEFAULT};
|
||||
use unlucky_design::shape::ShapeScale;
|
||||
use unlucky_design::spacing::{LayoutMetrics, Space};
|
||||
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
|
||||
use unlucky_design::DesignTokens;
|
||||
|
||||
#[test]
|
||||
fn color_role_table_is_frozen() {
|
||||
assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个");
|
||||
assert_eq!(Role::ALL[0], Role::Primary);
|
||||
assert_eq!(Role::ALL[16], Role::Surface);
|
||||
assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant);
|
||||
// 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。
|
||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
||||
let dark = Scheme::baseline(SchemeFlavor::Dark);
|
||||
for role in Role::ALL {
|
||||
assert!(
|
||||
light.get(role).is_opaque(),
|
||||
"{} 应当是不透明色",
|
||||
role.name()
|
||||
);
|
||||
assert!(dark.get(role).is_opaque());
|
||||
if role.name().starts_with("on-") {
|
||||
assert!(role.is_foreground(), "{} 应被判为前景色", role.name());
|
||||
}
|
||||
}
|
||||
assert!(light.get(Role::Primary).is_opaque());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tone_mapping_is_the_m3_table() {
|
||||
// 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。
|
||||
assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0);
|
||||
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0);
|
||||
assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0);
|
||||
assert_eq!(
|
||||
Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark),
|
||||
90.0
|
||||
);
|
||||
assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_scale_values_are_the_m3_baseline() {
|
||||
assert_eq!(TypeRole::ALL.len(), 15);
|
||||
let title = TypeRole::TitleLarge.style();
|
||||
assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0));
|
||||
let body = TypeRole::BodyMedium.style();
|
||||
assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0));
|
||||
assert_eq!(body.weight, Weight::Regular);
|
||||
let label = TypeRole::LabelLarge.style();
|
||||
assert_eq!(label.weight, Weight::Medium);
|
||||
assert_eq!(TypeRole::BodySmall.style().size_px, 12.0);
|
||||
// 强调档只动字重。
|
||||
assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0);
|
||||
assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn font_stacks_never_embed_font_files() {
|
||||
for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] {
|
||||
for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] {
|
||||
let stack = platform.font_stack(family);
|
||||
assert!(!stack.is_empty());
|
||||
for name in stack {
|
||||
let lower = name.to_ascii_lowercase();
|
||||
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
|
||||
assert!(
|
||||
!lower.contains(banned),
|
||||
"{} 的字体栈出现文件引用 {name}(只允许族名,禁止内嵌受限字体)",
|
||||
platform.name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_kind_is_frozen_and_append_only() {
|
||||
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
|
||||
// 迁移来的 24 个必须保持原次序(序号即持久化 id)。
|
||||
assert_eq!(IconKind::ALL[0], IconKind::Instances);
|
||||
assert_eq!(IconKind::ALL[4], IconKind::Ai);
|
||||
assert_eq!(IconKind::ALL[20], IconKind::More);
|
||||
assert_eq!(IconKind::ALL[23], IconKind::User);
|
||||
// 新增的 4 个追加在末尾。
|
||||
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
|
||||
assert_eq!(IconKind::ALL[27], IconKind::Trash);
|
||||
for kind in IconKind::ALL {
|
||||
assert!(kind.icon().path.starts_with('M'));
|
||||
assert_eq!(kind.icon().kind, kind);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn icon_stroke_follows_svg_semantics_not_slint() {
|
||||
// Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。
|
||||
let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px());
|
||||
assert!(
|
||||
(nav_width - 1.8).abs() < 1.0e-6,
|
||||
"20px 渲染应取 1.8 个 viewBox 单位"
|
||||
);
|
||||
let button_width = IconKind::Settings
|
||||
.icon()
|
||||
.stroke_width(IconSize::Button.px());
|
||||
assert!((button_width - 2.25).abs() < 1.0e-6);
|
||||
// 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。
|
||||
for size in IconSize::ALL {
|
||||
let w = stroke_width_for(size.px(), 1.5);
|
||||
assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6);
|
||||
}
|
||||
assert!((VIEWBOX - 24.0).abs() < f32::EPSILON);
|
||||
// 实心图标不描边。
|
||||
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
|
||||
assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_and_elevation_ladders() {
|
||||
assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0);
|
||||
assert_eq!(ShapeScale::Medium.dp(), 12.0);
|
||||
assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0);
|
||||
assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0);
|
||||
assert!(ShapeScale::ExtraLargeIncreased.is_expressive());
|
||||
|
||||
let dp: Vec<f32> = Elevation::ALL.iter().map(|e| e.dp()).collect();
|
||||
assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]);
|
||||
assert_eq!(
|
||||
MaterialLayer::Card.surface_role(),
|
||||
Role::SurfaceContainerLow
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_tokens_are_usable_as_data() {
|
||||
assert_eq!(DurationToken::Medium2.ms(), 300);
|
||||
assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6);
|
||||
assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6);
|
||||
let settle = SPRING_SPATIAL_DEFAULT.settle_ms(0.001);
|
||||
assert!(settle > 0.0 && settle < 2000.0);
|
||||
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacing_and_layout_metrics_are_shared() {
|
||||
assert_eq!(Space::X4.dp(), 16.0);
|
||||
let metrics = LayoutMetrics::DESKTOP;
|
||||
assert_eq!(metrics.header_height(false), 56.0);
|
||||
assert_eq!(metrics.header_height(true), 84.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_map_covers_the_page_skeleton() {
|
||||
assert_eq!(Component::ALL.len(), 20);
|
||||
for component in Component::ALL {
|
||||
let spec = component.spec();
|
||||
assert_eq!(spec.component, component);
|
||||
assert!(spec.text.style().size_px >= 11.0);
|
||||
}
|
||||
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
|
||||
// 浮层必须高于卡片层。
|
||||
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_tokens_entry_point() {
|
||||
let light = DesignTokens::baseline(SchemeFlavor::Light);
|
||||
let dark = DesignTokens::baseline(SchemeFlavor::Dark);
|
||||
assert_eq!(light.flavor, SchemeFlavor::Light);
|
||||
assert!(light.color(Role::Surface).relative_luminance() > 0.8);
|
||||
assert!(dark.color(Role::Surface).relative_luminance() < 0.05);
|
||||
assert!(!light.motion_scale.is_off());
|
||||
|
||||
let accent = DesignTokens::with_accent(
|
||||
SchemeFlavor::Light,
|
||||
unlucky_design::Color::from_hex("#006A5C").unwrap(),
|
||||
);
|
||||
assert_ne!(accent.color(Role::Primary), light.color(Role::Primary));
|
||||
assert_eq!(accent.color(Role::Surface), light.color(Role::Surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tonal_palette_hits_requested_tones() {
|
||||
let palette = TonalPalette::new(150.0, 0.1);
|
||||
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
|
||||
let color = palette.tone(tone);
|
||||
assert!(
|
||||
(color.lstar() - tone).abs() <= 1.0,
|
||||
"tone {tone} 实际 {}",
|
||||
color.lstar()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user