From 65ca0ed29ac2141ffa3175fd7ba515666bf9fd9d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 15:34:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(design):=20=E5=8A=A0=20unlucky-design=20?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=20crate=EF=BC=88M3=20=E8=89=B2=E5=BD=A9/?= =?UTF-8?q?=E6=8E=92=E7=89=88/=E5=BD=A2=E7=8A=B6/=E5=8A=A8=E6=95=88/?= =?UTF-8?q?=E5=9B=BE=E6=A0=87=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 已通告边界)。 --- Cargo.lock | 4 + Cargo.toml | 8 +- crates/design/Cargo.toml | 19 ++ crates/design/src/color/mod.rs | 253 ++++++++++++++++++ crates/design/src/color/palettes.rs | 141 ++++++++++ crates/design/src/color/roles.rs | 276 ++++++++++++++++++++ crates/design/src/color/scheme.rs | 313 +++++++++++++++++++++++ crates/design/src/color/space.rs | 190 ++++++++++++++ crates/design/src/color/state.rs | 96 +++++++ crates/design/src/color/tonal.rs | 146 +++++++++++ crates/design/src/component_map/mod.rs | 216 ++++++++++++++++ crates/design/src/component_map/table.rs | 256 ++++++++++++++++++ crates/design/src/elevation.rs | 226 ++++++++++++++++ crates/design/src/icons/glyphs.rs | 184 +++++++++++++ crates/design/src/icons/kinds.rs | 208 +++++++++++++++ crates/design/src/icons/mod.rs | 250 ++++++++++++++++++ crates/design/src/lib.rs | 95 +++++++ crates/design/src/motion/mod.rs | 305 ++++++++++++++++++++++ crates/design/src/motion/spring.rs | 214 ++++++++++++++++ crates/design/src/shape.rs | 168 ++++++++++++ crates/design/src/spacing.rs | 162 ++++++++++++ crates/design/src/typography/fonts.rs | 181 +++++++++++++ crates/design/src/typography/mod.rs | 308 ++++++++++++++++++++++ crates/design/tests/contrast_aa.rs | 199 ++++++++++++++ crates/design/tests/token_contract.rs | 205 +++++++++++++++ 25 files changed, 4622 insertions(+), 1 deletion(-) create mode 100644 crates/design/Cargo.toml create mode 100644 crates/design/src/color/mod.rs create mode 100644 crates/design/src/color/palettes.rs create mode 100644 crates/design/src/color/roles.rs create mode 100644 crates/design/src/color/scheme.rs create mode 100644 crates/design/src/color/space.rs create mode 100644 crates/design/src/color/state.rs create mode 100644 crates/design/src/color/tonal.rs create mode 100644 crates/design/src/component_map/mod.rs create mode 100644 crates/design/src/component_map/table.rs create mode 100644 crates/design/src/elevation.rs create mode 100644 crates/design/src/icons/glyphs.rs create mode 100644 crates/design/src/icons/kinds.rs create mode 100644 crates/design/src/icons/mod.rs create mode 100644 crates/design/src/lib.rs create mode 100644 crates/design/src/motion/mod.rs create mode 100644 crates/design/src/motion/spring.rs create mode 100644 crates/design/src/shape.rs create mode 100644 crates/design/src/spacing.rs create mode 100644 crates/design/src/typography/fonts.rs create mode 100644 crates/design/src/typography/mod.rs create mode 100644 crates/design/tests/contrast_aa.rs create mode 100644 crates/design/tests/token_contract.rs diff --git a/Cargo.lock b/Cargo.lock index c9f3efe..731a2e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6070,6 +6070,10 @@ dependencies = [ "zip", ] +[[package]] +name = "unlucky-design" +version = "0.1.0" + [[package]] name = "unsafe-libyaml" version = "0.2.11" diff --git a/Cargo.toml b/Cargo.toml index b6308f8..8f2ad0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "2" -members = ["crates/core", "crates/agent", "crates/app", "crates/cli"] +members = [ + "crates/core", + "crates/agent", + "crates/app", + "crates/cli", + "crates/design", +] [workspace.package] version = "0.1.0" diff --git a/crates/design/Cargo.toml b/crates/design/Cargo.toml new file mode 100644 index 0000000..6fcad9a --- /dev/null +++ b/crates/design/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "unlucky-design" +description = "Unlucky-Launcher 设计令牌:M3 Expressive 色彩/排版/形状/高程/动效/图标,纯数据 + 纯函数,零 UI 依赖" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 零 UI 依赖是硬约束(D3 纪律 + M61):本 crate 是渲染无关的**数据与纯函数**, +# 不得出现 iced / slint / winit / wgpu / gtk / egui / tauri 等 UI 或渲染框架, +# 也不引入任何第三方 crate(连 serde 都不加——序列化由消费方适配层自己做)。 +# 证据命令:cargo tree -p unlucky-design --edges normal +[dependencies] + +[lints] +workspace = true diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs new file mode 100644 index 0000000..0d76ca2 --- /dev/null +++ b/crates/design/src/color/mod.rs @@ -0,0 +1,253 @@ +//! # 色彩令牌(M3 color roles) +//! +//! 49 个 M3 语义色角色 × light/dark 两套,全部由**一个种子色**派生([`Palettes`]), +//! 再按官方 tone 映射表解析成具体色值([`Scheme`])。 +//! +//! ```no_run +//! use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor}; +//! +//! let light = Scheme::baseline(SchemeFlavor::Light); +//! let surface = light.get(Role::Surface); +//! let on_surface = light.get(Role::OnSurface); +//! assert!(on_surface.contrast_ratio(surface) > 4.5); +//! +//! // 用户自定义强调色:表面中性色不动,只换强调族。 +//! let custom = Scheme::from_accent(SchemeFlavor::Light, Color::from_hex("#006A5C").unwrap()); +//! assert_eq!(custom.get(Role::Surface), surface); +//! ``` +//! +//! ## 口径声明:这是 M3 **近似**,不是标准 HCT 动态取色 +//! +//! 标准 M3 的调色板建在 **HCT**(CAM16 色相/彩度 + CIELAB `L*`)上。本 crate 不实现 CAM16, +//! 用的是 **OKLCh**(色相/彩度)+ **CIELAB `L*`**(tone 轴)。具体差异: +//! +//! | 轴 | 标准 M3 | 本实现 | 影响 | +//! |---|---|---|---| +//! | tone | CIELAB `L*` | CIELAB `L*`(二分反解 OKLab 明度,误差 ≤1 tone) | **无偏差**:tone 定义严格对齐 | +//! | 色相 | CAM16 hue | OKLCh hue(种子色相原样保留) | 绝对色相角不同,但「同族色相恒定」这个性质成立;派生色(tertiary = +60°)偏移量与 M3 不完全相同 | +//! | 彩度 | CAM16 chroma(如 primary 36 / secondary 16 / neutral 6) | OKLCh chroma(标定值见 [`palettes`]) | 数值量纲不同,语义等价;饱和度观感与官方色板会有可见差别(不是抄错,是量纲换算不通用) | +//! | gamut | CAM16 gamut mapping(按色相压缩彩度) | 按 tone 二分压 OKLCh 彩度进 sRGB | 极端 tone(5/95+)的饱和度可能比官方色板略低 | +//! | 动态色 | Material You 从壁纸取 5 组种子 | 只用**一个**种子 + 固定派生规则 | 没有「多色壁纸取色」能力;本启动器用不到(壁纸取色是安卓端特性) | +//! | 对比度模式 | Dynamic Contrast 三档(standard/medium/high) | 只有 standard | 不提供高对比方案;WCAG AA 在 standard 下已满足(见 [`super::color::contrast`] 与测试) | +//! +//! 结论:**不许对外宣称「标准 M3 动态取色」**。要对比官方色板时按上表解释差异, +//! 不要试图逐值对齐 material-color-utilities 的输出(那需要 CAM16 + HCT,是另一个量级的实现)。 +//! +//! ## 对比度纪律(WCAG AA) +//! +//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**; +//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立** +//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 L*)。 +//! - 例外(显式登记,非缺陷): +//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边), +//! 目标 3:1,实测 light 4.29:1 / dark 6.5:1; +//! - [`Role::OutlineVariant`] 对 [`Role::Surface`]:纯装饰分隔线,M3 不要求对比度下限; +//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。 +//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。 + +pub mod palettes; +pub mod roles; +pub mod scheme; +pub mod space; +pub mod state; +pub mod tonal; + +pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED}; +pub use roles::Role; +pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA}; +pub use state::StateLayer; +pub use tonal::TonalPalette; + +/// sRGB 颜色(8 bit/通道 + alpha),刻意不用第三方颜色类型:本 crate 零依赖。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Color { + /// 红通道。 + pub r: u8, + /// 绿通道。 + pub g: u8, + /// 蓝通道。 + pub b: u8, + /// alpha(0 = 全透明,255 = 不透明)。 + pub a: u8, +} + +impl Color { + /// 不透明颜色。 + pub const fn rgb(r: u8, g: u8, b: u8) -> Self { + Self { r, g, b, a: 255 } + } + + /// 带 alpha 的颜色(`a` 为 0..=255)。 + pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self { + Self { r, g, b, a } + } + + /// 纯黑。 + pub const BLACK: Color = Color::rgb(0, 0, 0); + /// 纯白。 + pub const WHITE: Color = Color::rgb(255, 255, 255); + + /// 从 `#RRGGBB` / `#RGB` / `#AARRGGBB` 解析(不合法返回 `None`)。 + /// + /// 只给配置读取与测试用——UI 代码里**不许出现色字面量**,一律走 [`Role`]。 + pub fn from_hex(text: &str) -> Option { + let hex = text.trim().trim_start_matches('#'); + let parse = |s: &str| u8::from_str_radix(s, 16).ok(); + match hex.len() { + 3 => { + let d = |i: usize| { + let v = parse(&hex[i..i + 1])?; + Some(v * 17) + }; + Some(Self::rgb(d(0)?, d(1)?, d(2)?)) + } + 6 => Some(Self::rgb( + parse(&hex[0..2])?, + parse(&hex[2..4])?, + parse(&hex[4..6])?, + )), + 8 => Some(Self::rgba( + parse(&hex[2..4])?, + parse(&hex[4..6])?, + parse(&hex[6..8])?, + parse(&hex[0..2])?, + )), + _ => None, + } + } + + /// 不透明部分转 `#RRGGBB`(alpha < 255 时附 `#AARRGGBB` 形式)。 + pub fn to_hex(self) -> String { + if self.a == 255 { + format!("#{:02X}{:02X}{:02X}", self.r, self.g, self.b) + } else { + format!("#{:02X}{:02X}{:02X}{:02X}", self.a, self.r, self.g, self.b) + } + } + + /// 改 alpha(`alpha` 0..1,越界夹取)。 + pub fn with_alpha(self, alpha: f32) -> Self { + Self { + a: (alpha.clamp(0.0, 1.0) * 255.0).round() as u8, + ..self + } + } + + /// alpha 归一化到 0..1。 + pub fn alpha(self) -> f32 { + f32::from(self.a) / 255.0 + } + + /// 是否完全不透明。 + pub fn is_opaque(self) -> bool { + self.a == 255 + } + + /// WCAG 相对亮度(0..1,按 sRGB→XYZ 的 Y 行计算)。 + pub fn relative_luminance(self) -> f32 { + let lin = |c: u8| space::srgb_to_linear(f32::from(c) / 255.0); + space::luminance([lin(self.r), lin(self.g), lin(self.b)]) + } + + /// CIELAB `L*`(0..100)——这就是 M3 的 **tone**。 + /// + /// 调色板按这条轴生成,验收也按这条轴断言;想知道某个色值「是几号 tone」就用它。 + pub fn lstar(self) -> f32 { + space::lstar_from_luminance(self.relative_luminance()) + } + + /// WCAG 对比度(1..21)。`self` 与 `other` 都不透明时才严格;带 alpha 时先算「不计 alpha」的值。 + pub fn contrast_ratio(self, other: Self) -> f32 { + let a = self.relative_luminance(); + let b = other.relative_luminance(); + let (hi, lo) = if a > b { (a, b) } else { (b, a) }; + (hi + 0.05) / (lo + 0.05) + } + + /// alpha 合成:`self` 叠在 `bg` 之上,返回不透明结果。 + /// + /// 这是 M3 状态层/禁用态/遮罩的唯一合成口径(非预乘 alpha)。 + pub fn over(self, bg: Self) -> Self { + let sa = self.alpha(); + if sa >= 1.0 { + return self; + } + let blend = |fg: u8, b: u8| { + let v = f32::from(fg) * sa + f32::from(b) * (1.0 - sa); + v.round().clamp(0.0, 255.0) as u8 + }; + let a = sa + bg.alpha() * (1.0 - sa); + Self { + r: blend(self.r, bg.r), + g: blend(self.g, bg.g), + b: blend(self.b, bg.b), + a: (a * 255.0).round().clamp(0.0, 255.0) as u8, + } + } + + /// 两色线性插值(`t` 0..1),用于动画中间帧(GPU 路径的 shape morph 用得上)。 + pub fn lerp(self, other: Self, t: f32) -> Self { + let t = t.clamp(0.0, 1.0); + let mix = |a: u8, b: u8| { + (f32::from(a) + (f32::from(b) - f32::from(a)) * t) + .round() + .clamp(0.0, 255.0) as u8 + }; + Self { + r: mix(self.r, other.r), + g: mix(self.g, other.g), + b: mix(self.b, other.b), + a: mix(self.a, other.a), + } + } + + /// 从线性 RGB(0..1,可越界)构造:先夹取再 sRGB 编码。 + fn from_linear_f32(rgb: [f32; 3]) -> Self { + let enc = |c: f32| { + (space::linear_to_srgb(c.clamp(0.0, 1.0)) * 255.0) + .round() + .clamp(0.0, 255.0) as u8 + }; + Self::rgb(enc(rgb[0]), enc(rgb[1]), enc(rgb[2])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_roundtrip() { + let c = Color::from_hex("#6750A4").expect("合法 hex"); + assert_eq!(c, Color::rgb(0x67, 0x50, 0xA4)); + assert_eq!(c.to_hex(), "#6750A4"); + assert_eq!(Color::from_hex("6750a4"), Some(c)); + assert_eq!(Color::from_hex("#FFF"), Some(Color::WHITE)); + assert_eq!(Color::from_hex("#80FF0000").map(|c| c.a), Some(0x80)); + assert_eq!(Color::from_hex("#12345"), None); + assert_eq!(Color::from_hex("zzzzzz"), None); + } + + #[test] + fn contrast_extremes() { + assert!((Color::BLACK.contrast_ratio(Color::WHITE) - 21.0).abs() < 0.01); + assert!((Color::WHITE.contrast_ratio(Color::WHITE) - 1.0).abs() < 0.001); + } + + #[test] + fn over_composites_correctly() { + let black_50 = Color::BLACK.with_alpha(0.5); + let got = black_50.over(Color::WHITE); + // with_alpha 量化到 128/255 = 0.50196,所以结果落在 127(不是 128)。 + assert_eq!((got.r, got.a), (127, 255)); + } + + #[test] + fn lerp_hits_endpoints() { + let a = Color::rgb(0, 0, 0); + let b = Color::rgb(255, 255, 255); + assert_eq!(a.lerp(b, 0.0), a); + assert_eq!(a.lerp(b, 1.0), b); + assert_eq!(a.lerp(b, 0.5).r, 128); + } +} diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs new file mode 100644 index 0000000..c7c9bbc --- /dev/null +++ b/crates/design/src/color/palettes.rs @@ -0,0 +1,141 @@ +//! 六条 tone 调色板的集合(M3 scheme 的原料)。 +//! +//! 角色 → 调色板槽位 → tone 的三段映射在 [`super::scheme`];这里只负责「种子 → 六条调色板」。 + +use super::tonal::TonalPalette; +use super::Color; + +/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。 +pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); + +/// 调色板槽位(M3 的六条 tonal palette)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaletteSlot { + /// 主调色板(种子色相)。 + Primary, + /// 次调色板(同色相、低彩度)。 + Secondary, + /// 第三调色板(种子色相 +60°,中彩度)。 + Tertiary, + /// 中性调色板(近零彩度的表面色)。 + Neutral, + /// 中性变体调色板(比中性略彩色,做分隔与次级容器)。 + NeutralVariant, + /// 错误调色板(固定红相)。 + Error, +} + +// 下面几个常数是**我们的标定值**(OKLCh 彩度单位),不是 M3 的 CAM16 chroma: +// CAM16 的 chroma 36/16/24/6/8 量纲与本实现不同,直接照抄会得到过饱和的色板。 +/// 次色的彩度上限。 +const SECONDARY_CHROMA: f32 = 0.055; +/// 第三色的彩度上限。 +const TERTIARY_CHROMA: f32 = 0.085; +/// 中性的彩度上限(几乎是灰)。 +const NEUTRAL_CHROMA: f32 = 0.008; +/// 中性变体的彩度上限。 +const NEUTRAL_VARIANT_CHROMA: f32 = 0.016; +/// 错误色的色相(OKLCh 红相)。 +const ERROR_HUE_DEG: f32 = 25.0; +/// 错误色的彩度上限。 +const ERROR_CHROMA: f32 = 0.18; +/// 第三色调色板的色相偏移。 +const TERTIARY_HUE_SHIFT_DEG: f32 = 60.0; + +/// 一套完整的六条调色板。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Palettes { + /// 主要调色板。 + pub primary: TonalPalette, + /// 次要调色板。 + pub secondary: TonalPalette, + /// 第三调色板。 + pub tertiary: TonalPalette, + /// 中性调色板。 + pub neutral: TonalPalette, + /// 中性变体调色板。 + pub neutral_variant: TonalPalette, + /// 错误调色板。 + pub error: TonalPalette, +} + +impl Palettes { + /// 出厂基线(种子 `#6750A4`,等价 M3 baseline)。 + pub fn baseline() -> Self { + Self::from_seed(BASELINE_SEED) + } + + /// 从一个种子色派生全套调色板。 + pub fn from_seed(seed: Color) -> Self { + let primary = TonalPalette::from_color(seed); + Self { + primary, + secondary: primary.limited_chroma(SECONDARY_CHROMA), + tertiary: primary + .rotated(TERTIARY_HUE_SHIFT_DEG) + .limited_chroma(TERTIARY_CHROMA), + neutral: primary.with_chroma(NEUTRAL_CHROMA), + neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA), + error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA), + } + } + + /// 只换强调色:primary/secondary/tertiary 跟着新强调色走,**表面中性色保持不变**。 + /// + /// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏; + /// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。 + pub fn with_accent(self, accent: Color) -> Self { + let accent_primary = TonalPalette::from_color(accent); + Self { + primary: accent_primary, + secondary: accent_primary.limited_chroma(SECONDARY_CHROMA), + tertiary: accent_primary + .rotated(TERTIARY_HUE_SHIFT_DEG) + .limited_chroma(TERTIARY_CHROMA), + ..self + } + } + + /// 按槽位取调色板。 + pub fn slot(&self, slot: PaletteSlot) -> TonalPalette { + match slot { + PaletteSlot::Primary => self.primary, + PaletteSlot::Secondary => self.secondary, + PaletteSlot::Tertiary => self.tertiary, + PaletteSlot::Neutral => self.neutral, + PaletteSlot::NeutralVariant => self.neutral_variant, + PaletteSlot::Error => self.error, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baseline_seed_is_the_primary_palette_hue() { + let palettes = Palettes::baseline(); + let seed_lch = super::super::space::Oklch::from_color(BASELINE_SEED); + let delta = (palettes.primary.hue_deg() - seed_lch.hue_deg).abs(); + assert!(delta < 0.01, "primary 调色板应保持种子色相,偏差 {delta}°"); + } + + #[test] + fn accent_override_keeps_surfaces() { + let base = Palettes::baseline(); + let accent = Color::rgb(0x00, 0x6A, 0x5C); + let swapped = base.with_accent(accent); + assert_eq!(swapped.neutral, base.neutral); + assert_eq!(swapped.neutral_variant, base.neutral_variant); + assert_eq!(swapped.error, base.error); + assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg()); + } + + #[test] + fn neutral_variant_is_less_chromatic_than_primary() { + let palettes = Palettes::baseline(); + assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma()); + assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma()); + } +} diff --git a/crates/design/src/color/roles.rs b/crates/design/src/color/roles.rs new file mode 100644 index 0000000..4466cb7 --- /dev/null +++ b/crates/design/src/color/roles.rs @@ -0,0 +1,276 @@ +//! M3 语义色角色:`Role` 枚举是**唯一的名字清单**(49 个,M3 基础集全覆盖)。 +//! +//! 后续 iced 页面不许另起名字(如 `bg-card` / `fg-primary`)——Fluent 时代的旧名到此为止, +//! 要表达「卡片底」就用 [`Role::SurfaceContainerLow`],要表达「次要文字」就用 +//! [`Role::OnSurfaceVariant`](对应关系见 `DevDocs/SubAgentsTalk/design-令牌约定.md`)。 + +/// M3 语义色角色(49 个)。 +/// +/// 顺序即 [`Role::ALL`] 的顺序,也是 [`super::Scheme`] 内部数组的下标: +/// **只允许在末尾追加**,插到中间会静默改变已存盘的颜色映射。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Role { + /// 主色:主操作填充、选中指示。 + Primary, + /// 主色之上的前景(文字/图标)。 + OnPrimary, + /// 主色容器:次级强调块、选中片。 + PrimaryContainer, + /// 主色容器之上的前景。 + OnPrimaryContainer, + /// 次色:低频强调、图表。 + Secondary, + /// 次色之上的前景。 + OnSecondary, + /// 次色容器。 + SecondaryContainer, + /// 次色容器之上的前景。 + OnSecondaryContainer, + /// 第三色:与主/次形成对比的装饰强调。 + Tertiary, + /// 第三色之上的前景。 + OnTertiary, + /// 第三色容器。 + TertiaryContainer, + /// 第三色容器之上的前景。 + OnTertiaryContainer, + /// 错误色:错误文字、危险操作填充。 + Error, + /// 错误色之上的前景。 + OnError, + /// 错误容器:错误提示底。 + ErrorContainer, + /// 错误容器之上的前景。 + OnErrorContainer, + /// 表面:窗口/内容底色(M3 最新口径里取代 background)。 + Surface, + /// 表面之上的主体文字。 + OnSurface, + /// 表面变体:中性分隔/次级容器(**不是**文字色)。 + SurfaceVariant, + /// 表面变体之上的次级文字/图标。 + OnSurfaceVariant, + /// 背景(M3 已并入 surface,保留以兼容旧值)。 + Background, + /// 背景之上的文本(同上,保留兼容)。 + OnBackground, + /// 变暗的表面(模态背后的压暗层)。 + SurfaceDim, + /// 变亮的表面(深色下的抬高底)。 + SurfaceBright, + /// 最低层容器(比 surface 更低)。 + SurfaceContainerLowest, + /// 低层容器:卡片。 + SurfaceContainerLow, + /// 标准容器:卡片/面板。 + SurfaceContainer, + /// 高层容器:浮层。 + SurfaceContainerHigh, + /// 最高层容器:对话框/菜单。 + SurfaceContainerHighest, + /// 描边:输入框边框等(非文字,3:1 即可)。 + Outline, + /// 弱描边:分隔线(纯装饰)。 + OutlineVariant, + /// 反相表面:Snackbar / Tooltip 底。 + InverseSurface, + /// 反相表面之上的文字。 + InverseOnSurface, + /// 反相表面之上的强调。 + InversePrimary, + /// 阴影色(通常带 alpha,见 [`super::Scheme::shadow`])。 + Shadow, + /// 遮罩色(模态背景,通常带 alpha)。 + Scrim, + /// 表面着色(M3 旧规范的 elevation tint,现已被 surface container 取代)。 + SurfaceTint, + /// 固定主色(深浅模式同值,用于需要跨模式稳定的块)。 + PrimaryFixed, + /// 固定主色的暗档。 + PrimaryFixedDim, + /// 固定主色之上的前景。 + OnPrimaryFixed, + /// 固定主色暗档之上的前景。 + OnPrimaryFixedVariant, + /// 固定次色。 + SecondaryFixed, + /// 固定次色的暗档。 + SecondaryFixedDim, + /// 固定次色之上的前景。 + OnSecondaryFixed, + /// 固定次色暗档之上的前景。 + OnSecondaryFixedVariant, + /// 固定第三色。 + TertiaryFixed, + /// 固定第三色的暗档。 + TertiaryFixedDim, + /// 固定第三色之上的前景。 + OnTertiaryFixed, + /// 固定第三色暗档之上的前景。 + OnTertiaryFixedVariant, +} + +impl Role { + /// 全部 49 个角色,按 `Role as usize` 顺序排列。 + pub const ALL: [Role; 49] = [ + Role::Primary, + Role::OnPrimary, + Role::PrimaryContainer, + Role::OnPrimaryContainer, + Role::Secondary, + Role::OnSecondary, + Role::SecondaryContainer, + Role::OnSecondaryContainer, + Role::Tertiary, + Role::OnTertiary, + Role::TertiaryContainer, + Role::OnTertiaryContainer, + Role::Error, + Role::OnError, + Role::ErrorContainer, + Role::OnErrorContainer, + Role::Surface, + Role::OnSurface, + Role::SurfaceVariant, + Role::OnSurfaceVariant, + Role::Background, + Role::OnBackground, + Role::SurfaceDim, + Role::SurfaceBright, + Role::SurfaceContainerLowest, + Role::SurfaceContainerLow, + Role::SurfaceContainer, + Role::SurfaceContainerHigh, + Role::SurfaceContainerHighest, + Role::Outline, + Role::OutlineVariant, + Role::InverseSurface, + Role::InverseOnSurface, + Role::InversePrimary, + Role::Shadow, + Role::Scrim, + Role::SurfaceTint, + Role::PrimaryFixed, + Role::PrimaryFixedDim, + Role::OnPrimaryFixed, + Role::OnPrimaryFixedVariant, + Role::SecondaryFixed, + Role::SecondaryFixedDim, + Role::OnSecondaryFixed, + Role::OnSecondaryFixedVariant, + Role::TertiaryFixed, + Role::TertiaryFixedDim, + Role::OnTertiaryFixed, + Role::OnTertiaryFixedVariant, + ]; + + /// 稳定字符串名(kebab-case),用于日志、配置、快照对拍与文档表格。 + pub const fn name(self) -> &'static str { + match self { + Role::Primary => "primary", + Role::OnPrimary => "on-primary", + Role::PrimaryContainer => "primary-container", + Role::OnPrimaryContainer => "on-primary-container", + Role::Secondary => "secondary", + Role::OnSecondary => "on-secondary", + Role::SecondaryContainer => "secondary-container", + Role::OnSecondaryContainer => "on-secondary-container", + Role::Tertiary => "tertiary", + Role::OnTertiary => "on-tertiary", + Role::TertiaryContainer => "tertiary-container", + Role::OnTertiaryContainer => "on-tertiary-container", + Role::Error => "error", + Role::OnError => "on-error", + Role::ErrorContainer => "error-container", + Role::OnErrorContainer => "on-error-container", + Role::Surface => "surface", + Role::OnSurface => "on-surface", + Role::SurfaceVariant => "surface-variant", + Role::OnSurfaceVariant => "on-surface-variant", + Role::Background => "background", + Role::OnBackground => "on-background", + Role::SurfaceDim => "surface-dim", + Role::SurfaceBright => "surface-bright", + Role::SurfaceContainerLowest => "surface-container-lowest", + Role::SurfaceContainerLow => "surface-container-low", + Role::SurfaceContainer => "surface-container", + Role::SurfaceContainerHigh => "surface-container-high", + Role::SurfaceContainerHighest => "surface-container-highest", + Role::Outline => "outline", + Role::OutlineVariant => "outline-variant", + Role::InverseSurface => "inverse-surface", + Role::InverseOnSurface => "inverse-on-surface", + Role::InversePrimary => "inverse-primary", + Role::Shadow => "shadow", + Role::Scrim => "scrim", + Role::SurfaceTint => "surface-tint", + Role::PrimaryFixed => "primary-fixed", + Role::PrimaryFixedDim => "primary-fixed-dim", + Role::OnPrimaryFixed => "on-primary-fixed", + Role::OnPrimaryFixedVariant => "on-primary-fixed-variant", + Role::SecondaryFixed => "secondary-fixed", + Role::SecondaryFixedDim => "secondary-fixed-dim", + Role::OnSecondaryFixed => "on-secondary-fixed", + Role::OnSecondaryFixedVariant => "on-secondary-fixed-variant", + Role::TertiaryFixed => "tertiary-fixed", + Role::TertiaryFixedDim => "tertiary-fixed-dim", + Role::OnTertiaryFixed => "on-tertiary-fixed", + Role::OnTertiaryFixedVariant => "on-tertiary-fixed-variant", + } + } + + /// 该角色是否是「文字/图标前景色」(`on-*` 与 `inverse-on-surface` 一类)。 + /// + /// 用于对比度门禁区分两类要求:前景对容器 ≥ 4.5:1(WCAG AA 正文), + /// 非文字元素 ≥ 3:1。 + pub const fn is_foreground(self) -> bool { + matches!( + self, + Role::OnPrimary + | Role::OnPrimaryContainer + | Role::OnSecondary + | Role::OnSecondaryContainer + | Role::OnTertiary + | Role::OnTertiaryContainer + | Role::OnError + | Role::OnErrorContainer + | Role::OnSurface + | Role::OnSurfaceVariant + | Role::OnBackground + | Role::InverseOnSurface + | Role::InversePrimary + | Role::OnPrimaryFixed + | Role::OnPrimaryFixedVariant + | Role::OnSecondaryFixed + | Role::OnSecondaryFixedVariant + | Role::OnTertiaryFixed + | Role::OnTertiaryFixedVariant + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_is_in_enum_order() { + for (i, role) in Role::ALL.iter().enumerate() { + assert_eq!(*role as usize, i, "Role::ALL 顺序必须等于枚举声明顺序"); + } + } + + #[test] + fn names_are_unique_and_kebab() { + let mut seen = std::collections::BTreeSet::new(); + for role in Role::ALL { + let name = role.name(); + assert!(seen.insert(name), "角色名重复:{name}"); + assert!( + name.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "角色名必须是 kebab-case:{name}" + ); + } + } +} diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs new file mode 100644 index 0000000..344fd0d --- /dev/null +++ b/crates/design/src/color/scheme.rs @@ -0,0 +1,313 @@ +//! 一套完整的 M3 语义色方案(`Scheme`):角色 → 调色板槽位 → tone 的三段映射。 +//! +//! tone 表取自 M3 基础方案的官方映射(light/dark 各一列)。固定角色 +//! (`*-fixed*`)深浅同值,因为它们的用途就是「跨模式稳定」。 + +use super::palettes::{PaletteSlot, Palettes}; +use super::roles::Role; +use super::Color; + +/// 深浅模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SchemeFlavor { + /// 浅色(亮底暗字)。 + Light, + /// 深色(暗底亮字)。 + Dark, +} + +impl SchemeFlavor { + /// 是否深色模式。 + pub const fn is_dark(self) -> bool { + matches!(self, SchemeFlavor::Dark) + } + + /// 反过来那一个。 + pub const fn toggled(self) -> Self { + match self { + SchemeFlavor::Light => SchemeFlavor::Dark, + SchemeFlavor::Dark => SchemeFlavor::Light, + } + } + + /// 稳定字符串名(`light` / `dark`),写配置与日志用。 + pub const fn name(self) -> &'static str { + match self { + SchemeFlavor::Light => "light", + SchemeFlavor::Dark => "dark", + } + } +} + +/// 阴影色透明度(M3 阴影 = 纯黑 + 这个 alpha;绘制阴影时叠在背景上)。 +pub const SHADOW_ALPHA: f32 = 0.20; +/// 遮罩色透明度(模态背景压暗层)。 +pub const SCRIM_ALPHA: f32 = 0.32; + +/// 一条「角色 → 槽位 + tone」映射。 +struct ToneRow { + role: Role, + slot: PaletteSlot, + light: f32, + dark: f32, +} + +/// 49 个角色的 tone 映射表,**下标必须等于 `Role as usize`**(由测试钉死)。 +const TONE_TABLE: [ToneRow; 49] = [ + row(Role::Primary, PaletteSlot::Primary, 40.0, 80.0), + row(Role::OnPrimary, PaletteSlot::Primary, 100.0, 20.0), + row(Role::PrimaryContainer, PaletteSlot::Primary, 90.0, 30.0), + row(Role::OnPrimaryContainer, PaletteSlot::Primary, 10.0, 90.0), + row(Role::Secondary, PaletteSlot::Secondary, 40.0, 80.0), + row(Role::OnSecondary, PaletteSlot::Secondary, 100.0, 20.0), + row(Role::SecondaryContainer, PaletteSlot::Secondary, 90.0, 30.0), + row( + Role::OnSecondaryContainer, + PaletteSlot::Secondary, + 10.0, + 90.0, + ), + row(Role::Tertiary, PaletteSlot::Tertiary, 40.0, 80.0), + row(Role::OnTertiary, PaletteSlot::Tertiary, 100.0, 20.0), + row(Role::TertiaryContainer, PaletteSlot::Tertiary, 90.0, 30.0), + row(Role::OnTertiaryContainer, PaletteSlot::Tertiary, 10.0, 90.0), + row(Role::Error, PaletteSlot::Error, 40.0, 80.0), + row(Role::OnError, PaletteSlot::Error, 100.0, 20.0), + row(Role::ErrorContainer, PaletteSlot::Error, 90.0, 30.0), + row(Role::OnErrorContainer, PaletteSlot::Error, 10.0, 90.0), + row(Role::Surface, PaletteSlot::Neutral, 98.0, 6.0), + row(Role::OnSurface, PaletteSlot::Neutral, 10.0, 90.0), + row( + Role::SurfaceVariant, + PaletteSlot::NeutralVariant, + 90.0, + 30.0, + ), + row( + Role::OnSurfaceVariant, + PaletteSlot::NeutralVariant, + 30.0, + 80.0, + ), + row(Role::Background, PaletteSlot::Neutral, 99.0, 10.0), + row(Role::OnBackground, PaletteSlot::Neutral, 10.0, 90.0), + row(Role::SurfaceDim, PaletteSlot::Neutral, 87.0, 6.0), + row(Role::SurfaceBright, PaletteSlot::Neutral, 98.0, 24.0), + row( + Role::SurfaceContainerLowest, + PaletteSlot::Neutral, + 100.0, + 4.0, + ), + row(Role::SurfaceContainerLow, PaletteSlot::Neutral, 96.0, 10.0), + row(Role::SurfaceContainer, PaletteSlot::Neutral, 94.0, 12.0), + row(Role::SurfaceContainerHigh, PaletteSlot::Neutral, 92.0, 17.0), + row( + Role::SurfaceContainerHighest, + PaletteSlot::Neutral, + 90.0, + 22.0, + ), + row(Role::Outline, PaletteSlot::NeutralVariant, 50.0, 60.0), + row( + Role::OutlineVariant, + PaletteSlot::NeutralVariant, + 80.0, + 30.0, + ), + row(Role::InverseSurface, PaletteSlot::Neutral, 20.0, 90.0), + row(Role::InverseOnSurface, PaletteSlot::Neutral, 95.0, 20.0), + row(Role::InversePrimary, PaletteSlot::Primary, 80.0, 40.0), + row(Role::Shadow, PaletteSlot::Neutral, 0.0, 0.0), + row(Role::Scrim, PaletteSlot::Neutral, 0.0, 0.0), + row(Role::SurfaceTint, PaletteSlot::Primary, 40.0, 80.0), + row(Role::PrimaryFixed, PaletteSlot::Primary, 90.0, 90.0), + row(Role::PrimaryFixedDim, PaletteSlot::Primary, 80.0, 80.0), + row(Role::OnPrimaryFixed, PaletteSlot::Primary, 10.0, 10.0), + row( + Role::OnPrimaryFixedVariant, + PaletteSlot::Primary, + 30.0, + 30.0, + ), + row(Role::SecondaryFixed, PaletteSlot::Secondary, 90.0, 90.0), + row(Role::SecondaryFixedDim, PaletteSlot::Secondary, 80.0, 80.0), + row(Role::OnSecondaryFixed, PaletteSlot::Secondary, 10.0, 10.0), + row( + Role::OnSecondaryFixedVariant, + PaletteSlot::Secondary, + 30.0, + 30.0, + ), + row(Role::TertiaryFixed, PaletteSlot::Tertiary, 90.0, 90.0), + row(Role::TertiaryFixedDim, PaletteSlot::Tertiary, 80.0, 80.0), + row(Role::OnTertiaryFixed, PaletteSlot::Tertiary, 10.0, 10.0), + row( + Role::OnTertiaryFixedVariant, + PaletteSlot::Tertiary, + 30.0, + 30.0, + ), +]; + +/// `const` 友好的行构造器(数组初始化里不能调用非 const fn 之外的东西)。 +const fn row(role: Role, slot: PaletteSlot, light: f32, dark: f32) -> ToneRow { + ToneRow { + role, + slot, + light, + dark, + } +} + +/// 一套语义色(49 个角色,全部已解析成具体颜色)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Scheme { + flavor: SchemeFlavor, + colors: [Color; 49], +} + +impl Scheme { + /// 出厂基线方案(种子 `#6750A4`)。 + pub fn baseline(flavor: SchemeFlavor) -> Self { + Self::from_palettes(&Palettes::baseline(), flavor) + } + + /// 指定种子色的方案(表面色与强调色同源)。 + pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self { + Self::from_palettes(&Palettes::from_seed(seed), flavor) + } + + /// 只覆盖强调色(表面中性色仍走基线,见 [`Palettes::with_accent`])。 + pub fn from_accent(flavor: SchemeFlavor, accent: Color) -> Self { + Self::from_palettes(&Palettes::baseline().with_accent(accent), flavor) + } + + /// 直接从调色板集合构建(高级入口:整仓测试与自定义 scheme 用)。 + pub fn from_palettes(palettes: &Palettes, flavor: SchemeFlavor) -> Self { + let colors = std::array::from_fn(|i| { + let entry = &TONE_TABLE[i]; + let tone = match flavor { + SchemeFlavor::Light => entry.light, + SchemeFlavor::Dark => entry.dark, + }; + palettes.slot(entry.slot).tone(tone) + }); + Self { flavor, colors } + } + + /// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。 + pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f32 { + let entry = &TONE_TABLE[role as usize]; + debug_assert_eq!(entry.role, role, "TONE_TABLE 下标必须等于角色序号"); + match flavor { + SchemeFlavor::Light => entry.light, + SchemeFlavor::Dark => entry.dark, + } + } + + /// 取角色色值。 + pub fn get(&self, role: Role) -> Color { + self.colors[role as usize] + } + + /// 当前模式。 + pub fn flavor(&self) -> SchemeFlavor { + self.flavor + } + + /// 按角色顺序遍历(`for (role, color) in scheme.iter()`)。 + pub fn iter(&self) -> impl Iterator + '_ { + Role::ALL.iter().copied().map(|role| (role, self.get(role))) + } + + /// 阴影色(纯黑 + [`SHADOW_ALPHA`])。 + pub fn shadow(&self) -> Color { + self.get(Role::Shadow).with_alpha(SHADOW_ALPHA) + } + + /// 遮罩色(纯黑 + [`SCRIM_ALPHA`])。 + pub fn scrim(&self) -> Color { + self.get(Role::Scrim).with_alpha(SCRIM_ALPHA) + } + + /// 两个角色的 WCAG 对比度(1..21)。 + pub fn contrast(&self, fg: Role, bg: Role) -> f32 { + self.get(fg).contrast_ratio(self.get(bg)) + } +} + +impl std::ops::Index for Scheme { + type Output = Color; + + fn index(&self, role: Role) -> &Color { + &self.colors[role as usize] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tone_table_matches_role_order() { + for (i, entry) in TONE_TABLE.iter().enumerate() { + assert_eq!( + entry.role as usize, + i, + "TONE_TABLE 第 {i} 行是 {},顺序与 Role 枚举不一致", + entry.role.name() + ); + } + } + + #[test] + fn fixed_roles_are_identical_across_flavors() { + let light = Scheme::baseline(SchemeFlavor::Light); + let dark = Scheme::baseline(SchemeFlavor::Dark); + for role in [ + Role::PrimaryFixed, + Role::PrimaryFixedDim, + Role::OnPrimaryFixed, + Role::OnPrimaryFixedVariant, + Role::SecondaryFixed, + Role::TertiaryFixedDim, + ] { + assert_eq!( + light.get(role), + dark.get(role), + "{} 应跨模式同值", + role.name() + ); + } + } + + #[test] + fn light_and_dark_differ_on_text_pairs() { + let light = Scheme::baseline(SchemeFlavor::Light); + let dark = Scheme::baseline(SchemeFlavor::Dark); + assert_ne!(light.get(Role::OnSurface), dark.get(Role::OnSurface)); + assert!(light.get(Role::Surface).relative_luminance() > 0.8); + assert!(dark.get(Role::Surface).relative_luminance() < 0.05); + } + + #[test] + fn shadow_and_scrim_carry_alpha() { + let scheme = Scheme::baseline(SchemeFlavor::Light); + assert_eq!(scheme.shadow().a, (SHADOW_ALPHA * 255.0).round() as u8); + assert_eq!(scheme.scrim().a, (SCRIM_ALPHA * 255.0).round() as u8); + } + + #[test] + fn accent_override_changes_primary_only() { + let base = Scheme::baseline(SchemeFlavor::Light); + let teal = Scheme::from_accent(SchemeFlavor::Light, Color::rgb(0x00, 0x6A, 0x5C)); + assert_ne!(base.get(Role::Primary), teal.get(Role::Primary)); + assert_eq!(base.get(Role::Surface), teal.get(Role::Surface)); + assert_eq!( + base.get(Role::Outline), + teal.get(Role::Outline), + "强调色覆盖不该动中性描边" + ); + } +} diff --git a/crates/design/src/color/space.rs b/crates/design/src/color/space.rs new file mode 100644 index 0000000..d474037 --- /dev/null +++ b/crates/design/src/color/space.rs @@ -0,0 +1,190 @@ +//! 色彩空间转换:sRGB ↔ 线性 RGB ↔ CIELAB(tone 轴)+ OKLab/OKLCh(色相/彩度轴)。 +//! +//! 这里只放**纯数学**,不认识任何语义色角色。口径说明见 [`super`] 的模块文档。 + +use super::Color; + +/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。 +const GAMUT_EPS: f32 = 1.0e-4; + +/// sRGB 编码值(0..1)→ 线性光强。 +pub(crate) fn srgb_to_linear(c: f32) -> f32 { + if c <= 0.040_45 { + c / 12.92 + } else { + ((c + 0.055) / 1.055).powf(2.4) + } +} + +/// 线性光强 → sRGB 编码值(0..1),结果不夹取,越界由调用方处理。 +pub(crate) fn linear_to_srgb(c: f32) -> f32 { + if c <= 0.003_130_8 { + c * 12.92 + } else { + 1.055 * c.powf(1.0 / 2.4) - 0.055 + } +} + +/// 线性 RGB → CIE XYZ(D65 白点,sRGB 原色)。 +pub(crate) fn linear_to_xyz(rgb: [f32; 3]) -> [f32; 3] { + let [r, g, b] = rgb; + [ + 0.412_456_4 * r + 0.357_576_1 * g + 0.180_437_5 * b, + 0.212_672_9 * r + 0.715_152_2 * g + 0.072_175_0 * b, + 0.019_333_9 * r + 0.119_192 * g + 0.950_304_1 * b, + ] +} + +/// 相对亮度 Y(WCAG 与 CIELAB 共用这条 Y,白点 1.0)。 +pub(crate) fn luminance(rgb: [f32; 3]) -> f32 { + linear_to_xyz(rgb)[1] +} + +/// CIELAB `L*`(M3 的 **tone** 就是这个轴,0 = 黑 / 100 = 白)。 +pub(crate) fn lstar_from_luminance(y: f32) -> f32 { + let t = y.max(0.0); + if t > 0.008_856 { + 116.0 * t.cbrt() - 16.0 + } else { + 903.296_3 * t + } +} + +/// `L*` → 相对亮度 Y([`lstar_from_luminance`] 的逆)。 +pub(crate) fn luminance_from_lstar(lstar: f32) -> f32 { + let l = lstar.clamp(0.0, 100.0); + if l > 8.0 { + ((l + 16.0) / 116.0).powi(3) + } else { + l / 903.296_3 + } +} + +/// 线性 RGB → OKLab。 +pub(crate) fn linear_to_oklab(rgb: [f32; 3]) -> [f32; 3] { + let [r, g, b] = rgb; + let l = 0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_99 * b; + let m = 0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b; + let s = 0.088_302_46 * r + 0.281_718_84 * g + 0.629_978_7 * b; + let (l_, m_, s_) = (l.cbrt(), m.cbrt(), s.cbrt()); + [ + 0.210_454_26 * l_ + 0.793_617_8 * m_ - 0.004_072_047 * s_, + 1.977_998_5 * l_ - 2.428_592_2 * m_ + 0.450_593_7 * s_, + 0.025_904_037 * l_ + 0.782_771_77 * m_ - 0.808_675_77 * s_, + ] +} + +/// OKLab → 线性 RGB(可能越界,负值表示超出 sRGB gamut)。 +pub(crate) fn oklab_to_linear(lab: [f32; 3]) -> [f32; 3] { + let [l, a, b] = lab; + let l_ = l + 0.396_337_78 * a + 0.215_803_76 * b; + let m_ = l - 0.105_561_346 * a - 0.063_854_17 * b; + let s_ = l - 0.089_484_18 * a - 1.291_485_5 * b; + let (l3, m3, s3) = (l_ * l_ * l_, m_ * m_ * m_, s_ * s_ * s_); + [ + 4.076_741_7 * l3 - 3.307_711_6 * m3 + 0.230_969_94 * s3, + -1.268_438 * l3 + 2.609_757_4 * m3 - 0.341_319_38 * s3, + -0.004_196_086_3 * l3 - 0.703_418_6 * m3 + 1.707_614_7 * s3, + ] +} + +/// OKLCh → 线性 RGB。 +pub(crate) fn oklch_to_linear(l: f32, chroma: f32, hue_deg: f32) -> [f32; 3] { + let h = hue_deg.to_radians(); + oklab_to_linear([l, chroma * h.cos(), chroma * h.sin()]) +} + +/// 线性 RGB 是否落在 sRGB 立方体内([`GAMUT_EPS`] 容差)。 +pub(crate) fn in_gamut(rgb: [f32; 3]) -> bool { + rgb.iter() + .all(|c| *c >= -GAMUT_EPS && *c <= 1.0 + GAMUT_EPS) +} + +/// 求「在保证 `L* = lstar` 的前提下,OKLab 明度该取多少」。 +/// +/// OKLab 的 L 不是 Y 的纯函数(彩色会偏离),所以给定 tone 与色相/彩度时用二分反解, +/// 让最终颜色的 **CIELAB L\* 精确等于 tone**——M3 的 tone 定义在 L\* 上,这一点必须对齐。 +pub(crate) fn oklab_lightness_for_tone(chroma: f32, hue_deg: f32, lstar: f32) -> f32 { + let target = luminance_from_lstar(lstar); + let mut lo = 0.0_f32; + let mut hi = 1.0_f32; + for _ in 0..32 { + let mid = 0.5 * (lo + hi); + if luminance(oklch_to_linear(mid, chroma, hue_deg)) < target { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +/// 在该 tone / 色相下,把彩度压进 sRGB gamut 后能取到的最大值(上限 `chroma_max`)。 +pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32 { + let hi0 = chroma_max.max(0.0); + if in_gamut(oklch_to_linear( + oklab_lightness_for_tone(hi0, hue_deg, lstar), + hi0, + hue_deg, + )) { + return hi0; + } + let mut lo = 0.0_f32; + let mut hi = hi0; + for _ in 0..24 { + let mid = 0.5 * (lo + hi); + let ok = in_gamut(oklch_to_linear( + oklab_lightness_for_tone(mid, hue_deg, lstar), + mid, + hue_deg, + )); + if ok { + lo = mid; + } else { + hi = mid; + } + } + lo +} + +/// OKLCh 坐标(色相用度,0..360)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Oklch { + /// 明度 0..1(中性灰时 ≈ 相对亮度的立方根)。 + pub lightness: f32, + /// 彩度(0 = 中性灰)。 + pub chroma: f32, + /// 色相角,单位度,0..360。 + pub hue_deg: f32, +} + +impl Oklch { + /// 从一个 sRGB 颜色取色相/彩度(用作调色板的种子)。 + pub fn from_color(color: Color) -> Self { + let lin = color.to_linear_f32(); + let [l, a, b] = linear_to_oklab(lin); + let mut hue = b.atan2(a).to_degrees(); + if hue < 0.0 { + hue += 360.0; + } + Self { + lightness: l, + chroma: (a * a + b * b).sqrt(), + hue_deg: hue, + } + } + + /// 在色相环上旋转(用于派生 tertiary 这类同族但不同色的调色板)。 + pub fn rotated(mut self, deg: f32) -> Self { + self.hue_deg = (self.hue_deg + deg).rem_euclid(360.0); + self + } +} + +impl Color { + /// 线性光强三元组(0..1,越界不夹取)。 + fn to_linear_f32(self) -> [f32; 3] { + let f = |c: u8| srgb_to_linear(f32::from(c) / 255.0); + [f(self.r), f(self.g), f(self.b)] + } +} diff --git a/crates/design/src/color/state.rs b/crates/design/src/color/state.rs new file mode 100644 index 0000000..c76c08b --- /dev/null +++ b/crates/design/src/color/state.rs @@ -0,0 +1,96 @@ +//! M3 状态层(state layer)与禁用态透明度。 +//! +//! M3 的 hover/pressed 不是「另一种灰」,而是**内容色自身 + 固定 alpha** 叠在容器上—— +//! 所以同一套令牌在任何底色上都成立,不必为每种底色各配一套(这条从 Fluent 时代沿用, +//! 见 `DevDocs/SubAgentsTalk/ui-integration-contract.md` §2 的三态叠加层思路)。 + +use super::Color; + +/// 状态层的四种状态与对应 alpha(M3 规范值)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StateLayer { + /// 悬停:内容色 8%。 + Hover, + /// 键盘焦点:内容色 10%。 + Focus, + /// 按下:内容色 10%。 + Pressed, + /// 拖拽中:内容色 16%。 + Dragged, +} + +impl StateLayer { + /// 叠加透明度(0..1)。 + pub const fn alpha(self) -> f32 { + match self { + StateLayer::Hover => 0.08, + StateLayer::Focus | StateLayer::Pressed => 0.10, + StateLayer::Dragged => 0.16, + } + } + + /// 把状态层叠到容器色上,得到**最终不透明色**。 + /// + /// `content` 一般是容器上的前景角色(例如 FilledButton 用 `on-primary`, + /// 中性表面上的按钮用 `on-surface`)。 + pub fn over(self, content: Color, container: Color) -> Color { + content.with_alpha(self.alpha()).over(container) + } +} + +/// 禁用态的透明度(M3):容器 12%、内容 38%。 +/// +/// 别用「灰化」近似——深色模式下灰化会降低对比度,alpha 叠加两种模式都成立。 +pub const DISABLED_CONTAINER_ALPHA: f32 = 0.12; +/// 禁用态内容(文字/图标)透明度。 +pub const DISABLED_CONTENT_ALPHA: f32 = 0.38; + +/// 禁用态容器色:`on_surface` 12% 叠在 `surface` 上。 +pub fn disabled_container(scheme: &super::Scheme) -> Color { + scheme + .get(super::Role::OnSurface) + .with_alpha(DISABLED_CONTAINER_ALPHA) + .over(scheme.get(super::Role::Surface)) +} + +/// 禁用态内容色:`on_surface` 38% 叠在容器上。 +pub fn disabled_content(scheme: &super::Scheme, container: Color) -> Color { + scheme + .get(super::Role::OnSurface) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over(container) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::{Role, Scheme, SchemeFlavor}; + + #[test] + fn hover_is_lighter_than_pressed_on_dark_container() { + let scheme = Scheme::baseline(SchemeFlavor::Dark); + let container = scheme.get(Role::SurfaceContainer); + let content = scheme.get(Role::OnSurface); + let hover = StateLayer::Hover.over(content, container); + let pressed = StateLayer::Pressed.over(content, container); + assert_eq!(hover.a, 255); + assert!(pressed.relative_luminance() > hover.relative_luminance()); + } + + #[test] + fn state_layer_alpha_matches_m3() { + assert!((StateLayer::Hover.alpha() - 0.08).abs() < f32::EPSILON); + assert!((StateLayer::Dragged.alpha() - 0.16).abs() < f32::EPSILON); + } + + #[test] + fn disabled_content_stays_visible() { + let scheme = Scheme::baseline(SchemeFlavor::Light); + let container = disabled_container(&scheme); + let content = disabled_content(&scheme, container); + // 禁用态不追求 AA(规范就是这么定的),但必须仍可辨(> 1.5:1)。 + let ratio = content.contrast_ratio(container); + assert!(ratio > 1.5, "禁用态对比度过低:{ratio}"); + assert!(ratio < 4.5, "禁用态不该和正文一样醒目:{ratio}"); + } +} diff --git a/crates/design/src/color/tonal.rs b/crates/design/src/color/tonal.rs new file mode 100644 index 0000000..8249b93 --- /dev/null +++ b/crates/design/src/color/tonal.rs @@ -0,0 +1,146 @@ +//! tone 调色板:一个种子色 → 一条「固定色相/彩度、tone 任取」的调色板。 +//! +//! 这是 M3 tonal palette 的**近似实现**:色相恒定、按 tone 取色、按 tone 压彩度进 sRGB gamut。 +//! 口径与已知偏差见 [`super`] 模块文档(OKLCh 而非 CAM16/HCT)。 + +use super::space::{self, Oklch}; +use super::Color; + +/// 一条 tone 调色板(色相固定 + 彩度上限)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TonalPalette { + hue_deg: f32, + chroma: f32, +} + +impl TonalPalette { + /// 直接给色相(度)与彩度上限(OKLCh 单位,0 = 中性灰)建调色板。 + pub fn new(hue_deg: f32, chroma: f32) -> Self { + Self { + hue_deg: hue_deg.rem_euclid(360.0), + chroma: chroma.max(0.0), + } + } + + /// 从种子色取色相与彩度(等价 M3 的 `palette from seed`)。 + pub fn from_color(seed: Color) -> Self { + let lch = Oklch::from_color(seed); + Self::new(lch.hue_deg, lch.chroma) + } + + /// 固定色相,改彩度上限(用于把种子彩度压到 secondary/neutral 档)。 + pub fn with_chroma(self, chroma: f32) -> Self { + Self::new(self.hue_deg, chroma) + } + + /// 彩度不超过 `limit`(种子本身更灰时保持更灰,不硬拉饱和)。 + pub fn limited_chroma(self, limit: f32) -> Self { + self.with_chroma(self.chroma.min(limit)) + } + + /// 旋转色相(derived hue)。 + pub fn rotated(self, deg: f32) -> Self { + Self::new(self.hue_deg + deg, self.chroma) + } + + /// 该调色板的色相(度)。 + pub fn hue_deg(self) -> f32 { + self.hue_deg + } + + /// 该调色板的彩度上限(OKLCh 单位)。 + pub fn chroma(self) -> f32 { + self.chroma + } + + /// 取某个 tone 的颜色(tone 0..100,超出夹取)。 + /// + /// 实现口径:CIELAB `L*` 精确等于 tone(M3 的 tone 定义),彩度按该 tone 的 gamut 上限 + /// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度 + /// 与色相无关(这是对比度门禁能跨种子稳定的原因)。 + pub fn tone(self, tone: f32) -> Color { + let lstar = tone.clamp(0.0, 100.0); + // 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。 + if lstar <= 0.0 { + return Color::BLACK; + } + if lstar >= 100.0 { + return Color::WHITE; + } + let chroma = space::chroma_in_gamut(lstar, self.hue_deg, self.chroma); + let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar); + Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SEED: Color = Color::rgb(0x67, 0x50, 0xA4); + + fn lstar_of(color: Color) -> f32 { + color.lstar() + } + + #[test] + fn tone_matches_lstar_within_one_unit() { + let palette = TonalPalette::from_color(SEED); + for tone in [ + 0.0_f32, 6.0, 10.0, 30.0, 40.0, 50.0, 80.0, 90.0, 95.0, 98.0, 100.0, + ] { + let got = lstar_of(palette.tone(tone)); + assert!( + (got - tone).abs() <= 1.0, + "tone {tone} 的 L* 是 {got},偏差超过 1(量化误差上限)" + ); + } + } + + #[test] + fn extremes_are_pure_black_and_white() { + let palette = TonalPalette::from_color(SEED); + assert_eq!(palette.tone(0.0), Color::rgb(0, 0, 0)); + assert_eq!(palette.tone(100.0), Color::rgb(255, 255, 255)); + assert_eq!(palette.tone(-5.0), Color::rgb(0, 0, 0)); + assert_eq!(palette.tone(120.0), Color::rgb(255, 255, 255)); + } + + #[test] + fn seed_hue_is_preserved_across_tones() { + let palette = TonalPalette::from_color(SEED); + for tone in [30.0_f32, 50.0, 70.0] { + let lch = Oklch::from_color(palette.tone(tone)); + let delta = (lch.hue_deg - palette.hue_deg()).abs(); + let delta = delta.min(360.0 - delta); + assert!(delta < 3.0, "tone {tone} 色相偏移 {delta}° 过大"); + } + } + + #[test] + fn neutral_palette_is_gray() { + let neutral = TonalPalette::new(120.0, 0.0); + let c = neutral.tone(50.0); + let spread = f32::from(c.r.max(c.g).max(c.b) - c.r.min(c.g).min(c.b)); + assert!( + spread <= 1.0, + "彩度 0 的调色板应当接近中性灰,实测通道差 {spread}" + ); + } + + #[test] + fn chroma_never_leaves_gamut() { + // 高彩度种子在极端 tone 上必须被裁到 gamut 内(不出现「负 RGB」削顶后的色带)。 + let hot = TonalPalette::new(30.0, 0.4); + for tone in [5.0_f32, 20.0, 40.0, 60.0, 80.0, 95.0] { + let lch = Oklch::from_color(hot.tone(tone)); + assert!( + lch.chroma <= 0.4 + 1.0e-3, + "tone {tone} 彩度 {} 超出请求上限", + lch.chroma + ); + let got = lstar_of(hot.tone(tone)); + assert!((got - tone).abs() <= 1.0, "tone {tone} 的 L* 是 {got}"); + } + } +} diff --git a/crates/design/src/component_map/mod.rs b/crates/design/src/component_map/mod.rs new file mode 100644 index 0000000..a1404e3 --- /dev/null +++ b/crates/design/src/component_map/mod.rs @@ -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, + /// 状态过渡缓动。 + pub motion: Easing, + /// 状态过渡时长。 + pub duration: DurationToken, + /// 按下/选中时的形状形变。 + pub morph: Option, +} + +/// 需要固定档位的组件清单(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 + ); + } + } + } +} diff --git a/crates/design/src/component_map/table.rs b/crates/design/src/component_map/table.rs new file mode 100644 index 0000000..243dbe5 --- /dev/null +++ b/crates/design/src/component_map/table.rs @@ -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, + motion: Easing, + duration: DurationToken, + morph: Option, +) -> 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, + ), +]; diff --git a/crates/design/src/elevation.rs b/crates/design/src/elevation.rs new file mode 100644 index 0000000..80f2bf2 --- /dev/null +++ b/crates/design/src/elevation.rs @@ -0,0 +1,226 @@ +//! 高程令牌:elevation level、阴影参数、surface container 映射。 +//! +//! M3 在 2024 之后改了「怎么表达高程」:**不再**往表面上叠半透明的 surface tint, +//! 而是用 `surface-container-*` 这一族中性容器色表达层级(GPU 路径再叠阴影做空间感)。 +//! 本模块两种口径都给出,旧口径显式标为 deprecated,避免页面线混用。 + +use crate::color::Role; + +/// M3 高程级别 0..5(对应 0 / 1 / 3 / 6 / 8 / 12 dp)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Elevation { + /// 0dp:贴底内容(窗口底、内联区)。 + Level0, + /// 1dp:卡片、可抬起的列表项。 + Level1, + /// 3dp:可交互卡片、导航栏。 + Level2, + /// 6dp:FAB、侧栏、浮动工具条。 + Level3, + /// 8dp:菜单、popover。 + Level4, + /// 12dp:对话框、模态。 + Level5, +} + +impl Elevation { + /// 全部级别。 + pub const ALL: [Elevation; 6] = [ + Elevation::Level0, + Elevation::Level1, + Elevation::Level2, + Elevation::Level3, + Elevation::Level4, + Elevation::Level5, + ]; + + /// 标称高度(dp)。 + pub const fn dp(self) -> f32 { + match self { + Elevation::Level0 => 0.0, + Elevation::Level1 => 1.0, + Elevation::Level2 => 3.0, + Elevation::Level3 => 6.0, + Elevation::Level4 => 8.0, + Elevation::Level5 => 12.0, + } + } + + /// 该级别应使用的表面容器色角色(M3 现行口径)。 + /// + /// 层级靠**换底色**表达,不靠 tint;浅深模式都成立。 + pub const fn surface_role(self) -> Role { + match self { + Elevation::Level0 => Role::SurfaceContainerLowest, + Elevation::Level1 => Role::SurfaceContainerLow, + Elevation::Level2 => Role::SurfaceContainer, + Elevation::Level3 => Role::SurfaceContainerHigh, + Elevation::Level4 | Elevation::Level5 => Role::SurfaceContainerHighest, + } + } + + /// 阴影参数(**占位值,待 M63 视觉规格** — 见下面的 TODO)。 + /// + /// TODO(M63): 取值待视觉规格。官方只给 dp 与设计稿示意,没有可移植的 blur/alpha 公式; + /// 这里的 blur/offset/alpha 是临时占位(按「blur ≈ dp × 1.3 + 1.5」凑的数), + /// M63 的玻璃规格表落地后以那张表为准,别在页面里引用这些绝对值。 + pub const fn shadow(self) -> ShadowSpec { + match self { + Elevation::Level0 => ShadowSpec { + blur_px: 0.0, + offset_y_px: 0.0, + alpha: 0.0, + }, + Elevation::Level1 => ShadowSpec { + blur_px: 3.0, + offset_y_px: 1.0, + alpha: 0.12, + }, + Elevation::Level2 => ShadowSpec { + blur_px: 6.0, + offset_y_px: 2.0, + alpha: 0.14, + }, + Elevation::Level3 => ShadowSpec { + blur_px: 9.0, + offset_y_px: 4.0, + alpha: 0.16, + }, + Elevation::Level4 => ShadowSpec { + blur_px: 12.0, + offset_y_px: 6.0, + alpha: 0.18, + }, + Elevation::Level5 => ShadowSpec { + blur_px: 17.0, + offset_y_px: 8.0, + alpha: 0.20, + }, + } + } + + /// 旧口径(M3 2022):把 `surface-tint` 按这个 alpha 叠在 `surface` 上表达高程。 + /// + /// **deprecated**——现行 M3 用 [`Elevation::surface_role`];保留只为读旧稿时能对上数, + /// 新代码不要用。 + pub const fn deprecated_tint_alpha(self) -> f32 { + match self { + Elevation::Level0 => 0.0, + Elevation::Level1 => 0.05, + Elevation::Level2 => 0.08, + Elevation::Level3 => 0.11, + Elevation::Level4 => 0.12, + Elevation::Level5 => 0.14, + } + } +} + +/// 一层阴影的绘制参数。 +/// +/// TODO(M63): 取值待视觉规格(这份结构只是「接口预留」,数值由 M63 的玻璃规格表填充)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShadowSpec { + /// 高斯模糊半径(px)。 + pub blur_px: f32, + /// 向下偏移(px)。 + pub offset_y_px: f32, + /// 阴影色 alpha(叠在 [`crate::color::Scheme::shadow`] 之上)。 + pub alpha: f32, +} + +impl ShadowSpec { + /// 是否可见(0 表示这一级不画阴影)。 + pub fn is_visible(&self) -> bool { + self.alpha > 0.0 && self.blur_px > 0.0 + } +} + +/// 三个「材质层」的语义(沿用 `ui-integration-contract.md` §2 的三层,映射到 M3 令牌)。 +/// +/// 页面线按层取色,不许自己决定「这一层比那一层深一点」。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaterialLayer { + /// 底层(canvas):窗口底,永远不透明。 + Canvas, + /// 卡片层:页面内容/设置分组/AI 面板。 + Card, + /// 浮层:对话框、菜单、Toast。 + Overlay, +} + +impl MaterialLayer { + /// 该层的底色角色。 + pub const fn surface_role(self) -> Role { + match self { + MaterialLayer::Canvas => Role::Surface, + MaterialLayer::Card => Role::SurfaceContainerLow, + MaterialLayer::Overlay => Role::SurfaceContainerHigh, + } + } + + /// 该层的高程级别。 + pub const fn elevation(self) -> Elevation { + match self { + MaterialLayer::Canvas => Elevation::Level0, + MaterialLayer::Card => Elevation::Level1, + MaterialLayer::Overlay => Elevation::Level3, + } + } + + /// 该层是否允许半透明(D27:**只有 GPU 路径**才允许;CPU 路径一律纯色)。 + pub const fn allows_translucency(self) -> bool { + matches!(self, MaterialLayer::Card | MaterialLayer::Overlay) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn elevation_dp_ladder() { + let ladder: Vec = Elevation::ALL.iter().map(|e| e.dp()).collect(); + assert_eq!(ladder, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]); + } + + #[test] + fn shadow_grows_with_elevation() { + let mut prev = Elevation::Level0.shadow(); + for level in &Elevation::ALL[1..] { + let cur = level.shadow(); + assert!(cur.blur_px > prev.blur_px, "{level:?} 的模糊应更大"); + assert!(cur.offset_y_px >= prev.offset_y_px); + assert!(cur.alpha >= prev.alpha); + assert!(cur.is_visible()); + prev = cur; + } + assert!(!Elevation::Level0.shadow().is_visible()); + } + + #[test] + fn higher_levels_use_higher_surface_containers() { + assert_eq!( + Elevation::Level5.surface_role(), + Role::SurfaceContainerHighest + ); + assert_eq!( + Elevation::Level0.surface_role(), + Role::SurfaceContainerLowest + ); + } + + #[test] + fn material_layers_are_ordered() { + assert_eq!(MaterialLayer::Canvas.surface_role(), Role::Surface); + assert_eq!( + MaterialLayer::Card.surface_role(), + Role::SurfaceContainerLow + ); + assert_eq!( + MaterialLayer::Overlay.surface_role(), + Role::SurfaceContainerHigh + ); + assert!(!MaterialLayer::Canvas.allows_translucency()); + assert!(MaterialLayer::Overlay.allows_translucency()); + } +} diff --git a/crates/design/src/icons/glyphs.rs b/crates/design/src/icons/glyphs.rs new file mode 100644 index 0000000..c692fa3 --- /dev/null +++ b/crates/design/src/icons/glyphs.rs @@ -0,0 +1,184 @@ +//! 图标几何数据:24 网格、1.5px 视觉描边的 SVG path 字符串。 +//! +//! 源流:`DevDocs/ops/slint-reference/crates/app/ui/components/icons.slint`(M54 重画过一轮的 +//! Fluent 2 线性几何)。命令集沿用 `M/L/H/V/A/C/Q/Z`,与 SVG 路径语法一致,可直接交给 +//! 任何 SVG path 解析器(iced 侧是 `iced::widget::canvas` 的 `Path`/`lyon`)。 +//! +//! 这里只有数据:不许在数据里写颜色、尺寸、dpi——那些由渲染层决定。 + +use super::{Icon, IconKind, IconPaint}; + +/// 全部图标几何,**下标必须等于 `IconKind as usize`**(由测试钉死)。 +pub(super) const GLYPHS: [Icon; 28] = [ + // 实例:2×2 圆角方块(原 Slint 版是 4 个 Rectangle 描边,这里折成一条 path) + Icon::data( + IconKind::Instances, + "M 5 3 H 8 A 2 2 0 0 1 10 5 V 8 A 2 2 0 0 1 8 10 H 5 A 2 2 0 0 1 3 8 V 5 A 2 2 0 0 1 5 3 Z \ + M 16 3 H 19 A 2 2 0 0 1 21 5 V 8 A 2 2 0 0 1 19 10 H 16 A 2 2 0 0 1 14 8 V 5 A 2 2 0 0 1 16 3 Z \ + M 5 14 H 8 A 2 2 0 0 1 10 16 V 19 A 2 2 0 0 1 8 21 H 5 A 2 2 0 0 1 3 19 V 16 A 2 2 0 0 1 5 14 Z \ + M 16 14 H 19 A 2 2 0 0 1 21 16 V 19 A 2 2 0 0 1 19 21 H 16 A 2 2 0 0 1 14 19 V 16 A 2 2 0 0 1 16 14 Z", + IconPaint::Stroke, + ), + // 下载:箭头入托盘 + Icon::data( + IconKind::Download, + "M 12 3.5 V 15 M 7 10.5 L 12 15.5 L 17 10.5 M 4.5 20.5 H 19.5", + IconPaint::Stroke, + ), + // 日志:卷角文档 + 两行正文(不是三横——三横读作「菜单」) + Icon::data( + IconKind::Logs, + "M 6 3.5 H 14 L 18.5 8 V 20.5 H 6 Z M 14 3.5 V 8 H 18.5 M 9 12.5 H 15.5 M 9 16.5 H 15.5", + IconPaint::Stroke, + ), + // 设置:八齿齿轮 + 中心孔 + Icon::data( + IconKind::Settings, + "M 18.89 14.11 L 21.51 13.34 L 21.51 10.66 L 18.89 9.89 A 7.2 7.2 0 0 1 18.36 8.62 \ + L 19.67 6.22 L 17.78 4.33 L 15.38 5.64 A 7.2 7.2 0 0 1 14.11 5.11 L 13.34 2.49 L 10.66 2.49 \ + L 9.89 5.11 A 7.2 7.2 0 0 1 8.62 5.64 L 6.22 4.33 L 4.33 6.22 L 5.64 8.62 A 7.2 7.2 0 0 1 5.11 9.89 \ + L 2.49 10.66 L 2.49 13.34 L 5.11 14.11 A 7.2 7.2 0 0 1 5.64 15.38 L 4.33 17.78 L 6.22 19.67 \ + L 8.62 18.36 A 7.2 7.2 0 0 1 9.89 18.89 L 10.66 21.51 L 13.34 21.51 L 14.11 18.89 A 7.2 7.2 0 0 1 15.38 18.36 \ + L 17.78 19.67 L 19.67 17.78 L 18.36 15.38 A 7.2 7.2 0 0 1 18.89 14.11 Z \ + M 8.8 12 A 3.2 3.2 0 1 0 15.2 12 A 3.2 3.2 0 1 0 8.8 12 Z", + IconPaint::Stroke, + ), + // AI:四角星(凹边四角星,不是直线菱形) + Icon::data( + IconKind::Ai, + "M 12 3 Q 13.6 10.4 21 12 Q 13.6 13.6 12 21 Q 10.4 13.6 3 12 Q 10.4 10.4 12 3 Z", + IconPaint::Stroke, + ), + // 播放 / 停止:实心(与文字并排时不至于太轻) + Icon::data( + IconKind::Play, + "M 7.5 4.5 L 19 12 L 7.5 19.5 Z", + IconPaint::Fill, + ), + Icon::data( + IconKind::Stop, + "M 8 7 H 16 A 1 1 0 0 1 17 8 V 16 A 1 1 0 0 1 16 17 H 8 A 1 1 0 0 1 7 16 V 8 A 1 1 0 0 1 8 7 Z", + IconPaint::Fill, + ), + // 搜索:放大镜 + Icon::data( + IconKind::Search, + "M 10.5 4 A 6.5 6.5 0 1 0 10.5 17 A 6.5 6.5 0 1 0 10.5 4 Z M 15.4 15.4 L 20.5 20.5", + IconPaint::Stroke, + ), + Icon::data( + IconKind::Plus, + "M 12 5 V 19 M 5 12 H 19", + IconPaint::Stroke, + ), + Icon::data( + IconKind::Close, + "M 6.5 6.5 L 17.5 17.5 M 17.5 6.5 L 6.5 17.5", + IconPaint::Stroke, + ), + Icon::data(IconKind::Minimize, "M 6 12 H 18", IconPaint::Stroke), + Icon::data( + IconKind::Maximize, + "M 7.5 5.5 H 16.5 A 2 2 0 0 1 18.5 7.5 V 16.5 A 2 2 0 0 1 16.5 18.5 H 7.5 \ + A 2 2 0 0 1 5.5 16.5 V 7.5 A 2 2 0 0 1 7.5 5.5 Z", + IconPaint::Stroke, + ), + Icon::data( + IconKind::ChevronLeft, + "M 14.5 5.5 L 8 12 L 14.5 18.5", + IconPaint::Stroke, + ), + Icon::data( + IconKind::ChevronRight, + "M 9.5 5.5 L 16 12 L 9.5 18.5", + IconPaint::Stroke, + ), + Icon::data( + IconKind::ChevronDown, + "M 5.5 9.5 L 12 16 L 18.5 9.5", + IconPaint::Stroke, + ), + // 展开为独立页:双对角外箭头 + Icon::data( + IconKind::Expand, + "M 14 4 H 20 V 10 M 20 4 L 13.5 10.5 M 10 20 H 4 V 14 M 4 20 L 10.5 13.5", + IconPaint::Stroke, + ), + // 发送:纸飞机 + Icon::data( + IconKind::Send, + "M 21 3 L 3 10.5 L 10.2 13.6 L 13.4 20.5 Z M 10.2 13.6 L 21 3", + IconPaint::Stroke, + ), + Icon::data( + IconKind::Check, + "M 5 12.5 L 9.8 17.3 L 19 6.5", + IconPaint::Stroke, + ), + Icon::data( + IconKind::Warn, + "M 12 4.5 L 20.5 19.5 H 3.5 Z M 12 9.5 V 14 M 12 16.6 V 17", + IconPaint::Stroke, + ), + Icon::data( + IconKind::Error, + "M 12 4 A 8 8 0 1 0 12 20 A 8 8 0 1 0 12 4 Z M 12 7.5 V 13 M 12 16.6 V 17", + IconPaint::Stroke, + ), + // 更多:三圆点(实心,原 Slint 版是三个 filled Rectangle) + Icon::data( + IconKind::More, + "M 7.5 12 A 1.5 1.5 0 1 0 4.5 12 A 1.5 1.5 0 1 0 7.5 12 Z \ + M 13.5 12 A 1.5 1.5 0 1 0 10.5 12 A 1.5 1.5 0 1 0 13.5 12 Z \ + M 19.5 12 A 1.5 1.5 0 1 0 16.5 12 A 1.5 1.5 0 1 0 19.5 12 Z", + IconPaint::Fill, + ), + // 侧栏:面板框 + 左侧分栏线 + Icon::data( + IconKind::Sidebar, + "M 6 5 H 18 A 2 2 0 0 1 20 7 V 17 A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 V 7 \ + A 2 2 0 0 1 6 5 Z M 9.5 5.5 V 18.5", + IconPaint::Stroke, + ), + // 还原(已最大化时的标题栏按钮):前后两层方框 + Icon::data( + IconKind::Restore, + "M 9 4.5 H 19.5 V 15 M 15 8.5 H 4.5 V 19.5 H 15 Z", + IconPaint::Stroke, + ), + // 账户占位:头像剪影 + Icon::data( + IconKind::User, + "M 12 5.3 A 3.7 3.7 0 1 0 12 12.7 A 3.7 3.7 0 1 0 12 5.3 Z \ + M 4.5 20.5 C 4.5 16.9 8 15 12 15 C 16 15 19.5 16.9 19.5 20.5", + IconPaint::Stroke, + ), + // ── M61 新增(追加在末尾,不动既有次序;iced 页面需要而 Slint 版没有的)── + // 刷新:顺时针单圈 + 右上角箭头 + Icon::data( + IconKind::Refresh, + "M 20 12 A 8 8 0 1 1 12 4 C 14.2 4 16.3 4.9 17.8 6.4 L 20 8.5 \ + M 20 4.5 V 8.5 H 16", + IconPaint::Stroke, + ), + // 筛选:漏斗 + Icon::data( + IconKind::Filter, + "M 4.5 5.5 H 19.5 L 14 12.5 V 19 L 10 16.5 V 12.5 Z", + IconPaint::Stroke, + ), + // 目录:文件夹(实例目录 / 日志目录入口) + Icon::data( + IconKind::Folder, + "M 4 7.5 A 2 2 0 0 1 6 5.5 H 9.5 L 11.5 7.5 H 18 A 2 2 0 0 1 20 9.5 V 17 \ + A 2 2 0 0 1 18 19 H 6 A 2 2 0 0 1 4 17 Z", + IconPaint::Stroke, + ), + // 删除:垃圾桶 + Icon::data( + IconKind::Trash, + "M 4.5 7 H 19.5 M 9.5 7 V 4.5 H 14.5 V 7 M 6.8 7 L 7.8 19.5 H 16.2 L 17.2 7 \ + M 10.5 10.5 V 16 M 13.5 10.5 V 16", + IconPaint::Stroke, + ), +]; diff --git a/crates/design/src/icons/kinds.rs b/crates/design/src/icons/kinds.rs new file mode 100644 index 0000000..d736996 --- /dev/null +++ b/crates/design/src/icons/kinds.rs @@ -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() + ); + } + } +} diff --git a/crates/design/src/icons/mod.rs b/crates/design/src/icons/mod.rs new file mode 100644 index 0000000..056933e --- /dev/null +++ b/crates/design/src/icons/mod.rs @@ -0,0 +1,250 @@ +//! 图标令牌:`IconKind`(一次冻结,只允许在末尾追加)+ 24 网格几何数据。 +//! +//! ```no_run +//! use unlucky_design::icons::{IconKind, IconSize}; +//! +//! let icon = IconKind::Settings.icon(); +//! let w = icon.stroke_width(IconSize::Nav.px()); +//! assert!((w - 1.8).abs() < 1e-6); +//! ``` +//! +//! ## 几何口径(三线统一,M54 定稿) +//! +//! - **网格 24×24**([`VIEWBOX`]),内容留 2~3 单位边距; +//! - **视觉描边 1.5px**([`VISUAL_STROKE_PX`]):不管渲染多大,线宽看起来都是 1.5 逻辑像素; +//! - **圆头端点 + 圆角连接**(`stroke-linecap: round` / `stroke-linejoin: round`), +//! 适配层设这两个属性,数据里不表达; +//! - **实心图形才用 fill**(播放/停止/三点),其余一律描边,保证与文字并排时重量一致; +//! - 不用字体字符当图标(`+` `→` 一律禁止)。 +//! +//! ## ⚠️ 描边语义差异:Slint 与 SVG **不是一回事** +//! +//! - Slint 的 `Path.stroke-width` 是**绝对像素**:只乘 DPI 缩放,**不乘 viewBox**。 +//! 所以老代码写 `stroke-width: 1.5px` 意味着「渲染出来就是 1.5px」,viewBox 缩放对它无影响 +//! (实证:femtovg 后端 `itemrenderer.rs:343`)。 +//! - SVG / iced canvas 的 `stroke-width` 是**用户单位**(viewBox 单位):渲染时会被 +//! `render_px / VIEWBOX` 缩放。同样的 `1.5` 在 20px 渲染下只有 1.25px 视觉宽度。 +//! +//! 因此 iced 侧必须**按 SVG 语义重算**:要保持 1.5px 视觉线宽,viewBox 里的 stroke-width 得取 +//! `1.5 × 24 / render_px`(20px → 1.8、16px → 2.25、24px → 1.5)。[`Icon::stroke_width`] 就是这个 +//! 换算的纯函数——**不要在适配层手抄 1.5 这个数**,那是 Slint 口径。 + +mod glyphs; +mod kinds; + +pub use kinds::IconKind; + +/// 图标网格边长(viewBox 宽高)。 +pub const VIEWBOX: f32 = 24.0; + +/// 视觉描边宽度(逻辑像素,与渲染尺寸无关)。 +pub const VISUAL_STROKE_PX: f32 = 1.5; + +/// 描边语义换算:给定渲染尺寸与目标视觉线宽,返回 **viewBox 单位**的 stroke-width。 +/// +/// `render_px <= 0` 时返回 `visual_px`(退化情形不产生除零/无穷大)。 +pub fn stroke_width_for(render_px: f32, visual_px: f32) -> f32 { + if render_px <= 0.0 { + return visual_px; + } + visual_px * VIEWBOX / render_px +} + +/// 反向换算:viewBox 单位的 stroke-width 在实际渲染尺寸下的视觉线宽。 +pub fn visual_stroke_px(render_px: f32, stroke_width: f32) -> f32 { + stroke_width * render_px / VIEWBOX +} + +/// 图标的绘制方式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IconPaint { + /// 只描边(默认,绝大多数图标)。 + Stroke, + /// 填充(播放/停止/三点这类实心图形)。 + Fill, +} + +/// 图标的标准渲染尺寸(页面只从这几档里取,不许自定义零散值)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum IconSize { + /// 12px:行内小图标(徽标内)。 + Inline, + /// 16px:按钮/标题栏按钮。 + Button, + /// 20px:导航项、列表行。 + Nav, + /// 24px:卡片头、工具栏。 + Toolbar, + /// 32px:空态图标块。 + Empty, +} + +impl IconSize { + /// 像素值。 + pub const fn px(self) -> f32 { + match self { + IconSize::Inline => 12.0, + IconSize::Button => 16.0, + IconSize::Nav => 20.0, + IconSize::Toolbar => 24.0, + IconSize::Empty => 32.0, + } + } + + /// 全部档位,由小到大。 + pub const ALL: [IconSize; 5] = [ + IconSize::Inline, + IconSize::Button, + IconSize::Nav, + IconSize::Toolbar, + IconSize::Empty, + ]; +} + +/// 一个图标的几何数据。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Icon { + /// 图标种类。 + pub kind: IconKind, + /// SVG path 数据(viewBox `0 0 24 24`,可能含多个子路径)。 + pub path: &'static str, + /// 描边还是填充。 + pub paint: IconPaint, +} + +impl Icon { + /// 表项构造器(只在 [`glyphs`] 的数据表里用)。 + pub(super) const fn data(kind: IconKind, path: &'static str, paint: IconPaint) -> Self { + Self { kind, path, paint } + } + + /// 该图标在 `render_px` 下的 **viewBox 单位** stroke-width([`IconPaint::Fill`] 返回 0)。 + pub fn stroke_width(self, render_px: f32) -> f32 { + match self.paint { + IconPaint::Fill => 0.0, + IconPaint::Stroke => stroke_width_for(render_px, VISUAL_STROKE_PX), + } + } + + /// 是否实心填充。 + pub fn is_filled(self) -> bool { + self.paint == IconPaint::Fill + } + + /// 路径里是否含曲线命令(适配层可选:纯直线路径走不走曲线细分)。 + pub fn has_curves(self) -> bool { + self.path + .bytes() + .any(|b| matches!(b, b'C' | b'Q' | b'A' | b'c' | b'q' | b'a')) + } + + /// 子路径个数(`M` 命令计数):适配层可以据此一次性分配 `lyon` 的缓冲区。 + pub fn subpath_count(self) -> usize { + self.path.matches('M').count() + self.path.matches('m').count() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_use_only_svg_commands() { + const ALLOWED: &str = "MmLlHhVvAaCcQqZz0123456789 .,-"; + for kind in IconKind::ALL { + let path = kind.icon().path; + assert!(path.starts_with('M'), "{} 的路径必须以 M 开头", kind.name()); + for byte in path.bytes() { + assert!( + byte.is_ascii(), + "{} 的路径含非 ASCII(不许用字体字符/中文)", + kind.name() + ); + } + for ch in path.chars() { + assert!( + ALLOWED.contains(ch), + "{} 的路径含非法字符 {ch:?}", + kind.name() + ); + } + // 不许出现科学计数法(能解析但可读性差、易手误)。 + assert!( + !path.contains('e') && !path.contains('E'), + "{} 用了科学计数法", + kind.name() + ); + assert!( + path.matches(' ').count() >= 2, + "{} 的坐标分隔异常", + kind.name() + ); + } + } + + #[test] + fn coordinates_stay_inside_the_grid() { + for kind in IconKind::ALL { + for token in kind.icon().path.split([' ', ',']) { + if let Ok(value) = token.parse::() { + assert!( + (-1.0..=VIEWBOX + 1.0).contains(&value), + "{} 出现 {value}:超出 24 网格(弧半径/控制点也受这条约束)", + kind.name() + ); + } + } + } + } + + #[test] + fn subpaths_are_counted() { + assert_eq!(IconKind::Plus.icon().subpath_count(), 2); + assert_eq!(IconKind::Instances.icon().subpath_count(), 4); + assert_eq!(IconKind::Play.icon().subpath_count(), 1); + } + + #[test] + fn filled_icons_have_no_stroke() { + for kind in IconKind::ALL { + let icon = kind.icon(); + if icon.is_filled() { + assert_eq!(icon.stroke_width(20.0), 0.0, "{}", kind.name()); + } else { + assert!(icon.stroke_width(20.0) > 1.0, "{}", kind.name()); + } + } + } + + #[test] + fn stroke_width_follows_svg_semantics() { + // SVG 语义:viewBox 单位 × (render/24) = 视觉像素。 + assert!((stroke_width_for(20.0, VISUAL_STROKE_PX) - 1.8).abs() < 1.0e-6); + assert!((stroke_width_for(16.0, VISUAL_STROKE_PX) - 2.25).abs() < 1.0e-6); + assert!((stroke_width_for(24.0, VISUAL_STROKE_PX) - 1.5).abs() < 1.0e-6); + assert!((stroke_width_for(48.0, VISUAL_STROKE_PX) - 0.75).abs() < 1.0e-6); + // 退化输入不炸、不产生无穷大(Slint 口径的 1.5 绝对值这里不能照抄)。 + assert_eq!(stroke_width_for(0.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX); + assert_eq!(stroke_width_for(-4.0, VISUAL_STROKE_PX), VISUAL_STROKE_PX); + // 往返一致。 + for size in IconSize::ALL { + let w = stroke_width_for(size.px(), VISUAL_STROKE_PX); + assert!((visual_stroke_px(size.px(), w) - VISUAL_STROKE_PX).abs() < 1.0e-6); + } + } + + #[test] + fn icon_sizes_are_ordered() { + let px: Vec = IconSize::ALL.iter().map(|s| s.px()).collect(); + for pair in px.windows(2) { + assert!(pair[0] < pair[1]); + } + } + + #[test] + fn curve_flags_are_reported() { + assert!(IconKind::Settings.icon().has_curves()); + assert!(!IconKind::Plus.icon().has_curves()); + assert!(!IconKind::ChevronDown.icon().has_curves()); + } +} diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs new file mode 100644 index 0000000..616494b --- /dev/null +++ b/crates/design/src/lib.rs @@ -0,0 +1,95 @@ +//! # unlucky-design · ULL 设计令牌唯一真源 +//! +//! 视觉基线 = **Material 3 Expressive**(D26 锁定,风格与框架不再更换)。这个 crate 只装 +//! **渲染无关的数据与纯函数**:色彩角色、排版 ramp、形状刻度、高程、动效参数、图标几何。 +//! 它不依赖任何 UI / 渲染框架(iced / slint / winit / wgpu / gtk …),也不依赖任何第三方 crate, +//! 因此 iced 迁移、CLI、甚至离屏截图工具都能吃同一套值——这是「UI 整体性」的地基。 +//! +//! ## 边界(谁负责什么) +//! +//! | 层 | 负责 | 不负责 | +//! |---|---|---| +//! | `unlucky-design`(本 crate) | 令牌的值与它们之间的纯函数关系(tone→色、type role→字号/行高/字重、shape→半径、spring→时长) | 布局、控件、绘制、字体加载、图片、动画驱动 | +//! | iced 适配层(后续 mission) | 把令牌翻成 iced 的类型(`Color` / `Length` / `Font` / 动画时钟) | 自己发明新令牌名或新取值 | +//! | 页面 mission | 消费令牌与 [`component_map`] 的档位约定 | 硬编码色值 / 字号 / 半径 / 时长 | +//! +//! ## 分模块 +//! +//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档) +//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈 +//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 +//! - [`elevation`]:elevation level 与 surface tint / surface container 规则 +//! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数 +//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分) +//! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义 +//! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体) +//! +//! ## 最小用法 +//! +//! ``` +//! use unlucky_design::color::{Role, Scheme, SchemeFlavor}; +//! use unlucky_design::typography::{FontFamily, Platform, TypeRole}; +//! +//! let light = Scheme::baseline(SchemeFlavor::Light); +//! let ratio = light.contrast(Role::OnSurface, Role::Surface); +//! assert!(ratio >= 4.5, "正文对表面必须满足 WCAG AA"); +//! +//! let title = TypeRole::TitleLarge.style(); +//! assert_eq!(title.size_px, 22.0); +//! let stack = Platform::current().font_stack(FontFamily::Display); +//! assert!(!stack.is_empty()); +//! ``` + +#![forbid(unsafe_code)] + +pub mod color; +pub mod component_map; +pub mod elevation; +pub mod icons; +pub mod motion; +pub mod shape; +pub mod spacing; +pub mod typography; + +pub use color::{Color, Role, Scheme, SchemeFlavor}; +pub use motion::MotionScale; +pub use typography::TypeRole; + +/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。 +/// +/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`), +/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。 +#[derive(Debug, Clone, Copy)] +pub struct DesignTokens { + /// 当前深浅模式。 + pub flavor: SchemeFlavor, + /// 当前模式的 M3 语义色角色全表。 + pub colors: Scheme, + /// 动效时长的全局缩放(`1.0` = 标准;用户关掉动画时适配层传 `0.0`)。 + pub motion_scale: MotionScale, +} + +impl DesignTokens { + /// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。 + pub fn baseline(flavor: SchemeFlavor) -> Self { + Self { + flavor, + colors: Scheme::baseline(flavor), + motion_scale: MotionScale::STANDARD, + } + } + + /// 按用户强调色重建一套令牌(表面中性色仍走 baseline,强调色只驱动 primary/secondary/tertiary)。 + pub fn with_accent(flavor: SchemeFlavor, accent: Color) -> Self { + Self { + flavor, + colors: Scheme::from_accent(flavor, accent), + motion_scale: MotionScale::STANDARD, + } + } + + /// 便捷取色:`tokens.color(Role::Surface)`。 + pub fn color(&self, role: Role) -> Color { + self.colors.get(role) + } +} diff --git a/crates/design/src/motion/mod.rs b/crates/design/src/motion/mod.rs new file mode 100644 index 0000000..13fd512 --- /dev/null +++ b/crates/design/src/motion/mod.rs @@ -0,0 +1,305 @@ +//! 动效令牌:时长、缓动(可采样)、M3 Expressive 弹簧参数。 +//! +//! 三件东西分开表达,因为适配层要的接口不同: +//! - [`DurationToken`]:M3 的 short/medium/long/extra-long 十六档,给「固定时长 + easing」用; +//! - [`Easing`]:cubic-bezier 曲线,纯函数采样(适配层自己决定帧率); +//! - [`Spring`](见 [`spring`]):Expressive 的物理参数(stiffness/damping/mass), +//! 可用于实时求解或预估时长。 +//! +//! **CPU / 减少动效路径**:把 [`MotionScale`] 设成 [`MotionScale::NONE`], +//! 所有时长归零、`Easing::sample` 仍返回 0/1 端值(适配层按 `t >= 1` 直接落终态)。 + +mod spring; + +pub use spring::{ + MotionScale, Spring, SPRING_EFFECTS_DEFAULT, SPRING_EFFECTS_FAST, SPRING_SPATIAL_DEFAULT, + SPRING_SPATIAL_FAST, +}; + +/// 动效时长档位(M3 动效令牌,单位毫秒)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DurationToken { + /// 50ms:最小状态回弹。 + Short1, + /// 100ms:小图标/勾选。 + Short2, + /// 150ms:开关、小尺寸 fade。 + Short3, + /// 200ms:按钮状态、hover。 + Short4, + /// 250ms:小卡片展开。 + Medium1, + /// 300ms:导航指示条、tab 切换。 + Medium2, + /// 350ms:卡片展开、列表重排。 + Medium3, + /// 400ms:面板开合。 + Medium4, + /// 450ms:页面切换。 + Long1, + /// 500ms:大面板进出。 + Long2, + /// 550ms:全屏浮层。 + Long3, + /// 600ms:对话框进出。 + Long4, + /// 700ms:结构化过渡。 + ExtraLong1, + /// 800ms:结构化过渡。 + ExtraLong2, + /// 900ms:结构化过渡。 + ExtraLong3, + /// 1000ms:整屏形变(shape morph 的慢档)。 + ExtraLong4, +} + +impl DurationToken { + /// 毫秒值。 + pub const fn ms(self) -> u32 { + match self { + DurationToken::Short1 => 50, + DurationToken::Short2 => 100, + DurationToken::Short3 => 150, + DurationToken::Short4 => 200, + DurationToken::Medium1 => 250, + DurationToken::Medium2 => 300, + DurationToken::Medium3 => 350, + DurationToken::Medium4 => 400, + DurationToken::Long1 => 450, + DurationToken::Long2 => 500, + DurationToken::Long3 => 550, + DurationToken::Long4 => 600, + DurationToken::ExtraLong1 => 700, + DurationToken::ExtraLong2 => 800, + DurationToken::ExtraLong3 => 900, + DurationToken::ExtraLong4 => 1000, + } + } + + /// 稳定字符串名(`short1` … `extra-long4`),写配置与日志用。 + pub const fn name(self) -> &'static str { + match self { + DurationToken::Short1 => "short1", + DurationToken::Short2 => "short2", + DurationToken::Short3 => "short3", + DurationToken::Short4 => "short4", + DurationToken::Medium1 => "medium1", + DurationToken::Medium2 => "medium2", + DurationToken::Medium3 => "medium3", + DurationToken::Medium4 => "medium4", + DurationToken::Long1 => "long1", + DurationToken::Long2 => "long2", + DurationToken::Long3 => "long3", + DurationToken::Long4 => "long4", + DurationToken::ExtraLong1 => "extra-long1", + DurationToken::ExtraLong2 => "extra-long2", + DurationToken::ExtraLong3 => "extra-long3", + DurationToken::ExtraLong4 => "extra-long4", + } + } +} + +/// 三次贝塞尔缓动曲线(CSS `cubic-bezier` 同样的参数含义)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CubicBezier { + /// 控制点 1 的 x(0..1)。 + pub x1: f32, + /// 控制点 1 的 y。 + pub y1: f32, + /// 控制点 2 的 x(0..1)。 + pub x2: f32, + /// 控制点 2 的 y。 + pub y2: f32, +} + +impl CubicBezier { + /// 线性。 + pub const LINEAR: Self = Self { + x1: 0.0, + y1: 0.0, + x2: 1.0, + y2: 1.0, + }; + + /// 给定时间参数 `t`(0..1),返回进度(0..1,标准曲线不越界)。 + pub fn sample(self, t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + if t == 0.0 || t == 1.0 { + return t; + } + // 二分求 x(s) = t 的曲线参数 s,再取 y(s)。 + let mut lo = 0.0_f32; + let mut hi = 1.0_f32; + let mut s = t; + for _ in 0..24 { + s = 0.5 * (lo + hi); + let x = bezier_axis(self.x1, self.x2, s); + if x < t { + lo = s; + } else { + hi = s; + } + } + bezier_axis(self.y1, self.y2, s) + } +} + +/// 一维三次贝塞尔:端点固定为 0/1,控制点为 `c1`/`c2`。 +fn bezier_axis(c1: f32, c2: f32, s: f32) -> f32 { + let u = 1.0 - s; + 3.0 * u * u * s * c1 + 3.0 * u * s * s * c2 + s * s * s +} + +/// M3 缓动档位(值取自 Material 动效令牌)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Easing { + /// 线性(进度指示器)。 + Linear, + /// 标准:起止都平滑。 + Standard, + /// 标准减速:进场(出现在屏幕上)。 + StandardDecelerate, + /// 标准加速:退场。 + StandardAccelerate, + /// 强调:注意力转移的主曲线。 + Emphasized, + /// 强调减速:强调类进场。 + EmphasizedDecelerate, + /// 强调加速:强调类退场。 + EmphasizedAccelerate, +} + +impl Easing { + /// 曲线参数。 + pub const fn curve(self) -> CubicBezier { + match self { + Easing::Linear => CubicBezier::LINEAR, + Easing::Standard | Easing::Emphasized => CubicBezier { + x1: 0.2, + y1: 0.0, + x2: 0.0, + y2: 1.0, + }, + Easing::StandardDecelerate => CubicBezier { + x1: 0.0, + y1: 0.0, + x2: 0.0, + y2: 1.0, + }, + Easing::StandardAccelerate => CubicBezier { + x1: 0.3, + y1: 0.0, + x2: 1.0, + y2: 1.0, + }, + Easing::EmphasizedDecelerate => CubicBezier { + x1: 0.05, + y1: 0.7, + x2: 0.1, + y2: 1.0, + }, + Easing::EmphasizedAccelerate => CubicBezier { + x1: 0.3, + y1: 0.0, + x2: 0.8, + y2: 0.15, + }, + } + } + + /// 采样:`t` 0..1 → 进度 0..1。 + pub fn sample(self, t: f32) -> f32 { + self.curve().sample(t) + } + + /// 该曲线搭配的常用时长(我们的组件映射口径,非规范值)。 + pub const fn default_duration(self) -> DurationToken { + match self { + Easing::Linear => DurationToken::Long2, + _ => DurationToken::Medium2, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALL_EASINGS: [Easing; 7] = [ + Easing::Linear, + Easing::Standard, + Easing::StandardDecelerate, + Easing::StandardAccelerate, + Easing::Emphasized, + Easing::EmphasizedDecelerate, + Easing::EmphasizedAccelerate, + ]; + + #[test] + fn durations_are_monotone() { + let ladder = [ + DurationToken::Short1, + DurationToken::Short2, + DurationToken::Short3, + DurationToken::Short4, + DurationToken::Medium1, + DurationToken::Medium2, + DurationToken::Medium3, + DurationToken::Medium4, + DurationToken::Long1, + DurationToken::Long2, + DurationToken::Long3, + DurationToken::Long4, + DurationToken::ExtraLong1, + DurationToken::ExtraLong2, + DurationToken::ExtraLong3, + DurationToken::ExtraLong4, + ]; + assert_eq!(ladder.len(), 16); + for pair in ladder.windows(2) { + assert!(pair[0].ms() < pair[1].ms()); + } + assert_eq!(DurationToken::Short1.ms(), 50); + assert_eq!(DurationToken::ExtraLong4.ms(), 1000); + // 名字唯一 + let mut names = std::collections::BTreeSet::new(); + for token in ladder { + assert!(names.insert(token.name()), "时长名重复:{}", token.name()); + } + } + + #[test] + fn easing_hits_endpoints_and_stays_in_range() { + for easing in ALL_EASINGS { + assert!( + (easing.sample(0.0) - 0.0).abs() < 1.0e-6, + "{easing:?} 起点不为 0" + ); + assert!( + (easing.sample(1.0) - 1.0).abs() < 1.0e-6, + "{easing:?} 终点不为 1" + ); + let mut prev = 0.0; + for step in 1..=20 { + let v = easing.sample(step as f32 / 20.0); + assert!((-0.001..=1.001).contains(&v), "{easing:?} 采样越界:{v}"); + assert!(v + 1.0e-3 >= prev, "{easing:?} 采样非单调:{prev} → {v}"); + prev = v; + } + } + } + + #[test] + fn emphasized_decelerate_is_faster_early_than_accelerate() { + let early_decel = Easing::EmphasizedDecelerate.sample(0.25); + let early_accel = Easing::EmphasizedAccelerate.sample(0.25); + assert!(early_decel > early_accel); + } + + #[test] + fn default_durations_are_registered_tokens() { + for easing in ALL_EASINGS { + assert!(easing.default_duration().ms() >= 250); + } + } +} diff --git a/crates/design/src/motion/spring.rs b/crates/design/src/motion/spring.rs new file mode 100644 index 0000000..d224986 --- /dev/null +++ b/crates/design/src/motion/spring.rs @@ -0,0 +1,214 @@ +//! 弹簧参数(M3 Expressive 动效的物理表达)与动效时长缩放。 + +/// 弹簧参数。 +/// +/// **口径说明**:Material 的弹簧预置是「按体感标定」的一组 token,官方没有公开统一公式 +/// (Android 侧是 `SpringForce` 参数 + 阻尼比)。[`Spring::sample`] 走的是标准二阶系统阶跃响应 +/// **闭式解**——数学是真的,参数是我们的标定值(待 M63 视觉规格对齐)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Spring { + /// 刚度(越大越快)。 + pub stiffness: f32, + /// 阻尼系数(临界阻尼 = 2·√(k·m))。 + pub damping: f32, + /// 质量。 + pub mass: f32, +} + +impl Spring { + /// 构造(`mass` 取 1.0 时 stiffness/damping 就是常见标定值)。 + pub const fn new(stiffness: f32, damping: f32, mass: f32) -> Self { + Self { + stiffness, + damping, + mass, + } + } + + /// 自然角频率 ωn(rad/s)。 + pub fn natural_frequency(self) -> f32 { + (self.stiffness / self.mass).sqrt() + } + + /// 阻尼比 ζ(1.0 = 临界阻尼;< 1 会轻微弹过)。 + pub fn damping_ratio(self) -> f32 { + let denom = 2.0 * (self.stiffness * self.mass).sqrt(); + if denom <= 0.0 { + 1.0 + } else { + self.damping / denom + } + } + + /// 阶跃响应:`t_ms` 毫秒后的进度(0..1,欠阻尼时会轻微超过 1 = 回弹)。 + pub fn sample(self, t_ms: f32) -> f32 { + if t_ms <= 0.0 { + return 0.0; + } + let t = t_ms / 1000.0; + let wn = self.natural_frequency(); + let zeta = self.damping_ratio(); + if wn <= 0.0 { + return 1.0; + } + if zeta < 1.0 { + let wd = wn * (1.0 - zeta * zeta).sqrt(); + let decay = (-zeta * wn * t).exp(); + 1.0 - decay * ((wd * t).cos() + (zeta * wn / wd) * (wd * t).sin()) + } else if (zeta - 1.0).abs() < 1.0e-4 { + let decay = (-wn * t).exp(); + 1.0 - decay * (1.0 + wn * t) + } else { + let root = (zeta * zeta - 1.0).sqrt(); + let r1 = -wn * (zeta - root); + let r2 = -wn * (zeta + root); + let denom = r2 - r1; + if denom.abs() < f32::EPSILON { + return 1.0; + } + 1.0 - (r2 * (r1 * t).exp() - r1 * (r2 * t).exp()) / denom + } + } + + /// 收敛到 `threshold`(0.1% 是常用的「停下来」判定)所需毫秒数。 + pub fn settle_ms(self, threshold: f32) -> f32 { + let wn = self.natural_frequency(); + let zeta = self.damping_ratio(); + if wn <= 0.0 { + return 0.0; + } + let thr = threshold.clamp(1.0e-6, 0.5); + let seconds = if zeta < 1.0 { + let wd = (1.0 - zeta * zeta).sqrt().max(1.0e-4); + -(thr * wd).ln() / (zeta * wn) + } else if (zeta - 1.0).abs() < 1.0e-4 { + -(thr).ln() / wn + } else { + let root = (zeta * zeta - 1.0).sqrt(); + let slow = wn * (zeta - root); + -(thr).ln() / slow + }; + (seconds * 1000.0).max(0.0) + } +} + +/// 空间类快速弹簧(位移/尺寸):紧、几乎不弹。 +/// +/// TODO(M63): 取值待视觉规格(四组弹簧是我们的临时标定,M63 的动效表落地后对齐)。 +pub const SPRING_SPATIAL_FAST: Spring = Spring::new(1400.0, 76.0, 1.0); +/// 空间类默认弹簧(面板、卡片):标准手感。 +/// +/// TODO(M63): 取值待视觉规格。 +pub const SPRING_SPATIAL_DEFAULT: Spring = Spring::new(800.0, 55.0, 1.0); +/// 效应类快速弹簧(透明度/颜色):不带过冲。 +/// +/// TODO(M63): 取值待视觉规格。 +pub const SPRING_EFFECTS_FAST: Spring = Spring::new(3800.0, 120.0, 1.0); +/// 效应类默认弹簧。 +/// +/// TODO(M63): 取值待视觉规格。 +pub const SPRING_EFFECTS_DEFAULT: Spring = Spring::new(1600.0, 80.0, 1.0); + +/// 动效时长的全局缩放(用户「减少动效」/CPU 路径关动画时用)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MotionScale { + factor: f32, +} + +impl MotionScale { + /// 标准(1.0)。 + pub const STANDARD: MotionScale = MotionScale { factor: 1.0 }; + /// 关闭动效(时长归零;适配层应直接落终态)。 + pub const NONE: MotionScale = MotionScale { factor: 0.0 }; + /// 半速(弱动效机器/低帧率兜底)。 + pub const HALF: MotionScale = MotionScale { factor: 0.5 }; + + /// 自定义缩放(夹取到 0.0..=1.0;不放大,避免慢到不可用)。 + pub fn new(factor: f32) -> Self { + Self { + factor: factor.clamp(0.0, 1.0), + } + } + + /// 缩放系数。 + pub fn factor(self) -> f32 { + self.factor + } + + /// 是否完全关闭(适配层据此跳过动画)。 + pub fn is_off(self) -> bool { + self.factor <= 0.0 + } + + /// 把某一档时长换算成实际毫秒。 + pub fn ms(self, token: super::DurationToken) -> f32 { + token.ms() as f32 * self.factor + } + + /// 把弹簧的收敛时间换算成实际毫秒。 + pub fn spring_ms(self, spring: Spring) -> f32 { + spring.settle_ms(0.001) * self.factor + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::motion::DurationToken; + + const PRESETS: [Spring; 4] = [ + SPRING_SPATIAL_FAST, + SPRING_SPATIAL_DEFAULT, + SPRING_EFFECTS_FAST, + SPRING_EFFECTS_DEFAULT, + ]; + + #[test] + fn spring_presets_behave() { + for spring in PRESETS { + assert!(spring.damping_ratio() > 0.5, "弹簧应明显欠阻尼/近临界"); + assert_eq!(spring.sample(0.0), 0.0); + let settle = spring.settle_ms(0.001); + assert!( + (60.0..1200.0).contains(&settle), + "收敛时间 {settle}ms 不合理" + ); + let end = spring.sample(settle); + assert!((end - 1.0).abs() < 0.01, "收敛后应回到 1.0,实测 {end}"); + } + } + + #[test] + fn critically_damped_spring_does_not_overshoot() { + let wn = SPRING_SPATIAL_DEFAULT.natural_frequency(); + let critical = Spring::new(wn * wn, 2.0 * wn, 1.0); + assert!((critical.damping_ratio() - 1.0).abs() < 1.0e-3); + for step in 1..=40 { + let v = critical.sample(step as f32 * 5.0); + assert!(v <= 1.0 + 1.0e-3, "临界阻尼不应过冲:{v}"); + } + } + + #[test] + fn overdamped_spring_converges_slowly_without_overshoot() { + let wn = SPRING_SPATIAL_DEFAULT.natural_frequency(); + let over = Spring::new(wn * wn, 4.0 * wn, 1.0); + assert!(over.damping_ratio() > 1.5); + let settle = over.settle_ms(0.001); + assert!((over.sample(settle) - 1.0).abs() < 0.01); + for step in 1..=40 { + assert!(over.sample(step as f32 * 10.0) <= 1.0 + 1.0e-3); + } + } + + #[test] + fn motion_scale_scales_or_kills_duration() { + assert_eq!(MotionScale::STANDARD.ms(DurationToken::Medium2), 300.0); + assert_eq!(MotionScale::HALF.ms(DurationToken::Medium2), 150.0); + assert_eq!(MotionScale::NONE.ms(DurationToken::Medium2), 0.0); + assert!(MotionScale::NONE.is_off()); + assert_eq!(MotionScale::new(5.0).factor(), 1.0); + assert_eq!(MotionScale::new(-1.0).factor(), 0.0); + assert!(MotionScale::STANDARD.spring_ms(SPRING_SPATIAL_DEFAULT) > 0.0); + } +} diff --git a/crates/design/src/shape.rs b/crates/design/src/shape.rs new file mode 100644 index 0000000..8002265 --- /dev/null +++ b/crates/design/src/shape.rs @@ -0,0 +1,168 @@ +//! M3 Expressive 形状刻度 + shape morph 数据。 +//! +//! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用 +//! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。 + +use crate::motion::Spring; + +/// M3 形状刻度(含 Expressive 新增档)。 +/// +/// 基础档(M3):none 0 / extra-small 4 / small 8 / medium 12 / large 16 / extra-large 28 / full。 +/// Expressive 新增档:`large-increased` 20 / `extra-large-increased` 32 / `extra-extra-large` 48。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ShapeScale { + /// 直角(0dp):仅用于贴边容器/分隔。 + None, + /// 4dp:小控件(chip、小徽标)。 + ExtraSmall, + /// 8dp:控件(按钮、输入框、菜单项)。 + Small, + /// 12dp:卡片、列表容器(Expressive 里卡片默认升到这一档)。 + Medium, + /// 16dp:大卡片、面板。 + Large, + /// 20dp(Expressive):按下/选中时的「胀开」档。 + LargeIncreased, + /// 28dp:浮层、对话框。 + ExtraLarge, + /// 32dp(Expressive):大浮层、FAB 展开态。 + ExtraLargeIncreased, + /// 48dp(Expressive):超大容器、模态全屏卡。 + ExtraExtraLarge, + /// 药丸/圆:按边长取半径一半(导航指示条、头像、switch 轨道)。 + Full, +} + +impl ShapeScale { + /// 半径(dp;[`ShapeScale::Full`] 返回 `f32::INFINITY`,由 [`ShapeScale::radius_px`] 折算)。 + pub const fn dp(self) -> f32 { + match self { + ShapeScale::None => 0.0, + ShapeScale::ExtraSmall => 4.0, + ShapeScale::Small => 8.0, + ShapeScale::Medium => 12.0, + ShapeScale::Large => 16.0, + ShapeScale::LargeIncreased => 20.0, + ShapeScale::ExtraLarge => 28.0, + ShapeScale::ExtraLargeIncreased => 32.0, + ShapeScale::ExtraExtraLarge => 48.0, + ShapeScale::Full => f32::INFINITY, + } + } + + /// 实际渲染半径:`Full` 取边长一半,其余不超过边长一半(避免方角变圆角溢出)。 + pub fn radius_px(self, size_px: f32) -> f32 { + let half = size_px.max(0.0) / 2.0; + if self == ShapeScale::Full { + half + } else { + self.dp().min(half) + } + } + + /// 是否是 Expressive 才有的档位(基础 M3 没有)。 + pub const fn is_expressive(self) -> bool { + matches!( + self, + ShapeScale::LargeIncreased + | ShapeScale::ExtraLargeIncreased + | ShapeScale::ExtraExtraLarge + ) + } + + /// 在形状阶梯上的序号(0 = None,9 = Full),用于断言形变方向是「胀开」而不是「收紧」。 + pub const fn rank(self) -> u8 { + match self { + ShapeScale::None => 0, + ShapeScale::ExtraSmall => 1, + ShapeScale::Small => 2, + ShapeScale::Medium => 3, + ShapeScale::Large => 4, + ShapeScale::LargeIncreased => 5, + ShapeScale::ExtraLarge => 6, + ShapeScale::ExtraLargeIncreased => 7, + ShapeScale::ExtraExtraLarge => 8, + ShapeScale::Full => 9, + } + } +} + +/// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。 +/// +/// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ShapeMorph { + /// 静息档位。 + pub from: ShapeScale, + /// 目标档位。 + pub to: ShapeScale, + /// 驱动这次形变的弹簧。 + pub spring: Spring, +} + +impl ShapeMorph { + /// 恒定弹簧的形变。 + pub const fn new(from: ShapeScale, to: ShapeScale, spring: Spring) -> Self { + Self { from, to, spring } + } + + /// 在 `progress`(0..1,已含缓动)处的半径。`Full` 端点按 `size_px` 折算。 + pub fn radius_px(&self, size_px: f32, progress: f32) -> f32 { + let t = progress.clamp(0.0, 1.0); + let a = self.from.radius_px(size_px); + let b = self.to.radius_px(size_px); + a + (b - a) * t + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::motion::SPRING_SPATIAL_DEFAULT; + + #[test] + fn scale_ladder_is_monotone() { + let ladder = [ + ShapeScale::None, + ShapeScale::ExtraSmall, + ShapeScale::Small, + ShapeScale::Medium, + ShapeScale::Large, + ShapeScale::LargeIncreased, + ShapeScale::ExtraLarge, + ShapeScale::ExtraLargeIncreased, + ShapeScale::ExtraExtraLarge, + ]; + for pair in ladder.windows(2) { + assert!( + pair[0].dp() < pair[1].dp(), + "{:?} 应小于 {:?}", + pair[0], + pair[1] + ); + } + } + + #[test] + fn full_is_half_of_edge_and_clamped() { + assert_eq!(ShapeScale::Full.radius_px(48.0), 24.0); + assert_eq!(ShapeScale::ExtraLarge.radius_px(20.0), 10.0); + assert_eq!(ShapeScale::ExtraLarge.radius_px(200.0), 28.0); + assert_eq!(ShapeScale::None.radius_px(200.0), 0.0); + } + + #[test] + fn morph_hits_both_endpoints() { + let morph = ShapeMorph::new( + ShapeScale::Small, + ShapeScale::LargeIncreased, + SPRING_SPATIAL_DEFAULT, + ); + assert_eq!(morph.radius_px(200.0, 0.0), 8.0); + assert_eq!(morph.radius_px(200.0, 1.0), 20.0); + assert_eq!(morph.radius_px(200.0, 0.5), 14.0); + // 越界 progress 夹取,不产生负半径/超大半径。 + assert_eq!(morph.radius_px(200.0, -2.0), 8.0); + assert_eq!(morph.radius_px(200.0, 3.0), 20.0); + } +} diff --git a/crates/design/src/spacing.rs b/crates/design/src/spacing.rs new file mode 100644 index 0000000..22c6b43 --- /dev/null +++ b/crates/design/src/spacing.rs @@ -0,0 +1,162 @@ +//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。 +//! +//! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏), +//! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格, +//! 所以这层**不用改口径**,只是把值搬进唯一真源。 + +/// 栅格基数(dp)。 +pub const GRID: f32 = 4.0; + +/// 间距档位(M3/M2 通用 4px 栅格)。禁止 6/10/14 这类非整倍数值。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Space { + /// 4dp:图标与文字之间。 + X1, + /// 8dp:控件内边距、行内元素间距。 + X2, + /// 12dp:控件间距。 + X3, + /// 16dp:区块内行距、卡片内边距下限。 + X4, + /// 24dp:区块间距、页面内边距。 + X6, + /// 32dp:页级大留白。 + X8, +} + +impl Space { + /// 间距值(dp)。 + pub const fn dp(self) -> f32 { + match self { + Space::X1 => 4.0, + Space::X2 => 8.0, + Space::X3 => 12.0, + Space::X4 => 16.0, + Space::X6 => 24.0, + Space::X8 => 32.0, + } + } + + /// 全部档位,由小到大。 + pub const ALL: [Space; 6] = [ + Space::X1, + Space::X2, + Space::X3, + Space::X4, + Space::X6, + Space::X8, + ]; +} + +/// 把任意数值对齐到 4px 栅格(最近档)。 +pub fn snap_to_grid(value: f32) -> f32 { + (value / GRID).round() * GRID +} + +/// 骨架尺寸:窗口结构(标题栏、导航、页头、行高)的单一来源。 +/// +/// 这些值不是 M3 规范值(M3 只规定组件内部节奏),而是本启动器的**布局契约**, +/// 由页面/组件两条线共同消费——任何页面自己写 `56px` 都是重复定义。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LayoutMetrics { + /// 标题栏高度。 + pub titlebar_height: f32, + /// 标题栏按钮宽度(关闭/最大化/最小化)。 + pub titlebar_button_width: f32, + /// 展开态导航宽度。 + pub nav_expanded_width: f32, + /// 收起态导航宽度。 + pub nav_collapsed_width: f32, + /// 导航项高度。 + pub nav_item_height: f32, + /// 导航项间距。 + pub nav_item_gap: f32, + /// 单行页头高度。 + pub page_header_height: f32, + /// 两行页头高度(日志页)。 + pub page_header_tall_height: f32, + /// 运行中状态条高度。 + pub running_strip_height: f32, + /// 列表行高。 + pub row_height: f32, + /// 标准控件高(按钮/输入框)。 + pub control_height: f32, + /// 最小可点区域。 + pub touch_target: f32, + /// 卡片内边距。 + pub card_padding: f32, + /// AI 面板宽度。 + pub ai_panel_width: f32, +} + +impl LayoutMetrics { + /// 桌面基线(1180×760 窗口,M53 页面骨架的取值)。 + pub const DESKTOP: Self = Self { + titlebar_height: 40.0, + titlebar_button_width: 46.0, + nav_expanded_width: 88.0, + nav_collapsed_width: 48.0, + nav_item_height: 56.0, + nav_item_gap: 4.0, + page_header_height: 56.0, + page_header_tall_height: 84.0, + running_strip_height: 32.0, + row_height: 44.0, + control_height: 40.0, + touch_target: 44.0, + card_padding: 20.0, + ai_panel_width: 320.0, + }; +} + +impl LayoutMetrics { + /// 页头是否两行(日志页),决定主区与 AI 面板分隔线对齐的高度。 + pub fn header_height(&self, two_line: bool) -> f32 { + if two_line { + self.page_header_tall_height + } else { + self.page_header_height + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_space_is_on_the_grid() { + for space in Space::ALL { + assert_eq!(space.dp() % GRID, 0.0, "{space:?} 不在 4px 栅格上"); + } + } + + #[test] + fn layout_values_are_on_the_grid() { + let m = LayoutMetrics::DESKTOP; + for (name, value) in [ + ("titlebar_height", m.titlebar_height), + ("nav_item_height", m.nav_item_height), + ("page_header_height", m.page_header_height), + ("page_header_tall_height", m.page_header_tall_height), + ("row_height", m.row_height), + ("control_height", m.control_height), + ] { + assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上"); + } + } + + #[test] + fn snap_rounds_to_nearest_grid_step() { + assert_eq!(snap_to_grid(5.0), 4.0); + assert_eq!(snap_to_grid(6.5), 8.0); + assert_eq!(snap_to_grid(0.0), 0.0); + } + + #[test] + fn header_height_follows_line_count() { + let m = LayoutMetrics::DESKTOP; + assert_eq!(m.header_height(false), 56.0); + assert_eq!(m.header_height(true), 84.0); + } +} diff --git a/crates/design/src/typography/fonts.rs b/crates/design/src/typography/fonts.rs new file mode 100644 index 0000000..27e4527 --- /dev/null +++ b/crates/design/src/typography/fonts.rs @@ -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()); + } +} diff --git a/crates/design/src/typography/mod.rs b/crates/design/src/typography/mod.rs new file mode 100644 index 0000000..ebb937c --- /dev/null +++ b/crates/design/src/typography/mod.rs @@ -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); + } +} diff --git a/crates/design/tests/contrast_aa.rs b/crates/design/tests/contrast_aa.rs new file mode 100644 index 0000000..a209b09 --- /dev/null +++ b/crates/design/tests/contrast_aa.rs @@ -0,0 +1,199 @@ +//! WCAG AA 对比度门禁(M61 任务:「on-* 与其承载色至少满足 4.5:1,不达标的角色要显式标注例外」)。 +//! +//! 三个性质让这条门禁可以做到「**换任何种子色都成立**」,而不是只对出厂紫成立: +//! 1. 颜色建在 CIELAB `L*`(tone)轴上,`TonalPalette::tone` 精确命中 tone(误差 ≤1); +//! 2. WCAG 相对亮度只取决于同一条 Y 轴(`L*` ↔ Y 是双射); +//! 3. 角色 → tone 的映射与色相/彩度无关。 +//! +//! 所以对比度主要由「两个角色的 tone 差」决定,与 user 选的强调色无关——下面对多组种子 +//! 逐一验证,就是在钉这个性质。 + +use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor}; + +/// 正文文字:要求 ≥ 4.5:1(WCAG AA normal text)。 +const AA_TEXT: f32 = 4.5; +/// 非文字元素(描边、图标块):要求 ≥ 3:1(WCAG AA non-text)。 +const AA_NON_TEXT: f32 = 3.0; + +/// 文字/图标对(前景, 承载色)。两个模式都要过。 +const TEXT_PAIRS: [(Role, Role); 26] = [ + (Role::OnPrimary, Role::Primary), + (Role::OnPrimaryContainer, Role::PrimaryContainer), + (Role::OnSecondary, Role::Secondary), + (Role::OnSecondaryContainer, Role::SecondaryContainer), + (Role::OnTertiary, Role::Tertiary), + (Role::OnTertiaryContainer, Role::TertiaryContainer), + (Role::OnError, Role::Error), + (Role::OnErrorContainer, Role::ErrorContainer), + (Role::OnSurface, Role::Surface), + (Role::OnSurfaceVariant, Role::SurfaceVariant), + (Role::OnBackground, Role::Background), + (Role::InverseOnSurface, Role::InverseSurface), + (Role::InversePrimary, Role::InverseSurface), + (Role::OnPrimaryFixed, Role::PrimaryFixed), + (Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim), + (Role::OnSecondaryFixed, Role::SecondaryFixed), + (Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim), + (Role::OnTertiaryFixed, Role::TertiaryFixed), + (Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim), + // 卡片/浮层容器上的文字(M3 现行口径:层级靠 surface-container-* 表达) + (Role::OnSurface, Role::SurfaceContainerLowest), + (Role::OnSurface, Role::SurfaceContainerLow), + (Role::OnSurface, Role::SurfaceContainer), + (Role::OnSurface, Role::SurfaceContainerHigh), + (Role::OnSurface, Role::SurfaceContainerHighest), + (Role::OnSurfaceVariant, Role::SurfaceContainerHighest), + (Role::OnSurfaceVariant, Role::SurfaceContainerLow), +]; + +/// 显式登记的例外:`(前景, 承载色, 目标阈值, 原因)`。 +/// +/// 这些不是缺陷,是 M3 定义决定的(非文字元素 3:1、装饰件无要求)。 +const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [ + ( + Role::Outline, + Role::Surface, + AA_NON_TEXT, + "输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)", + ), + ( + Role::OutlineVariant, + Role::Surface, + AA_NON_TEXT * 0.5, + "分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值", + ), + ( + Role::SurfaceVariant, + Role::Surface, + 1.0, + "surface-variant 是容器色而不是前景色,不参与对比度门禁(M3 已不推荐用它做容器,保留仅为兼容旧值)", + ), +]; + +fn seeds() -> Vec { + vec![ + // 出厂基线(M3 baseline 紫) + Color::from_hex("#6750A4").expect("基线种子"), + // 冷蓝(原 Fluent 强调色,用户大概率会选这一族) + Color::from_hex("#0078D4").expect("蓝"), + // 青绿 + Color::from_hex("#006A5C").expect("青"), + // 暖橙(高彩度,gamut 压力大) + Color::from_hex("#E8710A").expect("橙"), + // 亮黄(最容易被白字吃掉的极端) + Color::from_hex("#FFD400").expect("黄"), + // 近灰(低彩度种子,检验 neutral 不塌) + Color::from_hex("#6E6E6E").expect("灰"), + ] +} + +#[test] +fn text_pairs_meet_wcag_aa_for_every_seed_and_flavor() { + let mut failures = Vec::new(); + for seed in seeds() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::from_seed(flavor, seed); + for (fg, bg) in TEXT_PAIRS { + let ratio = scheme.contrast(fg, bg); + if ratio < AA_TEXT { + failures.push(format!( + "seed {} / {} / {} on {} = {ratio:.2}", + seed.to_hex(), + flavor.name(), + fg.name(), + bg.name() + )); + } + } + } + } + assert!( + failures.is_empty(), + "以下角色对未达 WCAG AA(4.5:1):\n{}", + failures.join("\n") + ); +} + +#[test] +fn exception_list_is_honest_about_its_thresholds() { + // 例外必须**真的**是「非文字级」,不能悄悄把低于 3:1 的文字对塞进来。 + for seed in seeds() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::from_seed(flavor, seed); + for (fg, bg, threshold, reason) in EXCEPTIONS { + assert!( + !fg.is_foreground() || fg == Role::Outline, + "例外 {} 不该是文字前景色({reason})", + fg.name() + ); + let ratio = scheme.contrast(fg, bg); + assert!( + ratio >= threshold, + "例外 {} on {} 实测 {ratio:.2},低于登记阈值 {threshold}(seed {} / {})", + fg.name(), + bg.name(), + seed.to_hex(), + flavor.name() + ); + } + } + } +} + +#[test] +fn baseline_light_and_dark_both_pass() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::baseline(flavor); + for (fg, bg) in TEXT_PAIRS { + let ratio = scheme.contrast(fg, bg); + assert!( + ratio >= AA_TEXT, + "baseline {} 的 {} on {} 只有 {ratio:.2}", + flavor.name(), + fg.name(), + bg.name() + ); + } + } +} + +#[test] +fn accent_override_cannot_break_contrast() { + // 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。 + let base = Scheme::baseline(SchemeFlavor::Light); + for seed in seeds() { + let custom = Scheme::from_accent(SchemeFlavor::Light, seed); + for (fg, bg) in [ + (Role::OnPrimary, Role::Primary), + (Role::OnPrimaryContainer, Role::PrimaryContainer), + (Role::OnSecondary, Role::Secondary), + (Role::OnTertiary, Role::Tertiary), + ] { + let ratio = custom.contrast(fg, bg); + assert!( + ratio >= AA_TEXT, + "强调色 {} 下 {} on {} 只有 {ratio:.2}", + seed.to_hex(), + fg.name(), + bg.name() + ); + } + // 表面色不受强调色影响(这条同时是 `from_accent` 的语义断言)。 + assert_eq!(custom.get(Role::Surface), base.get(Role::Surface)); + } +} + +#[test] +fn outline_is_registered_as_non_text_exception() { + // 把「为什么 outline 不是 AA 例外失败项」钉死:它在 light 下 4.29:1(低于 4.5 但高于 3:1)。 + let light = Scheme::baseline(SchemeFlavor::Light); + let ratio = light.contrast(Role::Outline, Role::Surface); + assert!( + ratio < AA_TEXT, + "light 的 outline 若已达 4.5 应把例外删掉:{ratio:.2}" + ); + assert!( + ratio >= AA_NON_TEXT, + "light 的 outline 低于非文字 3:1:{ratio:.2}" + ); +} diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs new file mode 100644 index 0000000..29c9c0e --- /dev/null +++ b/crates/design/tests/token_contract.rs @@ -0,0 +1,205 @@ +//! 令牌契约测试:从**消费方视角**(只用公开 API)钉住 M61 承诺的那些性质。 +//! +//! 这份测试的作用是「冻结」:`IconKind`/`Role` 的次序与个数、type scale 的取值、形状/高程阶梯、 +//! 描边的 SVG 语义换算。后续 iced 页面线靠它保证不会因为一次「顺手改动」而悄悄换观感。 + +use unlucky_design::color::{Role, Scheme, SchemeFlavor, TonalPalette}; +use unlucky_design::component_map::Component; +use unlucky_design::elevation::{Elevation, MaterialLayer}; +use unlucky_design::icons::{ + stroke_width_for, visual_stroke_px, IconKind, IconPaint, IconSize, VIEWBOX, +}; +use unlucky_design::motion::{DurationToken, Easing, MotionScale, SPRING_SPATIAL_DEFAULT}; +use unlucky_design::shape::ShapeScale; +use unlucky_design::spacing::{LayoutMetrics, Space}; +use unlucky_design::typography::{FontFamily, Platform, TypeRole, Weight}; +use unlucky_design::DesignTokens; + +#[test] +fn color_role_table_is_frozen() { + assert_eq!(Role::ALL.len(), 49, "M3 基础角色集共 49 个"); + assert_eq!(Role::ALL[0], Role::Primary); + assert_eq!(Role::ALL[16], Role::Surface); + assert_eq!(Role::ALL[48], Role::OnTertiaryFixedVariant); + // 每个角色都能从两套 scheme 里取到值,且前景角色都带 on- 语义。 + let light = Scheme::baseline(SchemeFlavor::Light); + let dark = Scheme::baseline(SchemeFlavor::Dark); + for role in Role::ALL { + assert!( + light.get(role).is_opaque(), + "{} 应当是不透明色", + role.name() + ); + assert!(dark.get(role).is_opaque()); + if role.name().starts_with("on-") { + assert!(role.is_foreground(), "{} 应被判为前景色", role.name()); + } + } + assert!(light.get(Role::Primary).is_opaque()); +} + +#[test] +fn tone_mapping_is_the_m3_table() { + // 抽查几行关键映射(light / dark),防止有人改 tone 表时静默换观感。 + assert_eq!(Scheme::tone_of(Role::Primary, SchemeFlavor::Light), 40.0); + assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Light), 98.0); + assert_eq!(Scheme::tone_of(Role::Surface, SchemeFlavor::Dark), 6.0); + assert_eq!( + Scheme::tone_of(Role::OnPrimaryContainer, SchemeFlavor::Dark), + 90.0 + ); + assert_eq!(Scheme::tone_of(Role::Outline, SchemeFlavor::Light), 50.0); +} + +#[test] +fn type_scale_values_are_the_m3_baseline() { + assert_eq!(TypeRole::ALL.len(), 15); + let title = TypeRole::TitleLarge.style(); + assert_eq!((title.size_px, title.line_height_px), (22.0, 28.0)); + let body = TypeRole::BodyMedium.style(); + assert_eq!((body.size_px, body.line_height_px), (14.0, 20.0)); + assert_eq!(body.weight, Weight::Regular); + let label = TypeRole::LabelLarge.style(); + assert_eq!(label.weight, Weight::Medium); + assert_eq!(TypeRole::BodySmall.style().size_px, 12.0); + // 强调档只动字重。 + assert_eq!(TypeRole::TitleLarge.emphasized().size_px, 22.0); + assert!(TypeRole::TitleMedium.emphasized().weight.value() > Weight::Medium.value()); +} + +#[test] +fn font_stacks_never_embed_font_files() { + for platform in [Platform::Windows, Platform::MacOs, Platform::Linux] { + for family in [FontFamily::Display, FontFamily::Text, FontFamily::Mono] { + let stack = platform.font_stack(family); + assert!(!stack.is_empty()); + for name in stack { + let lower = name.to_ascii_lowercase(); + for banned in [".ttf", ".otf", ".ttc", "woff", "file"] { + assert!( + !lower.contains(banned), + "{} 的字体栈出现文件引用 {name}(只允许族名,禁止内嵌受限字体)", + platform.name() + ); + } + } + } + } +} + +#[test] +fn icon_kind_is_frozen_and_append_only() { + assert_eq!(IconKind::ALL.len(), 28, "M61 冻结 28 个图标"); + // 迁移来的 24 个必须保持原次序(序号即持久化 id)。 + 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); + // 新增的 4 个追加在末尾。 + assert_eq!(IconKind::ALL[24], IconKind::Refresh); + assert_eq!(IconKind::ALL[27], IconKind::Trash); + for kind in IconKind::ALL { + assert!(kind.icon().path.starts_with('M')); + assert_eq!(kind.icon().kind, kind); + } +} + +#[test] +fn icon_stroke_follows_svg_semantics_not_slint() { + // Slint 口径(绝对 1.5px)在 20px 渲染下会得到 1.5px 视觉线宽;SVG 口径要写 1.8。 + let nav_width = IconKind::Settings.icon().stroke_width(IconSize::Nav.px()); + assert!( + (nav_width - 1.8).abs() < 1.0e-6, + "20px 渲染应取 1.8 个 viewBox 单位" + ); + let button_width = IconKind::Settings + .icon() + .stroke_width(IconSize::Button.px()); + assert!((button_width - 2.25).abs() < 1.0e-6); + // 视觉宽度的往返一致性(这条是「别照抄 Slint 的 1.5」的守门测试)。 + for size in IconSize::ALL { + let w = stroke_width_for(size.px(), 1.5); + assert!((visual_stroke_px(size.px(), w) - 1.5).abs() < 1.0e-6); + } + assert!((VIEWBOX - 24.0).abs() < f32::EPSILON); + // 实心图标不描边。 + assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill); + assert_eq!(IconKind::Play.icon().stroke_width(24.0), 0.0); +} + +#[test] +fn shape_and_elevation_ladders() { + assert_eq!(ShapeScale::ExtraSmall.dp(), 4.0); + assert_eq!(ShapeScale::Medium.dp(), 12.0); + assert_eq!(ShapeScale::ExtraLarge.dp(), 28.0); + assert_eq!(ShapeScale::Full.radius_px(40.0), 20.0); + assert!(ShapeScale::ExtraLargeIncreased.is_expressive()); + + let dp: Vec = Elevation::ALL.iter().map(|e| e.dp()).collect(); + assert_eq!(dp, vec![0.0, 1.0, 3.0, 6.0, 8.0, 12.0]); + assert_eq!( + MaterialLayer::Card.surface_role(), + Role::SurfaceContainerLow + ); +} + +#[test] +fn motion_tokens_are_usable_as_data() { + assert_eq!(DurationToken::Medium2.ms(), 300); + assert!((Easing::Standard.sample(0.0)).abs() < 1.0e-6); + assert!((Easing::Standard.sample(1.0) - 1.0).abs() < 1.0e-6); + let settle = SPRING_SPATIAL_DEFAULT.settle_ms(0.001); + assert!(settle > 0.0 && settle < 2000.0); + assert_eq!(MotionScale::NONE.ms(DurationToken::Long4), 0.0); +} + +#[test] +fn spacing_and_layout_metrics_are_shared() { + assert_eq!(Space::X4.dp(), 16.0); + let metrics = LayoutMetrics::DESKTOP; + assert_eq!(metrics.header_height(false), 56.0); + assert_eq!(metrics.header_height(true), 84.0); +} + +#[test] +fn component_map_covers_the_page_skeleton() { + assert_eq!(Component::ALL.len(), 20); + for component in Component::ALL { + let spec = component.spec(); + assert_eq!(spec.component, component); + assert!(spec.text.style().size_px >= 11.0); + } + assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full); + // 浮层必须高于卡片层。 + assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation); +} + +#[test] +fn design_tokens_entry_point() { + let light = DesignTokens::baseline(SchemeFlavor::Light); + let dark = DesignTokens::baseline(SchemeFlavor::Dark); + assert_eq!(light.flavor, SchemeFlavor::Light); + assert!(light.color(Role::Surface).relative_luminance() > 0.8); + assert!(dark.color(Role::Surface).relative_luminance() < 0.05); + assert!(!light.motion_scale.is_off()); + + let accent = DesignTokens::with_accent( + SchemeFlavor::Light, + unlucky_design::Color::from_hex("#006A5C").unwrap(), + ); + assert_ne!(accent.color(Role::Primary), light.color(Role::Primary)); + assert_eq!(accent.color(Role::Surface), light.color(Role::Surface)); +} + +#[test] +fn tonal_palette_hits_requested_tones() { + let palette = TonalPalette::new(150.0, 0.1); + for tone in [20.0_f32, 40.0, 60.0, 80.0] { + let color = palette.tone(tone); + assert!( + (color.lstar() - tone).abs() <= 1.0, + "tone {tone} 实际 {}", + color.lstar() + ); + } +}