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」
This commit is contained in:
AskaEth committed 2026-11-01 15:58:19 +08:00
1 parent 65ca0ed29a
commit 83e19e58f0
20 files changed
+1367 -234

No files matched your search

+1 -1
View File
@@ -122,7 +122,7 @@ fn exception_list_is_honest_about_its_thresholds() {
let scheme = Scheme::from_seed(flavor, seed);
for (fg, bg, threshold, reason) in EXCEPTIONS {
assert!(
!fg.is_foreground() || fg == Role::Outline,
!fg.is_foreground(),
"例外 {} 不该是文字前景色({reason})",
fg.name()
);
+25 -6
View File
@@ -9,7 +9,9 @@ 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::motion::{
DurationToken, Easing, MotionScale, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
};
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::{LayoutMetrics, Space};
use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight};
@@ -73,13 +75,14 @@ fn font_stacks_never_embed_font_files() {
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 face in stack {
let lower = face.family.to_ascii_lowercase();
for banned in [".ttf", ".otf", ".ttc", "woff", "file"] {
assert!(
!lower.contains(banned),
"{} 的字体栈出现文件引用 {name}(只允许族名,禁止内嵌受限字体)",
platform.name()
"{} 的字体栈出现文件引用 {}(只允许族名,禁止内嵌受限字体)",
platform.name(),
face.family
);
}
}
@@ -87,6 +90,22 @@ fn font_stacks_never_embed_font_files() {
}
}
#[test]
fn cjk_weight_gap_is_registered() {
// 中文 700(Expressive 强调档要用的)在三平台都要能找到原生落点,否则就是合成加粗降级。
use unlucky_design::typography::{Weight, WeightMatch};
// Windows:Segoe UI Variable 覆盖 700(原生);CJK 落点是雅黑 700(原生,雅黑有 Bold)。
let win = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(win.quality, WeightMatch::Native);
assert!(Platform::Windows.cjk_face().supports(Weight::Bold));
// macOS:苹方只到 600 → CJK 的 700 只能合成/换族,这条 gap 已在文档登记。
assert!(!Platform::MacOs.cjk_face().supports(Weight::Bold));
// Linux:Noto Sans CJK 可变字体全覆盖。
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(Platform::Linux.cjk_face().supports(weight));
}
}
#[test]
fn icon_kind_is_frozen_and_append_only() {
assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标");
@@ -148,7 +167,7 @@ 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);
let settle = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.settle_ms(0.001);
assert!(settle > 0.0 && settle < 2000.0);
assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0);
}
+252
View File
@@ -0,0 +1,252 @@
//! 官方数值钉死(二):**色彩 tone 表 49 条**、7 条 easing 曲线、16 档时长、28 个图标下标、
//! 形状阶梯,以及「同族容器不撞色」的回归(reviewer P3-2)。
//!
//! 期望值全部独立于实现重写;实现改一个数、表不改,这里就红。
use unlucky_design::color::{Role, Scheme, SchemeFlavor};
use unlucky_design::icons::IconKind;
use unlucky_design::motion::{DurationToken, Easing};
use unlucky_design::shape::ShapeScale;
fn expect_tone(role: Role, light: f32, dark: f32) {
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Light),
light,
"{} 的 light tone 被改动了",
role.name()
);
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Dark),
dark,
"{} 的 dark tone 被改动了",
role.name()
);
}
#[test]
fn all_forty_nine_tones_are_pinned() {
expect_tone(Role::Primary, 40.0, 80.0);
expect_tone(Role::OnPrimary, 100.0, 20.0);
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
expect_tone(Role::Secondary, 40.0, 80.0);
expect_tone(Role::OnSecondary, 100.0, 20.0);
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
expect_tone(Role::Tertiary, 40.0, 80.0);
expect_tone(Role::OnTertiary, 100.0, 20.0);
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
expect_tone(Role::Error, 40.0, 80.0);
expect_tone(Role::OnError, 100.0, 20.0);
expect_tone(Role::ErrorContainer, 90.0, 30.0);
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
expect_tone(Role::Surface, 98.0, 6.0);
expect_tone(Role::OnSurface, 10.0, 90.0);
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
expect_tone(Role::Background, 99.0, 10.0);
expect_tone(Role::OnBackground, 10.0, 90.0);
expect_tone(Role::SurfaceDim, 87.0, 6.0);
expect_tone(Role::SurfaceBright, 98.0, 24.0);
expect_tone(Role::SurfaceContainerLowest, 100.0, 4.0);
expect_tone(Role::SurfaceContainerLow, 96.0, 10.0);
expect_tone(Role::SurfaceContainer, 94.0, 12.0);
expect_tone(Role::SurfaceContainerHigh, 92.0, 17.0);
expect_tone(Role::SurfaceContainerHighest, 90.0, 22.0);
expect_tone(Role::Outline, 50.0, 60.0);
expect_tone(Role::OutlineVariant, 80.0, 30.0);
expect_tone(Role::InverseSurface, 20.0, 90.0);
expect_tone(Role::InverseOnSurface, 95.0, 20.0);
expect_tone(Role::InversePrimary, 80.0, 40.0);
expect_tone(Role::Shadow, 0.0, 0.0);
expect_tone(Role::Scrim, 0.0, 0.0);
expect_tone(Role::SurfaceTint, 40.0, 80.0);
expect_tone(Role::PrimaryFixed, 90.0, 90.0);
expect_tone(Role::PrimaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnPrimaryFixed, 10.0, 10.0);
expect_tone(Role::OnPrimaryFixedVariant, 30.0, 30.0);
expect_tone(Role::SecondaryFixed, 90.0, 90.0);
expect_tone(Role::SecondaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnSecondaryFixed, 10.0, 10.0);
expect_tone(Role::OnSecondaryFixedVariant, 30.0, 30.0);
expect_tone(Role::TertiaryFixed, 90.0, 90.0);
expect_tone(Role::TertiaryFixedDim, 80.0, 80.0);
expect_tone(Role::OnTertiaryFixed, 10.0, 10.0);
expect_tone(Role::OnTertiaryFixedVariant, 30.0, 30.0);
}
#[test]
fn easing_curves_are_pinned() {
let cases: [(Easing, f32, f32, f32, f32); 7] = [
(Easing::Linear, 0.0, 0.0, 1.0, 1.0),
(Easing::Standard, 0.2, 0.0, 0.0, 1.0),
(Easing::StandardDecelerate, 0.0, 0.0, 0.0, 1.0),
(Easing::StandardAccelerate, 0.3, 0.0, 1.0, 1.0),
(Easing::Emphasized, 0.2, 0.0, 0.0, 1.0),
(Easing::EmphasizedDecelerate, 0.05, 0.7, 0.1, 1.0),
(Easing::EmphasizedAccelerate, 0.3, 0.0, 0.8, 0.15),
];
for (easing, x1, y1, x2, y2) in cases {
let curve = easing.curve();
assert_eq!(
(curve.x1, curve.y1, curve.x2, curve.y2),
(x1, y1, x2, y2),
"{easing:?} 的曲线参数被改动了"
);
}
}
#[test]
fn durations_are_pinned() {
let cases: [(DurationToken, u32); 16] = [
(DurationToken::Short1, 50),
(DurationToken::Short2, 100),
(DurationToken::Short3, 150),
(DurationToken::Short4, 200),
(DurationToken::Medium1, 250),
(DurationToken::Medium2, 300),
(DurationToken::Medium3, 350),
(DurationToken::Medium4, 400),
(DurationToken::Long1, 450),
(DurationToken::Long2, 500),
(DurationToken::Long3, 550),
(DurationToken::Long4, 600),
(DurationToken::ExtraLong1, 700),
(DurationToken::ExtraLong2, 800),
(DurationToken::ExtraLong3, 900),
(DurationToken::ExtraLong4, 1000),
];
for (token, ms) in cases {
assert_eq!(token.ms(), ms, "{} 的毫秒值被改动了", token.name());
}
}
#[test]
fn spring_presets_are_pinned() {
use unlucky_design::motion::{
spring, MotionSchemeKind, Spring, SpringCategory, SpringSpeed, SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_FAST, SPRING_EFFECTS_SLOW, SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_FAST, SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_STANDARD_SPATIAL_DEFAULT, SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_SLOW,
};
// A 级:StandardMotionTokens / ExpressiveMotionTokens(damping = 阻尼比 ζ,stiffness = k)。
let cases: [(Spring, f32, f32); 9] = [
// standard 方案 spatial:ζ=0.9(保守)
(SPRING_STANDARD_SPATIAL_FAST, 1400.0, 0.9),
(SPRING_STANDARD_SPATIAL_DEFAULT, 700.0, 0.9),
(SPRING_STANDARD_SPATIAL_SLOW, 300.0, 0.9),
// expressive 方案 spatial:ζ=0.6/0.8(回弹明显)
(SPRING_EXPRESSIVE_SPATIAL_FAST, 800.0, 0.6),
(SPRING_EXPRESSIVE_SPATIAL_DEFAULT, 380.0, 0.8),
(SPRING_EXPRESSIVE_SPATIAL_SLOW, 200.0, 0.8),
// effects 两套同值:ζ=1.0(不过冲)
(SPRING_EFFECTS_FAST, 3800.0, 1.0),
(SPRING_EFFECTS_DEFAULT, 1600.0, 1.0),
(SPRING_EFFECTS_SLOW, 800.0, 1.0),
];
for (entry, stiffness, damping_ratio) in cases {
assert_eq!(entry.stiffness, stiffness, "弹簧刚度被改动");
assert_eq!(entry.damping_ratio, damping_ratio, "弹簧阻尼比被改动");
}
// 二维表查询与常量必须一致(改了表结构也别想绕过 pin)。
assert_eq!(
spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default
),
SPRING_EXPRESSIVE_SPATIAL_DEFAULT
);
assert_eq!(
spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast
),
SPRING_STANDARD_SPATIAL_FAST
);
}
#[test]
fn icon_indices_are_pinned() {
let names = [
"instances",
"download",
"logs",
"settings",
"ai",
"play",
"stop",
"search",
"plus",
"close",
"minimize",
"maximize",
"chevron-left",
"chevron-right",
"chevron-down",
"expand",
"send",
"check",
"warn",
"error",
"more",
"sidebar",
"restore",
"user",
"refresh",
"filter",
"folder",
"trash",
];
assert_eq!(IconKind::ALL.len(), names.len());
for (i, kind) in IconKind::ALL.iter().enumerate() {
assert_eq!(
kind.name(),
names[i],
"第 {i} 个图标被换位/改名了(序号即持久化 id)"
);
}
}
#[test]
fn shape_ladder_is_pinned() {
let cases: [(ShapeScale, f32); 9] = [
(ShapeScale::None, 0.0),
(ShapeScale::ExtraSmall, 4.0),
(ShapeScale::Small, 8.0),
(ShapeScale::Medium, 12.0),
(ShapeScale::Large, 16.0),
(ShapeScale::LargeIncreased, 20.0),
(ShapeScale::ExtraLarge, 28.0),
(ShapeScale::ExtraLargeIncreased, 32.0),
(ShapeScale::ExtraExtraLarge, 48.0),
];
for (scale, dp) in cases {
assert_eq!(scale.dp(), dp, "{scale:?} 的半径档位被改动了");
}
assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0);
}
#[test]
fn same_family_containers_do_not_collide() {
// P3-2 回归:light 模式下 primary-container / secondary-container 曾经被 gamut 裁剪成同色。
let light = Scheme::baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(
primary, secondary,
"同族容器撞色(彩度压缩没生效):{primary:?}"
);
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// tertiary 与 primary 也该可区分(不同色相)。
assert_ne!(light.get(Role::TertiaryContainer), primary);
}
+106
View File
@@ -0,0 +1,106 @@
//! 官方数值钉死(一):**排版两张表**(baseline 15 档 + Expressive 强调 15 档)。
//!
//! 表的来源是 A 级 `androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。这里的期望值是
//! **独立于实现重写的一份**:任何人改动 `TypeRole::style()` / `emphasized()` 的数值,都必须同时改
//! 这张表才会同时绿——这就是「冻结」的含义(reviewer P2-1 的变异测试就是打这里)。
use unlucky_design::typography::{FontFamily, TypeRole, Weight};
/// baseline 单档对拍。
fn expect_baseline(
role: TypeRole,
size: f32,
line_height: f32,
weight: Weight,
tracking: f32,
family: FontFamily,
) {
let got = role.style();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px,
got.family
),
(size, line_height, weight, tracking, family),
"{} 的 baseline 值被改动了(官方 TypeScaleTokens)",
role.name()
);
}
/// Expressive 强调档单档对拍。
fn expect_emphasized(role: TypeRole, size: f32, line_height: f32, weight: Weight, tracking: f32) {
let got = role.emphasized();
assert_eq!(
(
got.size_px,
got.line_height_px,
got.weight,
got.letter_spacing_px
),
(size, line_height, weight, tracking),
"{} 的 Expressive 强调档值被改动了(官方 TypeScaleTokens *Emphasized*)",
role.name()
);
}
#[test]
fn baseline_type_scale_matches_official_tokens() {
use FontFamily::{Display, Text};
use Weight::{Bold, Medium, Regular};
expect_baseline(TypeRole::DisplayLarge, 57.0, 64.0, Regular, -0.2, Display);
expect_baseline(TypeRole::DisplayMedium, 45.0, 52.0, Regular, 0.0, Display);
expect_baseline(TypeRole::DisplaySmall, 36.0, 44.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineLarge, 32.0, 40.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineMedium, 28.0, 36.0, Regular, 0.0, Display);
expect_baseline(TypeRole::HeadlineSmall, 24.0, 32.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleLarge, 22.0, 28.0, Regular, 0.0, Display);
expect_baseline(TypeRole::TitleMedium, 16.0, 24.0, Medium, 0.2, Text);
expect_baseline(TypeRole::TitleSmall, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::BodyLarge, 16.0, 24.0, Regular, 0.5, Text);
expect_baseline(TypeRole::BodyMedium, 14.0, 20.0, Regular, 0.2, Text);
expect_baseline(TypeRole::BodySmall, 12.0, 16.0, Regular, 0.4, Text);
expect_baseline(TypeRole::LabelLarge, 14.0, 20.0, Medium, 0.1, Text);
expect_baseline(TypeRole::LabelMedium, 12.0, 16.0, Medium, 0.5, Text);
expect_baseline(TypeRole::LabelSmall, 11.0, 16.0, Medium, 0.5, Text);
let _ = Bold;
}
#[test]
fn expressive_emphasis_matches_official_tokens() {
use Weight::{Bold, Medium};
// 展示/正文类强调 → Medium(500);label 与小标题 → Bold(700)。
expect_emphasized(TypeRole::DisplayLarge, 57.0, 64.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplayMedium, 45.0, 52.0, Medium, 0.0);
expect_emphasized(TypeRole::DisplaySmall, 36.0, 44.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineLarge, 32.0, 40.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineMedium, 28.0, 36.0, Medium, 0.0);
expect_emphasized(TypeRole::HeadlineSmall, 24.0, 32.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleLarge, 22.0, 28.0, Medium, 0.0);
expect_emphasized(TypeRole::TitleMedium, 16.0, 24.0, Bold, 0.15);
expect_emphasized(TypeRole::TitleSmall, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::BodyLarge, 16.0, 24.0, Medium, 0.15);
expect_emphasized(TypeRole::BodyMedium, 14.0, 20.0, Medium, 0.25);
expect_emphasized(TypeRole::BodySmall, 12.0, 16.0, Medium, 0.4);
expect_emphasized(TypeRole::LabelLarge, 14.0, 20.0, Bold, 0.1);
expect_emphasized(TypeRole::LabelMedium, 12.0, 16.0, Bold, 0.5);
expect_emphasized(TypeRole::LabelSmall, 11.0, 16.0, Bold, 0.5);
}
#[test]
fn both_tables_cover_all_fifteen_roles() {
// 防止「表里少写一档」:15 个角色都必须被上面两张表摸到。
let mut names = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
names.insert(role.name());
assert_ne!(
role.style(),
role.emphasized(),
"{} 强调档等同 baseline",
role.name()
);
}
assert_eq!(names.len(), 15);
}