feat(design): 加 unlucky-design 令牌 crate(M3 色彩/排版/形状/动效/图标)

D26 把视觉基线锁到 Material 3 Expressive,本 crate 是「UI 整体性」的地基:
令牌唯一真源,后续 iced 页面只消费不新增。零第三方依赖、零 UI 框架依赖
(cargo tree -p unlucky-design --edges normal 只有它自己一行)。

- 色彩:49 个 M3 语义角色 × light/dark,种子色派生 6 条 tone 调色板;
  tone 轴严格用 CIELAB L*(偏差 ≤1),色相/彩度用 OKLCh 近似 CAM16 并在
  模块文档里写明口径与已知偏差(不宣称是标准 HCT 动态取色)
- 对比度:26 组文字对 × 6 组种子 × 2 模式全部 ≥4.5:1;3 条例外显式登记
  (Outline 非文字 3:1、OutlineVariant 装饰、SurfaceVariant 是容器色)
- 排版:M3 type scale 15 档 + 三平台字体族回退栈(只写族名,不内嵌受限字体)
- 形状/高程/动效:Expressive 形状刻度 + shape morph 数据、elevation 级别与
  surface container 映射、时长/缓动/弹簧(纯函数可采样)
- 图标:24 个从 Slint 版迁移的 SVG path + 4 个新增,IconKind 冻结只可末尾追加;
  写明 Slint 的 stroke-width 是绝对像素、SVG 是 viewBox 用户单位的语义差异,
  换算封装成 Icon::stroke_width
- 组件档位表:20 个组件的形状/高程/字体/动效一次取全

TODO(M63):elevation shadow 的 blur/offset/alpha 与四组弹簧参数是占位值,
接口先定、数值待玻璃视觉规格表(tower 已通告边界)。
This commit is contained in:
AskaEth committed 2026-11-01 15:34:04 +08:00
1 parent fef0922073
commit 65ca0ed29a
25 files changed
+4622 -1

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
//! 三平台字体族回退栈(Win10+ / macOS 12+ / Linux)。
//!
//! 只写族名,不写文件路径、不内嵌字体(D14 + D28)。每条栈的**最后一项必须是通用族名**,
//! 否则干净安装的系统上会退化成豆腐块。
/// 平台(决定字体族回退栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Platform {
/// Windows 10/11。
Windows,
/// macOS 12+。
MacOs,
/// Linux(X11/Wayland,桌面发行版)。
Linux,
}
impl Platform {
/// 当前编译目标对应的平台(运行时用户覆盖请显式传 [`Platform`])。
pub const fn current() -> Self {
if cfg!(target_os = "windows") {
Platform::Windows
} else if cfg!(target_os = "macos") {
Platform::MacOs
} else {
Platform::Linux
}
}
/// 稳定字符串名。
pub const fn name(self) -> &'static str {
match self {
Platform::Windows => "windows",
Platform::MacOs => "macos",
Platform::Linux => "linux",
}
}
/// 字体族回退栈(由首选到兜底,最后一项是通用族名)。
///
/// iced/cosmic-text 按顺序找第一个装了的族;`sans-serif` / `monospace` 是通用兜底,
/// 保证「一台干净安装的系统」也不会渲染成豆腐块。
pub const fn font_stack(self, family: FontFamily) -> &'static [&'static str] {
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",
],
}
}
}
/// 字体族的用途分类(决定取哪条栈)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum FontFamily {
/// 大字号展示(display/headline):光学尺寸更大。
Display,
/// 正文(title/body/label)。
Text,
/// 等宽(日志、代码、版本号)。
Mono,
}
#[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",
};
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()
);
}
}
#[test]
fn current_platform_returns_a_stack() {
let platform = Platform::current();
assert!(!platform.font_stack(FontFamily::Text).is_empty());
}
}
+308
View File
@@ -0,0 +1,308 @@
//! M3 type scale(15 档)+ 三平台字体族回退栈。
//!
//! **只写字体族名,不内嵌任何字体文件**(Segoe UI / SF Pro / Roboto 都受各自许可约束,
//! 内嵌再分发是违规的;引用系统已安装的族名是合规且体积为零的做法——这是 D14 与 D28 的共同要求)。
//! 中文由系统字体匹配:栈里显式列出 CJK 族名,纯英文环境不会去加载任何中文字体(D28)。
mod fonts;
pub use fonts::{FontFamily, Platform};
/// 字重(M3 只用这四档;可变字体按连续值渲染时取 [`Weight::value`])。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Weight {
/// 400:正文。
Regular,
/// 500:label / title-medium。
Medium,
/// 600:强调标题(本项目的「区块标题」档)。
SemiBold,
/// 700:页面标题 / 空态主文案。
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,
}
}
/// 再重一档(Expressive 的 emphasized 用得上,最重封顶 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
}
}
/// 该样式的字体族回退栈。
pub fn font_stack(self, platform: Platform) -> &'static [&'static str] {
platform.font_stack(self.family)
}
}
/// 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",
}
}
/// M3 基线样式(字号 / 行高 / 字重 / 字距,官方 type scale 值)。
pub const fn style(self) -> TypeStyle {
match self {
TypeRole::DisplayLarge => {
style(57.0, 64.0, Weight::Regular, -0.25, 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),
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),
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::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),
}
}
/// Expressive 强调档:**字重升一级,字号/行高/字距不变**。
///
/// 口径声明:M3 Expressive 确实有 emphasized 排版档,但官方没公开与这 15 档一一对应的数值;
/// 这里定的是**我们自己的规则**(加粗一档),目的是「同一 role 的强调态只改一个字重」,
/// 保证页面不会因为强调而改变布局高度。要更强的层级请换更大 role,不要继续加字重。
pub const fn emphasized(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 emphasized_only_bumps_weight() {
for role in TypeRole::ALL {
let base = role.style();
let emph = role.emphasized();
assert_eq!(base.size_px, emph.size_px);
assert_eq!(base.line_height_px, emph.line_height_px);
assert!(emph.weight.value() > base.weight.value() || base.weight == Weight::Bold);
}
}
#[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);
}
}