Files
Unlucky-Launcher/crates/design/src/typography/mod.rs
T
AskaEth 83e19e58f0 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」
2026-11-01 15:58:19 +08:00

347 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::{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 / Expressive 强调的正文与展示类。
Medium,
/// 600:本项目的「区块标题」档(介于 Medium 与 Bold 之间)。
SemiBold,
/// 700:页面标题 / 空态主文案 / Expressive 强调的 label 与 title-small-medium。
Bold,
}
impl Weight {
/// 数值字重(CSS/OpenType 口径)。
pub const fn value(self) -> u16 {
match self {
Weight::Regular => 400,
Weight::Medium => 500,
Weight::SemiBold => 600,
Weight::Bold => 700,
}
}
/// 再重一档(最重封顶 Bold)。
pub const fn heavier(self) -> Self {
match self {
Weight::Regular => Weight::Medium,
Weight::Medium => Weight::SemiBold,
Weight::SemiBold | Weight::Bold => Weight::Bold,
}
}
}
/// 一条排版样式(已解析成渲染要用的四个数)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TypeStyle {
/// 字号(px;1.0 缩放时与 dp 同值)。
pub size_px: f32,
/// 行高(px)。
pub line_height_px: f32,
/// 字重。
pub weight: Weight,
/// 字间距(px;M3 的 type scale 用 px 表达,中文场景下正字距更易读)。
pub letter_spacing_px: f32,
/// 用哪条字体栈。
pub family: FontFamily,
}
impl TypeStyle {
/// 按用户字号缩放(`factor` 1.0 = 标准;行高与字距同比例,夹取到 0.5..2.0)。
pub fn scaled(self, factor: f32) -> Self {
let f = factor.clamp(0.5, 2.0);
Self {
size_px: self.size_px * f,
line_height_px: self.line_height_px * f,
letter_spacing_px: self.letter_spacing_px * f,
..self
}
}
/// 该样式的字体族回退栈(栈项带字重覆盖,见 [`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 个角色。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TypeRole {
/// 57/64:落地页大标题(本启动器基本不用)。
DisplayLarge,
/// 45/52。
DisplayMedium,
/// 36/44。
DisplaySmall,
/// 32/40:空态主标题的大档。
HeadlineLarge,
/// 28/36。
HeadlineMedium,
/// 24/32。
HeadlineSmall,
/// 22/28:页面标题(导航栏下的主标题)。
TitleLarge,
/// 16/24:卡片标题。
TitleMedium,
/// 14/20:行标题。
TitleSmall,
/// 16/24:正文大档(说明文字)。
BodyLarge,
/// 14/20:默认正文。
BodyMedium,
/// 12/16:辅助说明。
BodySmall,
/// 14/20:按钮文字。
LabelLarge,
/// 12/16:标签、徽标。
LabelMedium,
/// 11/16:最小标签(谨慎使用,中文 11px 可读性差)。
LabelSmall,
}
impl TypeRole {
/// 全部 15 档,按 M3 的族序排列。
pub const ALL: [TypeRole; 15] = [
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
];
/// 稳定字符串名(`display-large` … `label-small`)。
pub const fn name(self) -> &'static str {
match self {
TypeRole::DisplayLarge => "display-large",
TypeRole::DisplayMedium => "display-medium",
TypeRole::DisplaySmall => "display-small",
TypeRole::HeadlineLarge => "headline-large",
TypeRole::HeadlineMedium => "headline-medium",
TypeRole::HeadlineSmall => "headline-small",
TypeRole::TitleLarge => "title-large",
TypeRole::TitleMedium => "title-medium",
TypeRole::TitleSmall => "title-small",
TypeRole::BodyLarge => "body-large",
TypeRole::BodyMedium => "body-medium",
TypeRole::BodySmall => "body-small",
TypeRole::LabelLarge => "label-large",
TypeRole::LabelMedium => "label-medium",
TypeRole::LabelSmall => "label-small",
}
}
/// **baseline MD3** 样式(官方 type scale 值:字号 / 行高 / 字重 / 字距 + 字体族分类)。
pub const fn style(self) -> TypeStyle {
match self {
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),
TypeRole::HeadlineMedium => {
style(28.0, 36.0, Weight::Regular, 0.0, FontFamily::Display)
}
TypeRole::HeadlineSmall => style(24.0, 32.0, Weight::Regular, 0.0, FontFamily::Display),
// 官方 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.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),
TypeRole::LabelSmall => style(11.0, 16.0, Weight::Medium, 0.5, FontFamily::Text),
}
}
/// **M3 Expressive** 官方强调档(15/15 全有值,A 级来源)。
///
/// 规则(官方):字号与行高不变;字重升到 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(),
..base
}
}
}
/// `const fn` 用的构造器。
const fn style(
size: f32,
line_height: f32,
weight: Weight,
tracking_px: f32,
family: FontFamily,
) -> TypeStyle {
TypeStyle {
size_px: size,
line_height_px: line_height,
weight,
letter_spacing_px: tracking_px,
family,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_role_has_sane_metrics() {
for role in TypeRole::ALL {
let s = role.style();
assert!(s.size_px >= 11.0, "{} 字号过小", role.name());
assert!(
s.line_height_px >= s.size_px,
"{} 行高小于字号({} < {})",
role.name(),
s.line_height_px,
s.size_px
);
assert!(
(1.1..=1.8).contains(&(s.line_height_px / s.size_px)),
"{} 行高倍数 {} 超出 1.1..1.8(M3 的行高是 1.12~1.5 量级)",
role.name(),
s.line_height_px / s.size_px
);
assert!(s.letter_spacing_px.abs() < 1.0, "{} 字距过大", role.name());
}
}
#[test]
fn each_family_is_monotone_by_size() {
// M3 的 type scale 是「五族 × 三档」,跨族不保证单调(title-small 14 → body-large 16),
// 但族内必须由大到小。
for family in [
[
TypeRole::DisplayLarge,
TypeRole::DisplayMedium,
TypeRole::DisplaySmall,
],
[
TypeRole::HeadlineLarge,
TypeRole::HeadlineMedium,
TypeRole::HeadlineSmall,
],
[
TypeRole::TitleLarge,
TypeRole::TitleMedium,
TypeRole::TitleSmall,
],
[
TypeRole::BodyLarge,
TypeRole::BodyMedium,
TypeRole::BodySmall,
],
[
TypeRole::LabelLarge,
TypeRole::LabelMedium,
TypeRole::LabelSmall,
],
] {
for pair in family.windows(2) {
assert!(
pair[0].style().size_px > pair[1].style().size_px,
"{} 应大于 {}",
pair[0].name(),
pair[1].name()
);
}
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for role in TypeRole::ALL {
let name = role.name();
assert!(seen.insert(name), "排版角色名重复:{name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '-'));
}
}
#[test]
fn simplified_emphasis_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
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();
let big = base.scaled(1.25);
assert!((big.size_px - 17.5).abs() < 1.0e-4);
assert!((big.line_height_px - 25.0).abs() < 1.0e-4);
assert_eq!(base.scaled(10.0).size_px, base.size_px * 2.0);
assert_eq!(base.scaled(0.1).size_px, base.size_px * 0.5);
}
}