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

+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()));
}
}
}