P2-1 字段底角色不对称(塔裁决方案 a):Silver 的 #dcddde 从 surface-container-high 改判到 surface-container-highest,与 Ink 的 #262b25 同位——component_map 的 TextField 口径就是最高 容器层,单一角色接法才不会一边掉层。Silver 的 high 改取推导值 #e4e4e5(tone 90),阶梯 94.12 → 90.61 → 88.07 保持单调、步长 ≥1.74 L*。实测:Error on 新 highest = 4.686(原按单一 角色接到 high 时的 4.379 失守已消除)、OnSurfaceVariant = 6.383、OnSurface = 12.576。 测试同步:Silver 三条字段用法 pin 从 high 改 highest(与 Ink 对称),并新增 `field_backdrop_is_the_same_role_on_both_boards` 把这个不对称钉住(顺带断言 high 比 highest 更亮、highest 就是规格卡的 #dcddde)。 P2-2 四处小疵: ① inkcat/size.rs 扩展 FAB 起始内距 16 → **28**(规格卡画的数):框 x=292 → 图标盒 320(`+` font-size 24 居中于 x=332),28 才是图上的距离;16 是 M3 官方口径,已在 doc 里登记为偏离, 并说明「要回 M3 的 16 得同时把图标中心挪回 316,那是要出截图的视觉改动」。测试补一条自洽 断言:292 + 28 + 24/2 = 332。 ② inkcat/metrics.rs 的 SWITCH_THUMB_INSET 注释重写(原句断成碎片且读起来像算错):写明 轨道 y=1060..1092 / 拇指 y=1064..1088 ⇒ 上下各 4、右端 116−112=4,以及行程 52−24−2×4 = 20 的推导。 ③ Silver「状态容器 / 容器层」数字统一成实测口径:boards/mod.rs 与 palette/visibility.rs 都写 「最低 1.02」(Ink 1.54),并点名最差的一组是 tertiary-container 压 surface-container-high = 1.016;visibility 表的 Silver 阶梯步长改写 −1.7 ~ −4.1。 ④ inkcat/type_scale.rs 正文清单只列 11 个**文字**字号(去掉 FAB 字形 20/26/34),并注明那三个 归 FabSize::icon_px、SpecText 没有 20 这一档。 P2-3 产品默认口径统一:palettes.rs 的「两个入口」表与 Palettes::baseline 文档、color/mod.rs 的「出厂默认色」小节、Scheme::baseline 文档统一写明「M228 起出厂外观入口是 DesignTokens::brand,本入口是 D38 取色链中性锚点」,与 lib.rs 同口径;顺带把 BASELINE_SEED / DEFAULT_SEED / DEFAULT_NEUTRAL_CHROMA 的「产品默认」措辞改成取色链口径。 另:tests/ink_cat_boards.rs 已 400 行贴线,把 4 条容器层可视性测试拆到新文件 tests/ink_cat_visibility.rs(纯搬移 + 补 import);palettes.rs 因新增口径说明到 403 行,按 「合并相邻段落、不删信息」压回 399 行;size.rs 现在 323 行。 五步(design 口径):fmt --all --check=0;check -p unlucky-design --all-targets=0(零 warning); test -p unlucky-design --all-targets=0(267 passed);clippy -p unlucky-design --all-targets -- -D warnings=0;400 行扫描只剩 README.md(豁免)、无 <2 字节 .rs。
324 lines
12 KiB
Rust
324 lines
12 KiB
Rust
//! 控件**尺寸档**:按钮 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 有):**28**,规格卡画的就这个数。
|
||
///
|
||
/// 推导(可逐位核):框 `x=292` 起、宽 160;`+` 是 `font-size=24`、`text-anchor=middle`、
|
||
/// 中心 `x=332` ⇒ 图标盒 = 320..344 ⇒ 内距 = 320 − 292 = **28**;标签 `x=352` 起,
|
||
/// 于是图标盒右沿 344 到标签 352 = [`FabSize::gap_px`] 的 8。
|
||
///
|
||
/// ⚠️ 与官方口径的偏离已登记:M3 ExtendedFAB 的起始内距是 **16**(图标盒 292+16=308)。
|
||
/// 规格卡画得更宽松,本表按**规格卡**取值(A 级依据 = 图中坐标,不是猜的);M229 若要用
|
||
/// M3 的 16,得同时把图标中心从 332 挪回 316 —— 那是一处视觉改动,要出截图,别悄悄改数。
|
||
pub const fn pad_start_px(self) -> f32 {
|
||
match self {
|
||
FabSize::Extended => 28.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);
|
||
// 内距 28 = 规格卡图中「框 292 → 图标盒 320」的实测距离(不是 M3 的 16)。
|
||
assert_eq!(FabSize::Extended.pad_start_px(), 28.0);
|
||
// 图标中心落点必须自洽:292 + 28 + 24/2 = 332,与规格卡 `x=332` 对得上。
|
||
assert_eq!(FabSize::Extended.pad_start_px() + 24.0 / 2.0 + 292.0, 332.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()));
|
||
}
|
||
}
|
||
}
|