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

+20
View File
@@ -57,6 +57,12 @@ pub struct Palettes {
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
///
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
reference_chroma: f32,
}
impl Palettes {
@@ -77,6 +83,7 @@ impl Palettes {
neutral: primary.with_chroma(NEUTRAL_CHROMA),
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
reference_chroma: primary.chroma(),
}
}
@@ -84,6 +91,7 @@ impl Palettes {
///
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
pub fn with_accent(self, accent: Color) -> Self {
let accent_primary = TonalPalette::from_color(accent);
Self {
@@ -107,6 +115,18 @@ impl Palettes {
PaletteSlot::Error => self.error,
}
}
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
/// 且换强调色时中性表面逐像素不变。
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
self.slot(slot).tone_scaled(tone, self.reference_chroma)
}
/// 彩度压缩的共享基准(调试/测试用)。
pub fn reference_chroma(&self) -> f32 {
self.reference_chroma
}
}
#[cfg(test)]
+1 -1
View File
@@ -191,7 +191,7 @@ impl Scheme {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
};
palettes.slot(entry.slot).tone(tone)
palettes.tone(entry.slot, tone)
});
Self { flavor, colors }
}
+8
View File
@@ -7,6 +7,9 @@ use super::Color;
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
const GAMUT_EPS: f32 = 1.0e-4;
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
/// sRGB 编码值(0..1)→ 线性光强。
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.040_45 {
@@ -147,6 +150,11 @@ pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32
lo
}
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
}
/// OKLCh 坐标(色相用度,0..360)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Oklch {
+18 -1
View File
@@ -59,6 +59,17 @@ impl TonalPalette {
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
pub fn tone(self, tone: f32) -> Color {
self.tone_scaled(tone, self.chroma)
}
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
///
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
let lstar = tone.clamp(0.0, 100.0);
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
if lstar <= 0.0 {
@@ -67,7 +78,13 @@ impl TonalPalette {
if lstar >= 100.0 {
return Color::WHITE;
}
let chroma = space::chroma_in_gamut(lstar, self.hue_deg, self.chroma);
let c_max = space::max_chroma(lstar, self.hue_deg);
let factor = if reference_chroma > 1.0e-6 {
(c_max / reference_chroma).min(1.0)
} else {
1.0
};
let chroma = (self.chroma * factor).min(c_max).max(0.0);
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
}
+56
View File
@@ -179,6 +179,62 @@ mod tests {
}
}
#[test]
fn elevation_matches_official_component_tokens() {
// A 级来源(androidx.compose.material3.tokens):
// FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0
// MenuTokens.ContainerElevation = Level2
// DialogTokens.ContainerElevation = Level3
// SnackbarTokens.ContainerElevation = Level3
// AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0
// 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。
for (component, expect) in [
(Component::FilledCard, Elevation::Level0),
(Component::OutlinedCard, Elevation::Level0),
(Component::Menu, Elevation::Level2),
(Component::Dialog, Elevation::Level3),
(Component::Snackbar, Elevation::Level3),
(Component::TopAppBar, Elevation::Level0),
(Component::ListItem, Elevation::Level0),
(Component::TextField, Elevation::Level0),
] {
assert_eq!(
component.spec().elevation,
expect,
"{} 的高程与官方组件令牌不一致",
component.name()
);
}
// R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。
for component in Component::ALL {
if component.spec().elevation > Elevation::Level0 {
assert!(
matches!(
component,
Component::Menu
| Component::Dialog
| Component::Snackbar
| Component::Tooltip
),
"{} 不该有阴影(只有浮层允许)",
component.name()
);
}
}
}
#[test]
fn shapes_match_official_component_tokens() {
// 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、
// 按钮/导航指示条 CornerFull、chip CornerSmall(8)。
assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium);
assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge);
assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall);
assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full);
assert_eq!(Component::Chip.spec().shape, ShapeScale::Small);
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
+23 -11
View File
@@ -1,10 +1,19 @@
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
//!
//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `<X>Tokens.kt`
//! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离:
//! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是
//! B 级(沿用 M3 旧规范「浮层 3dp」的直觉),有官方值后以官方为准;
//! - `ListItem`:官方 `ListTokens` 用 `CornerNone`(直角行),本项目取 `Small(8)`——列表行在本
//! 启动器里是**卡片内的独立可点块**(不是贴边通栏行),圆角与卡片同族更协调;偏差 8dp 已登记。
use super::{Component, ComponentSpec};
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing, SPRING_SPATIAL_DEFAULT, SPRING_SPATIAL_FAST};
use crate::motion::{
DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST,
};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
@@ -54,7 +63,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
@@ -98,13 +107,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
// FilledCardTokens.ContainerElevation = Level0(hover 才到 L1、dragged 到 L3)。
spec(
Component::OutlinedCard,
ShapeScale::Medium,
Elevation::Level1,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
@@ -114,7 +124,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
spec(
Component::FilledCard,
ShapeScale::Medium,
Elevation::Level2,
Elevation::Level0,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
@@ -122,7 +132,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Large,
SPRING_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
spec(
@@ -166,7 +176,7 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
Some(ShapeMorph::new(
ShapeScale::ExtraSmall,
ShapeScale::Small,
SPRING_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_FAST,
)),
),
spec(
@@ -199,10 +209,11 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
DurationToken::Long2,
None,
),
// DialogTokens.ContainerElevation = Level3,CornerExtraLarge = 28。
spec(
Component::Dialog,
ShapeScale::ExtraLarge,
Elevation::Level5,
Elevation::Level3,
TypeRole::HeadlineSmall,
Some(TypeRole::BodyMedium),
Easing::EmphasizedDecelerate,
@@ -210,13 +221,14 @@ pub(super) const SPECS: [ComponentSpec; 20] = [
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::ExtraLarge,
SPRING_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
)),
),
// MenuTokens:CornerExtraSmall(4) + Level2。
spec(
Component::Menu,
ShapeScale::Small,
Elevation::Level4,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodyMedium,
None,
Easing::StandardDecelerate,
+2 -1
View File
@@ -21,7 +21,8 @@
//!
//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。
//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响
//! (实证:femtovg 后端 `itemrenderer.rs:343`)。
//! (实证:femtovg 后端 `PathItemRenderer::render` 里的
//! `paint.set_line_width(stroke_width * scale_factor)`——只有 DPI 缩放因子,没有 viewBox 项)。
//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被
//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。
//!
+39
View File
@@ -39,6 +39,45 @@
//! let stack = Platform::current().font_stack(FontFamily::Display);
//! assert!(!stack.is_empty());
//! ```
//!
//! ## baseline MD3 vs M3 Expressive 差异清单
//!
//! 「M3 Expressive」不是「M3 换皮」——它真的加了一批令牌。本 crate 两代都提供,差异如下
//! (来源等级:**A** = androidx 源码 / 官方 token 文件;**B** = m3.material.io 文档表格;**C** = 社区转述)。
//!
//! | 领域 | baseline MD3 | M3 Expressive 新增/不同 | 本 crate 落点 | 等级 |
//! |---|---|---|---|---|
//! | 排版 | 15 档 type role,只有 Regular/Medium 两种字重档 | **每档多一条 `*Emphasized`**:字号/行高不变,字重升到 Medium(500) 或 Bold(700),字距按表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、body-medium 0.2→0.25) | [`typography::TypeRole::emphasized`](官方值);[`typography::TypeRole::emphasized_simplified`] 是我们的「只加一档字重」简化规则,**非官方** | A |
//! | 形状 | ExtraSmall 4 / Small 8 / Medium 12 / Large 16 / **ExtraLarge 28** / Full | 多出 **LargeIncreased 20 / ExtraLargeIncreased 32 / ExtraExtraLarge 48**,并引入 **shape morph**(按下/选中时档位之间弹性过渡) | [`shape::ShapeScale`](10 档)+ [`shape::ShapeMorph`] + [`component_map`] 的 `morph` 列 | A |
//! | 动效 | 16 档 duration + 7 条 cubic-bezier easing(时间驱动) | 把弹簧打包成 **standard / expressive 两套方案**,每套各有 spatial(位移,会回弹)与 effects(颜色/透明度,不回弹)× fast/default/slow。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) | [`motion::spring`] 的 **方案 × 类别 × 速度**二维表(12 格,`StandardMotionTokens`/`ExpressiveMotionTokens` 原值)+ 9 个具名常量;duration/easing 两代共用 | A |
//! | 组件尺寸 | 触控目标 48dp(无障碍下限本来就是 48) | Expressive 把按钮/列表的整体节奏放大(列表项 56/72/88),并明确 48dp 为**可点下限** | [`spacing::LayoutMetrics::DESKTOP`]:`touch_target = 48`(照规范)+ `control_height = 40`(可见高度,上下各 4px 内边距凑满 48)+ `row_height`/`nav_item_height` = 44、`titlebar_height` = 48(M63 §1.4+R13 的桌面档) | A/B |
//! | 字体 | 只有 400/500/700 的「概念」字重,没规定字体族要覆盖几档 | Expressive 的强调档**真的会要 700**(label-*、title-medium/small),中文字体覆盖差异巨大:雅黑只有 400/700(无 500)、苹方到 600 断(无 700)、Noto/思源可变字体全覆盖 | [`typography::FontFace`] 的 `WeightCoverage`(可变区间 / 静态表)+ [`typography::Platform::resolve_weight`] 纯函数给出「原生 / 最近档 / 合成加粗」三档结论 | B |
//! | 色彩 | 49 个角色(primary/secondary/tertiary/error 族、surface-container 系、`*-fixed*` 系、outline、inverse、scrim/shadow) | **没有新增颜色角色**;变化在用法:surface-container 系(Lowest…Highest)取代旧的 elevation tint 叠色,表达层级 | [`color::Role`] 全 49 个;[`elevation::Elevation::surface_role`] 给出层级映射,`deprecated_tint_alpha` 保留旧口径作对照 | A |
//! | 高程 | Level0..5 = 0/1/3/6/8/12dp | 数值不变;组件默认值不同(卡片 L0、菜单 L2、对话框 L3,见 [`component_map`]) | [`elevation::Elevation`] | A |
//!
//! ### 两条由此衍生的项目纪律
//!
//! 1. **shape morph 是克制的状态信号,不是每屏装饰**:Expressive 官方鼓励「形状即状态」,但
//! 桌面端信息密度高,圆角到处变会显得凌乱(社区已有人批评控制中心这么做)。本项目的约束是
//! **一屏最多 1 处形变、且只用于状态切换信号**;静态元素一律用 [`shape::ShapeScale`] 的固定档。
//! [`component_map`] 的 `morph` 列就是允许清单——不在表里的组件不许自己加形变。
//! 2. **中文字重必须真的有三档**:Expressive 的 700 落在中文字体上,雅黑缺 500、苹方缺 700,
//! 缺档就走「合成加粗」= 观感廉价。所以字体栈带 `WeightCoverage`,并在
//! [`typography::Platform::resolve_weight`] 里把降级路径显式化(合成加粗是最后一档)。
//!
//! 已知与文档的 0.05px 级差异:Compose 这一代 `TypeScaleTokens` 的 tracking 与 m3.material.io
//! 网页表格不同(display-large −0.2 vs −0.25、body-medium 0.2 vs 0.25、title-medium 0.2 vs 0.15),
//! 本项目**跟 Compose**(那是真正渲染出来的值,A 级)。
//!
//! ## 未支持的官方能力(gap 登记,不要以为已经有了)
//!
//! - **对比度等级**:Android 16 的「默认 / 中 / 高」三档对比度本 crate **不支持**。将来扩法是
//! **按等级偏移角色 tone 表**(例如高对比把 `on-*` 与承载色的 tone 差拉大 5~10),
//! 而不是新增一批角色——`Role` 的数量保持 49 不变,`Scheme` 多一个 `contrast_level` 维度。
//! (M63 的 G4 只说「高对比模式走 CPU 档」,那是渲染档位,不等价于支持对比度等级。)
//! - **动态壁纸取色**:Material You 的多色种子派生未实现(本启动器用不到)。
//! - **M3 的 elevation tint 叠色**:现行 M3 已改用 surface-container 表达层级,旧的
//! `deprecated_tint_alpha()` 只为读旧稿保留。
#![forbid(unsafe_code)]
+5 -2
View File
@@ -12,8 +12,11 @@
mod spring;
pub use spring::{
MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT,
SPRING_SPATIAL_FAST,
spring, MotionScale, 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,
};
/// 动效时长档位(M3 动效令牌,单位毫秒)。
+215 -49
View File
@@ -1,26 +1,27 @@
//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。
//!
//! **来源 A 级**:`androidx.compose.material3.tokens.ExpressiveMotionTokens`(v0_14_0)给出六组
//! `Spring{Damping, Stiffness}`——注意 Compose 的 `Damping` 是**阻尼比 ζ**(不是绝对阻尼系数),
//! 所以本模块的 [`Spring`] 也按 (stiffness, damping_ratio, mass) 存,两者可直接对照。
//! 阶跃响应用标准二阶系统闭式解(欠阻尼 / 临界 / 过阻尼三分支),是纯数学、无状态。
/// 弹簧参数。
///
/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式
/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应
/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。
/// 弹簧参数(Compose 口径:刚度 + 阻尼比 + 质量)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Spring {
/// 刚度(越大越快)。
/// 刚度 k(越大越快)。
pub stiffness: f32,
/// 阻尼系数(临界阻尼 = 2·√(k·m))。
pub damping: f32,
/// 质量。
/// 阻尼比 ζ(1.0 = 临界阻尼,< 1 会轻微弹过)。
pub damping_ratio: f32,
/// 质量 m。
pub mass: f32,
}
impl Spring {
/// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。
pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self {
/// 按 M3 令牌口径构造(刚度 + 阻尼比 + 质量)。
pub const fn from_ratio(stiffness: f32, damping_ratio: f32, mass: f32) -> Self {
Self {
stiffness,
damping,
damping_ratio,
mass,
}
}
@@ -30,14 +31,9 @@ impl Spring {
(self.stiffness / self.mass).sqrt()
}
/// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。
pub fn damping_ratio(self) -> f32 {
let denom = 2.0 * (self.stiffness * self.mass).sqrt();
if denom <= 0.0 {
1.0
} else {
self.damping / denom
}
/// 绝对阻尼系数 c = 2·ζ·√(k·m)(需要喂给「阻尼系数」式积分器时用)。
pub fn damping_coefficient(self) -> f32 {
2.0 * self.damping_ratio * (self.stiffness * self.mass).sqrt()
}
/// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。
@@ -47,7 +43,7 @@ impl Spring {
}
let t = t_ms / 1000.0;
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 1.0;
}
@@ -73,7 +69,7 @@ impl Spring {
/// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。
pub fn settle_ms(self, threshold: f32) -> f32 {
let wn = self.natural_frequency();
let zeta = self.damping_ratio();
let zeta = self.damping_ratio;
if wn <= 0.0 {
return 0.0;
}
@@ -92,22 +88,118 @@ impl Spring {
}
}
/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。
/// 动效方案(Expressive 把弹簧打包成 standard / expressive **两套**,不是一组)。
///
/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。
pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0);
/// 空间类默认弹簧(面板、卡片):标准手感。
/// - [`MotionSchemeKind::Standard`]:保守、几乎不弹(spatial ζ=0.9);
/// - [`MotionSchemeKind::Expressive`]:D26 的视觉基线,回弹明显(spatial ζ=0.6~0.8)。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0);
/// 效应类快速弹簧(透明度/颜色):不带过冲。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0);
/// 效应类默认弹簧。
///
/// TODO(M63): 取值待视觉规格。
pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0);
/// `effects`(透明度/颜色类)在两套方案里数值相同——效应类不该有可见回弹。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum MotionSchemeKind {
/// 标准方案(保守)。
Standard,
/// Expressive 方案(本项目默认)。
Expressive,
}
/// 弹簧类别:空间类管**位移/尺寸**,效应类管**颜色/透明度**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringCategory {
/// 位移/尺寸(会产生可见回弹)。
Spatial,
/// 颜色/透明度(不允许过冲)。
Effects,
}
/// 弹簧速度档。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpringSpeed {
/// 快速:hover/press、小元素。
Fast,
/// 默认:面板、卡片、按下形变。
Default,
/// 慢速:大位移(>120px 的面板/抽屉)。
Slow,
}
/// 二维表查询:`方案 × 类别 × 速度 → 弹簧`(A 级:`StandardMotionTokens` 与
/// `ExpressiveMotionTokens`,经 `MotionScheme.standard()/expressive()` 组装)。
pub const fn spring(
scheme: MotionSchemeKind,
category: SpringCategory,
speed: SpringSpeed,
) -> Spring {
use MotionSchemeKind::{Expressive, Standard};
use SpringCategory::{Effects, Spatial};
use SpringSpeed::{Default, Fast, Slow};
match (scheme, category, speed) {
(Standard, Spatial, Fast) => Spring::from_ratio(1400.0, 0.9, 1.0),
(Standard, Spatial, Default) => Spring::from_ratio(700.0, 0.9, 1.0),
(Standard, Spatial, Slow) => Spring::from_ratio(300.0, 0.9, 1.0),
(Expressive, Spatial, Fast) => Spring::from_ratio(800.0, 0.6, 1.0),
(Expressive, Spatial, Default) => Spring::from_ratio(380.0, 0.8, 1.0),
(Expressive, Spatial, Slow) => Spring::from_ratio(200.0, 0.8, 1.0),
// effects 两套同值:ζ=1.0(临界阻尼,不过冲)。
(_, Effects, Fast) => Spring::from_ratio(3800.0, 1.0, 1.0),
(_, Effects, Default) => Spring::from_ratio(1600.0, 1.0, 1.0),
(_, Effects, Slow) => Spring::from_ratio(800.0, 1.0, 1.0),
}
}
/// 标准方案 · 空间 · 快速。
pub const SPRING_STANDARD_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// 标准方案 · 空间 · 默认。
pub const SPRING_STANDARD_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// 标准方案 · 空间 · 慢速。
pub const SPRING_STANDARD_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Standard,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// Expressive 方案 · 空间 · 快速(D26 基线的默认选择)。
pub const SPRING_EXPRESSIVE_SPATIAL_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Fast,
);
/// Expressive 方案 · 空间 · 默认:面板、卡片、按下形变。
pub const SPRING_EXPRESSIVE_SPATIAL_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Default,
);
/// Expressive 方案 · 空间 · 慢速:大位移(>120px)。
pub const SPRING_EXPRESSIVE_SPATIAL_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Spatial,
SpringSpeed::Slow,
);
/// 效应类 · 快速(两套方案同值):hover/press 颜色。
pub const SPRING_EFFECTS_FAST: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Fast,
);
/// 效应类 · 默认(两套方案同值)。
pub const SPRING_EFFECTS_DEFAULT: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Default,
);
/// 效应类 · 慢速(两套方案同值)。
pub const SPRING_EFFECTS_SLOW: Spring = spring(
MotionSchemeKind::Expressive,
SpringCategory::Effects,
SpringSpeed::Slow,
);
/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -156,33 +248,100 @@ mod tests {
use super::*;
use crate::motion::DurationToken;
const PRESETS: [Spring; 4] = [
SPRING_SPATIAL_FAST,
SPRING_SPATIAL_DEFAULT,
const PRESETS: [Spring; 9] = [
SPRING_STANDARD_SPATIAL_FAST,
SPRING_STANDARD_SPATIAL_DEFAULT,
SPRING_STANDARD_SPATIAL_SLOW,
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
];
#[test]
fn every_cell_of_the_two_dimensional_table_resolves() {
for scheme in [MotionSchemeKind::Standard, MotionSchemeKind::Expressive] {
for category in [SpringCategory::Spatial, SpringCategory::Effects] {
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
let spring = spring(scheme, category, speed);
assert!(spring.stiffness > 0.0);
assert!(spring.damping_ratio > 0.0);
}
}
}
// effects 两套方案同值(效应类不该有可见回弹差异)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert_eq!(
spring(MotionSchemeKind::Standard, SpringCategory::Effects, speed),
spring(MotionSchemeKind::Expressive, SpringCategory::Effects, speed)
);
}
// expressive 的空间类更弹(阻尼比更低)。
for speed in [SpringSpeed::Fast, SpringSpeed::Default, SpringSpeed::Slow] {
assert!(
spring(MotionSchemeKind::Expressive, SpringCategory::Spatial, speed).damping_ratio
< spring(MotionSchemeKind::Standard, SpringCategory::Spatial, speed)
.damping_ratio
);
}
}
#[test]
fn spring_presets_behave() {
for spring in PRESETS {
assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界");
assert_eq!(spring.sample(0.0), 0.0);
let settle = spring.settle_ms(0.001);
assert!(
(60.0..1200.0).contains(&settle),
"收敛时间 {settle}ms 不合理"
(60.0..1500.0).contains(&settle),
"收敛时间 {settle}ms 不合理(k={} ζ={})",
spring.stiffness,
spring.damping_ratio
);
let end = spring.sample(settle);
assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}");
}
}
#[test]
fn effects_presets_are_critically_damped_and_never_overshoot() {
for spring in [
SPRING_EFFECTS_FAST,
SPRING_EFFECTS_DEFAULT,
SPRING_EFFECTS_SLOW,
] {
assert!((spring.damping_ratio - 1.0).abs() < 1.0e-6);
for step in 1..=60 {
let v = spring.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "效应类弹簧不应过冲:{v}");
}
}
}
#[test]
fn spatial_presets_overdamp_into_settle_without_wild_overshoot() {
for spring in [
SPRING_EXPRESSIVE_SPATIAL_FAST,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_SLOW,
] {
assert!(
spring.damping_ratio < 1.0,
"空间类弹簧欠阻尼(允许轻微过冲)"
);
let mut peak: f32 = 0.0;
for step in 1..=200 {
peak = peak.max(spring.sample(step as f32 * 2.0));
}
assert!(peak < 1.15, "过冲 {peak} 太大");
}
}
#[test]
fn critically_damped_spring_does_not_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::new(wn * wn, 2.0 * wn, 1.0);
assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3);
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let critical = Spring::from_ratio(wn * wn, 1.0, 1.0);
for step in 1..=40 {
let v = critical.sample(step as f32 * 5.0);
assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}");
@@ -191,9 +350,8 @@ mod tests {
#[test]
fn overdamped_spring_converges_slowly_without_overshoot() {
let wn = SPRING_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::new(wn * wn, 4.0 * wn, 1.0);
assert!(over.damping_ratio() > 1.5);
let wn = SPRING_EXPRESSIVE_SPATIAL_DEFAULT.natural_frequency();
let over = Spring::from_ratio(wn * wn, 2.0, 1.0);
let settle = over.settle_ms(0.001);
assert!((over.sample(settle) - 1.0).abs() < 0.01);
for step in 1..=40 {
@@ -201,6 +359,14 @@ mod tests {
}
}
#[test]
fn damping_coefficient_matches_ratio() {
let spring = SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
let c = spring.damping_coefficient();
let zeta = c / (2.0 * (spring.stiffness * spring.mass).sqrt());
assert!((zeta - spring.damping_ratio).abs() < 1.0e-5);
}
#[test]
fn motion_scale_scales_or_kills_duration() {
assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0);
@@ -209,6 +375,6 @@ mod tests {
assert!(MotionScale::NONE.is_off());
assert_eq!(MotionScale::new(5.0).factor(), 1.0);
assert_eq!(MotionScale::new(-1.0).factor(), 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0);
assert!(MotionScale::STANDARD.spring_ms(SPRING_EXPRESSIVE_SPATIAL_DEFAULT) > 0.0);
}
}
+2 -2
View File
@@ -118,7 +118,7 @@ impl ShapeMorph {
#[cfg(test)]
mod tests {
use super::*;
use crate::motion::SPRING_SPATIAL_DEFAULT;
use crate::motion::SPRING_EXPRESSIVE_SPATIAL_DEFAULT;
#[test]
fn scale_ladder_is_monotone() {
@@ -156,7 +156,7 @@ mod tests {
let morph = ShapeMorph::new(
ShapeScale::Small,
ShapeScale::LargeIncreased,
SPRING_SPATIAL_DEFAULT,
SPRING_EXPRESSIVE_SPATIAL_DEFAULT,
);
assert_eq!(morph.radius_px(200.0, 0.0), 8.0);
assert_eq!(morph.radius_px(200.0, 1.0), 20.0);
+43 -7
View File
@@ -77,11 +77,11 @@ pub struct LayoutMetrics {
pub page_header_tall_height: f32,
/// 运行中状态条高度。
pub running_strip_height: f32,
/// 列表行高。
/// 列表行高(桌面密集列表;见模块文档的「触控目标」说明)。
pub row_height: f32,
/// 标准控件高(按钮/输入框)。
/// 标准控件可见高度(按钮/输入框)。
pub control_height: f32,
/// 最小可点区域。
/// 最小**命中**区域(M3 Expressive 的无障碍下限)。
pub touch_target: f32,
/// 卡片内边距。
pub card_padding: f32,
@@ -90,20 +90,29 @@ pub struct LayoutMetrics {
}
impl LayoutMetrics {
/// 桌面基线(1180×760 窗口,M53 页面骨架的取值)。
/// 桌面基线(1180×760 窗口)。
///
/// **触控目标的换算依据**(reviewer P3-1:不许和 `metrics.slint` 沉默地不一致):
/// - 规范:M3 Expressive 要求可点目标 ≥**48dp**(无障碍下限,手机与桌面同标准)。
/// - 我们取 `touch_target = 48`(照规范),`control_height = 40`(**可见**高度):
/// 两者差 8,正好由控件上下各 4px 的内边距补足,于是「看起来 40 高的按钮」有 48 的命中区。
/// - `row_height = 44` / `nav_item_height = 44` 是 M63 §R13 + §1.4 表定的桌面密集档
/// (`metrics.slint` 的导航 56 是 Fluent 2 时代的旧节奏,D25 已终止):可见高度 44,
/// 点击命中也按 `touch_target`(48)判定,不靠行高本身。
/// - `titlebar_height = 48` 取自 M63 §1.4 的 S2 面板表(旧值 40 是 Fluent 口径)。
pub const DESKTOP: Self = Self {
titlebar_height: 40.0,
titlebar_height: 48.0,
titlebar_button_width: 46.0,
nav_expanded_width: 88.0,
nav_collapsed_width: 48.0,
nav_item_height: 56.0,
nav_item_height: 44.0,
nav_item_gap: 4.0,
page_header_height: 56.0,
page_header_tall_height: 84.0,
running_strip_height: 32.0,
row_height: 44.0,
control_height: 40.0,
touch_target: 44.0,
touch_target: 48.0,
card_padding: 20.0,
ai_panel_width: 320.0,
};
@@ -134,16 +143,43 @@ mod tests {
#[test]
fn layout_values_are_on_the_grid() {
let m = LayoutMetrics::DESKTOP;
// 受 4px 栅格约束的字段(间距/高度/宽度类)。
// `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度,
// 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。
for (name, value) in [
("titlebar_height", m.titlebar_height),
("nav_item_height", m.nav_item_height),
("nav_item_gap", m.nav_item_gap),
("page_header_height", m.page_header_height),
("page_header_tall_height", m.page_header_tall_height),
("running_strip_height", m.running_strip_height),
("row_height", m.row_height),
("control_height", m.control_height),
("touch_target", m.touch_target),
("card_padding", m.card_padding),
("ai_panel_width", m.ai_panel_width),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
assert_eq!(
m.titlebar_button_width, 46.0,
"标题栏按钮宽度应对齐 Win11 原生 caption"
);
}
#[test]
fn touch_target_meets_the_expressive_48dp_minimum() {
let m = LayoutMetrics::DESKTOP;
// M3 Expressive 无障碍下限:可点目标 ≥48dp。
assert_eq!(m.touch_target, 48.0);
// 可见控件高 + 上下各 4px 内边距 = 命中区。
assert_eq!(m.control_height + 2.0 * Space::X1.dp(), m.touch_target);
// 密集行(M63 §1.4/R13 的桌面档)可见高度 44,命中仍按 touch_target。
assert_eq!(m.row_height, 44.0);
assert_eq!(m.nav_item_height, 44.0);
assert!(m.row_height <= m.touch_target);
// M63 §1.4 的 S2 面板表:标题栏 48。
assert_eq!(m.titlebar_height, 48.0);
}
#[test]
@@ -0,0 +1,88 @@
//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。
//!
//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的
//! `<Role>Emphasized{Size,LineHeight,Tracking,Weight}` 四元组。要点:
//!
//! - **字号与行高与 baseline 完全相同**(Expressive 的强调不改变排版高度);
//! - 只动**字重**与**字距**:display/headline/title-large/body-* → Medium(500);
//! title-medium / title-small / label-* → Bold(700)(baseline 是 500);
//! - display-large 的字距从 −0.2 回 0;body-large 0.5 → 0.15;title-medium 0.2 → 0.15;
//! body-medium 0.2 → 0.25(唯一一个「强调后字距变大」的档)。
use super::{style, FontFamily, TypeRole, TypeStyle, Weight};
/// 取某档的官方强调样式。非 [`TypeRole`] 的每个变体都有值(15/15,无缺口)。
pub(super) const fn style_of(role: TypeRole) -> TypeStyle {
match role {
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineMedium => style(28.0, 36.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Medium, 0.0, FontFamily::Display),
// TitleLarge 官方用 Brand 族(与 Display 同栈)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Medium, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Bold, 0.15, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Medium, 0.25, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Medium, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Bold, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Bold, 0.5, FontFamily::Text),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emphasized_keeps_size_and_line_height() {
for role in TypeRole::ALL {
let base = role.style();
let emph = style_of(role);
assert_eq!(
base.size_px,
emph.size_px,
"{} 强调档不该改字号",
role.name()
);
assert_eq!(
base.line_height_px,
emph.line_height_px,
"{} 强调档不该改行高",
role.name()
);
}
}
#[test]
fn emphasized_weights_follow_the_official_split() {
// 官方两档:正文/展示类 → Medium(500);小标题与标签类 → Bold(700)。
for role in TypeRole::ALL {
let expect = match role {
TypeRole::TitleMedium
| TypeRole::TitleSmall
| TypeRole::LabelLarge
| TypeRole::LabelMedium
| TypeRole::LabelSmall => Weight::Bold,
_ => Weight::Medium,
};
assert_eq!(
style_of(role).weight,
expect,
"{} 强调档字重不对",
role.name()
);
}
}
#[test]
fn display_large_tracking_returns_to_zero() {
assert_eq!(TypeRole::DisplayLarge.style().letter_spacing_px, -0.2);
assert_eq!(TypeRole::DisplayLarge.emphasized().letter_spacing_px, 0.0);
assert_eq!(TypeRole::BodyLarge.style().letter_spacing_px, 0.5);
assert_eq!(TypeRole::BodyLarge.emphasized().letter_spacing_px, 0.15);
}
}
+255 -132
View File
@@ -1,7 +1,37 @@
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)。
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)+ **字重覆盖**模型。
//!
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
//! 否则干净安装的系统上会退化成豆腐块。
//!
//! ## 为什么要表达「这个族有哪几档字重」
//!
//! M3 Expressive 的强调档会要 **700**(title-medium/title-small/label-*)与 **500**
//! (display/headline/body-*)。而中文字体的字重覆盖差别很大:
//! **Windows 的 Microsoft YaHei UI 只有 400 / 700 两档**,没有 500;macOS 苹方到 600 就断了、
//! 没有 700;只有 Linux 常见的 Noto Sans CJK / 思源黑体(可变字体)才 100~900 全覆盖。
//!
//! 所以栈项不是「一个族名」,而是 `(族名, 字重覆盖)`([`FontFace`]),并且提供一个纯函数
//! [`Platform::resolve_weight`] 说明「这一档字重最终落到哪个族、是原生还是降级」:
//!
//! | 结果 | 含义 |
//! |---|---|
//! | [`WeightMatch::Native`] | 该族有这一档(可变字体在区间内也算原生) |
//! | [`WeightMatch::Nearest`] | 该族没有这一档,取最近的静态档(视觉上是「淡一点/粗一点」,不假) |
//! | [`WeightMatch::Synthetic`] | 最近的档差 ≥2 级(如只有 400 却要 700)→ 引擎**合成加粗**,观感廉价 |
//!
//! **合成加粗是降级,不是正常路径**(用户令:不许拿合成加粗当「还原到位」)。
//! iced/cosmic-text 侧机制:`Font { family, weight, .. }` 由 cosmic-text 按 (family, weight)
//! 选面,同族缺档时选最近字重;glyphon 另可用 swash 的合成(fake bold)兜底。
//! 中文缺档时的降级顺序:**① 换下一个有该档的族 → ② 用可变字体的 weight 轴 → ③ 最近静态档
//! → ④ 最后才是合成加粗**(并且不要用 700 排长中文正文)。
//!
//! ## 待确认(D28 联动,不在本 mission 拍板)
//!
//! 将来从 CDN 下载中文字体时,「一个 Regular 文件」不够——至少要 400/500/700 三档子集,
//! 或者一个可变字体(体积更大)。这个差异登记在这里,等 D28 的字体下载 mission 决定。
#[cfg(test)]
mod tests;
/// 平台(决定字体族回退栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -14,6 +44,179 @@ pub enum Platform {
Linux,
}
/// 字重覆盖(一个族实际提供了哪些字重)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightCoverage {
/// 可变字体:一个连续区间(weight 轴)。
Variable {
/// 最细一档。
min: u16,
/// 最粗一档。
max: u16,
},
/// 静态字体:离散字重表。
Static(&'static [u16]),
}
impl WeightCoverage {
/// 是否原生覆盖某档字重。
pub fn supports(&self, weight: u16) -> bool {
match self {
WeightCoverage::Variable { min, max } => (*min..=*max).contains(&weight),
WeightCoverage::Static(list) => list.contains(&weight),
}
}
/// 最近的可选字重(静态表取绝对值最近;可变字体夹取到区间内)。
pub fn nearest(&self, weight: u16) -> u16 {
match self {
WeightCoverage::Variable { min, max } => weight.clamp(*min, *max),
WeightCoverage::Static(list) => {
let mut best = list[0];
let mut best_delta = u16::MAX;
for candidate in *list {
let delta = candidate.abs_diff(weight);
if delta < best_delta {
best = *candidate;
best_delta = delta;
}
}
best
}
}
}
}
/// 一条字体栈项:族名 + 字重覆盖。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontFace {
/// 族名(只写名字,不带路径/文件名)。
pub family: &'static str,
/// 该族提供的字重。
pub weights: WeightCoverage,
}
impl FontFace {
/// 构造。
pub const fn new(family: &'static str, weights: WeightCoverage) -> Self {
Self { family, weights }
}
/// 该族是否原生覆盖某档字重。
pub fn supports(&self, weight: crate::typography::Weight) -> bool {
self.weights.supports(weight.value())
}
}
/// 字重匹配的质量。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WeightMatch {
/// 原生有这一档。
Native,
/// 同族最近档(差 <2 级)。
Nearest,
/// 差 ≥2 级,引擎只能合成加粗/减细(降级)。
Synthetic,
}
/// 字重解析结果:最终用哪个族、哪个字重、是原生还是降级。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FontChoice {
/// 选中的族名。
pub family: &'static str,
/// 请求的字重。
pub requested: u16,
/// 实际可用的字重。
pub available: u16,
/// 匹配质量。
pub quality: WeightMatch,
}
impl FontChoice {
/// 是否走了合成加粗(降级路径)。
pub fn is_synthetic(&self) -> bool {
self.quality == WeightMatch::Synthetic
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline/title-large):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
// 字重覆盖来源等级:**B**(各平台字体规格/厂商文档;没有一份官方 token 文件能一次给全,
// 所以按「常见安装」标定,并用测试钉死——改值要连带改测试与协作文档)。
const W_VARIABLE_SEGOE: WeightCoverage = WeightCoverage::Variable { min: 300, max: 700 };
const W_VARIABLE_SF: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_NOTO: WeightCoverage = WeightCoverage::Variable { min: 100, max: 900 };
const W_VARIABLE_CASCADIA: WeightCoverage = WeightCoverage::Variable { min: 200, max: 700 };
const W_VARIABLE_JETBRAINS: WeightCoverage = WeightCoverage::Variable { min: 100, max: 800 };
const W_STATIC_SEGOE: WeightCoverage = WeightCoverage::Static(&[300, 400, 600, 700]);
const W_STATIC_YAHEI: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_HELVETICA: WeightCoverage = WeightCoverage::Static(&[300, 400, 500, 700]);
const W_STATIC_PINGFANG: WeightCoverage = WeightCoverage::Static(&[100, 200, 300, 400, 500, 600]);
const W_STATIC_MENLO: WeightCoverage = WeightCoverage::Static(&[400, 700]);
const W_STATIC_DEJAVU: WeightCoverage = WeightCoverage::Static(&[400, 700]);
// 各平台字体栈(写成 const 数组,好让 `font_stack` 的 `const fn` 直接返回 `&'static [_]`)。
const WIN_DISPLAY: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Display", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_TEXT: [FontFace; 5] = [
FontFace::new("Segoe UI Variable Text", W_VARIABLE_SEGOE),
FontFace::new("Segoe UI", W_STATIC_SEGOE),
FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
FontFace::new("Microsoft YaHei", W_STATIC_YAHEI),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const WIN_MONO: [FontFace; 4] = [
FontFace::new("Cascadia Mono", W_VARIABLE_CASCADIA),
FontFace::new("Consolas", W_STATIC_SEGOE),
FontFace::new("Courier New", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const MAC_DISPLAY: [FontFace; 4] = [
FontFace::new("SF Pro Display", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_TEXT: [FontFace; 4] = [
FontFace::new("SF Pro Text", W_VARIABLE_SF),
FontFace::new("Helvetica Neue", W_STATIC_HELVETICA),
FontFace::new("PingFang SC", W_STATIC_PINGFANG),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const MAC_MONO: [FontFace; 4] = [
FontFace::new("SF Mono", W_VARIABLE_SF),
FontFace::new("Menlo", W_STATIC_MENLO),
FontFace::new("Monaco", W_STATIC_MENLO),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
const LINUX_SANS: [FontFace; 5] = [
FontFace::new("Roboto", W_VARIABLE_NOTO),
FontFace::new("Noto Sans", W_VARIABLE_NOTO),
FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans", W_STATIC_DEJAVU),
FontFace::new("sans-serif", W_VARIABLE_NOTO),
];
const LINUX_MONO: [FontFace; 4] = [
FontFace::new("JetBrains Mono", W_VARIABLE_JETBRAINS),
FontFace::new("Noto Sans Mono", W_VARIABLE_NOTO),
FontFace::new("DejaVu Sans Mono", W_STATIC_DEJAVU),
FontFace::new("monospace", W_STATIC_DEJAVU),
];
impl Platform {
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
pub const fn current() -> Self {
@@ -36,146 +239,66 @@ impl Platform {
}
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
///
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
pub const fn font_stack(self, family: FontFamily) -> &'static [FontFace] {
match (self, family) {
// Win10 起 Segoe UI 一定在;Win11 有光学尺寸分离的 Variable 版。
(Platform::Windows, FontFamily::Display) => &[
"Segoe UI Variable Display",
"Segoe UI",
"Microsoft YaHei UI",
"Microsoft YaHei",
"sans-serif",
],
(Platform::Windows, FontFamily::Text) => &[
"Segoe UI Variable Text",
"Segoe UI",
"Microsoft YaHei UI",
"Microsoft YaHei",
"sans-serif",
],
(Platform::Windows, FontFamily::Mono) => {
&["Cascadia Mono", "Consolas", "Courier New", "monospace"]
}
// macOS 12+:SF Pro 分 Display/Text 两种光学尺寸;中文走苹方。
(Platform::MacOs, FontFamily::Display) => &[
"SF Pro Display",
"Helvetica Neue",
"PingFang SC",
"sans-serif",
],
(Platform::MacOs, FontFamily::Text) => {
&["SF Pro Text", "Helvetica Neue", "PingFang SC", "sans-serif"]
}
(Platform::MacOs, FontFamily::Mono) => &["SF Mono", "Menlo", "Monaco", "monospace"],
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套。
(Platform::Linux, FontFamily::Display) | (Platform::Linux, FontFamily::Text) => &[
"Roboto",
"Noto Sans",
"Noto Sans CJK SC",
"DejaVu Sans",
"sans-serif",
],
(Platform::Linux, FontFamily::Mono) => &[
"JetBrains Mono",
"Noto Sans Mono",
"DejaVu Sans Mono",
"monospace",
],
(Platform::Windows, FontFamily::Display) => &WIN_DISPLAY,
(Platform::Windows, FontFamily::Text) => &WIN_TEXT,
(Platform::Windows, FontFamily::Mono) => &WIN_MONO,
// macOS 12+:SF Pro 分 Display/Text 光学尺寸;中文走苹方(只到 600)。
(Platform::MacOs, FontFamily::Display) => &MAC_DISPLAY,
(Platform::MacOs, FontFamily::Text) => &MAC_TEXT,
(Platform::MacOs, FontFamily::Mono) => &MAC_MONO,
// Linux:不假设发行版预装什么,Roboto/Noto 是最常见的两套(Display 与 Text 同栈)。
(Platform::Linux, FontFamily::Display | FontFamily::Text) => &LINUX_SANS,
(Platform::Linux, FontFamily::Mono) => &LINUX_MONO,
}
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
/// 该平台的**中文**兜底族(CJK 字形实际落到的那个)。
///
/// 注意:栈里的 CJK 族只是「缺字形时的候选」,最终由文本引擎逐字形回退决定;
/// 这个函数把它单独暴露出来,好让调用方按模块文档的降级顺序处理字重。
pub const fn cjk_face(self) -> FontFace {
match self {
Platform::Windows => FontFace::new("Microsoft YaHei UI", W_STATIC_YAHEI),
Platform::MacOs => FontFace::new("PingFang SC", W_STATIC_PINGFANG),
Platform::Linux => FontFace::new("Noto Sans CJK SC", W_VARIABLE_NOTO),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
#[test]
fn font_stacks_are_well_formed() {
for platform in PLATFORMS {
for family in FAMILIES {
let stack = platform.font_stack(family);
assert!(
!stack.is_empty(),
"{} 的 {family:?} 栈为空",
platform.name()
);
let last = stack[stack.len() - 1];
let generic = match family {
FontFamily::Mono => "monospace",
_ => "sans-serif",
/// 按栈顺序解析某档字重的落点(纯函数,不查系统)。
///
/// 规则:**优先原生**(栈里第一个真有的族)→ 都做不到时取首选族的最近档,
/// 差距 ≥200 判为 [`WeightMatch::Synthetic`]。
pub fn resolve_weight(
self,
family: FontFamily,
weight: crate::typography::Weight,
) -> FontChoice {
let stack = self.font_stack(family);
let requested = weight.value();
for face in stack {
if face.weights.supports(requested) {
return FontChoice {
family: face.family,
requested,
available: requested,
quality: WeightMatch::Native,
};
assert_eq!(
last,
generic,
"{} 的 {family:?} 栈必须以通用族名兜底",
platform.name()
);
for name in stack {
assert!(!name.is_empty());
assert!(
!name.contains(['/', '\\', '.', ':']),
"字体栈只允许族名,不得出现路径/文件名:{name}"
);
assert!(
name.is_ascii(),
"族名应为 ASCII(非 ASCII 名交给系统匹配):{name}"
);
}
}
}
}
#[test]
fn display_and_text_stacks_differ_on_windows_and_macos() {
assert_ne!(
Platform::Windows.font_stack(FontFamily::Display)[0],
Platform::Windows.font_stack(FontFamily::Text)[0]
);
assert_ne!(
Platform::MacOs.font_stack(FontFamily::Display)[0],
Platform::MacOs.font_stack(FontFamily::Text)[0]
);
}
#[test]
fn chinese_fallback_is_present_but_not_first() {
// 中文族名必须在栈里(否则 CJK 走系统随机匹配),但不能排第一(纯英文环境不加载中文字体)。
for platform in PLATFORMS {
let stack = platform.font_stack(FontFamily::Text);
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
assert!(
stack.iter().any(|name| cjk.contains(name)),
"{} 的正文栈缺 CJK 兜底",
platform.name()
);
assert!(
!cjk.contains(&stack[0]),
"{} 的首选字体不该是 CJK 族",
platform.name()
);
let first = stack[0];
let available = first.weights.nearest(requested);
FontChoice {
family: first.family,
requested,
available,
quality: if available.abs_diff(requested) >= 200 {
WeightMatch::Synthetic
} else {
WeightMatch::Nearest
},
}
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
}
+149
View File
@@ -0,0 +1,149 @@
//! 字体栈与字重解析的测试(独立文件是为了守住单文件 ≤400 行的硬约束)。
use super::*;
use crate::typography::Weight;
const PLATFORMS: [Platform; 3] = [Platform::Windows, Platform::MacOs, Platform::Linux];
const FAMILIES: [FontFamily; 3] = [FontFamily::Display, FontFamily::Text, FontFamily::Mono];
#[test]
fn font_stacks_are_well_formed() {
for platform in PLATFORMS {
for family in FAMILIES {
let stack = platform.font_stack(family);
assert!(
!stack.is_empty(),
"{} 的 {family:?} 栈为空",
platform.name()
);
let last = stack[stack.len() - 1].family;
let generic = match family {
FontFamily::Mono => "monospace",
_ => "sans-serif",
};
assert_eq!(
last,
generic,
"{} 的 {family:?} 栈必须以通用族名兜底",
platform.name()
);
for face in stack {
assert!(!face.family.is_empty());
assert!(
!face.family.contains(['/', '\\', '.', ':']),
"字体栈只允许族名,不得出现路径/文件名:{}",
face.family
);
assert!(
face.family.is_ascii(),
"族名应为 ASCII(非 ASCII 名交给系统匹配):{}",
face.family
);
// 每条栈项都必须声明字重覆盖(连 400 都没有的族写进栈没有意义)。
assert!(
face.supports(Weight::Regular),
"{} 连 400 都没有,写它没意义",
face.family
);
}
}
}
}
#[test]
fn display_and_text_stacks_differ_on_windows_and_macos() {
assert_ne!(
Platform::Windows.font_stack(FontFamily::Display)[0].family,
Platform::Windows.font_stack(FontFamily::Text)[0].family
);
assert_ne!(
Platform::MacOs.font_stack(FontFamily::Display)[0].family,
Platform::MacOs.font_stack(FontFamily::Text)[0].family
);
}
#[test]
fn chinese_fallback_is_present_but_not_first() {
// 中文族名必须在栈里,但不能排第一(纯英文环境不加载中文字体,D28)。
for platform in PLATFORMS {
let stack = platform.font_stack(FontFamily::Text);
let cjk = ["Microsoft YaHei UI", "PingFang SC", "Noto Sans CJK SC"];
assert!(
stack.iter().any(|face| cjk.contains(&face.family)),
"{} 的正文栈缺 CJK 兜底",
platform.name()
);
assert!(
!cjk.contains(&stack[0].family),
"{} 的首选字体不该是 CJK 族",
platform.name()
);
}
}
#[test]
fn cjk_weight_coverage_is_registered() {
// 用户点名的坑:中文字重覆盖三平台差别很大。
let win = Platform::Windows.cjk_face();
assert!(win.supports(Weight::Regular) && win.supports(Weight::Bold));
assert!(
!win.supports(Weight::Medium),
"雅黑没有 500——这条断言就是「中文 500 会掉到降级路径」的登记"
);
let mac = Platform::MacOs.cjk_face();
assert!(mac.supports(Weight::Medium));
assert!(
!mac.supports(Weight::Bold),
"苹方到 600 为止,700 要合成/换族"
);
let linux = Platform::Linux.cjk_face();
for weight in [Weight::Regular, Weight::Medium, Weight::Bold] {
assert!(
linux.supports(weight),
"思源黑体/Noto CJK 应覆盖 400/500/700"
);
}
}
#[test]
fn resolve_prefers_native_and_flags_synthetic() {
// Windows 正文 700:Segoe UI Variable 覆盖 → 原生。
let bold = Platform::Windows.resolve_weight(FontFamily::Text, Weight::Bold);
assert_eq!(bold.quality, WeightMatch::Native);
assert_eq!(bold.available, 700);
assert!(!bold.is_synthetic());
// Linux 正文 500:Roboto 可变 → 原生。
assert_eq!(
Platform::Linux
.resolve_weight(FontFamily::Text, Weight::Medium)
.quality,
WeightMatch::Native
);
// 静态族缺档:只有 400 的族请求 700 → nearest 400,差 300 → 判为合成降级。
let only_regular = FontFace::new("Courier New", WeightCoverage::Static(&[400]));
assert_eq!(only_regular.weights.nearest(700), 400);
let synthetic = FontChoice {
family: only_regular.family,
requested: 700,
available: only_regular.weights.nearest(700),
quality: WeightMatch::Synthetic,
};
assert!(synthetic.is_synthetic());
}
#[test]
fn nearest_picks_closest_static_weight() {
let coverage = WeightCoverage::Static(&[300, 400, 600, 700]);
assert_eq!(coverage.nearest(500), 400);
assert_eq!(coverage.nearest(650), 600);
assert_eq!(coverage.nearest(900), 700);
let variable = WeightCoverage::Variable { min: 100, max: 900 };
assert_eq!(variable.nearest(500), 500);
assert_eq!(variable.nearest(50), 100);
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
+59 -21
View File
@@ -1,23 +1,31 @@
//! M3 type scale(15 档)+ 三平台字体族回退栈。
//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。
//!
//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。
//! baseline 四元组取 `<Role>{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取
//! `<Role>Emphasized{...}`(见 [`emphasized`] 模块)。注意 Compose 这一代 token 的 tracking 与
//! m3.material.io 网页表格有 0.05px 级别的差异(display-large −0.2 vs −0.25、body-medium 0.2 vs
//! 0.25、title-medium 0.2 vs 0.15),本项目跟 **Compose(实际渲染值)**,差异登记在
//! crate 文档「baseline vs Expressive 差异清单」里。
//!
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
mod emphasized;
mod fonts;
pub use fonts::{FontFamily, Platform};
pub use fonts::{FontChoice, FontFace, FontFamily, Platform, WeightCoverage, WeightMatch};
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weight {
/// 400:正文。
Regular,
/// 500:label / title-medium。
/// 500:label / title-medium / Expressive 强调的正文与展示类。
Medium,
/// 600:强调标题(本项目的「区块标题」档)。
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
SemiBold,
/// 700:页面标题 / 空态主文案。
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
Bold,
}
@@ -32,7 +40,7 @@ impl Weight {
}
}
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 Bold)。
/// 再重一档(最重封顶 Bold)。
pub const fn heavier(self) -> Self {
match self {
Weight::Regular => Weight::Medium,
@@ -69,10 +77,15 @@ impl TypeStyle {
}
}
/// 该样式的字体族回退栈。
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`FontFace`])。
pub fn font_stack(self, platform: Platform) -> &'static [FontFace] {
platform.font_stack(self.family)
}
/// 该样式在这台平台上「字重最终落到哪个族」——中文缺档时看它判断是否走降级。
pub fn resolve(self, platform: Platform) -> FontChoice {
platform.resolve_weight(self.family, self.weight)
}
}
/// M3 type scale 的 15 个角色。
@@ -151,12 +164,10 @@ impl TypeRole {
}
}
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
pub const fn style(self) -> TypeStyle {
match self {
TypeRole::DisplayLarge => {
style(57.0, 64.0, Weight::Regular, -0.25, FontFamily::Display)
}
TypeRole::DisplayLarge => style(57.0, 64.0, Weight::Regular, -0.2, FontFamily::Display),
TypeRole::DisplayMedium => style(45.0, 52.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::DisplaySmall => style(36.0, 44.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::HeadlineLarge => style(32.0, 40.0, Weight::Regular, 0.0, FontFamily::Display),
@@ -164,11 +175,12 @@ impl TypeRole {
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
}
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Text),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.15, FontFamily::Text),
// 官方 TitleLarge 用 Brand 族(与 Display 同栈),其余 title 用 Plain(Text)。
TypeRole::TitleLarge => style(22.0, 28.0, Weight::Regular, 0.0, FontFamily::Display),
TypeRole::TitleMedium => style(16.0, 24.0, Weight::Medium, 0.2, FontFamily::Text),
TypeRole::TitleSmall => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::BodyLarge => style(16.0, 24.0, Weight::Regular, 0.5, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.25, FontFamily::Text),
TypeRole::BodyMedium => style(14.0, 20.0, Weight::Regular, 0.2, FontFamily::Text),
TypeRole::BodySmall => style(12.0, 16.0, Weight::Regular, 0.4, FontFamily::Text),
TypeRole::LabelLarge => style(14.0, 20.0, Weight::Medium, 0.1, FontFamily::Text),
TypeRole::LabelMedium => style(12.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
@@ -176,12 +188,20 @@ impl TypeRole {
}
}
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
///
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
/// 规则(官方):字号与行高不变;字重升到 500(展示/正文类)或 700(label 与小标题);
/// 字距按官方表微调(display-large −0.2→0、body-large 0.5→0.15、title-medium 0.2→0.15、
/// body-medium 0.2→0.25)。这是**官方值,不是我们的近似**。
pub const fn emphasized(self) -> TypeStyle {
emphasized::style_of(self)
}
/// **简化强调规则(非官方)**:只把字重升一级,字号/行高/字距全不动。
///
/// 用途:需要在「同一 role 内加强语气但不想引入任何字距变化」的场合(例如同屏多行标题
/// 需要严格对齐基线网格时)。**优先用 [`TypeRole::emphasized`]**——那才是 Expressive 的定义。
pub const fn emphasized_simplified(self) -> TypeStyle {
let base = self.style();
TypeStyle {
weight: base.weight.heavier(),
@@ -286,16 +306,34 @@ mod tests {
}
#[test]
fn emphasized_only_bumps_weight() {
fn simplified_emphasis_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
let emph = role.emphasized();
let emph = role.emphasized_simplified();
assert_eq!(base.size_px, emph.size_px);
assert_eq!(base.line_height_px, emph.line_height_px);
assert_eq!(base.letter_spacing_px, emph.letter_spacing_px);
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
}
}
#[test]
fn official_emphasis_is_at_least_as_heavy_as_baseline() {
for role in TypeRole::ALL {
assert!(
role.emphasized().weight.value() >= role.style().weight.value(),
"{} 的官方强调档不该比 baseline 更轻",
role.name()
);
assert_ne!(
role.emphasized(),
role.style(),
"{} 强调档与 baseline 相同",
role.name()
);
}
}
#[test]
fn scaling_is_proportional_and_bounded() {
let base = TypeRole::BodyMedium.style();
+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);
}