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

+208
View File
@@ -0,0 +1,208 @@
//! `IconKind` 枚举:**API 已冻结,只允许在末尾追加**(序号即持久化 id,插到中间会静默换图)。
use super::glyphs::GLYPHS;
use super::Icon;
/// 图标种类。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
#[non_exhaustive]
pub enum IconKind {
/// 实例(2×2 网格)。
Instances,
/// 下载。
Download,
/// 日志(文档 + 正文)。
Logs,
/// 设置(齿轮)。
Settings,
/// AI(四角星)。
Ai,
/// 播放(实心三角)。
Play,
/// 停止(实心圆角方)。
Stop,
/// 搜索(放大镜)。
Search,
/// 加号。
Plus,
/// 关闭(叉)。
Close,
/// 最小化(横线)。
Minimize,
/// 最大化(方框)。
Maximize,
/// 左尖角。
ChevronLeft,
/// 右尖角。
ChevronRight,
/// 下尖角。
ChevronDown,
/// 展开为独立页(双对角箭头)。
Expand,
/// 发送(纸飞机)。
Send,
/// 勾选。
Check,
/// 警告(三角感叹)。
Warn,
/// 错误(圆形感叹)。
Error,
/// 更多(三点,实心)。
More,
/// 侧栏开关。
Sidebar,
/// 还原窗口(双层方框)。
Restore,
/// 账户占位(头像剪影)。
User,
/// 刷新(M61 新增)。
Refresh,
/// 筛选(M61 新增)。
Filter,
/// 目录(M61 新增)。
Folder,
/// 删除(M61 新增)。
Trash,
}
impl IconKind {
/// 全部图标,按枚举声明顺序。
pub const ALL: [IconKind; 28] = [
IconKind::Instances,
IconKind::Download,
IconKind::Logs,
IconKind::Settings,
IconKind::Ai,
IconKind::Play,
IconKind::Stop,
IconKind::Search,
IconKind::Plus,
IconKind::Close,
IconKind::Minimize,
IconKind::Maximize,
IconKind::ChevronLeft,
IconKind::ChevronRight,
IconKind::ChevronDown,
IconKind::Expand,
IconKind::Send,
IconKind::Check,
IconKind::Warn,
IconKind::Error,
IconKind::More,
IconKind::Sidebar,
IconKind::Restore,
IconKind::User,
IconKind::Refresh,
IconKind::Filter,
IconKind::Folder,
IconKind::Trash,
];
/// 取几何数据。
pub fn icon(self) -> Icon {
GLYPHS[self as usize]
}
/// 稳定字符串名(kebab-case):写配置、日志、测试快照都用它,不要用 `Debug` 输出。
pub const fn name(self) -> &'static str {
match self {
IconKind::Instances => "instances",
IconKind::Download => "download",
IconKind::Logs => "logs",
IconKind::Settings => "settings",
IconKind::Ai => "ai",
IconKind::Play => "play",
IconKind::Stop => "stop",
IconKind::Search => "search",
IconKind::Plus => "plus",
IconKind::Close => "close",
IconKind::Minimize => "minimize",
IconKind::Maximize => "maximize",
IconKind::ChevronLeft => "chevron-left",
IconKind::ChevronRight => "chevron-right",
IconKind::ChevronDown => "chevron-down",
IconKind::Expand => "expand",
IconKind::Send => "send",
IconKind::Check => "check",
IconKind::Warn => "warn",
IconKind::Error => "error",
IconKind::More => "more",
IconKind::Sidebar => "sidebar",
IconKind::Restore => "restore",
IconKind::User => "user",
IconKind::Refresh => "refresh",
IconKind::Filter => "filter",
IconKind::Folder => "folder",
IconKind::Trash => "trash",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icons::IconPaint;
#[test]
fn glyph_table_matches_enum_order() {
for (i, icon) in GLYPHS.iter().enumerate() {
assert_eq!(
icon.kind as usize,
i,
"GLYPHS 第 {i} 项是 {},与 IconKind 顺序不一致",
icon.kind.name()
);
}
}
#[test]
fn all_kinds_resolve() {
assert_eq!(IconKind::ALL.len(), GLYPHS.len());
for kind in IconKind::ALL {
let icon = kind.icon();
assert_eq!(icon.kind, kind);
assert!(!icon.path.is_empty());
}
}
#[test]
fn names_are_unique_and_kebab() {
let mut seen = std::collections::BTreeSet::new();
for kind in IconKind::ALL {
let name = kind.name();
assert!(seen.insert(name), "图标名重复:{name}");
assert!(name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'));
}
}
#[test]
fn migrated_kinds_keep_their_positions() {
// 前 24 个是 Slint 版迁移来的(次序必须原样),后 4 个是 M61 追加的。
assert_eq!(IconKind::ALL[0], IconKind::Instances);
assert_eq!(IconKind::ALL[4], IconKind::Ai);
assert_eq!(IconKind::ALL[20], IconKind::More);
assert_eq!(IconKind::ALL[23], IconKind::User);
assert_eq!(IconKind::ALL[24], IconKind::Refresh);
assert_eq!(IconKind::ALL[27], IconKind::Trash);
}
#[test]
fn only_solid_icons_are_filled() {
for kind in IconKind::ALL {
let icon = kind.icon();
let expect_fill = matches!(kind, IconKind::Play | IconKind::Stop | IconKind::More);
assert_eq!(
icon.paint,
if expect_fill {
IconPaint::Fill
} else {
IconPaint::Stroke
},
"{} 的 fill/stroke 判定不对",
kind.name()
);
}
}
}