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