diff --git a/crates/design/src/color/boards/ink.rs b/crates/design/src/color/boards/ink.rs new file mode 100644 index 0000000..a6d1109 --- /dev/null +++ b/crates/design/src/color/boards/ink.rs @@ -0,0 +1,117 @@ +//! **Ink Cat 深色板**(规格卡 `DevDocs/artifacts/components-ink.svg` 的深色方案)。 +//! +//! 49 行按 [`Role`] 声明序排列(下标 = `Role as usize`),由 `boards` 模块的 +//! `anchors_pin_the_spec` 测试用 [`ANCHORS`] 逐条对账——**行错位会当场红**。 +//! +//! 取值分两类,来源都在行注释里: +//! - `[spec]`:规格卡上直接写出的色值([`ANCHORS`] 里同一份,测试逐条比对 hex); +//! - `[derive]`:规格卡没给的角色,按「同色相 + M3 的 tone 关系」从邻居推导后固化。 +//! +//! 推导口径(`[derive]` 行):容器/文字的 tone 差沿用 M3 ColorSpec2021 的成对关系 +//! (`on-*` 与承载色差 ≥50 tone 才稳过 AA),色相取承载色的 CAM16 色相,彩度只压不抬。 +//! 换算过程是 `Hct::from_components(hue, chroma, tone)`,结果已固化成字面量,不在运行期重算—— +//! 这样色板是**可逐值审阅的数据**,而不是「跑一遍才知道」的产物。 + +use crate::color::{Color, Role}; + +/// 规格卡直接给出的色值(`[spec]` 行),用于逐条对账与文档生成。 +/// +/// 顺序即规格卡上出现的顺序(BUTTONS → FAB → TEXT FIELDS → SWITCH/CHIPS → CARD/DIALOG/SNACKBAR +/// → COLOR ROLES / INK)。**这张表是色板的出处**:表里的值改了,测试 `anchors_pin_the_spec` +/// 会指着要改的行。 +pub const ANCHORS: [(Role, &str); 22] = [ + (Role::Primary, "#E8FFEF"), + (Role::OnPrimary, "#256B4B"), + (Role::PrimaryContainer, "#B3FBD2"), + (Role::OnPrimaryContainer, "#1B6243"), + (Role::Secondary, "#B4CCBC"), + (Role::SecondaryContainer, "#364B3F"), + (Role::OnSecondaryContainer, "#D0E8D7"), + (Role::Tertiary, "#FFB77D"), + (Role::TertiaryContainer, "#FFA453"), + (Role::Surface, "#0F150F"), + (Role::SurfaceContainer, "#151B15"), + (Role::SurfaceContainerHigh, "#1D231D"), + (Role::SurfaceContainerHighest, "#262B25"), + (Role::OnSurface, "#E4EAE0"), + (Role::OnSurfaceVariant, "#B6C5B4"), + (Role::Outline, "#7B8A7B"), + (Role::OutlineVariant, "#3C4A3D"), + (Role::Error, "#FF716C"), + (Role::OnError, "#490006"), + (Role::InverseSurface, "#E4EAE0"), + (Role::InverseOnSurface, "#282E27"), + (Role::InversePrimary, "#256B4B"), +]; + +/// Ink 板 49 个角色(下标 = `Role as usize`)。 +pub const COLORS: [Color; 49] = [ + Color::rgb(0xE8, 0xFF, 0xEF), // 01 primary [spec] 按钮/文字按钮标签、进度、选中 + Color::rgb(0x25, 0x6B, 0x4B), // 02 on-primary [spec] + Color::rgb(0xB3, 0xFB, 0xD2), // 03 primary-container [spec] FAB 底、头像底 + Color::rgb(0x1B, 0x62, 0x43), // 04 on-primary-container [spec] FAB 图标 + Color::rgb(0xB4, 0xCC, 0xBC), // 05 secondary [spec] + Color::rgb(0x37, 0x4B, 0x3E), // 06 on-secondary [derive] secondary @ tone 30 + Color::rgb(0x36, 0x4B, 0x3F), // 07 secondary-container [spec] tonal 按钮底、选中 chip + Color::rgb(0xD0, 0xE8, 0xD7), // 08 on-secondary-container [spec] tonal 按钮标签 + Color::rgb(0xFF, 0xB7, 0x7D), // 09 tertiary [spec] 暖橙装饰 + Color::rgb(0x55, 0x2B, 0x00), // 10 on-tertiary [derive] tertiary @ tone 22 + Color::rgb(0xFF, 0xA4, 0x53), // 11 tertiary-container [spec] + Color::rgb(0x4C, 0x27, 0x00), // 12 on-tertiary-container [derive] container @ tone 20 + Color::rgb(0xFF, 0x71, 0x6C), // 13 error [spec] 错误文字、badge 底 + Color::rgb(0x49, 0x00, 0x06), // 14 on-error [spec] badge 数字 + Color::rgb(0x93, 0x00, 0x0A), // 15 error-container [derive] error @ tone 30(= M3 深色 error-container) + Color::rgb(0xFF, 0xDA, 0xD6), // 16 on-error-container [derive] error @ tone 90 + Color::rgb(0x0F, 0x15, 0x0F), // 17 surface [spec] 窗口底 + Color::rgb(0xE4, 0xEA, 0xE0), // 18 on-surface [spec] 正文/标题 + Color::rgb(0x3C, 0x4A, 0x3D), // 19 surface-variant [spec](= outline-variant,M3 深色同 tone) + Color::rgb(0xB6, 0xC5, 0xB4), // 20 on-surface-variant [spec] 次要文字、字段标签 + Color::rgb(0x0F, 0x15, 0x0F), // 21 background [spec](= surface) + Color::rgb(0xE4, 0xEA, 0xE0), // 22 on-background [spec](= on-surface) + Color::rgb(0x0C, 0x13, 0x0C), // 23 surface-dim [derive] surface −1 tone 档 + Color::rgb(0x35, 0x3C, 0x34), // 24 surface-bright [derive] surface @ tone 24 + Color::rgb(0x08, 0x0D, 0x08), // 25 surface-container-lowest [derive] tone 3 + Color::rgb(0x13, 0x18, 0x13), // 26 surface-container-low [derive] tone 8 + Color::rgb(0x15, 0x1B, 0x15), // 27 surface-container [spec] 卡片/列表行底 + Color::rgb(0x1D, 0x23, 0x1D), // 28 surface-container-high [spec] 对话框底 + Color::rgb(0x26, 0x2B, 0x25), // 29 surface-container-highest [spec] 输入框底 + Color::rgb(0x7B, 0x8A, 0x7B), // 30 outline [spec] 字段描边、switch 关态 + Color::rgb(0x3C, 0x4A, 0x3D), // 31 outline-variant [spec] 分隔线、chip 描边 + Color::rgb(0xE4, 0xEA, 0xE0), // 32 inverse-surface [spec] snackbar 底 + Color::rgb(0x28, 0x2E, 0x27), // 33 inverse-on-surface [spec] snackbar 文字 + Color::rgb(0x25, 0x6B, 0x4B), // 34 inverse-primary [spec] snackbar 动作文字 + Color::rgb(0x00, 0x00, 0x00), // 35 shadow M3 定值:纯黑 + 令牌 alpha + Color::rgb(0x00, 0x00, 0x00), // 36 scrim M3 定值:纯黑 + 令牌 alpha + Color::rgb(0xE8, 0xFF, 0xEF), // 37 surface-tint = primary(M3 旧 tint 口径) + Color::rgb(0xB3, 0xFB, 0xD2), // 38 primary-fixed [derive] tone 93(= container) + Color::rgb(0x9D, 0xE4, 0xBC), // 39 primary-fixed-dim [derive] tone 85 + Color::rgb(0x00, 0x26, 0x14), // 40 on-primary-fixed [derive] tone 12 + Color::rgb(0x00, 0x4D, 0x2E), // 41 on-primary-fixed-variant [derive] tone 28 + Color::rgb(0xD0, 0xE8, 0xD7), // 42 secondary-fixed [derive] tone 90 + Color::rgb(0xB4, 0xCC, 0xBC), // 43 secondary-fixed-dim [derive] tone 80 + Color::rgb(0x0D, 0x1F, 0x14), // 44 on-secondary-fixed [derive] tone 10 + Color::rgb(0x33, 0x47, 0x39), // 45 on-secondary-fixed-variant [derive] tone 28 + Color::rgb(0xFC, 0xD9, 0xBE), // 46 tertiary-fixed [derive] tone 89 + Color::rgb(0xFF, 0xB7, 0x7D), // 47 tertiary-fixed-dim [derive] tone 80(= tertiary) + Color::rgb(0x2A, 0x17, 0x00), // 48 on-tertiary-fixed [derive] tone 10 + Color::rgb(0x56, 0x3D, 0x27), // 49 on-tertiary-fixed-variant [derive] tone 28 +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchors_pin_the_spec() { + for (role, hex) in ANCHORS { + let expected = Color::from_hex(hex).expect("规格卡色值"); + assert_eq!( + COLORS[role as usize], + expected, + "{} 的色值与规格卡({hex})不符:表里是 {}", + role.name(), + COLORS[role as usize].to_hex() + ); + } + } +} diff --git a/crates/design/src/color/boards/mod.rs b/crates/design/src/color/boards/mod.rs new file mode 100644 index 0000000..2f70f6a --- /dev/null +++ b/crates/design/src/color/boards/mod.rs @@ -0,0 +1,200 @@ +//! # 双主题色板(Ink 深 / Silver 浅) +//! +//! 产品视觉的两套固定色板,来源是**用户自制的规格卡** `DevDocs/artifacts/components-ink.svg` +//! (Ink Cat / M3 Expressive,1480×2260,色值/圆角/尺寸/字号全在源码里)。本模块只做一件事: +//! **把规格卡的色值固化成 49 个 M3 角色 × 2 套**,让 iced 侧拿到的仍是 +//! [`Scheme`](crate::color::Scheme)——角色名、取色入口、对比度门禁一个都不用换。 +//! +//! ## 两层配色来源,互不顶替 +//! +//! | 来源 | 入口 | 什么时候用 | +//! |---|---|---| +//! | **品牌色板**(本模块) | [`BrandBoard`] → [`Scheme::from_board`](crate::color::Scheme::from_board) | 出厂外观:Ink = 深色主方案、Silver = 浅色主方案 | +//! | **tone 派生**(既有) | [`Scheme::baseline`](crate::color::Scheme::baseline) / [`Scheme::from_accent`](crate::color::Scheme::from_accent) | D38 取色链(背景图 / 壁纸取色、用户自选强调色) | +//! +//! **为什么保留 `baseline` 而不是把它换成色板**(「保留旧令牌名兼容层」这一选,理由): +//! +//! 1. `Scheme::baseline` 是 D38 取色链的**中性锚点**:`from_accent` 的全部语义就是「表面色与 +//! baseline 一致、只换强调族」。把 baseline 改成固定板,取色链要么改语义、要么在板色上再叠 +//! 一层 tone 派生,两者都会让「换强调色不该让窗口底色变脏」这条产品口径失效。 +//! 2. 改 `baseline` 的色值等于**同时改 app-iced 的观感**(`theme::Ui::new` 直接吃 baseline), +//! 而换肤(含截图验收)归 M229;令牌层先把两套板备好,再让壳层一次性切过去,比「两处各改一半」 +//! 可控。 +//! 3. 兼容层是**零成本**的:`Role` 名字与 49 个下标一个没动,新增的是并列入口而不是替换入口。 +//! +//! ## 「跟随系统」是第三种设置态,不是第三套色板 +//! +//! 外观设置有三态:浅色 / 深色 / 跟随系统。前两态直接选板,第三态按系统外观落到 +//! [`BrandBoard::for_flavor`]。色板本身只有两套,**不要**再加一个 `System` 变体——那会让 +//! 「两套板」变成「三套板」,而系统外观随时会变,色板却应该是静态的。 +//! +//! ## 色值来源分两类(`[spec]` / `[derive]`) +//! +//! - **`[spec]`**:规格卡直写。每块的 [`BrandBoard::anchors`] 表就是这份清单,测试逐条比对 hex, +//! 所以「规格卡改了但代码没改」会红。 +//! - **`[derive]`**:规格卡没给的角色(`on-secondary`、`surface-container-low`、`*-fixed*` 等), +//! 按「同色相 + M3 的 tone 关系」从邻居推导后**固化成字面量**。推导规则写在 +//! `boards/ink.rs` / `boards/silver.rs` 的模块文档与行注释里,运行期不重算——色板是数据, +//! 不是每次启动现算的函数。 +//! +//! ## 对比度纪律(两套板同一道门,数字见 `palette::visibility` 的实测表) +//! +//! - 26 组文字/图标对两套板全部 ≥4.5:1(`tests/ink_cat_boards.rs`); +//! - **状态容器**(primary / secondary / tertiary / error 的 `*-container`)对 `surface` ≥ +//! [`crate::palette::visibility::STATE_CONTAINER_MIN`](1.20:1)——这是债务台账 +//! 「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:旧 baseline 的选中行底实测 +//! **1.057:1**(m223 视觉复审),Ink 板抬到 **1.97:1**、Silver 板 **1.23:1**; +//! - **层级容器**(`surface-container-*`)不参与那条数字门禁,只要求 L\\* 沿阶梯单调、相邻步长 +//! ≥ [`crate::palette::visibility::LAYER_STEP_MIN_LSTAR`]。M3 的容器层天生贴在一起 +//! (Silver 的状态容器对 `surface-container-high` 只有 1.05~1.13),所以裁决第 3 条是 +//! **构成要求**:选中态必须「容器底 + 文字对比 + 形状/图标/描边」三件套,不许只靠底色差。 + +mod ink; +mod silver; + +use super::{Color, Role, Scheme, SchemeFlavor}; + +/// 产品色板(两套,与深浅模式一一对应)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum BrandBoard { + /// **Ink Cat**:深色主方案(规格卡 `COLOR ROLES / INK`)。窗口底 `#0F150F`,主色淡绿。 + Ink, + /// **Silver Cat**:浅色主方案(规格卡 `SILVER CAT / LIGHT SCHEME`)。窗口底 `#F9F9FA`,主色钢蓝。 + Silver, +} + +impl BrandBoard { + /// 全部色板(由深到浅)。 + pub const ALL: [BrandBoard; 2] = [BrandBoard::Ink, BrandBoard::Silver]; + + /// 稳定字符串名(`ink` / `silver`),写配置与日志用。 + pub const fn name(self) -> &'static str { + match self { + BrandBoard::Ink => "ink", + BrandBoard::Silver => "silver", + } + } + + /// 该板对应的深浅模式(Ink = 深、Silver = 浅)。 + pub const fn flavor(self) -> SchemeFlavor { + match self { + BrandBoard::Ink => SchemeFlavor::Dark, + BrandBoard::Silver => SchemeFlavor::Light, + } + } + + /// 按深浅模式选板——这就是「跟随系统」的落点。 + pub const fn for_flavor(flavor: SchemeFlavor) -> Self { + match flavor { + SchemeFlavor::Dark => BrandBoard::Ink, + SchemeFlavor::Light => BrandBoard::Silver, + } + } + + /// 49 个角色的色值表(下标 = `Role as usize`)。 + pub const fn colors(self) -> [Color; 49] { + match self { + BrandBoard::Ink => ink::COLORS, + BrandBoard::Silver => silver::COLORS, + } + } + + /// 直接取角色色值。 + pub const fn get(self, role: Role) -> Color { + self.colors()[role as usize] + } + + /// 规格卡直写的色值清单(`[spec]` 行),出处对账与文档生成用。 + pub const fn anchors(self) -> &'static [(Role, &'static str)] { + match self { + BrandBoard::Ink => &ink::ANCHORS, + BrandBoard::Silver => &silver::ANCHORS, + } + } + + /// 这套色板对应的完整语义方案。 + pub fn scheme(self) -> Scheme { + Scheme::from_board(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn boards_map_to_flavors_both_ways() { + for board in BrandBoard::ALL { + assert_eq!(BrandBoard::for_flavor(board.flavor()), board); + } + assert_eq!( + BrandBoard::for_flavor(SchemeFlavor::Light), + BrandBoard::Silver + ); + assert_eq!(BrandBoard::for_flavor(SchemeFlavor::Dark), BrandBoard::Ink); + } + + #[test] + fn names_are_unique_and_ascii() { + let mut seen = BTreeSet::new(); + for board in BrandBoard::ALL { + let name = board.name(); + assert!(seen.insert(name), "色板名重复:{name}"); + assert!(name.chars().all(|c| c.is_ascii_lowercase())); + } + } + + #[test] + fn every_role_is_opaque_on_both_boards() { + for board in BrandBoard::ALL { + for role in Role::ALL { + assert!( + board.get(role).is_opaque(), + "{} 的 {} 不是不透明色", + board.name(), + role.name() + ); + } + } + } + + #[test] + fn anchors_are_a_subset_of_the_board() { + for board in BrandBoard::ALL { + for (role, hex) in board.anchors() { + assert_eq!( + board.get(*role), + Color::from_hex(hex).expect("规格卡色值"), + "{} 的 {} 与规格卡 {hex} 不符", + board.name(), + role.name() + ); + } + } + } + + #[test] + fn scheme_flavor_follows_the_board() { + for board in BrandBoard::ALL { + assert_eq!(board.scheme().flavor(), board.flavor()); + } + // 深板确实深、浅板确实浅(不是两个同义词)。 + let ink = BrandBoard::Ink.get(Role::Surface).relative_luminance(); + let silver = BrandBoard::Silver.get(Role::Surface).relative_luminance(); + assert!(ink < 0.02, "Ink 的窗口底太亮:{ink}"); + assert!(silver > 0.9, "Silver 的窗口底太暗:{silver}"); + } + + #[test] + fn the_two_boards_are_actually_different() { + let ink = BrandBoard::Ink.colors(); + let silver = BrandBoard::Silver.colors(); + let same = Role::ALL + .iter() + .filter(|role| ink[**role as usize] == silver[**role as usize]) + .count(); + // 只有 shadow / scrim(纯黑,M3 定值)该跨板同值——其余都得变。 + assert_eq!(same, 2, "跨板同值的角色数应从 2(shadow/scrim)开始算"); + } +} diff --git a/crates/design/src/color/boards/silver.rs b/crates/design/src/color/boards/silver.rs new file mode 100644 index 0000000..69f626d --- /dev/null +++ b/crates/design/src/color/boards/silver.rs @@ -0,0 +1,99 @@ +//! **Silver Cat 浅色板**(规格卡 `SILVER CAT / LIGHT SCHEME` 一节 + 同页浅色字段/按钮)。 +//! +//! 与 [`super::ink`] 同一套口径:49 行按 [`Role`] 声明序排列,`[spec]` = 规格卡直写值 +//! (与 [`ANCHORS`] 同源,测试逐条比对 hex),`[derive]` = 按同色相 + M3 tone 关系推导后固化。 + +use crate::color::{Color, Role}; + +/// 规格卡直接给出的浅色色值,逐条对账用。 +/// +/// 出处分三处:SILVER CAT / LIGHT SCHEME 的色板行、同页 Filled/Tonal/Outlined 按钮与两个字段 +/// (`on-primary` 取自 Filled 的文字 `#f6f9ff`、`on-secondary-container` 取自 Tonal 的文字 +/// `#0d1d2a`、`outline-variant` 取自色板方块的描边 `#bec8cd`)。 +pub const ANCHORS: [(Role, &str); 13] = [ + (Role::Primary, "#006499"), + (Role::OnPrimary, "#F6F9FF"), + (Role::PrimaryContainer, "#87C7FF"), + (Role::SecondaryContainer, "#D4E4F6"), + (Role::OnSecondaryContainer, "#0D1D2A"), + (Role::Surface, "#F9F9FA"), + (Role::SurfaceContainer, "#EEEEEF"), + (Role::SurfaceContainerHigh, "#DCDDDE"), + (Role::OnSurface, "#1A1C1D"), + (Role::OnSurfaceVariant, "#434D51"), + (Role::Outline, "#6F797D"), + (Role::OutlineVariant, "#BEC8CD"), + (Role::Error, "#AC3434"), +]; + +/// Silver 板 49 个角色(下标 = `Role as usize`)。 +pub const COLORS: [Color; 49] = [ + Color::rgb(0x00, 0x64, 0x99), // 01 primary [spec] 实心按钮底、文字按钮标签 + Color::rgb(0xF6, 0xF9, 0xFF), // 02 on-primary [spec] Filled 按钮文字 + Color::rgb(0x87, 0xC7, 0xFF), // 03 primary-container [spec] + Color::rgb(0x00, 0x34, 0x4E), // 04 on-primary-container [derive] container @ tone 20 + Color::rgb(0x4A, 0x60, 0x76), // 05 secondary [derive] tone 40,蓝灰 + Color::rgb(0xF6, 0xF9, 0xFF), // 06 on-secondary [derive] tone 98 + Color::rgb(0xD4, 0xE4, 0xF6), // 07 secondary-container [spec] Tonal 按钮底、选中 chip + Color::rgb(0x0D, 0x1D, 0x2A), // 08 on-secondary-container [spec] Tonal 按钮文字 + Color::rgb(0x7C, 0x52, 0x25), // 09 tertiary [derive] tone 40,暖棕(与主色互补) + Color::rgb(0xFF, 0xFF, 0xFF), // 10 on-tertiary [derive] tone 100 + Color::rgb(0xFF, 0xDC, 0xC1), // 11 tertiary-container [derive] tone 90 + Color::rgb(0x2B, 0x17, 0x00), // 12 on-tertiary-container [derive] tone 10 + Color::rgb(0xAC, 0x34, 0x34), // 13 error [spec] 错误文字 + Color::rgb(0xFF, 0xFF, 0xFF), // 14 on-error [derive] tone 100 + Color::rgb(0xFF, 0xDA, 0xD6), // 15 error-container [derive] error @ tone 90 + Color::rgb(0x41, 0x00, 0x02), // 16 on-error-container [derive] error @ tone 10 + Color::rgb(0xF9, 0xF9, 0xFA), // 17 surface [spec] 窗口底 + Color::rgb(0x1A, 0x1C, 0x1D), // 18 on-surface [spec] 正文/标题 + Color::rgb(0xD9, 0xDE, 0xE2), // 19 surface-variant [derive] neutral-variant tone 88 + Color::rgb(0x43, 0x4D, 0x51), // 20 on-surface-variant [spec] 次要文字、字段标签 + Color::rgb(0xF9, 0xF9, 0xFA), // 21 background [spec](= surface) + Color::rgb(0x1A, 0x1C, 0x1D), // 22 on-background [spec](= on-surface) + Color::rgb(0xD8, 0xDA, 0xDA), // 23 surface-dim [derive] tone 87 + Color::rgb(0xF9, 0xF9, 0xFA), // 24 surface-bright [derive](= surface,M3 浅色同 tone 98) + Color::rgb(0xFF, 0xFF, 0xFF), // 25 surface-container-lowest [derive] tone 100 + Color::rgb(0xF3, 0xF3, 0xF4), // 26 surface-container-low [derive] tone 96 + Color::rgb(0xEE, 0xEE, 0xEF), // 27 surface-container [spec] 卡片/列表行底 + Color::rgb(0xDC, 0xDD, 0xDE), // 28 surface-container-high [spec] 输入框底 + Color::rgb(0xD5, 0xD6, 0xD7), // 29 surface-container-highest [derive] tone 86 + Color::rgb(0x6F, 0x79, 0x7D), // 30 outline [spec] 字段描边 + Color::rgb(0xBE, 0xC8, 0xCD), // 31 outline-variant [spec] 分隔线、chip 描边 + Color::rgb(0x2F, 0x31, 0x33), // 32 inverse-surface [derive] tone 20 + Color::rgb(0xF0, 0xF0, 0xF1), // 33 inverse-on-surface [derive] tone 95 + Color::rgb(0x87, 0xC7, 0xFF), // 34 inverse-primary [derive](= primary-container) + Color::rgb(0x00, 0x00, 0x00), // 35 shadow M3 定值:纯黑 + 令牌 alpha + Color::rgb(0x00, 0x00, 0x00), // 36 scrim M3 定值:纯黑 + 令牌 alpha + Color::rgb(0x00, 0x64, 0x99), // 37 surface-tint = primary(M3 旧 tint 口径) + Color::rgb(0x87, 0xC7, 0xFF), // 38 primary-fixed [derive] tone 78(= container) + Color::rgb(0x5F, 0xAE, 0xE6), // 39 primary-fixed-dim [derive] tone 68 + Color::rgb(0x00, 0x1E, 0x2E), // 40 on-primary-fixed [derive] tone 10 + Color::rgb(0x00, 0x3A, 0x57), // 41 on-primary-fixed-variant [derive] tone 23 + Color::rgb(0xD4, 0xE4, 0xF6), // 42 secondary-fixed [derive] tone 90 + Color::rgb(0xB8, 0xC8, 0xDA), // 43 secondary-fixed-dim [derive] tone 80 + Color::rgb(0x0D, 0x1D, 0x2A), // 44 on-secondary-fixed [derive] tone 10 + Color::rgb(0x33, 0x48, 0x5A), // 45 on-secondary-fixed-variant [derive] tone 30 + Color::rgb(0xFF, 0xDC, 0xC1), // 46 tertiary-fixed [derive] tone 90 + Color::rgb(0xF0, 0xBD, 0x8E), // 47 tertiary-fixed-dim [derive] tone 80 + Color::rgb(0x2B, 0x17, 0x00), // 48 on-tertiary-fixed [derive] tone 10 + Color::rgb(0x5E, 0x3F, 0x1A), // 49 on-tertiary-fixed-variant [derive] tone 29 +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn anchors_pin_the_spec() { + for (role, hex) in ANCHORS { + let expected = Color::from_hex(hex).expect("规格卡色值"); + assert_eq!( + COLORS[role as usize], + expected, + "{} 的色值与规格卡({hex})不符:表里是 {}", + role.name(), + COLORS[role as usize].to_hex() + ); + } + } +} diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index ec027ef..e6c9bec 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -105,6 +105,7 @@ //! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。 //! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。 +pub mod boards; pub mod hct; pub mod palettes; pub mod roles; @@ -119,6 +120,7 @@ mod hct_solver; pub(crate) mod space; mod viewing_conditions; +pub use boards::BrandBoard; pub use hct::Hct; pub use palettes::{ PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED, NEUTRAL_HUE_DEG, diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index 68892f8..b849ad2 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -13,6 +13,7 @@ //! 表错一格,对齐测试就会红。三档对比度(medium/high)不在本 crate 范围内,见 //! [`super`] 的 gap 登记。 +use super::boards::BrandBoard; use super::palettes::{PaletteSlot, Palettes}; use super::roles::Role; use super::Color; @@ -212,6 +213,19 @@ impl Scheme { Self { flavor, colors } } + /// **品牌色板**(Ink 深 / Silver 浅)→ 完整方案:`unlucky_design::color::BrandBoard`。 + /// + /// 与 [`Scheme::baseline`] 的区别不是算法而是**来源**:baseline 由种子 + tone 表推导(D38 + /// 取色链),本入口直接吃用户规格卡固化的 49 个色值(`color::boards`)。深浅模式由板自己 + /// 决定(`BrandBoard::flavor`),所以不需要再传 [`SchemeFlavor`]——穿错了会得到「深板浅色」 + /// 这种自相矛盾的方案。 + pub fn from_board(board: BrandBoard) -> Self { + Self { + flavor: board.flavor(), + colors: board.colors(), + } + } + /// 从 49 个已解析色值直接构造(**底层入口**,下标必须等于 `Role as usize`)。 /// /// 只给两类调用方用: diff --git a/crates/design/src/inkcat/metrics.rs b/crates/design/src/inkcat/metrics.rs new file mode 100644 index 0000000..35e529e --- /dev/null +++ b/crates/design/src/inkcat/metrics.rs @@ -0,0 +1,251 @@ +//! 控件方框几何(规格卡的 `CARD / DIALOG / SNACKBAR / LIST`、`TEXT FIELDS`、`SWITCH / CHECKBOX / +//! RADIO / CHIPS`、`PROGRESS` 五节)。 +//! +//! 口径与 [`crate::component_map::ComponentMetrics`] 一致:`width`/`height` 为 `0.0` 表示 +//! **由内容决定**(自适应宽),不是「尺寸为零」。差别只在**来源**——这里是规格卡的值, +//! `component_map` 是官方组件令牌的值,两者不一致处已在 [`super`] 的登记表里逐条写明。 +//! +//! 圆角一律引用 [`ShapeScale`],不写裸数字;`SpecBox::radius_px()` 用**高度**折算 +//! (药丸按高的一半、非药丸按刻度值且不超过半高),所以同一个结构既能表达 60 高药丸行(30), +//! 也能表达 150 高圆角卡(16)。 + +use crate::shape::ShapeScale; + +/// 一个控件方框(宽 / 高 / 圆角档)。`0.0` = 由内容决定。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpecBox { + /// 可见外框宽(px;`0.0` = 自适应)。 + pub width: f32, + /// 可见外框高(px)。 + pub height: f32, + /// 圆角档([`ShapeScale`] 的刻度值)。 + pub shape: ShapeScale, +} + +impl SpecBox { + /// 实际圆角(px):按高度折算,且不超过高的一半。 + pub fn radius_px(&self) -> f32 { + self.shape.radius_px(self.height) + } + + /// 是否是药丸/整圆(圆角 = 半高)。 + pub fn is_pill(&self) -> bool { + (self.radius_px() - self.height / 2.0).abs() < f32::EPSILON && self.height > 0.0 + } +} + +/// 卡片:300×150、圆角 **16**(规格卡 `ELEVATED CARD` 的 `rx=16`)。 +/// +/// 与官方 `FilledCardTokens` 的 `CornerMedium(12)` 差 4 —— 规格卡口径取 16(Large)。 +pub const CARD: SpecBox = SpecBox { + width: 300.0, + height: 150.0, + shape: ShapeScale::Large, +}; + +/// 对话框:380×190、圆角 **28**(与官方 `DialogTokens.CornerExtraLarge` 一致)。 +pub const DIALOG: SpecBox = SpecBox { + width: 380.0, + height: 190.0, + shape: ShapeScale::ExtraLarge, +}; + +/// Snackbar:340×52、圆角 **16**(官方是 `ExtraSmall(4)`,规格卡取 16 —— 登记差异)。 +pub const SNACKBAR: SpecBox = SpecBox { + width: 340.0, + height: 52.0, + shape: ShapeScale::Large, +}; + +/// 列表行:300×**60**、圆角 **30**(= 药丸;官方 `CornerNone`,本项目 `Small(8)`,规格卡药丸 —— 登记差异)。 +pub const LIST_ROW: SpecBox = SpecBox { + width: 300.0, + height: 60.0, + shape: ShapeScale::Full, +}; + +/// Chip(assist / filter):高 **32**、圆角 **8**、宽度自适应(标签 13px、左右内距 16)。 +/// +/// 选中态的**形状不变**(规格卡 `Selected` 只是换填充),所以这里不给形变数据。 +pub const CHIP: SpecBox = SpecBox { + width: 0.0, + height: 32.0, + shape: ShapeScale::Small, +}; + +/// Chip 左右内距(px)。规格卡 chip 自适应宽;16 与 M3 `AssistChipTokens` 的 `HorizontalPadding` +/// 同值,且实测标签订了这个量级。 +pub const CHIP_PAD_X: f32 = 16.0; + +/// 徽标(数字圆点):20 Ø、整圆。 +pub const BADGE: SpecBox = SpecBox { + width: 20.0, + height: 20.0, + shape: ShapeScale::Full, +}; + +/// 列表行头像:40 Ø、整圆。 +pub const AVATAR: SpecBox = SpecBox { + width: 40.0, + height: 40.0, + shape: ShapeScale::Full, +}; + +/// 加载指示:56 Ø,圆形态(`rx=28`)与方形态(`rx=12` = [`ShapeScale::Medium`])两种。 +pub const LOADING: SpecBox = SpecBox { + width: 56.0, + height: 56.0, + shape: ShapeScale::Full, +}; + +/// 加载指示的方形态圆角档(规格卡 `rx=12`)。 +pub const LOADING_SQUIRCLE: ShapeScale = ShapeScale::Medium; + +// ---- 文本字段(TEXT FIELDS / FILLED - OUTLINED - ERROR + ROUNDED 变体)---- + +/// 字段高度(px;filled / outlined / error / 搜索框都是 56)。 +pub const FIELD_HEIGHT: f32 = 56.0; +/// 默认圆角档:**4**(规格卡标题就写着 `CORNER 4`;与官方 `CornerExtraSmall` 一致)。 +pub const FIELD_SHAPE: ShapeScale = ShapeScale::ExtraSmall; +/// 药丸变体圆角档:**28**(规格卡 `ROUNDED (CORNER 28, BRAND VARIANT)`)。 +pub const FIELD_SHAPE_ROUNDED: ShapeScale = ShapeScale::ExtraLarge; +/// 默认字段的内容内距(px):标签 `x=80` − 字段 `x=64` = **16**。 +pub const FIELD_INSET: f32 = 16.0; +/// 药丸字段的内容内距(px):**32**。 +/// +/// 规格卡专门写了一行提醒:`label inset 32 inside pills (not 16), otherwise it reads as clipped`—— +/// 药丸两端是半圆,沿用 16 会让标签贴到弧上。 +pub const FIELD_PILL_INSET: f32 = 32.0; +/// 字段悬浮标签字号(px)与输入值字号(px)。 +pub const FIELD_LABEL_PX: f32 = 12.0; +/// 字段输入值字号(px)。 +pub const FIELD_VALUE_PX: f32 = 16.0; +/// 错误提示行字号(px)。 +pub const FIELD_ERROR_PX: f32 = 12.0; + +/// 搜索框:560 宽是页级骨架量(这里只给高度与形状),圆角 **28** = 药丸,图标内距 **32**。 +pub const SEARCH_SHAPE: ShapeScale = ShapeScale::Full; +/// 搜索框图标中心距左边(px):`cx=1048` − 框 `x=1016` = **32**。 +pub const SEARCH_INSET: f32 = 32.0; + +// ---- 开关 / 复选框 / 单选框 ---- + +/// 开关:轨道 52×32 药丸,拇指 **24 Ø**、内缩 **4**(关态拇指 16 Ø + 描边 2)。 +pub const SWITCH_TRACK: SpecBox = SpecBox { + width: 52.0, + height: 32.0, + shape: ShapeScale::Full, +}; +/// 开关拇指直径(px)。 +pub const SWITCH_THUMB: f32 = 24.0; +/// 拇指相对轨道的内缩(px):拇指 `x=88` − 轨道 `x=64` − 描边 …= 实测 0 起点 24 偏移、右端留 4。 +pub const SWITCH_THUMB_INSET: f32 = 4.0; +/// 关态拇指直径(px):规格卡 `16×16 rx8`。 +pub const SWITCH_THUMB_OFF: f32 = 16.0; + +/// 复选框:**18×18**、圆角 **4**、描边 **2**(规格卡 `rect 18×18 rx4` + 勾线 `stroke-width=2`)。 +/// +/// 与官方 20×20 / 2dp 差 2px 与 2dp —— 4 是 `ShapeScale` 的刻度值,所以走容器层不违反 D32。 +pub const CHECKBOX: SpecBox = SpecBox { + width: 18.0, + height: 18.0, + shape: ShapeScale::ExtraSmall, +}; +/// 复选框描边/勾线宽度(px)。 +pub const CHECKBOX_STROKE: f32 = 2.0; + +/// 单选框:**18 Ø**、环线 **2**、内点 **10 Ø**(规格卡 `r=9` 环 + `r=5` 实心)。 +pub const RADIO: SpecBox = SpecBox { + width: 18.0, + height: 18.0, + shape: ShapeScale::Full, +}; +/// 单选框环线宽度(px)。 +pub const RADIO_RING: f32 = 2.0; +/// 单选框内点直径(px)。 +pub const RADIO_DOT: f32 = 10.0; +/// 勾选控件与标签的间距(px;规格卡 `18` 框 → 标签 `x=398`,实测 8)。 +pub const CHOICE_GAP: f32 = 8.0; + +// ---- 进度 ---- + +/// 线性进度条厚度(px):`rect height=4`。 +pub const PROGRESS_THICKNESS: f32 = 4.0; +/// 波形进度的描边宽度(px):`stroke-width=6`、`stroke-linecap=round`。 +pub const PROGRESS_WAVY_STROKE: f32 = 6.0; + +#[cfg(test)] +mod tests { + use super::*; + use crate::spacing::GRID; + + #[test] + fn spec_boxes_hit_the_spec_corners() { + // 规格卡逐个比对(rx 一栏就是 `radius_px()`)。 + assert_eq!(CARD.radius_px(), 16.0, "卡片 rx=16"); + assert_eq!(DIALOG.radius_px(), 28.0, "对话框 rx=28"); + assert_eq!(SNACKBAR.radius_px(), 16.0, "snackbar rx=16"); + assert_eq!(LIST_ROW.radius_px(), 30.0, "列表行 rx=30(药丸)"); + assert_eq!(CHIP.radius_px(), 8.0, "chip rx=8"); + assert_eq!(BADGE.radius_px(), 10.0, "徽标是圆点"); + assert_eq!(AVATAR.radius_px(), 20.0, "头像是圆"); + assert_eq!(LOADING.radius_px(), 28.0, "加载圆形态"); + assert_eq!(LOADING_SQUIRCLE.dp(), 12.0, "加载方形态 rx=12"); + assert_eq!(CHECKBOX.radius_px(), 4.0, "复选框 rx=4"); + assert_eq!(SWITCH_TRACK.radius_px(), 16.0, "开关轨道 rx=16"); + } + + #[test] + fn pills_are_only_the_ones_the_spec_draws_as_pills() { + assert!(LIST_ROW.is_pill()); + assert!(BADGE.is_pill()); + assert!(AVATAR.is_pill()); + assert!(LOADING.is_pill()); + assert!(RADIO.is_pill()); + assert!(SWITCH_TRACK.is_pill()); + assert!(!CARD.is_pill()); + assert!(!DIALOG.is_pill()); + assert!(!SNACKBAR.is_pill()); + assert!(!CHECKBOX.is_pill()); + } + + #[test] + fn field_variants_keep_the_inset_rule() { + assert_eq!(FIELD_SHAPE.dp(), 4.0); + assert_eq!(FIELD_SHAPE_ROUNDED.dp(), 28.0); + // 药丸内距必须是默认内距的两倍——规格卡那行提醒就是这条。 + assert_eq!(FIELD_PILL_INSET, FIELD_INSET * 2.0); + } + + #[test] + fn heights_are_on_the_grid() { + for (name, value) in [ + ("snackbar", SNACKBAR.height), + ("list-row", LIST_ROW.height), + ("chip", CHIP.height), + ("badge", BADGE.height), + ("avatar", AVATAR.height), + ("loading", LOADING.height), + ("field", FIELD_HEIGHT), + ("switch", SWITCH_TRACK.height), + ("switch-thumb", SWITCH_THUMB), + ("progress", PROGRESS_THICKNESS), + ("choice-gap", CHOICE_GAP), + ("search-inset", SEARCH_INSET), + ] { + assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上"); + } + // 微控件 18:规格卡实测值(M3 官方 `CheckboxTokens.ContainerWidth/Height` 也是 18), + // 它与 2dp/4dp 圆角一样属「微控件刻度」,不受 4px 栅格约束。 + assert_eq!((CHECKBOX.width, CHECKBOX.height), (18.0, 18.0)); + assert_eq!((RADIO.width, RADIO.height), (18.0, 18.0)); + // 卡 / 对话框的 300×150 与 380×190 是规格卡画布上的**示意**尺寸(实际高由内容决定), + // 所以只钉数值、不参与栅格门禁。 + assert_eq!((CARD.width, CARD.height), (300.0, 150.0)); + assert_eq!((DIALOG.width, DIALOG.height), (380.0, 190.0)); + // 例外:描边类不受栅格约束(它们是一像素/线宽量,不是布局尺寸),单独钉住。 + assert_eq!(CHECKBOX_STROKE, 2.0); + assert_eq!(RADIO_RING, 2.0); + assert_eq!(PROGRESS_WAVY_STROKE, 6.0, "波形进度描边 stroke-width=6"); + } +} diff --git a/crates/design/src/inkcat/mod.rs b/crates/design/src/inkcat/mod.rs new file mode 100644 index 0000000..6534b33 --- /dev/null +++ b/crates/design/src/inkcat/mod.rs @@ -0,0 +1,48 @@ +//! # Ink Cat 规格卡 → 令牌(形状 / 尺寸档 / 字号校核) +//! +//! 来源是用户自制的规格卡 `DevDocs/artifacts/components-ink.svg`(Ink Cat / M3 Expressive, +//! 1480×2260)。色板在 [`crate::color::boards`],**本模块只装几何与排版**: +//! +//! | 子模块 | 内容 | +//! |---|---| +//! | [`size`] | 按钮 XS~XL 五档([`size::ControlSize`])+ FAB 四态([`size::FabSize`]) | +//! | [`metrics`] | 控件方框几何:卡片 / 对话框 / snackbar / 列表行 / chip / 徽标 / 字段 / 复选框 / 单选框 / 开关 / 进度 / 加载 | +//! | [`type_scale`] | 规格卡出现的每个字号 → 最近官方档的**校核结论**(命中 / 落在两档之间 / 低于 UI 下限) | +//! +//! ## 与 [`crate::component_map`] 的关系:并列来源,不是替代 +//! +//! `component_map` 的口径是**官方 M3 组件令牌**(`androidx.compose.material3.tokens`,A 级); +//! 规格卡是**用户自己的视觉决议**(也是 A 级,但是产品口径)。两者在若干处不一致, +//! 本模块把差异**逐条登记**而不是偷偷改 `component_map`: +//! +//! | 项 | 规格卡 | `component_map`(官方) | 处置 | +//! |---|---|---|---| +//! | 卡片圆角 | **16**(`rx=16`) | `FilledCard/OutlinedCard = Medium(12)` | 登记:换肤时取 [`metrics::CARD`](Large 16)。表值不动——app-iced 的 `pages/frame/tests.rs` 按 12 pin 住,改表等于连带改观感,归 M229 | +//! | Snackbar | 高 **52** / 圆角 **16** | `ExtraSmall(4)` | 登记同上([`metrics::SNACKBAR`]) | +//! | 列表行 | 高 **60** / 药丸 **30** | `Small(8)` | 登记同上([`metrics::LIST_ROW`]) | +//! | 输入框 | 圆角 **4**(默认)/ **28**(药丸变体) | `ExtraSmall(4)` | 一致 + 新增药丸变体([`metrics::FIELD_SHAPE_ROUNDED`]) | +//! | Chip | 高 **32** / 圆角 **8** | `Small(8)`、无高度 | 一致 + 补高度([`metrics::CHIP`]) | +//! | Checkbox | **18×18** / 圆角 **4** / 描边 2 | 20×20 / 圆角 2(微半径) | 差异登记([`metrics::CHECKBOX`])。4 是 `ShapeScale` 刻度值,42 号位不冲突 | +//! | Radio | **18 Ø** / 环 2 / 内点 10 | 20 Ø / 环 2 / 内点 10 | 差异登记([`metrics::RADIO`]) | +//! | 按钮档 | **XS~XL = 32/40/56/96/136** | 无档位(`LayoutMetrics::control_height = 40`) | 新增 [`size::ControlSize`] | +//! | FAB | 四态 40/56/80/160×56,圆角 12/16/20/16 | 无 | 新增 [`size::FabSize`] | +//! | 对话框 | 380×190 / 圆角 **28** | `ExtraLarge(28)` | 一致([`metrics::DIALOG`]) | +//! +//! **为什么不动 `component_map` 的表值**:那张表已经被 iced 壳层消费并 pin 住(卡片 12、 +//! 搜索条 Full 等),改值等于在令牌层做了一次没人截图验收的换肤。本模块先给出规格卡口径的 +//! 数据,M229 换肤时一次切换 + 出图验收,避免「一半旧一半新」。 +//! +//! ## 数值口径 +//! +//! - 所有尺寸单位都是**逻辑 px**(100% DPI 下 1px = 1dp),与 `ShapeScale::dp()` 同一刻度; +//! - `0.0` 一律表示「由内容决定」(与 [`crate::component_map::ComponentMetrics`] 同约定), +//! 不是「尺寸为零」; +//! - 规格卡没写、但实现需要的值(按钮水平内距)标 `[估]` 并注明推导规则。 + +pub mod metrics; +pub mod size; +pub mod type_scale; + +pub use metrics::SpecBox; +pub use size::{ControlSize, FabSize}; +pub use type_scale::{SpecFit, SpecText, UI_TEXT_FLOOR_PX}; diff --git a/crates/design/src/inkcat/size.rs b/crates/design/src/inkcat/size.rs new file mode 100644 index 0000000..e1f54bc --- /dev/null +++ b/crates/design/src/inkcat/size.rs @@ -0,0 +1,312 @@ +//! 控件**尺寸档**:按钮 XS~XL 五档 + FAB 四态(规格卡 `BUTTONS / XS - XL` 与 +//! `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。 +//! +//! 为什么要有这一层:`LayoutMetrics::control_height`(40)只够表达「标准控件多高」, +//! 而规格卡把按钮做成了**五档梯子**(32/40/56/96/136),FAB 又自成一套四态。这些是「档位」, +//! 不是骨架尺寸(不随窗口变),也不是单个控件的内部微几何(`ComponentMetrics` 管的是画进 +//! 方框里的那几个像素)。所以单独一层,一个枚举一个档,消费方按语义取。 +//! +//! ## 取值与实测(规格卡 `rx` 全部等于半高 ⇒ 真药丸) +//! +//! | 档 | 高 | 圆角 | 字号 | 规格卡依据 | +//! |---|---|---|---|---| +//! | XS | 32 | 16 | 14 | `40.24×32 rx16`,标签 `font-size=14` | +//! | S | 40 | 20 | 14 | `40.12×40 rx20` | +//! | M | 56 | 28 | 16 | `57.28×56 rx28`(五档行同一档) | +//! | L | 96 | 48 | 24 | `109.92×96 rx48` | +//! | XL | 136 | 68 | 32 | `165.12×136 rx68` | +//! +//! 圆角一律走 [`ShapeScale::Full`](`radius_px(高)` = 半高),**不写裸数字**:规格卡的 rx +//! 恰好都是半高,`Full` 与之一致,还顺带保证「换档不会漏改圆角」。 +//! +//! 字号是规格卡 measured 值;字重 500(Expressive 的按钮文字就是 Medium)。 +//! **规格卡没给水平内距**(按钮是自适应宽),[`ControlSize::pad_x`] 是按「半高向下取 4 栅格、 +//! 封顶 48」推的 `[估]` 值——药丸两端要留出半圆安全区,这条规则比逐档拍数字更经得起改档。 + +use crate::shape::ShapeScale; +use crate::typography::{TypeRole, TypeStyle, Weight}; + +/// 按钮尺寸档(规格卡 `XS - XL`)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ControlSize { + /// 32 高:行内小按钮、徽标式操作。 + Xs, + /// 40 高:默认密集按钮(= `LayoutMetrics::control_height`)。 + S, + /// 56 高:主操作按钮(规格卡 `BUTTONS / 5 LEVELS` 那一行就是这一档)。 + M, + /// 96 高:页面级主行动(空态、引导页)。 + L, + /// 136 高:展示型入口(规格卡给的最大档,慎用)。 + Xl, +} + +impl ControlSize { + /// 由小到大全部档位。 + pub const ALL: [ControlSize; 5] = [ + ControlSize::Xs, + ControlSize::S, + ControlSize::M, + ControlSize::L, + ControlSize::Xl, + ]; + + /// 稳定字符串名(`xs` … `xl`)。 + pub const fn name(self) -> &'static str { + match self { + ControlSize::Xs => "xs", + ControlSize::S => "s", + ControlSize::M => "m", + ControlSize::L => "l", + ControlSize::Xl => "xl", + } + } + + /// 可见高度(px)。 + pub const fn height_px(self) -> f32 { + match self { + ControlSize::Xs => 32.0, + ControlSize::S => 40.0, + ControlSize::M => 56.0, + ControlSize::L => 96.0, + ControlSize::Xl => 136.0, + } + } + + /// 圆角档:全部药丸(规格卡每档的 rx 都等于半高)。 + pub const fn shape(self) -> ShapeScale { + ShapeScale::Full + } + + /// 实际圆角(= 半高,规格卡 rx 实测值 16/20/28/48/68)。 + pub fn radius_px(self) -> f32 { + self.shape().radius_px(self.height_px()) + } + + /// 标签字号(规格卡 measured:14/14/16/24/32)。 + pub const fn label_px(self) -> f32 { + match self { + ControlSize::Xs | ControlSize::S => 14.0, + ControlSize::M => 16.0, + ControlSize::L => 24.0, + ControlSize::Xl => 32.0, + } + } + + /// 标签该用的官方字级(`[spec]` 字号对应的最近档;同一字号多档时取 `Role::ALL` 序里靠前的那个)。 + pub const fn type_role(self) -> TypeRole { + match self { + ControlSize::Xs | ControlSize::S => TypeRole::LabelLarge, + ControlSize::M => TypeRole::BodyLarge, + ControlSize::L => TypeRole::HeadlineSmall, + ControlSize::Xl => TypeRole::HeadlineLarge, + } + } + + /// 标签样式:字号/行高跟字级、**字重固定 500**(规格卡按钮文字都是 `font-weight=500`, + /// 而官方 body/headline 是 400 —— 这是按钮语义,不是排版漂移)。 + pub fn label(self) -> TypeStyle { + TypeStyle { + weight: Weight::Medium, + ..self.type_role().style() + } + } + + /// 水平内距(px)。`[估]`:规格卡是自适应宽、没写内距。 + /// + /// 规则 = 半高向下取 4 栅格,封顶 48(再宽只会白吃宽度,观感不再变)。 + pub const fn pad_x(self) -> f32 { + match self { + ControlSize::Xs => 16.0, + ControlSize::S => 20.0, + ControlSize::M => 28.0, + ControlSize::L => 48.0, + ControlSize::Xl => 48.0, + } + } + + /// 该档是否达到无障碍命中下限(M3:可点目标 ≥48dp)。XS/S 需要外扩命中区。 + pub const fn meets_touch_target(self) -> bool { + self.height_px() >= 48.0 + } +} + +/// FAB 四态(规格卡 `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。 +/// +/// 规格卡的圆角是 12/16/20/16 —— **不是**半高(40 的半高是 20,规格卡是 12),所以这里 +/// 逐个写档位([`ShapeScale::Medium`] / [`ShapeScale::Large`] / [`ShapeScale::LargeIncreased`]), +/// 不用 `Full`。扩展态(Extended)是「药丸 + 图标 + 标签」,几何见 [`FabSize::Extended`] 的各访问器。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FabSize { + /// 40×40,圆角 12,图标 20。 + Small, + /// 56×56,圆角 16,图标 26(= M3 默认 FAB 尺寸)。 + Default, + /// 80×80,圆角 20,图标 34。 + Medium, + /// 160×56(最小宽,随标签撑开),圆角 16,图标 24,标签 15。 + Extended, +} + +impl FabSize { + /// 全部四态。 + pub const ALL: [FabSize; 4] = [ + FabSize::Small, + FabSize::Default, + FabSize::Medium, + FabSize::Extended, + ]; + + /// 稳定字符串名(`small` / `default` / `medium` / `extended`)。 + pub const fn name(self) -> &'static str { + match self { + FabSize::Small => "small", + FabSize::Default => "default", + FabSize::Medium => "medium", + FabSize::Extended => "extended", + } + } + + /// 宽度(px;Extended 是**最小**宽,实际由标签撑开)。 + pub const fn width_px(self) -> f32 { + match self { + FabSize::Small => 40.0, + FabSize::Default => 56.0, + FabSize::Medium => 80.0, + FabSize::Extended => 160.0, + } + } + + /// 高度(px)。 + pub const fn height_px(self) -> f32 { + match self { + FabSize::Small => 40.0, + FabSize::Default => 56.0, + FabSize::Medium => 80.0, + FabSize::Extended => 56.0, + } + } + + /// 圆角档(规格卡 rx:12 / 16 / 20 / 16)。 + pub const fn shape(self) -> ShapeScale { + match self { + FabSize::Small => ShapeScale::Medium, + FabSize::Default | FabSize::Extended => ShapeScale::Large, + FabSize::Medium => ShapeScale::LargeIncreased, + } + } + + /// 实际圆角(px)。 + pub fn radius_px(self) -> f32 { + self.shape().radius_px(self.height_px()) + } + + /// 图标(`+` 字形)字号(规格卡 measured:20/26/34/24)。 + pub const fn icon_px(self) -> f32 { + match self { + FabSize::Small => 20.0, + FabSize::Default => 26.0, + FabSize::Medium => 34.0, + FabSize::Extended => 24.0, + } + } + + /// 标签字号(px;只有 Extended 有标签)。 + pub const fn label_px(self) -> f32 { + match self { + FabSize::Extended => 15.0, + _ => 0.0, + } + } + + /// 起始内距(px;只有 Extended 有:图标左边 = 16)。 + pub const fn pad_start_px(self) -> f32 { + match self { + FabSize::Extended => 16.0, + _ => 0.0, + } + } + + /// 图标与标签的间距(px;Extended 实测 8)。 + pub const fn gap_px(self) -> f32 { + match self { + FabSize::Extended => 8.0, + _ => 0.0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spacing::GRID; + + #[test] + fn button_ladder_is_pill_and_monotone() { + for pair in ControlSize::ALL.windows(2) { + assert!( + pair[0].height_px() < pair[1].height_px(), + "{:?} 应矮于 {:?}", + pair[0], + pair[1] + ); + assert!(pair[0].label_px() <= pair[1].label_px()); + assert!(pair[0].pad_x() <= pair[1].pad_x()); + } + for size in ControlSize::ALL { + // 规格卡实测:rx = 半高(16/20/28/48/68)。 + assert_eq!(size.radius_px(), size.height_px() / 2.0, "{size:?}"); + assert_eq!(size.shape(), ShapeScale::Full); + assert_eq!(size.height_px() % GRID, 0.0, "{size:?} 不在 4px 栅格上"); + assert_eq!(size.pad_x() % GRID, 0.0, "{size:?} 的内距不在 4px 栅格上"); + // 标签字号与所取字级一致(字重才是被覆盖的那一项)。 + assert_eq!(size.label().size_px, size.type_role().style().size_px); + assert_eq!(size.label().weight, Weight::Medium); + } + // 命中下限:只有 L / XL 自身就够 48。 + assert!(!ControlSize::Xs.meets_touch_target()); + assert!(!ControlSize::S.meets_touch_target()); + assert!(ControlSize::M.meets_touch_target()); + } + + #[test] + fn fab_corners_are_the_spec_values() { + // 规格卡 rx:40→12、56→16、80→20、扩展 56→16(都不是半高,所以不许用 Full)。 + for (size, radius) in [ + (FabSize::Small, 12.0), + (FabSize::Default, 16.0), + (FabSize::Medium, 20.0), + (FabSize::Extended, 16.0), + ] { + assert_eq!(size.radius_px(), radius, "{size:?}"); + assert_ne!(size.shape(), ShapeScale::Full, "{size:?} 不是药丸"); + } + // 方态三档是正方形;扩展态是药丸。 + for size in [FabSize::Small, FabSize::Default, FabSize::Medium] { + assert_eq!(size.width_px(), size.height_px(), "{size:?} 该是正方形"); + assert_eq!(size.label_px(), 0.0); + assert_eq!(size.pad_start_px(), 0.0); + } + assert_eq!(FabSize::Extended.width_px(), 160.0); + assert_eq!(FabSize::Extended.height_px(), 56.0); + assert_eq!(FabSize::Extended.label_px(), 15.0); + assert_eq!(FabSize::Extended.pad_start_px(), 16.0); + assert_eq!(FabSize::Extended.gap_px(), 8.0); + // 图标字号随档递增(扩展态回到 24,因为它跟标签同一行)。 + assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px()); + assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px()); + assert_eq!(FabSize::Extended.icon_px(), 24.0); + } + + #[test] + fn names_are_unique() { + let mut seen = std::collections::BTreeSet::new(); + for size in ControlSize::ALL { + assert!(seen.insert(size.name())); + } + let mut seen = std::collections::BTreeSet::new(); + for size in FabSize::ALL { + assert!(seen.insert(size.name())); + } + } +} diff --git a/crates/design/src/inkcat/type_scale.rs b/crates/design/src/inkcat/type_scale.rs new file mode 100644 index 0000000..6f88fea --- /dev/null +++ b/crates/design/src/inkcat/type_scale.rs @@ -0,0 +1,284 @@ +//! 规格卡字号 → 官方 type scale 的**校核**(不是又一张字号表)。 +//! +//! 规格卡上每一处文字都写了 `font-size`:40 / 32 / 24 / 20 / 16 / 15 / 14 / 13 / 12 / 11 / 10 / 9。 +//! 其中一部分**命中** M3 的 15 档(14 / 16 / 24 / 32 / 12 / 11),一部分**落在两档之间** +//! (13 / 15 / 40),还有一部分**低于本项目允许的 UI 下限**(10 / 9)。 +//! +//! 本模块的职责就是把「规格卡字级」翻译成这三类结论,让换肤时不用逐个拿卷尺量: +//! +//! | 结论 | 含义 | 处置 | +//! |---|---|---| +//! | [`SpecFit::Exact`] | 恰好是官方某档 | 直接用该 [`TypeRole`] | +//! | [`SpecFit::Between`] | 落在相邻两档之间 | 取**较大**那档(可读性优先),并在报告里登记 1px 级偏离 | +//! | [`SpecFit::BelowFloor`] | 低于 [`UI_TEXT_FLOOR_PX`](11) | **不进 UI**:只允许出现在规格卡/图注/设计稿里 | +//! +//! ## 为什么下限是 11 +//! +//! [`TypeRole::LabelSmall`] 是官方最小档(11/16),且 crate 文档已经写明「中文 11px 可读性差, +//! 谨慎使用」。规格卡的 10(小节标题 `BUTTONS / 5 LEVELS`)与 9(色板角色名)是**设计稿标注**, +//! 不是界面文字——搬进 UI 就是拿中文在图上看蚂蚁。所以下限写成常量,由 +//! [`SpecText::in_ui`] 与测试把它钉死。 +//! +//! ## 同字号多档怎么办 +//! +//! M3 有若干同字号档(14 有 title-small / body-medium / label-large;16 有 title-medium / +//! body-large;12 有 body-small / label-medium)。[`nearest_role`] 在距离相同时按 +//! [`TypeRole::ALL`] 的声明序取第一个(title → body → label),这是**稳定的**、与文档一致的选择; +//! 需要别的语义(例如「这是按钮文字」)就显式指定 [`TypeRole`],不要指望 `nearest_role` 猜。 + +use crate::typography::{TypeRole, Weight}; + +/// 允许进 UI 的最小字号(px)。低于它的规格卡字号只能是设计稿标注。 +pub const UI_TEXT_FLOOR_PX: f32 = 11.0; + +/// 规格卡上出现的文字用途(每个用途一个 `font-size`,值取自规格卡)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SpecText { + /// 卡片大标题:40 / 500 / 0(`Ink Cat / M3 Expressive` 那行)。 + PageTitle, + /// 副标题:15 / 400 / 0.3。 + Subtitle, + /// 小节标注(全大写 + 字距):10 / 600 / 1.2。 + SectionCaps, + /// 大按钮标签:32 / 500 / 0(XL 档)。 + DisplayLabel, + /// 对话框标题:24 / 400 / 0。 + DialogTitle, + /// 标准控件标签:16 / 500 / 0(M 档按钮、卡片标题)。 + ControlLabel, + /// 小控件标签:14 / 500 / 0(XS/S 档按钮、对话框动作、snackbar)。 + ControlLabelSmall, + /// 正文:13 / 400 / 0(卡片支持文字、chip 标签、列表副文案)。 + Body, + /// 辅助文字:12 / 400 / 0(字段标签、图标按钮说明)。 + Caption, + /// 徽标数字:11 / 600 / 0。 + Badge, + /// 色板角色名(设计稿标注):9 / 400 / 0。 + SwatchCaption, +} + +impl SpecText { + /// 规格卡里出现的全部用途。 + pub const ALL: [SpecText; 11] = [ + SpecText::PageTitle, + SpecText::Subtitle, + SpecText::SectionCaps, + SpecText::DisplayLabel, + SpecText::DialogTitle, + SpecText::ControlLabel, + SpecText::ControlLabelSmall, + SpecText::Body, + SpecText::Caption, + SpecText::Badge, + SpecText::SwatchCaption, + ]; + + /// 字号(px,规格卡 `font-size`)。 + pub const fn px(self) -> f32 { + match self { + SpecText::PageTitle => 40.0, + SpecText::Subtitle => 15.0, + SpecText::SectionCaps => 10.0, + SpecText::DisplayLabel => 32.0, + SpecText::DialogTitle => 24.0, + SpecText::ControlLabel => 16.0, + SpecText::ControlLabelSmall => 14.0, + SpecText::Body => 13.0, + SpecText::Caption => 12.0, + SpecText::Badge => 11.0, + SpecText::SwatchCaption => 9.0, + } + } + + /// 字重(规格卡 `font-weight`)。 + pub const fn weight(self) -> Weight { + match self { + SpecText::SectionCaps | SpecText::Badge => Weight::SemiBold, + SpecText::PageTitle + | SpecText::DisplayLabel + | SpecText::ControlLabel + | SpecText::ControlLabelSmall => Weight::Medium, + _ => Weight::Regular, + } + } + + /// 字距(px,规格卡 `letter-spacing`)。 + pub const fn tracking_px(self) -> f32 { + match self { + SpecText::SectionCaps => 1.2, + SpecText::Subtitle => 0.3, + _ => 0.0, + } + } + + /// 校核结论(命中 / 落在两档之间 / 低于下限)。 + pub fn fit(self) -> SpecFit { + fit_of(self.px()) + } + + /// 能否进 UI(≥ [`UI_TEXT_FLOOR_PX`])。 + pub const fn in_ui(self) -> bool { + self.px() >= UI_TEXT_FLOOR_PX + } +} + +/// 一个字号与官方 type scale 的关系。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SpecFit { + /// 恰好等于官方某档(同字号多档时取 `TypeRole::ALL` 序里第一个)。 + Exact(TypeRole), + /// 落在相邻两档之间(小档, 大档)。 + Between(TypeRole, TypeRole), + /// 低于 [`UI_TEXT_FLOOR_PX`]:只能是设计稿标注。 + BelowFloor, +} + +impl SpecFit { + /// 建议采用的档位(`BelowFloor` 返回 `None`:不该进 UI)。 + /// + /// `Between` 取**较大**那档——宁可偏大 1px,也不要偏小到中文糊成一团。 + pub fn recommended(self) -> Option { + match self { + SpecFit::Exact(role) => Some(role), + SpecFit::Between(_, larger) => Some(larger), + SpecFit::BelowFloor => None, + } + } +} + +/// `px` 的最近官方档(距离相同取**较大**那档;同字号多档取 `TypeRole::ALL` 序里第一个)。 +pub fn nearest_role(px: f32) -> TypeRole { + let mut best = TypeRole::BodyMedium; + let mut best_key = (f32::INFINITY, f32::NEG_INFINITY); + for role in TypeRole::ALL { + let size = role.style().size_px; + // 排序键:(距离, −字号);严格优于当前才换,所以同键时保留 `ALL` 序靠前的那个。 + let key = ((size - px).abs(), -size); + if key.0 < best_key.0 || (key.0 == best_key.0 && key.1 < best_key.1) { + best = role; + best_key = key; + } + } + best +} + +/// `px` 与官方 type scale 的关系。 +pub fn fit_of(px: f32) -> SpecFit { + if px < UI_TEXT_FLOOR_PX { + return SpecFit::BelowFloor; + } + let mut exact = None; + let mut lower: Option<(f32, TypeRole)> = None; + let mut upper: Option<(f32, TypeRole)> = None; + for role in TypeRole::ALL { + let size = role.style().size_px; + if (size - px).abs() < f32::EPSILON { + // 同字号多档(14/16/12):保留 `ALL` 序里第一个(title → body → label)。 + exact = exact.or(Some(role)); + } else if size < px { + if lower.is_none_or(|(best, _)| size > best) { + lower = Some((size, role)); + } + } else if upper.is_none_or(|(best, _)| size < best) { + upper = Some((size, role)); + } + } + match (exact, lower, upper) { + (Some(role), _, _) => SpecFit::Exact(role), + (None, Some((_, lower)), Some((_, upper))) => SpecFit::Between(lower, upper), + // 超出官方区间(px > 57):取最近档,不硬造一个不存在的「之间」。 + _ => SpecFit::Exact(nearest_role(px)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn spec_sizes_classify_as_documented() { + // 命中:14 / 16 / 24 / 32 / 12 / 11 + assert_eq!( + SpecText::ControlLabelSmall.fit(), + SpecFit::Exact(TypeRole::TitleSmall) + ); + assert_eq!( + SpecText::ControlLabel.fit(), + SpecFit::Exact(TypeRole::TitleMedium) + ); + assert_eq!( + SpecText::DialogTitle.fit(), + SpecFit::Exact(TypeRole::HeadlineSmall) + ); + assert_eq!( + SpecText::DisplayLabel.fit(), + SpecFit::Exact(TypeRole::HeadlineLarge) + ); + assert_eq!(SpecText::Caption.fit(), SpecFit::Exact(TypeRole::BodySmall)); + assert_eq!(SpecText::Badge.fit(), SpecFit::Exact(TypeRole::LabelSmall)); + // 落在两档之间:13(12/14)、15(14/16)、40(36/45) + assert_eq!( + SpecText::Body.fit(), + SpecFit::Between(TypeRole::BodySmall, TypeRole::TitleSmall) + ); + assert_eq!( + SpecText::Subtitle.fit(), + SpecFit::Between(TypeRole::TitleSmall, TypeRole::TitleMedium) + ); + assert_eq!( + SpecText::PageTitle.fit(), + SpecFit::Between(TypeRole::DisplaySmall, TypeRole::DisplayMedium) + ); + // 低于下限:10 / 9 + assert_eq!(SpecText::SectionCaps.fit(), SpecFit::BelowFloor); + assert_eq!(SpecText::SwatchCaption.fit(), SpecFit::BelowFloor); + } + + #[test] + fn only_the_two_annotation_sizes_are_out_of_ui() { + let out: Vec<&str> = SpecText::ALL + .iter() + .filter(|text| !text.in_ui()) + .map(|text| match text { + SpecText::SectionCaps => "section-caps", + SpecText::SwatchCaption => "swatch-caption", + _ => "不该出现在这里", + }) + .collect(); + assert_eq!(out, vec!["section-caps", "swatch-caption"]); + // 下限就是 LabelSmall 的字号(官方最小档),不是随手拍的数。 + assert_eq!(UI_TEXT_FLOOR_PX, TypeRole::LabelSmall.style().size_px); + } + + #[test] + fn recommended_rounds_up_not_down() { + assert_eq!(SpecFit::BelowFloor.recommended(), None); + // 13 → 取 14,不取 12。 + assert_eq!(fit_of(13.0).recommended(), Some(TypeRole::TitleSmall)); + // 15 → 取 16。 + assert_eq!(fit_of(15.0).recommended(), Some(TypeRole::TitleMedium)); + // 40 → 取 45,不取 36。 + assert_eq!(fit_of(40.0).recommended(), Some(TypeRole::DisplayMedium)); + } + + #[test] + fn nearest_role_is_stable_for_equal_distances() { + // 15 与 14 / 16 等距 → 取较大档 16。 + assert_eq!(nearest_role(15.0), TypeRole::TitleMedium); + // 官方最小/最大档之外也不越界。 + assert_eq!(nearest_role(4.0), TypeRole::LabelSmall); + assert_eq!(nearest_role(80.0), TypeRole::DisplayLarge); + } + + #[test] + fn spec_weights_and_tracking_are_pinned() { + assert_eq!(SpecText::SectionCaps.weight(), Weight::SemiBold); + assert_eq!(SpecText::Badge.weight(), Weight::SemiBold); + assert_eq!(SpecText::ControlLabel.weight(), Weight::Medium); + assert_eq!(SpecText::Body.weight(), Weight::Regular); + assert!((SpecText::SectionCaps.tracking_px() - 1.2).abs() < 1.0e-6); + assert!((SpecText::Subtitle.tracking_px() - 0.3).abs() < 1.0e-6); + assert_eq!(SpecText::ControlLabel.tracking_px(), 0.0); + } +} diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs index f479ac1..d573ebf 100644 --- a/crates/design/src/lib.rs +++ b/crates/design/src/lib.rs @@ -17,6 +17,10 @@ //! //! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度 //! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档) +//! - [`color::boards`]:**双主题色板**(Ink 深 / Silver 浅,用户规格卡 `components-ink.svg` 的固化值) +//! + [`BrandBoard`] 选择器;与 tone 派生的 `Scheme::baseline` 并列(理由见该模块文档) +//! - [`inkcat`]:规格卡的**形状 / 尺寸档 / 字号校核**——按钮 XS~XL、FAB 四态、控件方框几何、 +//! 规格卡字号 → 官方 type scale 的命中/落档/低于下限结论 //! - [`palette`]:配色来源(D38/D40)——图片像素 → 种子 → 完整方案(Monet 式量化 + 打分)、 //! 内置色卡、三级优先与失败回落、对比度收敛。**只吃已解码的像素**,解码在 `crates/palette-extract` //! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈 @@ -91,13 +95,14 @@ pub mod color; pub mod component_map; pub mod elevation; pub mod icons; +pub mod inkcat; pub mod motion; pub mod palette; pub mod shape; pub mod spacing; pub mod typography; -pub use color::{Color, Role, Scheme, SchemeFlavor}; +pub use color::{BrandBoard, Color, Role, Scheme, SchemeFlavor}; pub use motion::MotionScale; pub use palette::{Card, CardKey, Level as SourceLevel, Provenance, Seed}; pub use spacing::stroke; @@ -105,8 +110,9 @@ pub use typography::TypeRole; /// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。 /// -/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`), -/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。 +/// iced 适配层只持有这个结构(换肤 = 重新 `DesignTokens::brand(board)`,换强调色 = +/// `DesignTokens::with_accent(flavor, accent)`),不要在适配层里缓存散装色值—— +/// 那样热切会有一半画对一半画错。 #[derive(Debug, Clone, Copy)] pub struct DesignTokens { /// 当前深浅模式。 @@ -118,9 +124,13 @@ pub struct DesignTokens { } impl DesignTokens { - /// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板) + /// **tone 派生**的一套令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板) /// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。 /// + /// 定位(M228 起):这是 **D38 取色链的锚点**——「莫奈关闭时机内色卡的底色」与 + /// `with_accent` 的中性基准。**产品出厂外观请走 [`DesignTokens::brand`]**(Ink / Silver + /// 两套固化色板);两条路径并列而不是互相替换,理由见 `color::boards` 的模块文档。 + /// /// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走 /// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。 pub fn baseline(flavor: SchemeFlavor) -> Self { @@ -140,6 +150,19 @@ impl DesignTokens { } } + /// **品牌色板**(Ink 深 / Silver 浅)的令牌:出厂外观的唯一入口。 + /// + /// `flavor` 由色板自己决定(`BrandBoard::flavor`),所以「跟随系统」只需 + /// `DesignTokens::brand(BrandBoard::for_flavor(system_flavor))`——第三种设置态在设置层解析, + /// 令牌层只认两套板。 + pub fn brand(board: BrandBoard) -> Self { + Self { + flavor: board.flavor(), + colors: board.scheme(), + 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/palette/mod.rs b/crates/design/src/palette/mod.rs index c5e2f39..353bed5 100644 --- a/crates/design/src/palette/mod.rs +++ b/crates/design/src/palette/mod.rs @@ -30,6 +30,8 @@ //! 挑主色在 [`score`]——**不是取平均色**,理由见 [`quantize`] 的模块文档。 //! - [`card`]:内置色卡(≥6 张,含出厂默认「天蓝」)。卡的 `key` 是**持久化键**,写进用户设置。 //! - [`wcag`]:对任意一套方案跑对比度断言;不达标就沿 tone 轴修正,并记录「改了什么」。 +//! - [`visibility`]:容器层可视性裁决(状态容器对 `surface` 的 1.20:1 门禁 + 容器阶梯单调), +//! 即债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层结论。 //! - [`resolve`]:纯函数 `resolve_source(...) -> (Scheme, Provenance)`,实现三级优先与回落。 //! //! ## D40 的两条实测事实(不是风格偏好,是物理事实) @@ -43,6 +45,7 @@ pub mod card; pub mod resolve; pub mod seed; +pub mod visibility; pub mod wcag; mod quantize; @@ -54,6 +57,10 @@ pub use seed::{ analyze_rgba, seed_from_rgba, Failure, Seed, MAX_SAMPLES, MIN_PIXELS, MIN_SEED_CHROMA, MUTED_SEED_CHROMA, }; +pub use visibility::{ + check_surface_hierarchy, LayerStep, SurfaceHierarchy, LAYER_ROLES, LAYER_STEP_MIN_LSTAR, + STATE_CONTAINERS, STATE_CONTAINER_MIN, +}; pub use wcag::{converge, Adjustment, WcagReport, AA_NON_TEXT, AA_TEXT, TEXT_PAIRS}; /// 一套方案的来源层级([`Level`] 的别名,读起来更清楚)。 diff --git a/crates/design/src/palette/visibility.rs b/crates/design/src/palette/visibility.rs new file mode 100644 index 0000000..03af0a9 --- /dev/null +++ b/crates/design/src/palette/visibility.rs @@ -0,0 +1,217 @@ +//! # 容器层可视性裁决(债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层结论) +//! +//! ## 问题 +//! +//! m223 视觉复审实测:选中行底(`SecondaryContainer`)与它压着的卡片只差 **1.057:1**, +//! 大色块 + 色相偏移肉眼可辨,但**数不出层级**。逐个页面补色修不完(每个用选中底色的页面都要 +//! 自己调一次,而且下一套色板又得来一遍),所以裁决必须落在令牌层。 +//! +//! ## 裁决(三条,按顺序适用) +//! +//! 1. **文字对比**:所有 `on-*` 对承载色 ≥ 4.5:1 —— 既有 [`super::wcag`] 那道门,不重复定义。 +//! 2. **状态容器对窗口底 ≥ [`STATE_CONTAINER_MIN`] = 1.20:1**:表达「状态」的容器 +//! ([`STATE_CONTAINERS`]:primary / secondary / tertiary / error 的 `*-container`)压在 +//! `surface` 上时必须数得出来。这是唯一一条**数字门禁**,[`SurfaceHierarchy::state_failures`] +//! 守它。 +//! 3. **容器层之间的亮度差不设门禁,改为「组合指示」要求**:M3 的容器层天生贴在一起 +//! (官方口径浅色 `secondary-container` 对 `surface-container-high` 只有 1.05:1, +//! 深色也只有 1.54:1 —— 这是配色结构的必然,不是哪套板的失误)。所以 +//! **禁止**把「容器底色差异」当作唯一的选中 / 层级指示:选中态必须是 +//! 「容器底(过第 2 条)+ 文字对比(第 1 条)+ 形状 / 图标 / 描边指示」三件套。 +//! 另一半同理:`surface-container-*` 只表达「几乎看不见的抬升」, +//! 拿它当选中态用连第 2 条都过不了。 +//! +//! 第 3 条为什么不是「把阈值降到 1.05 就完事」:那样等于承认「选中态可以看不见」, +//! 而用户抱怨的正是这个。把不可测的那一半转成**构成要求**,才是能落地的那一半。 +//! +//! ## 实测(`tests/ink_cat_boards.rs` 守住第 2 条与阶梯) +//! +//! | 板 | 最弱状态容器 / `surface` | 状态容器 / 最亮(深)或最暗(浅)的容器层 | 阶梯 L\\* 步长 | +//! |---|---|---|---| +//! | Ink | **1.97**(secondary-container) | 1.54 ~ 12.13 | +1.4 ~ +4.4 | +//! | Silver | **1.23**(tertiary/error-container) | 1.05 ~ 1.24 | −1.8 ~ −6.1 | +//! | 旧 baseline(债务现场) | 1.23 | **1.057**(对卡片,m223 实测) | — | +//! +//! 第三列就是第 3 条的由来:它过不了任何有意义的门禁,所以不设门禁、只设组合要求。 +//! +//! ## 阶梯本身还是要查([`SurfaceHierarchy::layer_violations`]) +//! +//! `surface-container-lowest` → `highest` 必须**单调**且相邻步长 ≥ [`LAYER_STEP_MIN_LSTAR`] +//! (1.0 个 L\\* 单位)。方向由深浅模式决定:深色板递增(越靠上越亮)、浅色板递减。 +//! 这条抓的是「两档写成同一个色」「顺序填反」这类事故——不抓「差距够不够好看」。 + +use crate::color::{Role, Scheme, SchemeFlavor}; + +/// 状态容器对 `surface` 的最低对比度(唯一的**数字**门禁)。 +pub const STATE_CONTAINER_MIN: f32 = 1.20; + +/// 层级阶梯相邻两档的最小 L\\* 步长(低于这个数就分不出「哪层更高」)。 +pub const LAYER_STEP_MIN_LSTAR: f32 = 1.0; + +/// 表达**状态**的容器角色(必须过 [`STATE_CONTAINER_MIN`])。 +pub const STATE_CONTAINERS: [Role; 4] = [ + Role::PrimaryContainer, + Role::SecondaryContainer, + Role::TertiaryContainer, + Role::ErrorContainer, +]; + +/// 表达**层级**的容器角色(由低到高;只查阶梯单调与步长)。 +pub const LAYER_ROLES: [Role; 5] = [ + Role::SurfaceContainerLowest, + Role::SurfaceContainerLow, + Role::SurfaceContainer, + Role::SurfaceContainerHigh, + Role::SurfaceContainerHighest, +]; + +/// 阶梯上的一步:相对**前一档**的 L\\* 变化量(带符号;深色板应为正、浅色板为负)。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LayerStep { + /// 这一档是谁。 + pub role: Role, + /// 相对前一档的 L\\* 差。 + pub delta_lstar: f32, +} + +/// 一套方案的容器层可视性体检结果(纯数据,不改方案)。 +#[derive(Debug, Clone, PartialEq)] +pub struct SurfaceHierarchy { + /// 方案自己的深浅模式(决定阶梯方向)。 + pub flavor: SchemeFlavor, + /// `surface` 的 L\\*(报告与对账用)。 + pub surface_lstar: f32, + /// 每个状态容器对 `surface` 的实测对比度(顺序同 [`STATE_CONTAINERS`])。 + pub state: [(Role, f32); 4], + /// 层级阶梯的实测 L\\*(顺序同 [`LAYER_ROLES`])。 + pub layers: [(Role, f32); 5], +} + +impl SurfaceHierarchy { + /// 状态容器里最低的一档(观察「刚好卡线」用)。 + pub fn worst_state(&self) -> (Role, f32) { + self.state.iter().copied().fold( + self.state[0], + |acc, item| if item.1 < acc.1 { item } else { acc }, + ) + } + + /// 未达 [`STATE_CONTAINER_MIN`] 的状态容器。 + pub fn state_failures(&self) -> Vec<(Role, f32)> { + self.state + .iter() + .copied() + .filter(|(_, ratio)| *ratio < STATE_CONTAINER_MIN) + .collect() + } + + /// 阶梯的每一步(4 步:lowest→low→container→high→highest)。 + pub fn layer_steps(&self) -> [LayerStep; 4] { + let mut out = [LayerStep { + role: LAYER_ROLES[1], + delta_lstar: 0.0, + }; 4]; + for i in 1..self.layers.len() { + out[i - 1] = LayerStep { + role: self.layers[i].0, + delta_lstar: self.layers[i].1 - self.layers[i - 1].1, + }; + } + out + } + + /// 方向反了或步长不足的阶梯步(深色板要递增、浅色板要递减)。 + pub fn layer_violations(&self) -> Vec { + let sign = if self.flavor == SchemeFlavor::Dark { + 1.0 + } else { + -1.0 + }; + self.layer_steps() + .into_iter() + .filter(|step| step.delta_lstar * sign < LAYER_STEP_MIN_LSTAR) + .collect() + } + + /// 第 2 条与阶梯都过。 + pub fn is_clean(&self) -> bool { + self.state_failures().is_empty() && self.layer_violations().is_empty() + } +} + +/// 跑一遍容器层体检(纯函数)。 +pub fn check_surface_hierarchy(scheme: &Scheme) -> SurfaceHierarchy { + let surface = scheme.get(Role::Surface); + SurfaceHierarchy { + flavor: scheme.flavor(), + surface_lstar: surface.lstar(), + state: std::array::from_fn(|i| { + let role = STATE_CONTAINERS[i]; + (role, scheme.contrast(role, Role::Surface)) + }), + layers: std::array::from_fn(|i| { + let role = LAYER_ROLES[i]; + (role, scheme.get(role).lstar()) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::color::{Color, Scheme}; + + #[test] + fn dark_ladder_must_climb_and_light_ladder_must_fall() { + // 造假:深色板把 highest 设成纯黑 → 阶梯反向,必须被抓住。 + let base = Scheme::baseline(SchemeFlavor::Dark); + let mut colors = [Color::BLACK; 49]; + for (role, color) in base.iter() { + colors[role as usize] = color; + } + colors[Role::SurfaceContainerHighest as usize] = Color::BLACK; + let broken = Scheme::from_colors(SchemeFlavor::Dark, colors); + let report = check_surface_hierarchy(&broken); + assert!( + report + .layer_violations() + .iter() + .any(|step| step.role == Role::SurfaceContainerHighest), + "{:?}", + report.layer_steps() + ); + assert!(!report.is_clean()); + } + + #[test] + fn state_container_below_the_floor_is_reported() { + // 造假:secondary-container 直接等于 surface(1:1)→ 必须报。 + let base = Scheme::baseline(SchemeFlavor::Light); + let mut colors = [Color::BLACK; 49]; + for (role, color) in base.iter() { + colors[role as usize] = color; + } + colors[Role::SecondaryContainer as usize] = base.get(Role::Surface); + let broken = Scheme::from_colors(SchemeFlavor::Light, colors); + let report = check_surface_hierarchy(&broken); + assert_eq!( + report.state_failures(), + vec![(Role::SecondaryContainer, 1.0)] + ); + } + + #[test] + fn the_official_baseline_shows_why_the_debt_exists() { + // 官方口径(tone 表)浅色板:状态容器对 surface 只有 ~1.23,对容器层只有 ~1.05。 + // 前半条过不了 1.20 的是「白银下界」,后半条根本不可门禁化——这就是裁决第 3 条。 + let report = check_surface_hierarchy(&Scheme::official_baseline(SchemeFlavor::Light)); + let (role, ratio) = report.worst_state(); + // 官方浅色的四个 container 都是 tone 90、surface 是 tone 98 ⇒ 四个值几乎相同, + // 所以只断言量级(1.23 上下),不断言「哪一族最弱」。 + assert!( + (1.15..1.30).contains(&ratio), + "官方 baseline 的 {} 对 surface = {ratio:.3},与文档的 1.23 不符", + role.name() + ); + } +} diff --git a/crates/design/tests/ink_cat_boards.rs b/crates/design/tests/ink_cat_boards.rs new file mode 100644 index 0000000..0d8f26b --- /dev/null +++ b/crates/design/tests/ink_cat_boards.rs @@ -0,0 +1,369 @@ +//! Ink Cat 双主题的**消费方视角**门禁(M228):色板完整性、双主题切换、对比度、容器层可视性。 +//! +//! 只用公开 API(`unlucky_design::...`),所以它同时是「iced 适配层能拿到什么」的样本: +//! 换肤时只需要 `BrandBoard` → `Scheme` → `Role`,一个色值字面量都不用抄。 +//! 规格卡的几何与字级校核在同目录的 `ink_cat_geometry.rs`(单文件 ≤400 行)。 + +use unlucky_design::color::BrandBoard::{Ink, Silver}; +use unlucky_design::color::{BrandBoard, Color, Role, SchemeFlavor}; +use unlucky_design::palette::visibility::{ + check_surface_hierarchy, LAYER_ROLES, STATE_CONTAINERS, STATE_CONTAINER_MIN, +}; +use unlucky_design::palette::wcag::{check, AA_TEXT}; +use unlucky_design::DesignTokens; + +// ---------------------------------------------------------------- 色板完整性 + +#[test] +fn every_board_covers_all_49_roles_and_reports_its_flavor() { + for board in BrandBoard::ALL { + let scheme = board.scheme(); + assert_eq!(scheme.flavor(), board.flavor()); + assert_eq!(scheme.iter().count(), Role::ALL.len()); + for role in Role::ALL { + let color = scheme.get(role); + assert!( + color.is_opaque(), + "{} 的 {} 不是不透明色", + board.name(), + role.name() + ); + assert_eq!(color, board.get(role), "两个入口必须同源"); + } + } +} + +#[test] +fn spec_anchors_survive_the_public_api() { + // 规格卡上写死的色值(两套板各自的一批),一份都不许漂。 + let cases: [(BrandBoard, Role, &str); 12] = [ + (BrandBoard::Ink, Role::Primary, "#E8FFEF"), + (BrandBoard::Ink, Role::OnPrimary, "#256B4B"), + (BrandBoard::Ink, Role::Surface, "#0F150F"), + (BrandBoard::Ink, Role::SurfaceContainerHighest, "#262B25"), + (BrandBoard::Ink, Role::Error, "#FF716C"), + (BrandBoard::Ink, Role::Outline, "#7B8A7B"), + (BrandBoard::Silver, Role::Primary, "#006499"), + (BrandBoard::Silver, Role::PrimaryContainer, "#87C7FF"), + (BrandBoard::Silver, Role::Surface, "#F9F9FA"), + (BrandBoard::Silver, Role::SecondaryContainer, "#D4E4F6"), + (BrandBoard::Silver, Role::OnSurface, "#1A1C1D"), + (BrandBoard::Silver, Role::Error, "#AC3434"), + ]; + for (board, role, hex) in cases { + assert_eq!( + board.scheme().get(role), + Color::from_hex(hex).expect("规格卡色值"), + "{} / {} 与规格卡的 {hex} 不符", + board.name(), + role.name() + ); + } +} + +// ---------------------------------------------------------------- 双主题切换 + +#[test] +fn follow_system_resolves_to_the_right_board() { + // 「跟随系统」是设置层的第三种态,令牌层只提供映射。 + assert_eq!( + BrandBoard::for_flavor(SchemeFlavor::Dark), + BrandBoard::Ink, + "系统深色 → Ink" + ); + assert_eq!( + BrandBoard::for_flavor(SchemeFlavor::Light), + BrandBoard::Silver, + "系统浅色 → Silver" + ); + for board in BrandBoard::ALL { + assert_eq!( + BrandBoard::for_flavor(board.flavor()), + board, + "映射必须可逆" + ); + } +} + +#[test] +fn switching_boards_swaps_all_the_surface_roles() { + let ink = BrandBoard::Ink.scheme(); + let silver = BrandBoard::Silver.scheme(); + // 窗口底 / 正文 / 字段底 / 分层容器:切板后全都得换。 + for role in [ + Role::Surface, + Role::OnSurface, + Role::SurfaceContainerLow, + Role::SurfaceContainerHighest, + Role::Primary, + Role::SecondaryContainer, + Role::Error, + Role::InverseSurface, + ] { + assert_ne!( + ink.get(role), + silver.get(role), + "{} 在两套板之间没变——换肤会露出旧颜色", + role.name() + ); + } + // 深浅判定:Ink 是真的深、Silver 是真的浅。 + assert!(ink.get(Role::Surface).relative_luminance() < 0.02); + assert!(silver.get(Role::Surface).relative_luminance() > 0.90); +} + +#[test] +fn design_tokens_brand_entry_is_the_product_default() { + let ink = DesignTokens::brand(BrandBoard::Ink); + assert_eq!(ink.flavor, SchemeFlavor::Dark); + assert_eq!(ink.color(Role::Surface), BrandBoard::Ink.get(Role::Surface)); + // 品牌板与「取色链」两条路径互不干扰:baseline 仍是 tone 派生(D38 的锚点)。 + let baseline = DesignTokens::baseline(SchemeFlavor::Dark); + assert_eq!(baseline.flavor, SchemeFlavor::Dark); + assert_ne!( + baseline.color(Role::Surface), + ink.color(Role::Surface), + "baseline 若已变成品牌板,D38 取色链的表面色锚点就没了" + ); +} + +// ---------------------------------------------------------------- 对比度 + +#[test] +fn both_boards_pass_the_full_wcag_table() { + for board in BrandBoard::ALL { + let report = check(&board.scheme()); + assert!( + report.is_clean(), + "{} 有 {} 项未达 AA:{:?}", + board.name(), + report.failures.len(), + report.failures + ); + assert!( + report.worst >= AA_TEXT, + "{} 最紧的一对只有 {:.2}", + board.name(), + report.worst + ); + } +} + +/// 规格卡里真实出现的「文字压在什么色上」组合(25 组,含 `on-*-container` 全族)。 +/// +/// 只列规格卡上画出来的用法——不列「有可能这么用」的组合,否则就是在替页面线编需求。 +const SPEC_TEXT_ON_SURFACE: [(BrandBoard, Role, Role, &str); 25] = [ + (Ink, Role::OnPrimary, Role::Primary, "Filled 按钮"), + ( + Ink, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + "Tonal 按钮", + ), + (Ink, Role::OnSurface, Role::Surface, "Outlined/Text 按钮"), + (Ink, Role::Primary, Role::Surface, "Text 按钮标签"), + ( + Ink, + Role::Primary, + Role::SurfaceContainerLowest, + "Elevated 标签", + ), + ( + Ink, + Role::OnPrimaryContainer, + Role::PrimaryContainer, + "FAB 图标", + ), + ( + Ink, + Role::OnSurfaceVariant, + Role::SurfaceContainerHighest, + "字段标签", + ), + ( + Ink, + Role::OnSurface, + Role::SurfaceContainerHighest, + "字段内容", + ), + ( + Ink, + Role::Error, + Role::SurfaceContainerHighest, + "错误字段标签", + ), + (Ink, Role::Error, Role::Surface, "错误提示"), + (Ink, Role::OnError, Role::Error, "徽标数字"), + ( + Ink, + Role::OnSurfaceVariant, + Role::Surface, + "chip 未选 / 次要图标", + ), + ( + Ink, + Role::InverseOnSurface, + Role::InverseSurface, + "snackbar 文字", + ), + ( + Ink, + Role::InversePrimary, + Role::InverseSurface, + "snackbar 动作", + ), + ( + Ink, + Role::OnSurfaceVariant, + Role::SurfaceContainer, + "卡片副文案", + ), + ( + Ink, + Role::OnSurfaceVariant, + Role::SurfaceContainerHigh, + "对话框正文", + ), + (Silver, Role::OnPrimary, Role::Primary, "Filled 按钮"), + ( + Silver, + Role::OnSecondaryContainer, + Role::SecondaryContainer, + "Tonal 按钮", + ), + (Silver, Role::OnSurface, Role::Surface, "Outlined 按钮"), + (Silver, Role::Primary, Role::Surface, "Text 按钮标签"), + ( + Silver, + Role::OnSurfaceVariant, + Role::SurfaceContainerHigh, + "字段标签", + ), + ( + Silver, + Role::OnSurface, + Role::SurfaceContainerHigh, + "字段内容", + ), + ( + Silver, + Role::Error, + Role::SurfaceContainerHigh, + "错误字段标签", + ), + ( + Silver, + Role::OnSurfaceVariant, + Role::SurfaceContainer, + "卡片副文案", + ), + ( + Silver, + Role::InverseOnSurface, + Role::InverseSurface, + "snackbar 文字", + ), +]; + +#[test] +fn spec_text_pairs_meet_aa() { + let mut failures = Vec::new(); + for (board, fg, bg, usage) in SPEC_TEXT_ON_SURFACE { + let ratio = board.scheme().contrast(fg, bg); + if ratio < AA_TEXT { + failures.push(format!( + "{} / {usage}:{} on {} = {ratio:.2}", + board.name(), + fg.name(), + bg.name() + )); + } + } + assert!( + failures.is_empty(), + "规格卡用法未达 AA(4.5:1):\n{}", + failures.join("\n") + ); +} + +#[test] +fn state_containers_clear_the_visibility_floor() { + // 债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:状态容器 ≥1.20:1。 + for board in BrandBoard::ALL { + let report = check_surface_hierarchy(&board.scheme()); + assert!( + report.state_failures().is_empty(), + "{} 的状态容器未达 {STATE_CONTAINER_MIN}:1:{:?}", + board.name(), + report.state_failures() + ); + let (role, ratio) = report.worst_state(); + assert!( + ratio >= STATE_CONTAINER_MIN, + "{} 最弱的状态容器 {} 只有 {ratio:.3}", + board.name(), + role.name() + ); + // 旧 baseline 的现场值 1.057 必须被显著甩开,否则「修了但没修好」。 + assert!( + ratio > 1.20, + "{} 只是刚好卡线({ratio:.3}),等于没修", + board.name() + ); + } + // 两套板覆盖 4 个状态容器角色,一个不落。 + assert_eq!(STATE_CONTAINERS.len(), 4); +} + +#[test] +fn surface_container_ladder_is_monotone_with_visible_steps() { + for board in BrandBoard::ALL { + let report = check_surface_hierarchy(&board.scheme()); + assert!( + report.layer_violations().is_empty(), + "{} 的容器阶梯有断层或方向错:{:?}", + board.name(), + report.layer_violations() + ); + assert!(report.is_clean(), "{} 的容器层体检没过", board.name()); + // 方向:深色板越靠上越亮、浅色板越靠上越暗。 + let steps = report.layer_steps(); + for step in steps { + let expect_positive = board.flavor() == SchemeFlavor::Dark; + assert_eq!( + step.delta_lstar > 0.0, + expect_positive, + "{} 的 {} 阶梯方向错", + board.name(), + step.role.name() + ); + } + assert_eq!(LAYER_ROLES.len(), 5); + } +} + +// ---------------------------------------------------------------- 规则:容器层不当状态用 + +#[test] +fn container_layers_are_not_state_indicators() { + // 裁决第 3 条的可测化一半:把「状态容器压在最亮容器层上」的实际数字钉住, + // 提醒后来者别把它当唯一指示(浅色板这里必然贴到 1.05~1.24)。 + let silver = BrandBoard::Silver.scheme(); + let worst = Role::ALL + .iter() + .filter(|role| STATE_CONTAINERS.contains(role)) + .map(|role| silver.contrast(*role, Role::SurfaceContainerHigh)) + .fold(f32::INFINITY, f32::min); + assert!( + (1.0..1.5).contains(&worst), + "Silver 的状态容器对 surface-container-high = {worst:.3},与「浅色必然贴近」的登记不符" + ); + // Ink 相反:深色板的状态容器与容器层拉得开,所以深色可以靠底色差做弱指示。 + let ink = BrandBoard::Ink.scheme(); + let ink_worst = Role::ALL + .iter() + .filter(|role| STATE_CONTAINERS.contains(role)) + .map(|role| ink.contrast(*role, Role::SurfaceContainerHighest)) + .fold(f32::INFINITY, f32::min); + assert!(ink_worst >= 1.5, "Ink 最弱的状态容器 = {ink_worst:.3}"); +} diff --git a/crates/design/tests/ink_cat_geometry.rs b/crates/design/tests/ink_cat_geometry.rs new file mode 100644 index 0000000..3bae90a --- /dev/null +++ b/crates/design/tests/ink_cat_geometry.rs @@ -0,0 +1,100 @@ +//! Ink Cat 规格卡的**几何与字级**门禁(M228):按钮 XS~XL、FAB 四态、控件方框、字号校核。 +//! +//! 与 `ink_cat_boards.rs` 分开是单文件 ≤400 行的硬约束;两侧都只用公开 API。 + +use unlucky_design::inkcat::metrics; +use unlucky_design::inkcat::size::{ControlSize, FabSize}; +use unlucky_design::inkcat::type_scale::{SpecFit, SpecText, UI_TEXT_FLOOR_PX}; +use unlucky_design::shape::ShapeScale; +use unlucky_design::typography::{TypeRole, Weight}; + +#[test] +fn buttons_and_fabs_hit_the_spec_geometry() { + // 按钮五档:32/40/56/96/136,圆角一律半高(药丸)。 + let heights: Vec = ControlSize::ALL.iter().map(|s| s.height_px()).collect(); + assert_eq!(heights, vec![32.0, 40.0, 56.0, 96.0, 136.0]); + for size in ControlSize::ALL { + assert_eq!( + size.radius_px(), + size.height_px() / 2.0, + "{size:?} 该是药丸" + ); + } + // M 档就是规格卡 `BUTTONS / 5 LEVELS` 那一行的 56 高。 + assert_eq!(ControlSize::M.height_px(), 56.0); + assert_eq!(ControlSize::M.label_px(), 16.0); + + // FAB 四态圆角 12/16/20/16(都不是半高,所以不用 Full)。 + let radii: Vec = FabSize::ALL.iter().map(|s| s.radius_px()).collect(); + assert_eq!(radii, vec![12.0, 16.0, 20.0, 16.0]); + let sizes: Vec = FabSize::ALL.iter().map(|s| s.height_px()).collect(); + assert_eq!(sizes, vec![40.0, 56.0, 80.0, 56.0]); +} + +#[test] +fn component_boxes_hit_the_spec_corners() { + assert_eq!(metrics::CARD.radius_px(), 16.0, "ELEVATED CARD rx=16"); + assert_eq!(metrics::DIALOG.radius_px(), 28.0, "对话框 rx=28"); + assert_eq!(metrics::SNACKBAR.radius_px(), 16.0, "snackbar rx=16"); + assert_eq!(metrics::SNACKBAR.height, 52.0); + assert_eq!(metrics::LIST_ROW.radius_px(), 30.0, "列表行药丸"); + assert_eq!(metrics::LIST_ROW.height, 60.0); + assert_eq!(metrics::CHIP.height, 32.0); + assert_eq!(metrics::CHIP.radius_px(), 8.0); + // 输入框:默认圆角 4、药丸变体 28,内距 16 / 32(规格卡专门提醒过)。 + assert_eq!(metrics::FIELD_SHAPE, ShapeScale::ExtraSmall); + assert_eq!(metrics::FIELD_SHAPE_ROUNDED, ShapeScale::ExtraLarge); + assert_eq!(metrics::FIELD_HEIGHT, 56.0); + assert_eq!(metrics::FIELD_INSET, 16.0); + assert_eq!(metrics::FIELD_PILL_INSET, 32.0); + // 开关 / 勾选 / 进度。 + assert_eq!( + (metrics::SWITCH_TRACK.width, metrics::SWITCH_TRACK.height), + (52.0, 32.0) + ); + assert_eq!(metrics::SWITCH_THUMB, 24.0); + assert_eq!(metrics::CHECKBOX.radius_px(), 4.0); + assert_eq!(metrics::PROGRESS_THICKNESS, 4.0); +} + +#[test] +fn spec_font_sizes_map_onto_the_official_scale() { + // 命中官方档的六档。 + for (text, role) in [ + (SpecText::ControlLabelSmall, TypeRole::TitleSmall), + (SpecText::DialogTitle, TypeRole::HeadlineSmall), + (SpecText::DisplayLabel, TypeRole::HeadlineLarge), + (SpecText::Caption, TypeRole::BodySmall), + (SpecText::Badge, TypeRole::LabelSmall), + ] { + assert_eq!(text.fit(), SpecFit::Exact(role), "{text:?}"); + } + // 落在两档之间的三档:取较大那档,登记 1px 级偏离。 + for (text, lower, upper) in [ + (SpecText::Body, TypeRole::BodySmall, TypeRole::TitleSmall), + ( + SpecText::Subtitle, + TypeRole::TitleSmall, + TypeRole::TitleMedium, + ), + ( + SpecText::PageTitle, + TypeRole::DisplaySmall, + TypeRole::DisplayMedium, + ), + ] { + assert_eq!(text.fit(), SpecFit::Between(lower, upper), "{text:?}"); + assert_eq!(text.fit().recommended(), Some(upper)); + } + // 低于 UI 下限的两档:只能是设计稿标注。 + for text in [SpecText::SectionCaps, SpecText::SwatchCaption] { + assert!(!text.in_ui(), "{text:?} 不该进 UI"); + assert_eq!(text.fit(), SpecFit::BelowFloor); + assert_eq!(text.fit().recommended(), None); + } + assert_eq!(UI_TEXT_FLOOR_PX, 11.0); + // 按钮标签字重:规格卡是 500(官方 body/headline 是 400,这是按钮语义)。 + for size in ControlSize::ALL { + assert_eq!(size.label().weight, Weight::Medium); + } +}