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:
1 parent
7fe38fb240
commit
0169d4c8fb
14 files changed
+2047
-4
No files matched your search
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user