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

+216
View File
@@ -0,0 +1,216 @@
//! 组件 → 档位映射表:哪个组件用哪档形状 / 高程 / 字体 / 动效。
//!
//! 目的不是「规定 M3 官方长什么样」(官方只给组件规格,不给启动器里的档位表),而是
//! **给 iced 页面线一个不需要再谈判的默认值**:同一个组件在五个页面里长得一样,
//! 才有用户要的「UI 整体性」。
//!
//! 口径:
//! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`];
//! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`);
//! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换;
//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。
//!
//! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`;
//! 页面线不许在页面里覆盖这些档位。
mod table;
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 组件的排版 + 形状 + 动效档位。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ComponentSpec {
/// 该档位属于哪个组件(表自描述,便于测试与文档生成)。
pub component: Component,
/// 圆角档位。
pub shape: ShapeScale,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。
pub text: TypeRole,
/// 辅助文字角色。
pub support: Option<TypeRole>,
/// 状态过渡缓动。
pub motion: Easing,
/// 状态过渡时长。
pub duration: DurationToken,
/// 按下/选中时的形状形变。
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
TopAppBar,
/// 导航项(侧栏一行)。
NavigationItem,
/// 主操作按钮(实心强调色,每屏最多一个)。
PrimaryButton,
/// 次操作按钮(tonal)。
SecondaryButton,
/// 文本按钮(低权重)。
TextButton,
/// 图标按钮。
IconButton,
/// 描边卡片。
OutlinedCard,
/// 填充卡片(层级高于描边卡)。
FilledCard,
/// 列表行。
ListItem,
/// 文本输入框。
TextField,
/// 分段按钮(磁贴/列表切换、小中大三档)。
SegmentedButton,
/// 贴片/徽标。
Chip,
/// 开关。
Switch,
/// 滑块。
Slider,
/// 进度条。
ProgressIndicator,
/// 对话框。
Dialog,
/// 下拉菜单。
Menu,
/// 底部提示条。
Snackbar,
/// 悬浮提示。
Tooltip,
/// 空态(图标 + 标题 + 说明 + 标准按钮)。
EmptyState,
}
impl Component {
/// 全部组件。
pub const ALL: [Component; 20] = [
Component::TopAppBar,
Component::NavigationItem,
Component::PrimaryButton,
Component::SecondaryButton,
Component::TextButton,
Component::IconButton,
Component::OutlinedCard,
Component::FilledCard,
Component::ListItem,
Component::TextField,
Component::SegmentedButton,
Component::Chip,
Component::Switch,
Component::Slider,
Component::ProgressIndicator,
Component::Dialog,
Component::Menu,
Component::Snackbar,
Component::Tooltip,
Component::EmptyState,
];
/// 该组件的档位。
pub fn spec(self) -> ComponentSpec {
table::SPECS[self as usize]
}
/// 稳定字符串名(kebab-case)。
pub const fn name(self) -> &'static str {
match self {
Component::TopAppBar => "top-app-bar",
Component::NavigationItem => "navigation-item",
Component::PrimaryButton => "primary-button",
Component::SecondaryButton => "secondary-button",
Component::TextButton => "text-button",
Component::IconButton => "icon-button",
Component::OutlinedCard => "outlined-card",
Component::FilledCard => "filled-card",
Component::ListItem => "list-item",
Component::TextField => "text-field",
Component::SegmentedButton => "segmented-button",
Component::Chip => "chip",
Component::Switch => "switch",
Component::Slider => "slider",
Component::ProgressIndicator => "progress-indicator",
Component::Dialog => "dialog",
Component::Menu => "menu",
Component::Snackbar => "snackbar",
Component::Tooltip => "tooltip",
Component::EmptyState => "empty-state",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_matches_component_order() {
for (i, spec) in table::SPECS.iter().enumerate() {
assert_eq!(
spec.component as usize, i,
"SPECS 第 {i} 行顺序与枚举不一致"
);
}
}
#[test]
fn every_component_resolves() {
assert_eq!(Component::ALL.len(), table::SPECS.len());
for component in Component::ALL {
let spec = component.spec();
assert_eq!(spec.component, component);
assert!(spec.text.style().size_px >= 11.0);
}
}
#[test]
fn names_are_unique() {
let mut seen = std::collections::BTreeSet::new();
for component in Component::ALL {
assert!(
seen.insert(component.name()),
"组件名重复:{}",
component.name()
);
}
}
#[test]
fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() {
// 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是
// 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。
let primary = Component::PrimaryButton.spec();
assert_eq!(primary.shape, ShapeScale::Full);
assert_eq!(primary.text, TypeRole::LabelLarge);
// 浮层类必须比卡片层高。
assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation);
assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation);
}
#[test]
fn morphs_always_expand_upwards() {
for component in Component::ALL {
if let Some(morph) = component.spec().morph {
assert_ne!(
morph.from,
morph.to,
"{} 的形变起止不能相同",
component.name()
);
// 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾),
// 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。
assert!(
morph.from.rank() < morph.to.rank(),
"{} 的形变方向反了:{:?} → {:?}",
component.name(),
morph.from,
morph.to
);
}
}
}
}
+256
View File
@@ -0,0 +1,256 @@
//! 组件档位数据表(**下标必须等于 `Component as usize`**,由测试钉死)。
//!
//! 一行一个组件。改值就是在改全站观感——同步协作文档再改。
use super::{Component, ComponentSpec};
use crate::elevation::Elevation;
use crate::motion::{DurationToken, Easing, SPRING_SPATIAL_DEFAULT, SPRING_SPATIAL_FAST};
use crate::shape::{ShapeMorph, ShapeScale};
use crate::typography::TypeRole;
/// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。
const fn spec(
component: Component,
shape: ShapeScale,
elevation: Elevation,
text: TypeRole,
support: Option<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> ComponentSpec {
ComponentSpec {
component,
shape,
elevation,
text,
support,
motion,
duration,
morph,
}
}
/// 20 个组件的档位表。
pub(super) const SPECS: [ComponentSpec; 20] = [
spec(
Component::TopAppBar,
ShapeScale::None,
Elevation::Level0,
TypeRole::TitleLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
spec(
Component::NavigationItem,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::PrimaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Short4,
None,
),
spec(
Component::SecondaryButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::IconButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Full,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::OutlinedCard,
ShapeScale::Medium,
Elevation::Level1,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
None,
),
spec(
Component::FilledCard,
ShapeScale::Medium,
Elevation::Level2,
TypeRole::TitleMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium1,
Some(ShapeMorph::new(
ShapeScale::Medium,
ShapeScale::Large,
SPRING_SPATIAL_DEFAULT,
)),
),
spec(
Component::ListItem,
ShapeScale::Small,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::TextField,
ShapeScale::ExtraSmall,
Elevation::Level0,
TypeRole::BodyLarge,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short4,
None,
),
spec(
Component::SegmentedButton,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelLarge,
None,
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Chip,
ShapeScale::Small,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Standard,
DurationToken::Short3,
Some(ShapeMorph::new(
ShapeScale::ExtraSmall,
ShapeScale::Small,
SPRING_SPATIAL_FAST,
)),
),
spec(
Component::Switch,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Emphasized,
DurationToken::Medium2,
None,
),
spec(
Component::Slider,
ShapeScale::Full,
Elevation::Level0,
TypeRole::BodyMedium,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Short2,
None,
),
spec(
Component::ProgressIndicator,
ShapeScale::Full,
Elevation::Level0,
TypeRole::LabelMedium,
None,
Easing::Linear,
DurationToken::Long2,
None,
),
spec(
Component::Dialog,
ShapeScale::ExtraLarge,
Elevation::Level5,
TypeRole::HeadlineSmall,
Some(TypeRole::BodyMedium),
Easing::EmphasizedDecelerate,
DurationToken::Long4,
Some(ShapeMorph::new(
ShapeScale::Large,
ShapeScale::ExtraLarge,
SPRING_SPATIAL_DEFAULT,
)),
),
spec(
Component::Menu,
ShapeScale::Small,
Elevation::Level4,
TypeRole::BodyMedium,
None,
Easing::StandardDecelerate,
DurationToken::Short4,
None,
),
spec(
Component::Snackbar,
ShapeScale::ExtraSmall,
Elevation::Level3,
TypeRole::BodyMedium,
None,
Easing::EmphasizedDecelerate,
DurationToken::Medium2,
None,
),
spec(
Component::Tooltip,
ShapeScale::ExtraSmall,
Elevation::Level2,
TypeRole::BodySmall,
None,
Easing::StandardDecelerate,
DurationToken::Short2,
None,
),
spec(
Component::EmptyState,
ShapeScale::Large,
Elevation::Level0,
TypeRole::HeadlineSmall,
Some(TypeRole::BodySmall),
Easing::Standard,
DurationToken::Medium2,
None,
),
];