diff --git a/crates/design/src/component_map/metrics.rs b/crates/design/src/component_map/metrics.rs new file mode 100644 index 0000000..add93f8 --- /dev/null +++ b/crates/design/src/component_map/metrics.rs @@ -0,0 +1,179 @@ +//! 组件级微度量(`ComponentMetrics`):**单个控件内部的几何**,不是窗口骨架。 +//! +//! 为什么要有这个结构(M69 / G10):Checkbox 的 20×20、圆角 2、描边 2、状态层 Ø40 这类值, +//! `ShapeScale`(刻度)装不了、`LayoutMetrics`(骨架)也不该装——M66 对账时它们只能写在组件 +//! 实现里,而 D30 禁止组件层自定义数值。这里给它们一个**唯一落点**。 +//! +//! ## 与 `LayoutMetrics` 的分工(两把尺子,量不同的东西) +//! +//! | | [`crate::spacing::LayoutMetrics`] | `ComponentMetrics`(本模块) | +//! |---|---|---| +//! | 管什么 | 窗口骨架与布局节奏:标题栏/页头/行高、dock 宽度、命中区 | 单个控件方框内的几何:外框、微圆角、描边/环线宽、状态层、内点、图标偏移 | +//! | 随什么变 | 窗口尺寸、列宽、用户折叠导航 | 只随组件自身规格变 | +//! | 例子 | `nav_drawer_width = 240`、`search_bar_height = 56` | checkbox 20×20、描边 2、状态层 Ø40 | +//! | 判据 | 决定「一块面板/一行占多宽多高」 | 决定「画进这个方框里的那几个像素」 | +//! +//! 冲突时的规则是**同一个量只许有一份**:搜索条高 56 属骨架级,所以 +//! [`ComponentMetrics::SEARCH_BAR`] 的 `height` 留 `0.0` 并在文档里指回 LM;drawer 宽同理。 +//! 测试 `component_metrics_never_duplicate_layout_metrics` 把这条钉死。 +//! +//! 数值来源:`androidx.compose.material3:material3-android:1.4.0` 源码(A 级,M66 逐行核对, +//! 见 `research/15` §18~§23)。逐字段的出处写在下面各组件的关联常量上;**团队口径**(官方没有 +//! token 的)单独注明。 + +use super::Component; +use crate::icons::IconSize; +use crate::shape::ComponentRadius; +use crate::spacing::Space; + +/// 单个控件的内部几何。 +/// +/// `0.0` 表示**该控件没有这一项**(不是「默认值 0」)——拿 `0.0` 反推数值一定错。 +/// 骨架级尺寸(条高、面板宽)不在这里,去 [`crate::spacing::LayoutMetrics`] 拿。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ComponentMetrics { + /// 可见外框宽(dp;`0.0` = 由内容或骨架决定)。 + pub width: f32, + /// 可见外框高(dp;`0.0` = 由内容或骨架决定)。 + pub height: f32, + /// 微控件圆角(D32 第二层)。`None` = 该控件的圆角走 `ShapeScale`(见 `ComponentSpec::shape`)。 + pub radius: Option, + /// 描边 / 环线宽度(dp)。**禁用态也不许变细**——官方 disabled 只改透明度。 + pub stroke: f32, + /// 状态层(hover / focus / pressed)圆的直径(dp;`0.0` = 不画状态层)。 + pub state_layer: f32, + /// 内点直径(dp):radio 选中时的实心点,其余控件 `0.0`。 + pub inner_dot: f32, + /// 控件相对容器的外距(dp):drawer 项 = 12,决定选中指示器左右各内缩 12。 + pub inset: f32, + /// 内容起始侧内边距(dp)。**图标不算在内**:图标可视边 = `pad_start + icon_offset`。 + pub pad_start: f32, + /// 内容结束侧内边距(dp)。同起始侧,图标可视边 = `pad_end + icon_offset`。 + pub pad_end: f32, + /// 图标边长(dp;`0.0` = 无图标)。 + pub icon: f32, + /// 图标相对内边距的**额外**偏移(dp)。图标可视边 = 内边距 + 本值(search bar:12 + 4 = 16), + /// **不要**把内边距本身当图标边用,也不要再手抄那个 12 / 16。 + pub icon_offset: f32, + /// 图标与文字(或相邻元素)的间距(dp)。 + pub gap: f32, +} + +impl ComponentMetrics { + /// Checkbox(`research/15` §18 / §23)。 + /// + /// A 级出处:`Checkbox.kt:737 CheckboxSize = 20.dp`、`:738 RadiusSize = 2.dp`(圆角走 D32 的 + /// [`ComponentRadius::Micro`])、`CheckboxTokens.UnselectedOutlineWidth = 2`、 + /// `StateLayerSize = 40` + `StateLayerShape = CornerFull`。 + /// 注意 `CheckboxTokens.ContainerWidth/Height = 18` 是**死值**(全仓 0 引用),Compose 实际画 + /// 20(§26.2 矛盾 ①)——判据取 20。 + /// 勾选控件 ↔ 标签间距 8 是**团队口径**:官方没有「框 + 标签」组合的 token(§18.9,估)。 + pub const CHECKBOX: Self = Self { + width: 20.0, + height: 20.0, + radius: Some(ComponentRadius::Micro), + stroke: 2.0, + state_layer: 40.0, + inner_dot: 0.0, + inset: 0.0, + pad_start: 0.0, + pad_end: 0.0, + icon: 0.0, + icon_offset: 0.0, + gap: Space::X2.dp(), + }; + + /// Radio(`research/15` §19 / §23)。 + /// + /// A 级出处:`RadioButtonTokens.IconSize = 20`、`RadioStrokeWidth = 2.dp`;内点名义 + /// `RadioButtonDotSize = 12`,但绘制半径 = 12/2 − stroke/2 = 5 → **实际内点直径 10** + /// (§19.2:抄 12 会明显偏大)。圆角走 `ShapeScale::Full`(真圆),不是微半径。 + /// 标签间距 8 同 Checkbox(团队口径)。 + pub const RADIO: Self = Self { + width: 20.0, + height: 20.0, + radius: None, + stroke: 2.0, + state_layer: 40.0, + inner_dot: 10.0, + inset: 0.0, + pad_start: 0.0, + pad_end: 0.0, + icon: 0.0, + icon_offset: 0.0, + gap: Space::X2.dp(), + }; + + /// Search bar(`research/15` §20A / §23)。 + /// + /// 高度与宽度**不在这里**:条高 56 是骨架级(`LayoutMetrics::search_bar_height`), + /// 最小宽 360 同样属骨架级(尚未进 LM,M69 报告里登记为待办)。这里只装画进 bar 里的几何: + /// 图标 24(`IconSize::Toolbar`)、**图标可视边 = `pad_start + icon_offset`** = 12 + 4 = **16** + /// (右侧同式为 16,§20.4)、圆角走 `ShapeScale::Full`(56 → 28 = 官方 `SearchBarCornerRadius`), + /// 所以 `radius` 是 `None`。 + /// + /// 为什么要拆成 12 + 4 而不是直接写 16:官方 = 文本框内容内边距 **12**(`TextFieldImpl` 默认) + /// 加上 `SearchBarIconOffsetX = 4`。拆开才能让「文本从 12 起、图标从 16 起」都被消费方拿到; + /// 只写 16 的话文本内边距没有落点,消费方只能自己写 `12.0`——正是 G10 要消灭的字面量。 + /// 图标位置一律按 `pad + icon_offset` 求和,不要单独把 `pad` 当图标边用。 + pub const SEARCH_BAR: Self = Self { + width: 0.0, + height: 0.0, + radius: None, + stroke: 0.0, + state_layer: 0.0, + inner_dot: 0.0, + inset: 0.0, + pad_start: Space::X3.dp(), + pad_end: Space::X3.dp(), + icon: IconSize::Toolbar.px(), + icon_offset: 4.0, + // 图标 ↔ 文本 8 是**团队口径 [估]**:`research/15` §20.4 只给了图标尺寸/位置与 48 命中框, + // 没有「图标 ↔ 文字」的 token(Compose 由 `TextFieldImpl` 内部排版决定)。 + gap: Space::X2.dp(), + }; + + /// Navigation drawer(`research/15` §21 / §23)。 + /// + /// 宽度与项高**不在这里**:drawer 240 / rail 88 在 `LayoutMetrics`,项高 44 = + /// `LayoutMetrics::nav_item_height`。这里装项内几何(§21.6,A 级: + /// `NavigationDrawerItemDefaults.ItemPadding = 12`、项内 start 16 / end 24、 + /// `IconSize = 24`、图标 ↔ label 12)。 + /// 容器形状 `CornerLargeEnd`(只有靠内容那一侧两角 16)由 `ComponentSpec::shape` 的 + /// `Large` + 组件实现按侧位决定哪两个角,度量层不表达「部分角」。 + pub const NAVIGATION_DRAWER: Self = Self { + width: 0.0, + height: 0.0, + radius: None, + stroke: 0.0, + state_layer: 0.0, + inner_dot: 0.0, + inset: 12.0, + pad_start: Space::X4.dp(), + pad_end: Space::X6.dp(), + icon: IconSize::Toolbar.px(), + icon_offset: 0.0, + gap: Space::X3.dp(), + }; + + /// drawer 选中指示器宽度:容器宽 − 2 × [`ComponentMetrics::inset`]。 + /// + /// 240 − 24 = **216**;官方 360 − 24 = 336 是同一个公式(§21.5)——写死 336 就是忘了推导。 + pub fn indicator_width(&self, container_width: f32) -> f32 { + (container_width - self.inset * 2.0).max(0.0) + } +} + +/// 组件 → 组件级微度量。`None` = 该组件没有控件内部几何(只有骨架值,去 LM 拿)。 +/// +/// Divider 是 `None` 的显式例子:它的唯一几何量是厚度,而厚度是**一像素线** +/// (`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`),不是「控件内部」的东西。 +pub(super) const fn metrics_of(component: Component) -> Option { + match component { + Component::Checkbox => Some(ComponentMetrics::CHECKBOX), + Component::Radio => Some(ComponentMetrics::RADIO), + Component::Search => Some(ComponentMetrics::SEARCH_BAR), + Component::NavigationDrawer => Some(ComponentMetrics::NAVIGATION_DRAWER), + _ => None, + } +} diff --git a/crates/design/src/component_map/mod.rs b/crates/design/src/component_map/mod.rs index f154aad..8a7629c 100644 --- a/crates/design/src/component_map/mod.rs +++ b/crates/design/src/component_map/mod.rs @@ -8,16 +8,25 @@ //! - 形状档位见 [`crate::shape::ShapeScale`],高程见 [`crate::elevation::Elevation`]; //! - `text` 是主要文字角色,`support` 是辅助文字(没有就 `None`); //! - `motion` + `duration` 是状态过渡(hover/press/展开),不是页面切换; -//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`。 +//! - `morph` 是按下/选中时的形状形变(Expressive 特征),不需要就 `None`; +//! - 圆角分两层(D32):容器级查 [`crate::shape::ShapeScale`],≤24dp 的微控件查 +//! [`crate::shape::ComponentRadius`](落在 [`ComponentSpec::micro_radius`])。 +//! +//! 档位(形状/高程/字级/动效)之外,**控件内部的微几何**(20×20、描边 2、状态层 Ø40 …)在 +//! [`ComponentMetrics`] 里,入口是 [`Component::metrics`]——两者分工见该项的文档 +//! (`component_map::metrics` 模块)。 //! //! **改这里的值 = 改全站观感**,必须同步 `DevDocs/SubAgentsTalk/design-令牌约定.md`; //! 页面线不许在页面里覆盖这些档位。 +mod metrics; mod table; +pub use metrics::ComponentMetrics; + use crate::elevation::Elevation; use crate::motion::{DurationToken, Easing}; -use crate::shape::{ShapeMorph, ShapeScale}; +use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale}; use crate::typography::TypeRole; /// 组件的排版 + 形状 + 动效档位。 @@ -25,15 +34,24 @@ use crate::typography::TypeRole; pub struct ComponentSpec { /// 该档位属于哪个组件(表自描述,便于测试与文档生成)。 pub component: Component, - /// 圆角档位。 + /// 圆角档位(容器级,[`ShapeScale`] 的严格刻度)。 pub shape: ShapeScale, + /// 微控件圆角(D32 第二层)。 + /// + /// `Some` 时**本字段才是半径真源**,此时 [`ComponentSpec::shape`] 必须是 + /// [`ShapeScale::None`](占位、不参与渲染)——因为 2dp 不在 `ShapeScale` 刻度里, + /// 硬塞进 `shape` 会破坏「圆角只取刻度值」。这层配对由测试钉死。 + pub micro_radius: Option, /// 高程级别(决定 surface container 角色与是否画阴影)。 pub elevation: Elevation, - /// 主要文字角色。 + /// 主要文字角色。**无文字的组件(如 `Divider`)这里是占位值**,消费方不得据此排版。 pub text: TypeRole, /// 辅助文字角色。 pub support: Option, /// 状态过渡缓动。 + /// + /// 该字段不是 `Option`,所以**没有动效语义的组件(如 `Divider`)这里是占位值**—— + /// 读之前先确认组件本身有状态过渡,无过渡的组件不得据此排动效。 pub motion: Easing, /// 状态过渡时长。 pub duration: DurationToken, @@ -41,7 +59,10 @@ pub struct ComponentSpec { pub morph: Option, } -/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。 +/// 需要固定档位的组件清单(M53 页面骨架 + M69 组件线补的 5 个)。 +/// +/// **冻结为只可末尾追加**:判别值当索引用(`table::SPECS[self as usize]`),改名/换位等于 +/// 让整张表错行。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Component { /// 顶部标题栏(第一层 chrome)。 @@ -84,11 +105,35 @@ pub enum Component { Tooltip, /// 空态(图标 + 标题 + 说明 + 标准按钮)。 EmptyState, + /// 复选框(安装选项 / mod 启用 / 日志过滤 / Java 管理勾选)。 + /// + /// 圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 [`ComponentSpec::micro_radius`](D32 第二层); + /// 20×20 / 描边 2 / 状态层 Ø40 见 [`ComponentMetrics::CHECKBOX`]。 + Checkbox, + /// 单选框(Java 版本单选 / 安装模式选择)。 + /// + /// 真圆(`ShapeScale::Full`);20 / 环线 2 / 内点 10 / 状态层 Ø40 见 [`ComponentMetrics::RADIO`]。 + Radio, + /// 搜索条(实例 / 版本 / mod 搜索,页级入口)。 + /// + /// 高 56 是骨架级(`LayoutMetrics::search_bar_height`),圆角 `Full`(56 → 28); + /// 图标 24 + 偏移 4 见 [`ComponentMetrics::SEARCH_BAR`]。高程取 **L0**(源码矛盾已登记)。 + Search, + /// 导航抽屉(左侧常驻导航的展开形态,D33)。 + /// + /// 形状 `CornerLargeEnd`(只有靠内容那侧两角 16),宽 240 = `LayoutMetrics::nav_drawer_width`; + /// 项内几何见 [`ComponentMetrics::NAVIGATION_DRAWER`]。 + NavigationDrawer, + /// 分隔线(列表行分隔 / 区块分隔)。 + /// + /// **无高程、无圆角**;厚度是一像素线(`LayoutMetrics::divider_thickness` = `stroke::HAIRLINE`), + /// 颜色 `OutlineVariant`(列表内)或 `Outline`(search view 头部分隔,见 §20.8)。 + Divider, } impl Component { /// 全部组件。 - pub const ALL: [Component; 20] = [ + pub const ALL: [Component; 25] = [ Component::TopAppBar, Component::NavigationItem, Component::PrimaryButton, @@ -109,6 +154,12 @@ impl Component { Component::Snackbar, Component::Tooltip, Component::EmptyState, + // M69 G9:末尾追加的 5 个(前 20 个次序不许动)。 + Component::Checkbox, + Component::Radio, + Component::Search, + Component::NavigationDrawer, + Component::Divider, ]; /// 该组件的档位。 @@ -116,6 +167,14 @@ impl Component { table::SPECS[self as usize] } + /// 该组件的**控件内部**微度量;`None` = 只有骨架值(去 `LayoutMetrics` 拿)。 + /// + /// 这一条是 G10 的消费入口:组件线拿到 `Component::Checkbox.metrics()` 就有 20×20 / 圆角 2 / + /// 描边 2 / 状态层 Ø40,**不许再在实现里写这些字面量**。 + pub fn metrics(self) -> Option { + metrics::metrics_of(self) + } + /// 稳定字符串名(kebab-case)。 pub const fn name(self) -> &'static str { match self { @@ -139,134 +198,15 @@ impl Component { Component::Snackbar => "snackbar", Component::Tooltip => "tooltip", Component::EmptyState => "empty-state", + // M69 G9:末尾追加(`name()` 是持久化字符串 id,同样只许末尾追加)。 + Component::Checkbox => "checkbox", + Component::Radio => "radio", + Component::Search => "search", + Component::NavigationDrawer => "navigation-drawer", + Component::Divider => "divider", } } } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn table_matches_component_order() { - for (i, spec) in table::SPECS.iter().enumerate() { - assert_eq!( - spec.component as usize, i, - "SPECS 第 {i} 行顺序与枚举不一致" - ); - } - } - - #[test] - fn every_component_resolves() { - assert_eq!(Component::ALL.len(), table::SPECS.len()); - for component in Component::ALL { - let spec = component.spec(); - assert_eq!(spec.component, component); - assert!(spec.text.style().size_px >= 11.0); - } - } - - #[test] - fn names_are_unique() { - let mut seen = std::collections::BTreeSet::new(); - for component in Component::ALL { - assert!( - seen.insert(component.name()), - "组件名重复:{}", - component.name() - ); - } - } - - #[test] - fn elevation_matches_official_component_tokens() { - // A 级来源(androidx.compose.material3.tokens): - // FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0 - // MenuTokens.ContainerElevation = Level2 - // DialogTokens.ContainerElevation = Level3 - // SnackbarTokens.ContainerElevation = Level3 - // AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0 - // 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。 - for (component, expect) in [ - (Component::FilledCard, Elevation::Level0), - (Component::OutlinedCard, Elevation::Level0), - (Component::Menu, Elevation::Level2), - (Component::Dialog, Elevation::Level3), - (Component::Snackbar, Elevation::Level3), - (Component::TopAppBar, Elevation::Level0), - (Component::ListItem, Elevation::Level0), - (Component::TextField, Elevation::Level0), - ] { - assert_eq!( - component.spec().elevation, - expect, - "{} 的高程与官方组件令牌不一致", - component.name() - ); - } - // R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。 - for component in Component::ALL { - if component.spec().elevation > Elevation::Level0 { - assert!( - matches!( - component, - Component::Menu - | Component::Dialog - | Component::Snackbar - | Component::Tooltip - ), - "{} 不该有阴影(只有浮层允许)", - component.name() - ); - } - } - } - - #[test] - fn shapes_match_official_component_tokens() { - // 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、 - // 按钮/导航指示条 CornerFull、chip CornerSmall(8)。 - assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium); - assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium); - assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge); - assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall); - assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full); - assert_eq!(Component::Chip.spec().shape, ShapeScale::Small); - } - - #[test] - fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() { - // 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是 - // 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。 - let primary = Component::PrimaryButton.spec(); - assert_eq!(primary.shape, ShapeScale::Full); - assert_eq!(primary.text, TypeRole::LabelLarge); - // 浮层类必须比卡片层高。 - assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation); - assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation); - } - - #[test] - fn morphs_always_expand_upwards() { - for component in Component::ALL { - if let Some(morph) = component.spec().morph { - assert_ne!( - morph.from, - morph.to, - "{} 的形变起止不能相同", - component.name() - ); - // 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾), - // 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。 - assert!( - morph.from.rank() < morph.to.rank(), - "{} 的形变方向反了:{:?} → {:?}", - component.name(), - morph.from, - morph.to - ); - } - } - } -} +mod tests; diff --git a/crates/design/src/component_map/table.rs b/crates/design/src/component_map/table.rs index add52b2..26f846e 100644 --- a/crates/design/src/component_map/table.rs +++ b/crates/design/src/component_map/table.rs @@ -2,6 +2,10 @@ //! //! 一行一个组件。改值就是在改全站观感——同步协作文档再改。 //! +//! **末尾 5 行(M69 G9)** 的来源是 `research/15` §18~§23(同一套 `material3-android:1.4.0` 源码): +//! Checkbox / Radio / Search / NavigationDrawer / Divider。其中 Checkbox 的 **2dp** 圆角走 +//! [`ComponentRadius`](D32 第二层,不塞进 `ShapeScale`),Divider 的厚度是一像素线(G12)而不在这张表里。 +//! //! **高程/形状取值来源(A 级)**:`androidx.compose.material3.tokens` 的各组件 `Tokens.kt` //! (FilledCard/OutlinedCard/ElevatedCard/Menu/Dialog/Snackbar/AppBar/List)。已登记的两处偏离: //! - `Tooltip`:`PlainTooltipTokens.kt` 只有 Corner 行、**没有 Elevation 行**,Table 的 Level2 是 @@ -14,7 +18,7 @@ use crate::elevation::Elevation; use crate::motion::{ DurationToken, Easing, SPRING_EXPRESSIVE_SPATIAL_DEFAULT, SPRING_EXPRESSIVE_SPATIAL_FAST, }; -use crate::shape::{ShapeMorph, ShapeScale}; +use crate::shape::{ComponentRadius, ShapeMorph, ShapeScale}; use crate::typography::TypeRole; /// 一个组件的档位行(`const fn` 便捷构造,字段名在 [`ComponentSpec`] 里)。 @@ -31,6 +35,7 @@ const fn spec( ComponentSpec { component, shape, + micro_radius: None, elevation, text, support, @@ -40,8 +45,35 @@ const fn spec( } } -/// 20 个组件的档位表。 -pub(super) const SPECS: [ComponentSpec; 20] = [ +/// 圆角走「微控件层」(D32)的档位行:`shape` 自动填占位 `None`,半径取白名单值。 +/// +/// 为什么单独一个构造器:`micro_radius` 与 `shape = None` 必须成对出现,用构造器保证配对, +/// 不让 25 行表里出现「填了微半径忘了把 shape 清掉」。 +const fn spec_micro( + component: Component, + micro_radius: ComponentRadius, + elevation: Elevation, + text: TypeRole, + support: Option, + motion: Easing, + duration: DurationToken, + morph: Option, +) -> ComponentSpec { + ComponentSpec { + component, + shape: ShapeScale::None, + micro_radius: Some(micro_radius), + elevation, + text, + support, + motion, + duration, + morph, + } +} + +/// 25 个组件的档位表。 +pub(super) const SPECS: [ComponentSpec; 25] = [ spec( Component::TopAppBar, ShapeScale::None, @@ -265,4 +297,75 @@ pub(super) const SPECS: [ComponentSpec; 20] = [ DurationToken::Medium2, None, ), + // ---- M69 G9:末尾追加的 5 个(`research/15` §18~§23;前 20 行次序不许动)---- + // Checkbox:圆角 **2dp** 不在 `ShapeScale` 刻度里 → 走 `spec_micro`(D32 第二层)。 + // 高程 L0:`CheckboxTokens` 没有 Elevation 行,容器是「描边/填充」两态而非浮层。 + // 字级 `BodyLarge` 是**团队口径**:官方没有「框 + 标签」组合 token(§18.9),取列表行同一档。 + // 动效取 M3 motion scheme 的 fast-effects(100ms standard)——[推导],非 token 原文。 + spec_micro( + Component::Checkbox, + ComponentRadius::Micro, + Elevation::Level0, + TypeRole::BodyLarge, + None, + Easing::Standard, + DurationToken::Short2, + None, + ), + // Radio:真圆(`RadioButtonTokens.IconSize = 20` + `CornerFull`),内点/环线宽见 + // `ComponentMetrics::RADIO`。字级同 Checkbox(团队口径)。 + spec( + Component::Radio, + ShapeScale::Full, + Elevation::Level0, + TypeRole::BodyLarge, + None, + Easing::Standard, + DurationToken::Short2, + None, + ), + // Search bar:圆角 `CornerFull` / `SearchBarCornerRadius = 56/2 = 28`。 + // **高程取 L0**——`SearchBarTokens.ContainerElevation = Level3` 与 + // `SearchBarDefaults.TonalElevation/ShadowElevation = Level0` 冲突(§26.2 矛盾 ②), + // 判据按观感取「无可见阴影 + tone 分层」,这条已在 `design-令牌约定` 登记。 + // 无 shape morph:bar ⇄ docked 展开时**半径不变**(28 → 28),只长面积(§20C)。 + spec( + Component::Search, + ShapeScale::Full, + Elevation::Level0, + TypeRole::BodyLarge, + Some(TypeRole::BodySmall), + Easing::Emphasized, + DurationToken::Medium2, + None, + ), + // Navigation drawer:官方 `ContainerShape = CornerLargeEnd`(只有靠内容那侧两角 16, + // 贴边侧直角)。`ShapeScale` 表达不了「部分角」,这里记内侧角的 `Large(16)`, + // 哪两个角由组件按侧位决定;写四角 16 = 变成浮动卡片(§21.2 判据)。 + // 常驻形态 `Surface` + L0(模态才用 `SurfaceContainerLow` + scrim,本项目只在 <600 窗宽兜底)。 + spec( + Component::NavigationDrawer, + ShapeScale::Large, + Elevation::Level0, + TypeRole::LabelLarge, + Some(TypeRole::TitleSmall), + Easing::Emphasized, + DurationToken::Medium2, + None, + ), + // Divider:无高程、无圆角、**无文字**(`text` 是占位值,消费方不得据此排版); + // 厚度是一像素线 `LayoutMetrics::divider_thickness`,颜色 `OutlineVariant` + // (search view 头部分隔是 `Outline`,两个不同角色,§20.8)。 + // **`motion` / `duration` 也是占位值**(`ComponentSpec.motion` 不是 `Option`):Divider 没有 + // 官方状态过渡令牌,消费方**不得据此排动效**;下轮把该字段改成 `Option` 更彻底。 + spec( + Component::Divider, + ShapeScale::None, + Elevation::Level0, + TypeRole::BodySmall, + None, + Easing::Standard, + DurationToken::Short2, + None, + ), ]; diff --git a/crates/design/src/component_map/tests.rs b/crates/design/src/component_map/tests.rs new file mode 100644 index 0000000..47034c4 --- /dev/null +++ b/crates/design/src/component_map/tests.rs @@ -0,0 +1,202 @@ +//! `component_map` 的守门测试:表槽位 ↔ 枚举次序、官方高程/形状、圆角分层(D32)、微度量入口。 +//! +//! 拆成独立文件是为了让 `mod.rs` 留出文档空间(项目硬约束:单文件 ≤400 行)。 + +use super::*; + +#[test] +fn table_matches_component_order() { + for (i, spec) in table::SPECS.iter().enumerate() { + assert_eq!( + spec.component as usize, i, + "SPECS 第 {i} 行顺序与枚举不一致" + ); + } +} + +#[test] +fn every_component_resolves() { + assert_eq!(Component::ALL.len(), table::SPECS.len()); + for component in Component::ALL { + let spec = component.spec(); + assert_eq!(spec.component, component); + assert!(spec.text.style().size_px >= 11.0); + } +} + +#[test] +fn names_are_unique() { + let mut seen = std::collections::BTreeSet::new(); + for component in Component::ALL { + assert!( + seen.insert(component.name()), + "组件名重复:{}", + component.name() + ); + } +} + +#[test] +fn elevation_matches_official_component_tokens() { + // A 级来源(androidx.compose.material3.tokens): + // FilledCardTokens/OutlinedCardTokens.ContainerElevation = Level0 + // MenuTokens.ContainerElevation = Level2 + // DialogTokens.ContainerElevation = Level3 + // SnackbarTokens.ContainerElevation = Level3 + // AppBarTokens = Level0、ListTokens.ItemContainerElevation = Level0 + // 偏离只允许出现在有登记的地方(Tooltip 是 B 级:token 文件里没有 Elevation 行)。 + for (component, expect) in [ + (Component::FilledCard, Elevation::Level0), + (Component::OutlinedCard, Elevation::Level0), + (Component::Menu, Elevation::Level2), + (Component::Dialog, Elevation::Level3), + (Component::Snackbar, Elevation::Level3), + (Component::TopAppBar, Elevation::Level0), + (Component::ListItem, Elevation::Level0), + (Component::TextField, Elevation::Level0), + ] { + assert_eq!( + component.spec().elevation, + expect, + "{} 的高程与官方组件令牌不一致", + component.name() + ); + } + // R4「全屏都在飘」的守门:一屏常见元素里带阴影(>Level0)的只允许是浮层。 + for component in Component::ALL { + if component.spec().elevation > Elevation::Level0 { + assert!( + matches!( + component, + Component::Menu | Component::Dialog | Component::Snackbar | Component::Tooltip + ), + "{} 不该有阴影(只有浮层允许)", + component.name() + ); + } + } +} + +#[test] +fn shapes_match_official_component_tokens() { + // 官方:卡片 CornerMedium(12)、对话框 CornerExtraLarge(28)、菜单 CornerExtraSmall(4)、 + // 按钮/导航指示条 CornerFull、chip CornerSmall(8)。 + assert_eq!(Component::FilledCard.spec().shape, ShapeScale::Medium); + assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium); + assert_eq!(Component::Dialog.spec().shape, ShapeScale::ExtraLarge); + assert_eq!(Component::Menu.spec().shape, ShapeScale::ExtraSmall); + assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full); + assert_eq!(Component::Chip.spec().shape, ShapeScale::Small); +} + +#[test] +fn only_primary_actions_use_filled_emphasis_and_one_dialog_layer() { + // 契约 §1:状态色不复用 accent、每屏最多一个实心 accent → 只有 primary-button 是 + // 「实心强调」,其余按钮的形态由页面决定但不能是 Full + Level0 + Emphasized 的组合。 + let primary = Component::PrimaryButton.spec(); + assert_eq!(primary.shape, ShapeScale::Full); + assert_eq!(primary.text, TypeRole::LabelLarge); + // 浮层类必须比卡片层高。 + assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation); + assert!(Component::Menu.spec().elevation > Component::FilledCard.spec().elevation); +} + +#[test] +fn morphs_always_expand_upwards() { + for component in Component::ALL { + if let Some(morph) = component.spec().morph { + assert_ne!( + morph.from, + morph.to, + "{} 的形变起止不能相同", + component.name() + ); + // 方向必须「沿阶梯胀开」(morph 对 `Full` 也没问题,rank 把它排在末尾), + // 不比较像素跨度:`Full` 的半径依赖边长,用固定边长比会被 `None` 之外的大档拉爆。 + assert!( + morph.from.rank() < morph.to.rank(), + "{} 的形变方向反了:{:?} → {:?}", + component.name(), + morph.from, + morph.to + ); + } + } +} + +#[test] +fn appended_variants_keep_the_frozen_order() { + // 枚举冻结为「只可末尾追加」:前 20 个的次序不能动(M69 只在末尾加 5 个)。 + assert_eq!(Component::ALL.len(), 25); + assert_eq!(Component::ALL[0], Component::TopAppBar); + assert_eq!(Component::ALL[19], Component::EmptyState); + assert_eq!(Component::ALL[20], Component::Checkbox); + assert_eq!(Component::ALL[24], Component::Divider); + for (i, component) in Component::ALL.iter().enumerate() { + assert_eq!(*component as usize, i, "ALL 的次序与枚举判别值不一致"); + } +} + +#[test] +fn micro_radius_only_on_micro_controls() { + // D32:微半径只能给 ≤24dp 的微控件,且此时 `shape` 必须是占位 None(不参与渲染)。 + let mut owners = Vec::new(); + for component in Component::ALL { + let spec = component.spec(); + let Some(radius) = spec.micro_radius else { + continue; + }; + owners.push(component); + assert_eq!(radius, ComponentRadius::Micro, "白名单外的微半径不许过表"); + assert_eq!( + spec.shape, + ShapeScale::None, + "{} 有微半径时 shape 必须占位 None(2dp 不在刻度里)", + component.name() + ); + let metrics = component + .metrics() + .expect("带微半径的组件必须有 ComponentMetrics"); + assert!( + metrics.width <= crate::shape::MICRO_CONTROL_MAX_SIZE + && metrics.height <= crate::shape::MICRO_CONTROL_MAX_SIZE, + "{} 超出微控件尺寸上限", + component.name() + ); + assert_eq!( + radius.radius_px(metrics.width), + Some(radius.dp()), + "{} 的微半径按自身尺寸取不到", + component.name() + ); + } + assert_eq!( + owners, + vec![Component::Checkbox], + "当前只允许 Checkbox 用微半径" + ); +} + +#[test] +fn metrics_resolve_only_for_controls_with_inner_geometry() { + for component in [ + Component::Checkbox, + Component::Radio, + Component::Search, + Component::NavigationDrawer, + ] { + assert!( + component.metrics().is_some(), + "{} 缺 ComponentMetrics", + component.name() + ); + } + // Divider 只有厚度,而厚度是一像素线(LM),不该出现在 CM 里。 + assert!(Component::Divider.metrics().is_none()); + // 骨架级尺寸不许在 CM 里重复一份(Single Source of Truth)。 + for component in [Component::Search, Component::NavigationDrawer] { + let metrics = component.metrics().unwrap(); + assert_eq!(metrics.width, 0.0, "{} 的宽度属骨架级", component.name()); + assert_eq!(metrics.height, 0.0, "{} 的高度属骨架级", component.name()); + } +} diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs index 5e45d0b..c4c2388 100644 --- a/crates/design/src/lib.rs +++ b/crates/design/src/lib.rs @@ -15,14 +15,16 @@ //! //! ## 分模块 //! -//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(OKLCh 近似,口径见模块文档) +//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度 +//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档) //! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈 -//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 +//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 + [`shape::ComponentRadius`](微控件圆角层,D32) //! - [`elevation`]:elevation level 与 surface tint / surface container 规则 //! - [`motion`]:时长、easing(可采样)、Expressive 弹簧参数 -//! - [`spacing`]:4px 栅格与骨架尺寸(原 `metrics.slint` 的框架无关部分) +//! - [`spacing`]:4px 栅格、骨架尺寸(原 `metrics.slint` 的框架无关部分)、[`stroke::HAIRLINE`](一像素线) //! - [`icons`]:24 网格图标几何(SVG path 字符串)+ 描边语义 //! - [`component_map`]:组件 → 档位映射表(哪个组件用哪档形状/高程/动效/字体) +//! + [`component_map::ComponentMetrics`](控件内部微几何,与 `LayoutMetrics` 分工见该模块文档) //! //! ## 最小用法 //! @@ -92,6 +94,7 @@ pub mod typography; pub use color::{Color, Role, Scheme, SchemeFlavor}; pub use motion::MotionScale; +pub use spacing::stroke; pub use typography::TypeRole; /// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。 diff --git a/crates/design/src/shape.rs b/crates/design/src/shape.rs index 0f99d49..9126982 100644 --- a/crates/design/src/shape.rs +++ b/crates/design/src/shape.rs @@ -2,6 +2,17 @@ //! //! 形状是「唯一真源」的一部分:组件不许写裸半径(`border_radius(8)`),一律引用 //! [`ShapeScale`];需要动起来的(按下、选中、展开)用 [`ShapeMorph`] 的 from→to 数据。 +//! +//! ## 圆角分两层(D32) +//! +//! 官方实现与官方刻度表并不总一致:Checkbox 的圆角是 **2dp**,但 `ShapeScale` 的刻度是 +//! `{0,4,8,12,16,20,28,32,48,full}`——2 不在里面。D32 的判决是**分层**而不是放宽判据: +//! +//! - **容器级**(卡片、弹窗、抽屉、按钮、面板、输入框外壳)只许用 [`ShapeScale`] 的刻度值; +//! - **微控件级**(≤24dp 的 Checkbox 等)用 [`ComponentRadius`] 的白名单值。 +//! +//! 一个元素该查哪一层由 [`radius_layer_for`] 判定(按尺寸,唯一入口);微控件层取值走 +//! [`ComponentRadius::radius_px`],**尺寸超界时返回 `None`**——这不是文档约定,是签名保证。 use crate::motion::Spring; @@ -87,6 +98,88 @@ impl ShapeScale { } } +/// 微控件(≤24dp)的尺寸上限(dp)。 +/// +/// 超过这条线的元素属**容器级**,圆角必须查 [`ShapeScale`] 的严格刻度;[`ComponentRadius`] +/// 只在这条线以内有效——由 [`ComponentRadius::radius_px`] 在运行期拦住(传大尺寸拿不到微半径)。 +/// 24 这个门槛不是官方数字,是 D32 给「微控件」划的界(Checkbox 20、指示点、徽标都在以内)。 +pub const MICRO_CONTROL_MAX_SIZE: f32 = 24.0; + +/// 微控件圆角(D32 第二层):**来源官方组件实现,不是 [`ShapeScale`] 的成员**。 +/// +/// 为什么要有这一层:官方 Checkbox 的圆角是 **2dp**(`Checkbox.kt:738 RadiusSize = 2.dp`, +/// `CheckboxTokens.ContainerShape = RoundedCornerShape(2)`),而 M3 形状刻度只有 +/// `{0,4,8,12,16,20,28,32,48,full}`。把 2 塞进刻度会废掉「无任意圆角」这条守门(D30 rubric K1), +/// 不收又只能在组件里抄 `2.0` 字面量。D32 的判决是**分层**,不是放宽: +/// +/// | 层 | 管什么 | 取值 | +/// |---|---|---| +/// | [`ShapeScale`] | 容器级:卡片/弹窗/抽屉/按钮/面板/输入框外壳 | 严格刻度,不许自造 | +/// | `ComponentRadius` | 微控件级:Checkbox 等 ≤24dp 的元素 | 白名单,当前只有 [`ComponentRadius::Micro`] | +/// +/// 用法:先 [`radius_layer_for`] 判层,微控件再 `radius_px(size)`。**返回 `None` 说明尺寸超界, +/// 必须改查 [`ShapeScale`]**,不许把微半径硬套到容器上。 +/// +/// 公开的**唯一取值口是 [`ComponentRadius::radius_px`]**(带尺寸闸):本类型的 `dp()` 是 +/// `pub(crate)`,故意不对外开放——否则任何调用方都能无条件拿到 `2.0`,「只许 ≤24dp」就成了 +/// 自觉而不是约束(D32 的原意是拦住,不是提醒)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ComponentRadius { + /// 2dp:官方 Checkbox 容器圆角。 + Micro, +} + +impl ComponentRadius { + /// 白名单里的全部微半径(D32 当前只有一项;加值必须先在 `design-令牌约定` 登记)。 + /// + /// 枚举得出的只是**变体**(`Micro` 这个名字),拿不到数值——取值必须走 [`Self::radius_px`]。 + pub const ALL: [ComponentRadius; 1] = [ComponentRadius::Micro]; + + /// 半径(dp)。**`pub(crate)`:crate 内推导与自己用,不对外开放。** + /// + /// 对任何尺寸都返回同一个数,所以它天然绕开 D32 的尺寸闸;对外只留 + /// [`Self::radius_px`](`Option`,>24dp 返回 `None`)。 + pub(crate) const fn dp(self) -> f32 { + match self { + ComponentRadius::Micro => 2.0, + } + } + + /// 微控件的实际半径:尺寸超过 [`MICRO_CONTROL_MAX_SIZE`] 时返回 **`None`**。 + /// + /// 这是 D32 里「能编译期拦住就别靠自觉」的落点——文档约定拦不住,签名拦得住: + /// 拿到 `None` 的调用点没法「顺手用一下 2dp」,只能改查 [`ShapeScale`]。 + /// 另外半径不超过边长一半(2dp 半径放到更小的元素上会溢成圆)。 + pub fn radius_px(self, size_px: f32) -> Option { + if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE { + Some(self.dp().min(size_px / 2.0)) + } else { + None + } + } +} + +/// 一个元素的圆角该查哪一层([`radius_layer_for`] 的返回)。 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum RadiusLayer { + /// 容器级:查 [`ShapeScale`] 的严格刻度。 + Scale, + /// 微控件级(≤24dp):查 [`ComponentRadius`] 的白名单。 + Component, +} + +/// 纯函数:按元素尺寸判定圆角该查哪一层。 +/// +/// 「某尺寸的元素该查哪一个」是 D32 里唯一需要判断的地方,所以只留这一个入口—— +/// 组件层不许自己写 `if size <= 24.0`,那等于把阈值抄了第二份。 +pub fn radius_layer_for(size_px: f32) -> RadiusLayer { + if size_px > 0.0 && size_px <= MICRO_CONTROL_MAX_SIZE { + RadiusLayer::Component + } else { + RadiusLayer::Scale + } +} + /// 一次形状形变(Expressive 的「按下胀开」「展开成药丸」等)。 /// /// 数据形态是纯的:起止档位 + 弹簧参数。适配层负责跑时钟,`progress` 由弹簧/缓动给出。 @@ -165,4 +258,45 @@ mod tests { assert_eq!(morph.radius_px(200.0, -2.0), 8.0); assert_eq!(morph.radius_px(200.0, 3.0), 20.0); } + + #[test] + fn component_radius_is_whitelisted_and_refuses_containers() { + // D32:微半径白名单当前只有 2dp,且永远不是 ShapeScale 的成员。 + assert_eq!(ComponentRadius::ALL.len(), 1); + assert_eq!(ComponentRadius::Micro.dp(), 2.0); + for scale in [ + ShapeScale::None, + ShapeScale::ExtraSmall, + ShapeScale::Small, + ShapeScale::Medium, + ShapeScale::Large, + ShapeScale::LargeIncreased, + ShapeScale::ExtraLarge, + ShapeScale::ExtraLargeIncreased, + ShapeScale::ExtraExtraLarge, + ] { + assert_ne!(scale.dp(), 2.0, "{scale:?} 不该等于微半径(2 不在刻度里)"); + } + + // 20×20 的 checkbox 拿 2dp;超 24 的元素必须拿不到。 + assert_eq!(ComponentRadius::Micro.radius_px(20.0), Some(2.0)); + assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0)); + assert_eq!(ComponentRadius::Micro.radius_px(24.5), None); + assert_eq!(ComponentRadius::Micro.radius_px(200.0), None); + assert_eq!(ComponentRadius::Micro.radius_px(0.0), None); + // 半径不超过边长一半。 + assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0)); + } + + #[test] + fn radius_layer_follows_the_size_threshold() { + assert_eq!(radius_layer_for(20.0), RadiusLayer::Component); + assert_eq!( + radius_layer_for(MICRO_CONTROL_MAX_SIZE), + RadiusLayer::Component + ); + assert_eq!(radius_layer_for(24.5), RadiusLayer::Scale); + assert_eq!(radius_layer_for(320.0), RadiusLayer::Scale); + assert_eq!(radius_layer_for(0.0), RadiusLayer::Scale); + } } diff --git a/crates/design/src/spacing.rs b/crates/design/src/spacing.rs index 0737817..618a471 100644 --- a/crates/design/src/spacing.rs +++ b/crates/design/src/spacing.rs @@ -1,8 +1,37 @@ -//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格与骨架尺寸。 +//! 形状 / 高程 / 动效之外的「尺子」:4px 栅格、骨架尺寸、一像素线。 //! //! 源流有两条:`DevDocs/ops/slint-reference/crates/app/ui/theme/metrics.slint`(M52 定稿的骨架节奏), //! 以及 `ui-integration-contract.md` §3「间距只允许 4/8/12/16/24/32」。M3 同样用 4dp 栅格, //! 所以这层**不用改口径**,只是把值搬进唯一真源。 +//! +//! ## 职责边界:`LayoutMetrics` vs `ComponentMetrics`(M69 / G10) +//! +//! | 结构 | 管什么 | 例子 | +//! |---|---|---| +//! | [`LayoutMetrics`] | **窗口骨架与布局节奏**:标题栏/页头/行的节奏、dock 宽度(导航、AI 面板)、命中区 | [`LayoutMetrics::nav_drawer_width`] / [`LayoutMetrics::search_bar_height`] / `touch_target` | +//! | `component_map::ComponentMetrics` | **单个控件内部几何**:外框尺寸、微圆角、描边/环线宽、状态层直径、内点、图标偏移 | checkbox 20×20 + 描边 2 + 状态层 Ø40 | +//! | [`stroke::HAIRLINE`] | **一像素线**的唯一真源:分隔线厚度、容器描边、聚焦指示 | [`LayoutMetrics::divider_thickness`] | +//! +//! 判据:一个值若随窗口/列宽变化、或决定一块面板占多宽,归 LM;若它只在某个控件自己的方框 +//! 内起作用(画进去的那几个像素),归 CM。**同一个值不许两边各写一份**。 + +/// 一像素线(G12):全站 1px 线的**唯一真源**。 +/// +/// 为什么要单独开一个命名空间:`1.0` 在项目里同时承担三种语义——分隔线厚度、容器描边宽度、 +/// 聚焦指示描边宽度。各自抄字面量就会漂成 1.0 / 1.5 / 2.0 混排(M66 的 G12 正是这么来的)。 +/// 从这里取,语义和数值只有一个落点。 +/// +/// iced 侧怎么落地(适配层照此抄,别再自己定 `STROKE_PX`): +/// - **容器描边**:`Border { width: unlucky_design::stroke::HAIRLINE, .. }`(卡片/输入框/chip/菜单); +/// - **分隔线**:[`LayoutMetrics::divider_thickness`](数值就是 [`stroke::HAIRLINE`])—— +/// 横向分隔线是容器高度、纵向是宽度,都取它; +/// - **聚焦环**:内描边与外描边**都用**它(M3 的聚焦指示是 1dp,做成 2dp 就是加粗); +/// - **不用于**:elevation 的 blur / offset(那是 [`crate::elevation`] 的量)、 +/// 图标描边(图标走 SVG 的 viewBox 单位,见 [`crate::icons::stroke_width_for`])。 +pub mod stroke { + /// 一像素线宽度(逻辑 px @100% DPI = 1dp;DividerTokens.Thickness)。 + pub const HAIRLINE: f32 = 1.0; +} /// 栅格基数(dp)。 pub const GRID: f32 = 4.0; @@ -63,10 +92,31 @@ pub struct LayoutMetrics { pub titlebar_height: f32, /// 标题栏按钮宽度(关闭/最大化/最小化)。 pub titlebar_button_width: f32, - /// 展开态导航宽度。 - pub nav_expanded_width: f32, - /// 收起态导航宽度。 - pub nav_collapsed_width: f32, + /// 展开态导航(drawer 形态)宽度。 + /// + /// **G11 语义修正(M69 / D33)**:D16「左侧固定、允许折叠」落地为**同一个常驻容器**的两种 + /// 宽度——展开 240(drawer)、折叠 88(rail)。旧字段 `nav_expanded_width = 88` 的名字与值 + /// 相反(88 其实是 rail),已删除,**不留别名**:两个名字指同一个量等于把选择权还给组件层。 + /// + /// 官方 `NavigationDrawerTokens.ContainerWidth = 360`、`MinimumDrawerWidth = 240`; + /// 本项目取官方下限 240——360 + AI 面板 320 = 680 = 1180 窗宽的 58%,内容区只剩 500 放不下两列卡片。 + pub nav_drawer_width: f32, + /// 折叠态导航(rail 形态)宽度。 + /// + /// 88 = 12b §5.1 对官方 rail 96 的登记偏离(−8):指示器宽 88 − 2×16 = 56 仍成立, + /// 再窄就装不下 56 宽的选中指示器。 + pub nav_rail_width: f32, + /// 搜索条高度(G11)。 + /// + /// 官方 `SearchBarTokens.ContainerHeight = 56`。**不走 [`LayoutMetrics::control_height`](40)**: + /// 搜索条是页级入口而不是密集行控件;56 也顺带 ≥ 命中下限 48。圆角由 `ShapeScale::Full` + /// 按边长折算(56 → 28,正好等于官方 `SearchBarCornerRadius`)。 + pub search_bar_height: f32, + /// 分隔线厚度(G11 + G12)。 + /// + /// 官方 `DividerTokens.Thickness = 1.0.dp`。值就是 [`stroke::HAIRLINE`]——**一像素线不受 4px + /// 栅格约束**,所以它不在栅格测试的表里(测试里显式豁免并写明原因)。 + pub divider_thickness: f32, /// 导航项高度。 pub nav_item_height: f32, /// 导航项间距。 @@ -103,8 +153,14 @@ impl LayoutMetrics { pub const DESKTOP: Self = Self { titlebar_height: 48.0, titlebar_button_width: 46.0, - nav_expanded_width: 88.0, - nav_collapsed_width: 48.0, + // G11 / D33:展开 = drawer 240,折叠 = rail 88(旧 `nav_expanded_width=88` / + // `nav_collapsed_width=48` 的名字与值相反,已删)。 + nav_drawer_width: 240.0, + nav_rail_width: 88.0, + // G11:官方 SearchBarTokens.ContainerHeight = 56。 + search_bar_height: 56.0, + // G11/G12:官方 DividerTokens.Thickness = 1.0dp → 一像素线的唯一真源。 + divider_thickness: stroke::HAIRLINE, nav_item_height: 44.0, nav_item_gap: 4.0, page_header_height: 56.0, @@ -146,8 +202,13 @@ mod tests { // 受 4px 栅格约束的字段(间距/高度/宽度类)。 // `titlebar_button_width` 刻意不在表内:46 对齐 Windows 11 原生 caption 按钮宽度, // 属 OS 惯例值,单列一条断言钉住,不让它静默漂移。 + // `divider_thickness` 同理不在表内:它是**一像素线**(1 不是 4 的倍数), + // 归 `stroke::HAIRLINE` 那条规则管,下面单独断言。 for (name, value) in [ ("titlebar_height", m.titlebar_height), + ("nav_drawer_width", m.nav_drawer_width), + ("nav_rail_width", m.nav_rail_width), + ("search_bar_height", m.search_bar_height), ("nav_item_height", m.nav_item_height), ("nav_item_gap", m.nav_item_gap), ("page_header_height", m.page_header_height), @@ -165,6 +226,37 @@ mod tests { m.titlebar_button_width, 46.0, "标题栏按钮宽度应对齐 Win11 原生 caption" ); + // 一像素线的显式豁免(不是「忘了加进表」,是它本来就不受栅格约束)。 + assert_eq!(m.divider_thickness, stroke::HAIRLINE); + assert_ne!(m.divider_thickness % GRID, 0.0); + } + + #[test] + fn nav_widths_follow_the_two_form_decision() { + // D33:左侧导航是同一容器的两种宽度,展开(drawer)必须比折叠(rail)宽。 + let m = LayoutMetrics::DESKTOP; + assert_eq!(m.nav_drawer_width, 240.0, "展开 drawer 取官方下限 240"); + assert_eq!(m.nav_rail_width, 88.0, "折叠 rail 为 88(登记偏离官方 96)"); + assert!(m.nav_rail_width < m.nav_drawer_width); + // rail 里的选中指示器 = 容器宽 − 2×16;88 − 32 = 56,再窄就装不下 56 宽指示器。 + assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0); + // 内容区下限:1180 窗宽 − drawer − AI 面板 仍 ≥600(够放两列卡片)。 + assert!(1180.0 - m.nav_drawer_width - m.ai_panel_width >= 600.0); + } + + #[test] + fn search_bar_height_is_a_page_level_entry() { + let m = LayoutMetrics::DESKTOP; + assert_eq!(m.search_bar_height, 56.0, "官方 ContainerHeight = 56"); + assert!( + m.search_bar_height >= m.touch_target, + "页级入口必须 ≥ 命中下限" + ); + // 圆角走 ShapeScale::Full,56 → 28,正好是官方 SearchBarCornerRadius。 + assert_eq!( + crate::shape::ShapeScale::Full.radius_px(m.search_bar_height), + 28.0 + ); } #[test] diff --git a/crates/design/tests/token_contract.rs b/crates/design/tests/token_contract.rs index 5940ecb..cd4612b 100644 --- a/crates/design/tests/token_contract.rs +++ b/crates/design/tests/token_contract.rs @@ -182,7 +182,8 @@ fn spacing_and_layout_metrics_are_shared() { #[test] fn component_map_covers_the_page_skeleton() { - assert_eq!(Component::ALL.len(), 20); + // M69(G9)在末尾追加 Checkbox/Radio/Search/NavigationDrawer/Divider:20 → 25。 + assert_eq!(Component::ALL.len(), 25); for component in Component::ALL { let spec = component.spec(); assert_eq!(spec.component, component); @@ -191,6 +192,9 @@ fn component_map_covers_the_page_skeleton() { assert_eq!(Component::PrimaryButton.spec().shape, ShapeScale::Full); // 浮层必须高于卡片层。 assert!(Component::Dialog.spec().elevation > Component::OutlinedCard.spec().elevation); + // 末尾追加的 5 个不许改动既有次序。 + assert_eq!(Component::ALL[0], Component::TopAppBar); + assert_eq!(Component::ALL[19], Component::EmptyState); } #[test] diff --git a/crates/design/tests/tokext_pins.rs b/crates/design/tests/tokext_pins.rs new file mode 100644 index 0000000..3f3b32e --- /dev/null +++ b/crates/design/tests/tokext_pins.rs @@ -0,0 +1,297 @@ +//! M69(TOKEXT)的数值钉死:G9~G13 五项缺口逐条关门。 +//! +//! 期望值全部**独立于实现重写**(照 `research/15` §18~§23 与 `00-decision-log.md` D32/D33 抄), +//! 实现改一个数、这里不改就红。覆盖: +//! - **G13** `ComponentRadius` 白名单 + 尺寸闸(2 不在 `ShapeScale` 里,刻度不许被加宽); +//! - **G11** `LayoutMetrics` 的 4 个新骨架值 + nav 语义修正; +//! - **G10** `ComponentMetrics` 逐字段; +//! - **G9** `Component` 25 个变体与档位表一行对一行; +//! - **G12** 一像素线单一真源。 + +use unlucky_design::component_map::{Component, ComponentMetrics}; +use unlucky_design::elevation::Elevation; +use unlucky_design::icons::IconSize; +use unlucky_design::shape::{radius_layer_for, ComponentRadius, RadiusLayer, ShapeScale}; +use unlucky_design::spacing::{stroke, LayoutMetrics, Space}; +use unlucky_design::typography::TypeRole; + +// ---------- G13:圆角分层(D32) ---------- + +#[test] +fn shape_scale_is_unchanged_and_never_contains_two() { + // 刻度一个字都不许动(D32 第 1 条:ShapeScale 保持严格)。 + let ladder: [(ShapeScale, f32); 9] = [ + (ShapeScale::None, 0.0), + (ShapeScale::ExtraSmall, 4.0), + (ShapeScale::Small, 8.0), + (ShapeScale::Medium, 12.0), + (ShapeScale::Large, 16.0), + (ShapeScale::LargeIncreased, 20.0), + (ShapeScale::ExtraLarge, 28.0), + (ShapeScale::ExtraLargeIncreased, 32.0), + (ShapeScale::ExtraExtraLarge, 48.0), + ]; + for (scale, dp) in ladder { + assert_eq!(scale.dp(), dp, "{scale:?} 的刻度被改动了"); + // 2 是官方 Checkbox 的圆角,但它**不在**刻度里——加进来就等于废掉 K1 守门。 + assert_ne!(dp, 2.0, "2dp 不许进 ShapeScale(走 ComponentRadius)"); + } + assert_eq!(ShapeScale::Full.radius_px(64.0), 32.0); +} + +#[test] +fn component_radius_is_a_one_item_whitelist() { + assert_eq!(ComponentRadius::ALL, [ComponentRadius::Micro]); + // 2dp 只能通过带尺寸闸的 `radius_px` 拿到(`dp()` 是 crate 内部口,外部看不见)。 + assert_eq!( + ComponentRadius::Micro.radius_px(20.0), + Some(2.0), + "官方 Checkbox RadiusSize = 2.dp" + ); + // 尺寸闸:>24dp 一律拿不到微半径(D32 的「只允许 ≤24dp 微控件」)。 + assert_eq!(ComponentRadius::Micro.radius_px(24.0), Some(2.0)); + assert_eq!(ComponentRadius::Micro.radius_px(25.0), None); + assert_eq!(ComponentRadius::Micro.radius_px(240.0), None); + assert_eq!(ComponentRadius::Micro.radius_px(0.0), None); + // 边长小于两倍半径时按边长一半折算(2dp 元素不能有 2dp 圆角)。 + assert_eq!(ComponentRadius::Micro.radius_px(2.0), Some(1.0)); +} + +#[test] +fn radius_layer_for_routes_by_size() { + assert_eq!(radius_layer_for(20.0), RadiusLayer::Component); + assert_eq!(radius_layer_for(24.0), RadiusLayer::Component); + assert_eq!(radius_layer_for(28.0), RadiusLayer::Scale); + assert_eq!(radius_layer_for(360.0), RadiusLayer::Scale); + // 抽屉/面板这类容器永远不该被判成微控件。 + for container in [ + LayoutMetrics::DESKTOP.nav_drawer_width, + LayoutMetrics::DESKTOP.ai_panel_width, + ] { + assert_eq!(radius_layer_for(container), RadiusLayer::Scale); + } +} + +// ---------- G11:骨架值 + nav 语义修正 ---------- + +#[test] +fn layout_metrics_new_skeleton_values_are_pinned() { + let m = LayoutMetrics::DESKTOP; + // A 级:SearchBarTokens.ContainerHeight = 56。 + assert_eq!(m.search_bar_height, 56.0); + // A 级:NavigationDrawer 下限 240(官方 ContainerWidth = 360,1180 窗下过宽,D33 取 240)。 + assert_eq!(m.nav_drawer_width, 240.0); + // 12b §5.1 登记偏离:官方 rail 96 → 本项目 88。 + assert_eq!(m.nav_rail_width, 88.0); + // A 级:DividerTokens.Thickness = 1.0dp(一像素线,不受 4px 栅格约束)。 + assert_eq!(m.divider_thickness, 1.0); +} + +#[test] +fn nav_widths_mean_what_their_names_say() { + // D33:展开(drawer)比折叠(rail)宽;旧字段 nav_expanded_width=88 / nav_collapsed_width=48 + // 的名字与值相反,已在 M69 改名并删除,这里钉住「新名字不会再次反向」。 + let m = LayoutMetrics::DESKTOP; + assert!( + m.nav_drawer_width > m.nav_rail_width, + "展开态(drawer {})必须比折叠态(rail {})宽", + m.nav_drawer_width, + m.nav_rail_width + ); + // rail 里 56 宽的选中指示器要放得下(88 − 2×16)。 + assert_eq!(m.nav_rail_width - Space::X4.dp() * 2.0, 56.0); + // drawer 里 216 宽的指示器同样由「宽 − 2×12」推出(见 ComponentMetrics::indicator_width)。 + assert_eq!( + ComponentMetrics::NAVIGATION_DRAWER.indicator_width(m.nav_drawer_width), + 216.0 + ); +} + +// ---------- G10:组件级微度量 ---------- + +#[test] +fn checkbox_metrics_are_pinned() { + let cm = ComponentMetrics::CHECKBOX; + // 20×20(不是 token 文件里那个死值 18,见 research/15 §26.2 矛盾 ①)。 + assert_eq!((cm.width, cm.height), (20.0, 20.0)); + assert_eq!( + cm.radius, + Some(ComponentRadius::Micro), + "圆角 2dp 走 D32 第二层" + ); + assert_eq!(cm.stroke, 2.0, "CheckboxTokens.UnselectedOutlineWidth = 2"); + assert_eq!(cm.state_layer, 40.0, "StateLayerSize = 40"); + assert_eq!(cm.inner_dot, 0.0); + assert_eq!(cm.gap, Space::X2.dp(), "框 ↔ 标签 8(团队口径)"); + assert_eq!( + Component::Checkbox.metrics(), + Some(ComponentMetrics::CHECKBOX) + ); +} + +#[test] +fn radio_metrics_are_pinned() { + let cm = ComponentMetrics::RADIO; + assert_eq!( + (cm.width, cm.height), + (20.0, 20.0), + "RadioButtonTokens.IconSize = 20" + ); + assert_eq!(cm.stroke, 2.0, "RadioStrokeWidth = 2.dp"); + // 内点 10 不是 12:12 是含描边前的名义值(绘制半径 = 6 − 1 = 5)。 + assert_eq!(cm.inner_dot, 10.0); + assert_eq!(cm.state_layer, 40.0); + assert_eq!(cm.radius, None, "radio 是真圆,圆角走 ShapeScale::Full"); + assert_eq!(Component::Radio.spec().shape, ShapeScale::Full); +} + +#[test] +fn search_bar_metrics_are_pinned() { + let cm = ComponentMetrics::SEARCH_BAR; + // 高 56 属骨架级:CM 里必须留 0,不许重复一份。 + assert_eq!((cm.width, cm.height), (0.0, 0.0)); + assert_eq!(cm.icon, IconSize::Toolbar.px(), "leading/trailing 图标 24"); + // A 级(§20.4):图标可视左缘 16 = 文本框内容内边距 12 + `SearchBarIconOffsetX = 4`。 + assert_eq!(cm.pad_start, Space::X3.dp(), "内容内边距 12"); + assert_eq!(cm.icon_offset, 4.0, "SearchBarIconOffsetX = 4"); + assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标可视左缘必须是 16"); + // 右侧同式:图标右缘也是 16。 + assert_eq!(cm.pad_end, Space::X3.dp()); + assert_eq!(cm.pad_end + cm.icon_offset, 16.0, "图标可视右缘必须是 16"); + // 守门:不许把 pad 本身写成 16(否则 pad + offset = 20,越过 rubric 的 ±2px)。 + assert_ne!(cm.pad_start, 16.0); + assert_ne!(cm.pad_end, 16.0); + // 搜索条圆角 = 高/2 = 28,等于官方 SearchBarCornerRadius。 + assert_eq!( + ShapeScale::Full.radius_px(LayoutMetrics::DESKTOP.search_bar_height), + 28.0 + ); +} + +#[test] +fn navigation_drawer_metrics_are_pinned() { + let cm = ComponentMetrics::NAVIGATION_DRAWER; + assert_eq!((cm.width, cm.height), (0.0, 0.0), "宽/项高属骨架级"); + assert_eq!( + cm.inset, 12.0, + "NavigationDrawerItemDefaults.ItemPadding = 12" + ); + assert_eq!(cm.pad_start, Space::X4.dp(), "项内 start 16"); + assert_eq!(cm.pad_end, Space::X6.dp(), "项内 end 24"); + assert_eq!(cm.icon, IconSize::Toolbar.px()); + // 与 search bar 同一条公式:图标边 = pad + icon_offset(抽屉的 offset 是 0)。 + assert_eq!(cm.pad_start + cm.icon_offset, 16.0, "图标起点 = 项内 start"); + assert_eq!(cm.gap, Space::X3.dp(), "图标 ↔ label 12"); + // 指示器宽 = 容器宽 − 24;官方 360 − 24 = 336,本项目 240 − 24 = 216。 + assert_eq!(cm.indicator_width(360.0), 336.0); + assert_eq!(cm.indicator_width(240.0), 216.0); +} + +#[test] +fn component_metrics_never_duplicate_layout_metrics() { + // 同一量只许一份:CM 里出现的骨架级尺寸必须留 0。 + let m = LayoutMetrics::DESKTOP; + for component in [Component::Search, Component::NavigationDrawer] { + let cm = component.metrics().expect("这两个控件必须有 CM"); + assert_eq!(cm.width, 0.0, "{} 的宽度属 LM", component.name()); + assert_eq!(cm.height, 0.0, "{} 的高度属 LM", component.name()); + assert_ne!(cm.height, m.search_bar_height); + assert_ne!(cm.width, m.nav_drawer_width); + } + // Divider 的厚度也是 LM(一像素线)→ 它没有 CM。 + assert_eq!(Component::Divider.metrics(), None); + assert_eq!(m.divider_thickness, stroke::HAIRLINE); +} + +// ---------- G12:一像素线 ---------- + +#[test] +fn hairline_is_the_single_source_of_one_pixel_lines() { + assert_eq!(stroke::HAIRLINE, 1.0); + // 分隔线厚度直接引用它,不是另写一个字面量。 + assert_eq!(LayoutMetrics::DESKTOP.divider_thickness, stroke::HAIRLINE); + // 一像素线不受 4px 栅格约束(栅格测试里显式豁免,这里反向钉一次)。 + assert_ne!(stroke::HAIRLINE % unlucky_design::spacing::GRID, 0.0); +} + +// ---------- G9:枚举 25 变体 ↔ 表槽位 ---------- + +#[test] +fn component_enum_is_the_frozen_twenty_five() { + let names = [ + "top-app-bar", + "navigation-item", + "primary-button", + "secondary-button", + "text-button", + "icon-button", + "outlined-card", + "filled-card", + "list-item", + "text-field", + "segmented-button", + "chip", + "switch", + "slider", + "progress-indicator", + "dialog", + "menu", + "snackbar", + "tooltip", + "empty-state", + // M69 末尾追加的 5 个 + "checkbox", + "radio", + "search", + "navigation-drawer", + "divider", + ]; + assert_eq!(Component::ALL.len(), names.len(), "M66 的 G9:20 → 25"); + for (i, component) in Component::ALL.iter().enumerate() { + assert_eq!(component.name(), names[i], "第 {i} 个组件被换位/改名了"); + // 表槽位与枚举判别值一一对应(`spec()` 就是按下标取的)。 + assert_eq!(*component as usize, i); + assert_eq!(component.spec().component, *component); + } +} + +#[test] +fn new_components_carry_the_official_elevation_and_shape() { + // Checkbox / Radio / Search / NavigationDrawer / Divider:全部 L0(都不是浮层)。 + for component in [ + Component::Checkbox, + Component::Radio, + Component::Search, + Component::NavigationDrawer, + Component::Divider, + ] { + assert_eq!( + component.spec().elevation, + Elevation::Level0, + "{} 不该有阴影(R4:只有浮层允许)", + component.name() + ); + } + // Checkbox 的圆角真源是 micro_radius,shape 只是占位 None。 + let checkbox = Component::Checkbox.spec(); + assert_eq!(checkbox.micro_radius, Some(ComponentRadius::Micro)); + assert_eq!(checkbox.shape, ShapeScale::None); + // 其余 4 个不碰微半径层。 + for component in [ + Component::Radio, + Component::Search, + Component::NavigationDrawer, + Component::Divider, + ] { + assert_eq!(component.spec().micro_radius, None, "{}", component.name()); + } + // NavigationDrawer = CornerLargeEnd 的内侧角 16;Divider 无圆角。 + assert_eq!(Component::NavigationDrawer.spec().shape, ShapeScale::Large); + assert_eq!(Component::Divider.spec().shape, ShapeScale::None); + // Checkbox / Radio 的标签字级是团队口径(官方无「框 + 标签」token)。 + assert_eq!(Component::Checkbox.spec().text, TypeRole::BodyLarge); + assert_eq!(Component::Radio.spec().text, TypeRole::BodyLarge); + // Search 的字级:输入 BodyLarge、占位/辅助 BodySmall。 + assert_eq!(Component::Search.spec().text, TypeRole::BodyLarge); + assert_eq!(Component::Search.spec().support, Some(TypeRole::BodySmall)); +}