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

+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());
}
}