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:
1 parent
fef0922073
commit
65ca0ed29a
25 files changed
+4622
-1
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user