feat(design): M69 令牌补齐 —— 圆角分层 / 组件微度量 / 骨架值与一像素线

G13(D32 分层):新增 shape::ComponentRadius(白名单只有 Micro=2)、
MICRO_CONTROL_MAX_SIZE=24 与纯函数 radius_layer_for();微半径走
radius_px(size) -> Option<f32>,尺寸 >24dp 一律 None,靠签名而不是自觉拦。
ShapeScale 与 rank() 一个字未动,2 不进刻度。

G11:LayoutMetrics 加 search_bar_height=56 / nav_rail_width=88 /
nav_drawer_width=240 / divider_thickness=1;删除语义相反的
nav_expanded_width=88 / nav_collapsed_width=48(D33:展开 drawer 240 /
折叠 rail 88;值 88 的后继是新名字 nav_rail_width,48 无后继)。

G10:新增 component_map::ComponentMetrics(12 字段 + 4 个命名常量 +
indicator_width),入口 Component::metrics();框内几何与 LayoutMetrics 的
骨架值分工写进模块文档,骨架级尺寸在 CM 里留 0 不许重复。

G9:Component 末尾追加 Checkbox / Radio / Search / NavigationDrawer /
Divider(20 -> 25),同步 table.rs 槽位、ALL 与 name();ComponentSpec 新增
micro_radius(Some 时 shape 必须是占位 None,由构造器 + 测试钉死)。

G12:新增 spacing::stroke::HAIRLINE = 1.0(crate 根再导出为
unlucky_design::stroke),divider_thickness 直接引用它。

其余:lib.rs 的「OKLCh 近似」文案改回真实口径(真 HCT / ColorSpec2021);
component_map 单测拆到 tests.rs 以免 mod.rs 超 400 行;测试 145 -> 165。
This commit is contained in:
AskaEth committed 2026-11-01 16:53:30 +08:00
1 parent b89cc91b78
commit 141e9b12c7
9 files changed
+1066 -145

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
//! 组件级微度量(`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<ComponentRadius>,
/// 描边 / 环线宽度(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)。
pub pad_start: f32,
/// 内容结束侧内边距(dp)。
pub pad_end: f32,
/// 图标边长(dp;`0.0` = 无图标)。
pub icon: f32,
/// 图标相对内边距的**额外**偏移(dp)。
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`)、可视左缘 16 = 文本框默认 12 + `SearchBarIconOffsetX = 4`、
/// 右端同样 16(§20.4)。圆角走 `ShapeScale::Full`(56 → 28 = 官方 `SearchBarCornerRadius`),
/// 所以 `radius` 是 `None`。
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::X4.dp(),
pad_end: Space::X4.dp(),
icon: IconSize::Toolbar.px(),
icon_offset: 4.0,
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<ComponentMetrics> {
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,
}
}
+67 -131
View File
@@ -8,16 +8,24 @@
//! - 形状档位见 [`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`]——两者分工见 [`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,8 +33,14 @@ 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<ComponentRadius>,
/// 高程级别(决定 surface container 角色与是否画阴影)。
pub elevation: Elevation,
/// 主要文字角色。
@@ -41,7 +55,10 @@ pub struct ComponentSpec {
pub morph: Option<ShapeMorph>,
}
/// 需要固定档位的组件清单(M53 页面骨架里实际用到的那些)。
/// 需要固定档位的组件清单(M53 页面骨架 + M69 组件线补的 5 个)。
///
/// **冻结为只可末尾追加**:判别值当索引用(`table::SPECS[self as usize]`),改名/换位等于
/// 让整张表错行。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Component {
/// 顶部标题栏(第一层 chrome)。
@@ -84,11 +101,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 +150,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 +163,14 @@ impl Component {
table::SPECS[self as usize]
}
/// 该组件的**控件内部**微度量;`None` = 只有骨架值(去 `LayoutMetrics` 拿)。
///
/// 这一条是 G10 的消费入口:组件线拿到 `Component::Checkbox.metrics()` 就有 20×20 / 圆角 2 /
/// 描边 2 / 状态层 Ø40,**不许再在实现里写这些字面量**。
pub fn metrics(self) -> Option<ComponentMetrics> {
metrics::metrics_of(self)
}
/// 稳定字符串名(kebab-case)。
pub const fn name(self) -> &'static str {
match self {
@@ -139,134 +194,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;
+104 -3
View File
@@ -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` 的各组件 `<X>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<TypeRole>,
motion: Easing,
duration: DurationToken,
morph: Option<ShapeMorph>,
) -> 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,73 @@ 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)。
spec(
Component::Divider,
ShapeScale::None,
Elevation::Level0,
TypeRole::BodySmall,
None,
Easing::Standard,
DurationToken::Short2,
None,
),
];
+202
View File
@@ -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());
}
}