feat(design): 加 Ink/Silver 双主题色板 + 规格卡几何与字号令牌(M228)

规格卡 DevDocs/artifacts/components-ink.svg 的令牌化,只动 crates/design,零依赖不变。

- color::boards:BrandBoard(Ink 深 / Silver 浅)→ 49 角色 × 2 套固化色板。
  spec 直写值进 ANCHORS(测试逐条比 hex,规格卡改了代码不改会红);规格卡没给的
  角色按「同色相 + M3 tone 关系」推导后固化,规则写在 boards/ink.rs 的行注释里。
  Scheme::from_board / DesignTokens::brand 是两个新入口;Scheme::baseline 一字未改
  (它是 D38 取色链的中性锚点,改它等于同时改 app-iced 观感 + 取色语义)。
- palette::visibility:容器层可视性裁决,即债务台账「SecondaryContainer 对 Surface
  仅 1.06」的令牌层结论。状态容器(*-container)对 surface ≥1.20:1 是唯一数字门禁;
  容器层之间不设亮度门禁,改为「选中态 = 容器底 + 文字对比 + 形状/图标」三件套的构成
  要求(浅色板实测 1.05~1.24,任何有意义的门禁都过不了,详情见模块文档实测表)。
- inkcat:规格卡的尺寸与形状数据。ControlSize(按钮 XS~XL = 32/40/56/96/136,圆角
  一律半高)、FabSize(40/56/80/160×56,圆角 12/16/20/16)、控件方框(卡片 16、
  对话框 28、snackbar 52@16、列表行 60 药丸、chip 32@8、字段 4/28 + 内距 16/32、
  复选 18@4、单选 18、开关 52×32 + 拇指 24、进度 4/6)、SpecText(规格卡 11 个字号
  → 命中 / 落档 / 低于 11px 下限三类结论)。
  component_map 的表值一处未动:app-iced 的 pages/frame/tests.rs:42 与 shell/tests.rs:30
  按 Medium(12) pin 住卡片,改表等于连带改观感且破坏既有断言;差异在 inkcat 模块文档
  逐条登记,换肤时由 M229 一次切换 + 截图验收。
- 测试:186 lib(+13 条新单测)+ tests/ink_cat_boards.rs(10 条:完整性/锚点/双主题
  切换/26 对 WCAG AA/25 组规格卡用法/状态容器 1.2 门禁/阶梯单调)+ tests/ink_cat_geometry.rs
  (3 条:按钮 FAB 几何、控件圆角、字号校核)。
This commit is contained in:
AskaEth committed 2026-11-10 22:34:05 +08:00
1 parent 7fe38fb240
commit 0169d4c8fb
14 files changed
+2047 -4

No files matched your search

+251
View File
@@ -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");
}
}
+48
View File
@@ -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};
+312
View File
@@ -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()));
}
}
}
+284
View File
@@ -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<TypeRole> {
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);
}
}