From 83e19e58f0fd018724ec161166e446051fce04df Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 15:58:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(design):=20M61=20r1=20=E6=94=B6=E5=8F=A3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=AE=98=E6=96=B9=20emphasized/=E5=BC=B9?= =?UTF-8?q?=E7=B0=A7/=E9=AB=98=E7=A8=8B=20+=20=E5=86=BB=E7=BB=93=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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」 --- crates/design/src/color/palettes.rs | 20 + crates/design/src/color/scheme.rs | 2 +- crates/design/src/color/space.rs | 8 + crates/design/src/color/tonal.rs | 19 +- crates/design/src/component_map/mod.rs | 56 +++ crates/design/src/component_map/table.rs | 34 +- crates/design/src/icons/mod.rs | 3 +- crates/design/src/lib.rs | 39 ++ crates/design/src/motion/mod.rs | 7 +- crates/design/src/motion/spring.rs | 264 ++++++++++--- crates/design/src/shape.rs | 4 +- crates/design/src/spacing.rs | 50 ++- crates/design/src/typography/emphasized.rs | 88 +++++ crates/design/src/typography/fonts.rs | 387 +++++++++++++------- crates/design/src/typography/fonts/tests.rs | 149 ++++++++ crates/design/src/typography/mod.rs | 80 ++-- crates/design/tests/contrast_aa.rs | 2 +- crates/design/tests/token_contract.rs | 31 +- crates/design/tests/token_pins.rs | 252 +++++++++++++ crates/design/tests/type_scale_pins.rs | 106 ++++++ 20 files changed, 1367 insertions(+), 234 deletions(-) create mode 100644 crates/design/src/typography/emphasized.rs create mode 100644 crates/design/src/typography/fonts/tests.rs create mode 100644 crates/design/tests/token_pins.rs create mode 100644 crates/design/tests/type_scale_pins.rs diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index c7c9bbc..171a061 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -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)] diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index 344fd0d..30e9150 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -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 } } diff --git a/crates/design/src/color/space.rs b/crates/design/src/color/space.rs index d474037..7ad29e1 100644 --- a/crates/design/src/color/space.rs +++ b/crates/design/src/color/space.rs @@ -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 { diff --git a/crates/design/src/color/tonal.rs b/crates/design/src/color/tonal.rs index 8249b93..a9fc8c4 100644 --- a/crates/design/src/color/tonal.rs +++ b/crates/design/src/color/tonal.rs @@ -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)) } diff --git a/crates/design/src/component_map/mod.rs b/crates/design/src/component_map/mod.rs index a1404e3..f154aad 100644 --- a/crates/design/src/component_map/mod.rs +++ b/crates/design/src/component_map/mod.rs @@ -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 是 diff --git a/crates/design/src/component_map/table.rs b/crates/design/src/component_map/table.rs index 243dbe5..add52b2 100644 --- a/crates/design/src/component_map/table.rs +++ b/crates/design/src/component_map/table.rs @@ -1,10 +1,19 @@ //! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。 //! //! 一行一个组件。改值就是在改全站观感——同步协作文档再改。 +//! +//! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `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, diff --git a/crates/design/src/icons/mod.rs b/crates/design/src/icons/mod.rs index 056933e..126b32e 100644 --- a/crates/design/src/icons/mod.rs +++ b/crates/design/src/icons/mod.rs @@ -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 视觉宽度。 //! diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs index 616494b..5e45d0b 100644 --- a/crates/design/src/lib.rs +++ b/crates/design/src/lib.rs @@ -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)] diff --git a/crates/design/src/motion/mod.rs b/crates/design/src/motion/mod.rs index 13fd512..df59e11 100644 --- a/crates/design/src/motion/mod.rs +++ b/crates/design/src/motion/mod.rs @@ -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 动效令牌,单位毫秒)。 diff --git a/crates/design/src/motion/spring.rs b/crates/design/src/motion/spring.rs index d224986..6baa0c0 100644 --- a/crates/design/src/motion/spring.rs +++ b/crates/design/src/motion/spring.rs @@ -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); } } diff --git a/crates/design/src/shape.rs b/crates/design/src/shape.rs index 8002265..0f99d49 100644 --- a/crates/design/src/shape.rs +++ b/crates/design/src/shape.rs @@ -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); diff --git a/crates/design/src/spacing.rs b/crates/design/src/spacing.rs index 22c6b43..0737817 100644 --- a/crates/design/src/spacing.rs +++ b/crates/design/src/spacing.rs @@ -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] diff --git a/crates/design/src/typography/emphasized.rs b/crates/design/src/typography/emphasized.rs new file mode 100644 index 0000000..c7c2974 --- /dev/null +++ b/crates/design/src/typography/emphasized.rs @@ -0,0 +1,88 @@ +//! 官方 M3 **Expressive** 强调排版表(15 档,A 级来源)。 +//! +//! 来源:`androidx.compose.material3.tokens.TypeScaleTokens` v0_103 的 +//! `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); + } +} diff --git a/crates/design/src/typography/fonts.rs b/crates/design/src/typography/fonts.rs index 27e4527..f1ceede 100644 --- a/crates/design/src/typography/fonts.rs +++ b/crates/design/src/typography/fonts.rs @@ -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()); - } } diff --git a/crates/design/src/typography/fonts/tests.rs b/crates/design/src/typography/fonts/tests.rs new file mode 100644 index 0000000..638e976 --- /dev/null +++ b/crates/design/src/typography/fonts/tests.rs @@ -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()); +} diff --git a/crates/design/src/typography/mod.rs b/crates/design/src/typography/mod.rs index ebb937c..45e5153 100644 --- a/crates/design/src/typography/mod.rs +++ b/crates/design/src/typography/mod.rs @@ -1,23 +1,31 @@ -//! M3 type scale(15 档)+ 三平台字体族回退栈。 +//! M3 type scale(15 档,baseline + Expressive 强调两套)+ 三平台字体族回退栈。 +//! +//! **数值来源 A 级**:`androidx.compose.material3.tokens.TypeScaleTokens`(v0_103)。 +//! baseline 四元组取 `{Size,LineHeight,Tracking,Weight}`,Expressive 强调档取 +//! `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(); diff --git a/crates/design/tests/contrast_aa.rs b/crates/design/tests/contrast_aa.rs index a209b09..a5650b8 100644 --- a/crates/design/tests/contrast_aa.rs +++ b/crates/design/tests/contrast_aa.rs @@ -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() ); diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs index 29c9c0e..079f146 100644 --- a/crates/design/tests/token_contract.rs +++ b/crates/design/tests/token_contract.rs @@ -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); } diff --git a/crates/design/tests/token_pins.rs b/crates/design/tests/token_pins.rs new file mode 100644 index 0000000..ff1cf60 --- /dev/null +++ b/crates/design/tests/token_pins.rs @@ -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); +} diff --git a/crates/design/tests/type_scale_pins.rs b/crates/design/tests/type_scale_pins.rs new file mode 100644 index 0000000..990537f --- /dev/null +++ b/crates/design/tests/type_scale_pins.rs @@ -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); +}