Merge branch 'feat/m228-ink-cat-dual-theme-design-tokens-in'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-10 22:34:08 +08:00
commit 6c060a09e2
16 files changed
+2144 -16

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
//! **Ink Cat 深色板**(规格卡 `DevDocs/artifacts/components-ink.svg` 的深色方案)。
//!
//! 49 行按 [`Role`] 声明序排列(下标 = `Role as usize`),由 `boards` 模块的
//! `anchors_pin_the_spec` 测试用 [`ANCHORS`] 逐条对账——**行错位会当场红**。
//!
//! 取值分两类,来源都在行注释里:
//! - `[spec]`:规格卡上直接写出的色值([`ANCHORS`] 里同一份,测试逐条比对 hex);
//! - `[derive]`:规格卡没给的角色,按「同色相 + M3 的 tone 关系」从邻居推导后固化。
//!
//! 推导口径(`[derive]` 行):容器/文字的 tone 差沿用 M3 ColorSpec2021 的成对关系
//! (`on-*` 与承载色差 ≥50 tone 才稳过 AA),色相取承载色的 CAM16 色相,彩度只压不抬。
//! 换算过程是 `Hct::from_components(hue, chroma, tone)`,结果已固化成字面量,不在运行期重算——
//! 这样色板是**可逐值审阅的数据**,而不是「跑一遍才知道」的产物。
use crate::color::{Color, Role};
/// 规格卡直接给出的色值(`[spec]` 行),用于逐条对账与文档生成。
///
/// 顺序即规格卡上出现的顺序(BUTTONS → FAB → TEXT FIELDS → SWITCH/CHIPS → CARD/DIALOG/SNACKBAR
/// → COLOR ROLES / INK)。**这张表是色板的出处**:表里的值改了,测试 `anchors_pin_the_spec`
/// 会指着要改的行。
pub const ANCHORS: [(Role, &str); 22] = [
(Role::Primary, "#E8FFEF"),
(Role::OnPrimary, "#256B4B"),
(Role::PrimaryContainer, "#B3FBD2"),
(Role::OnPrimaryContainer, "#1B6243"),
(Role::Secondary, "#B4CCBC"),
(Role::SecondaryContainer, "#364B3F"),
(Role::OnSecondaryContainer, "#D0E8D7"),
(Role::Tertiary, "#FFB77D"),
(Role::TertiaryContainer, "#FFA453"),
(Role::Surface, "#0F150F"),
(Role::SurfaceContainer, "#151B15"),
(Role::SurfaceContainerHigh, "#1D231D"),
(Role::SurfaceContainerHighest, "#262B25"),
(Role::OnSurface, "#E4EAE0"),
(Role::OnSurfaceVariant, "#B6C5B4"),
(Role::Outline, "#7B8A7B"),
(Role::OutlineVariant, "#3C4A3D"),
(Role::Error, "#FF716C"),
(Role::OnError, "#490006"),
(Role::InverseSurface, "#E4EAE0"),
(Role::InverseOnSurface, "#282E27"),
(Role::InversePrimary, "#256B4B"),
];
/// Ink 板 49 个角色(下标 = `Role as usize`)。
pub const COLORS: [Color; 49] = [
Color::rgb(0xE8, 0xFF, 0xEF), // 01 primary [spec] 按钮/文字按钮标签、进度、选中
Color::rgb(0x25, 0x6B, 0x4B), // 02 on-primary [spec]
Color::rgb(0xB3, 0xFB, 0xD2), // 03 primary-container [spec] FAB 底、头像底
Color::rgb(0x1B, 0x62, 0x43), // 04 on-primary-container [spec] FAB 图标
Color::rgb(0xB4, 0xCC, 0xBC), // 05 secondary [spec]
Color::rgb(0x37, 0x4B, 0x3E), // 06 on-secondary [derive] secondary @ tone 30
Color::rgb(0x36, 0x4B, 0x3F), // 07 secondary-container [spec] tonal 按钮底、选中 chip
Color::rgb(0xD0, 0xE8, 0xD7), // 08 on-secondary-container [spec] tonal 按钮标签
Color::rgb(0xFF, 0xB7, 0x7D), // 09 tertiary [spec] 暖橙装饰
Color::rgb(0x55, 0x2B, 0x00), // 10 on-tertiary [derive] tertiary @ tone 22
Color::rgb(0xFF, 0xA4, 0x53), // 11 tertiary-container [spec]
Color::rgb(0x4C, 0x27, 0x00), // 12 on-tertiary-container [derive] container @ tone 20
Color::rgb(0xFF, 0x71, 0x6C), // 13 error [spec] 错误文字、badge 底
Color::rgb(0x49, 0x00, 0x06), // 14 on-error [spec] badge 数字
Color::rgb(0x93, 0x00, 0x0A), // 15 error-container [derive] error @ tone 30(= M3 深色 error-container)
Color::rgb(0xFF, 0xDA, 0xD6), // 16 on-error-container [derive] error @ tone 90
Color::rgb(0x0F, 0x15, 0x0F), // 17 surface [spec] 窗口底
Color::rgb(0xE4, 0xEA, 0xE0), // 18 on-surface [spec] 正文/标题
Color::rgb(0x3C, 0x4A, 0x3D), // 19 surface-variant [spec](= outline-variant,M3 深色同 tone)
Color::rgb(0xB6, 0xC5, 0xB4), // 20 on-surface-variant [spec] 次要文字、字段标签
Color::rgb(0x0F, 0x15, 0x0F), // 21 background [spec](= surface)
Color::rgb(0xE4, 0xEA, 0xE0), // 22 on-background [spec](= on-surface)
Color::rgb(0x0C, 0x13, 0x0C), // 23 surface-dim [derive] surface −1 tone 档
Color::rgb(0x35, 0x3C, 0x34), // 24 surface-bright [derive] surface @ tone 24
Color::rgb(0x08, 0x0D, 0x08), // 25 surface-container-lowest [derive] tone 3
Color::rgb(0x13, 0x18, 0x13), // 26 surface-container-low [derive] tone 8
Color::rgb(0x15, 0x1B, 0x15), // 27 surface-container [spec] 卡片/列表行底
Color::rgb(0x1D, 0x23, 0x1D), // 28 surface-container-high [spec] 对话框底
Color::rgb(0x26, 0x2B, 0x25), // 29 surface-container-highest [spec] 输入框底
Color::rgb(0x7B, 0x8A, 0x7B), // 30 outline [spec] 字段描边、switch 关态
Color::rgb(0x3C, 0x4A, 0x3D), // 31 outline-variant [spec] 分隔线、chip 描边
Color::rgb(0xE4, 0xEA, 0xE0), // 32 inverse-surface [spec] snackbar 底
Color::rgb(0x28, 0x2E, 0x27), // 33 inverse-on-surface [spec] snackbar 文字
Color::rgb(0x25, 0x6B, 0x4B), // 34 inverse-primary [spec] snackbar 动作文字
Color::rgb(0x00, 0x00, 0x00), // 35 shadow M3 定值:纯黑 + 令牌 alpha
Color::rgb(0x00, 0x00, 0x00), // 36 scrim M3 定值:纯黑 + 令牌 alpha
Color::rgb(0xE8, 0xFF, 0xEF), // 37 surface-tint = primary(M3 旧 tint 口径)
Color::rgb(0xB3, 0xFB, 0xD2), // 38 primary-fixed [derive] tone 93(= container)
Color::rgb(0x9D, 0xE4, 0xBC), // 39 primary-fixed-dim [derive] tone 85
Color::rgb(0x00, 0x26, 0x14), // 40 on-primary-fixed [derive] tone 12
Color::rgb(0x00, 0x4D, 0x2E), // 41 on-primary-fixed-variant [derive] tone 28
Color::rgb(0xD0, 0xE8, 0xD7), // 42 secondary-fixed [derive] tone 90
Color::rgb(0xB4, 0xCC, 0xBC), // 43 secondary-fixed-dim [derive] tone 80
Color::rgb(0x0D, 0x1F, 0x14), // 44 on-secondary-fixed [derive] tone 10
Color::rgb(0x33, 0x47, 0x39), // 45 on-secondary-fixed-variant [derive] tone 28
Color::rgb(0xFC, 0xD9, 0xBE), // 46 tertiary-fixed [derive] tone 89
Color::rgb(0xFF, 0xB7, 0x7D), // 47 tertiary-fixed-dim [derive] tone 80(= tertiary)
Color::rgb(0x2A, 0x17, 0x00), // 48 on-tertiary-fixed [derive] tone 10
Color::rgb(0x56, 0x3D, 0x27), // 49 on-tertiary-fixed-variant [derive] tone 28
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchors_pin_the_spec() {
for (role, hex) in ANCHORS {
let expected = Color::from_hex(hex).expect("规格卡色值");
assert_eq!(
COLORS[role as usize],
expected,
"{} 的色值与规格卡({hex})不符:表里是 {}",
role.name(),
COLORS[role as usize].to_hex()
);
}
}
}
+201
View File
@@ -0,0 +1,201 @@
//! # 双主题色板(Ink 深 / Silver 浅)
//!
//! 产品视觉的两套固定色板,来源是**用户自制的规格卡** `DevDocs/artifacts/components-ink.svg`
//! (Ink Cat / M3 Expressive,1480×2260,色值/圆角/尺寸/字号全在源码里)。本模块只做一件事:
//! **把规格卡的色值固化成 49 个 M3 角色 × 2 套**,让 iced 侧拿到的仍是
//! [`Scheme`](crate::color::Scheme)——角色名、取色入口、对比度门禁一个都不用换。
//!
//! ## 两层配色来源,互不顶替
//!
//! | 来源 | 入口 | 什么时候用 |
//! |---|---|---|
//! | **品牌色板**(本模块) | [`BrandBoard`] → [`Scheme::from_board`](crate::color::Scheme::from_board) | 出厂外观:Ink = 深色主方案、Silver = 浅色主方案 |
//! | **tone 派生**(既有) | [`Scheme::baseline`](crate::color::Scheme::baseline) / [`Scheme::from_accent`](crate::color::Scheme::from_accent) | D38 取色链(背景图 / 壁纸取色、用户自选强调色) |
//!
//! **为什么保留 `baseline` 而不是把它换成色板**(「保留旧令牌名兼容层」这一选,理由):
//!
//! 1. `Scheme::baseline` 是 D38 取色链的**中性锚点**:`from_accent` 的全部语义就是「表面色与
//! baseline 一致、只换强调族」。把 baseline 改成固定板,取色链要么改语义、要么在板色上再叠
//! 一层 tone 派生,两者都会让「换强调色不该让窗口底色变脏」这条产品口径失效。
//! 2. 改 `baseline` 的色值等于**同时改 app-iced 的观感**(`theme::Ui::new` 直接吃 baseline),
//! 而换肤(含截图验收)归 M229;令牌层先把两套板备好,再让壳层一次性切过去,比「两处各改一半」
//! 可控。
//! 3. 兼容层是**零成本**的:`Role` 名字与 49 个下标一个没动,新增的是并列入口而不是替换入口。
//!
//! ## 「跟随系统」是第三种设置态,不是第三套色板
//!
//! 外观设置有三态:浅色 / 深色 / 跟随系统。前两态直接选板,第三态按系统外观落到
//! [`BrandBoard::for_flavor`]。色板本身只有两套,**不要**再加一个 `System` 变体——那会让
//! 「两套板」变成「三套板」,而系统外观随时会变,色板却应该是静态的。
//!
//! ## 色值来源分两类(`[spec]` / `[derive]`)
//!
//! - **`[spec]`**:规格卡直写。每块的 [`BrandBoard::anchors`] 表就是这份清单,测试逐条比对 hex,
//! 所以「规格卡改了但代码没改」会红。
//! - **`[derive]`**:规格卡没给的角色(`on-secondary`、`surface-container-low`、`*-fixed*` 等),
//! 按「同色相 + M3 的 tone 关系」从邻居推导后**固化成字面量**。推导规则写在
//! `boards/ink.rs` / `boards/silver.rs` 的模块文档与行注释里,运行期不重算——色板是数据,
//! 不是每次启动现算的函数。
//!
//! ## 对比度纪律(两套板同一道门,数字见 `palette::visibility` 的实测表)
//!
//! - 26 组文字/图标对两套板全部 ≥4.5:1(`tests/ink_cat_boards.rs`);
//! - **状态容器**(primary / secondary / tertiary / error 的 `*-container`)对 `surface` ≥
//! [`crate::palette::visibility::STATE_CONTAINER_MIN`](1.20:1)——这是债务台账
//! 「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:旧 baseline 的选中行底实测
//! **1.057:1**(m223 视觉复审),Ink 板抬到 **1.97:1**、Silver 板 **1.23:1**;
//! - **层级容器**(`surface-container-*`)不参与那条数字门禁,只要求 L\\* 沿阶梯单调、相邻步长
//! ≥ [`crate::palette::visibility::LAYER_STEP_MIN_LSTAR`]。M3 的容器层天生贴在一起
//! (Silver 的状态容器压在容器层上实测最低 **1.02**、Ink 最低 1.54,见 `palette::visibility`
//! 的实测表),所以裁决第 3 条是**构成要求**:选中态必须「容器底 + 文字对比 + 形状/图标/描边」
//! 三件套,不许只靠底色差。
mod ink;
mod silver;
use super::{Color, Role, Scheme, SchemeFlavor};
/// 产品色板(两套,与深浅模式一一对应)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum BrandBoard {
/// **Ink Cat**:深色主方案(规格卡 `COLOR ROLES / INK`)。窗口底 `#0F150F`,主色淡绿。
Ink,
/// **Silver Cat**:浅色主方案(规格卡 `SILVER CAT / LIGHT SCHEME`)。窗口底 `#F9F9FA`,主色钢蓝。
Silver,
}
impl BrandBoard {
/// 全部色板(由深到浅)。
pub const ALL: [BrandBoard; 2] = [BrandBoard::Ink, BrandBoard::Silver];
/// 稳定字符串名(`ink` / `silver`),写配置与日志用。
pub const fn name(self) -> &'static str {
match self {
BrandBoard::Ink => "ink",
BrandBoard::Silver => "silver",
}
}
/// 该板对应的深浅模式(Ink = 深、Silver = 浅)。
pub const fn flavor(self) -> SchemeFlavor {
match self {
BrandBoard::Ink => SchemeFlavor::Dark,
BrandBoard::Silver => SchemeFlavor::Light,
}
}
/// 按深浅模式选板——这就是「跟随系统」的落点。
pub const fn for_flavor(flavor: SchemeFlavor) -> Self {
match flavor {
SchemeFlavor::Dark => BrandBoard::Ink,
SchemeFlavor::Light => BrandBoard::Silver,
}
}
/// 49 个角色的色值表(下标 = `Role as usize`)。
pub const fn colors(self) -> [Color; 49] {
match self {
BrandBoard::Ink => ink::COLORS,
BrandBoard::Silver => silver::COLORS,
}
}
/// 直接取角色色值。
pub const fn get(self, role: Role) -> Color {
self.colors()[role as usize]
}
/// 规格卡直写的色值清单(`[spec]` 行),出处对账与文档生成用。
pub const fn anchors(self) -> &'static [(Role, &'static str)] {
match self {
BrandBoard::Ink => &ink::ANCHORS,
BrandBoard::Silver => &silver::ANCHORS,
}
}
/// 这套色板对应的完整语义方案。
pub fn scheme(self) -> Scheme {
Scheme::from_board(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
#[test]
fn boards_map_to_flavors_both_ways() {
for board in BrandBoard::ALL {
assert_eq!(BrandBoard::for_flavor(board.flavor()), board);
}
assert_eq!(
BrandBoard::for_flavor(SchemeFlavor::Light),
BrandBoard::Silver
);
assert_eq!(BrandBoard::for_flavor(SchemeFlavor::Dark), BrandBoard::Ink);
}
#[test]
fn names_are_unique_and_ascii() {
let mut seen = BTreeSet::new();
for board in BrandBoard::ALL {
let name = board.name();
assert!(seen.insert(name), "色板名重复:{name}");
assert!(name.chars().all(|c| c.is_ascii_lowercase()));
}
}
#[test]
fn every_role_is_opaque_on_both_boards() {
for board in BrandBoard::ALL {
for role in Role::ALL {
assert!(
board.get(role).is_opaque(),
"{} 的 {} 不是不透明色",
board.name(),
role.name()
);
}
}
}
#[test]
fn anchors_are_a_subset_of_the_board() {
for board in BrandBoard::ALL {
for (role, hex) in board.anchors() {
assert_eq!(
board.get(*role),
Color::from_hex(hex).expect("规格卡色值"),
"{} 的 {} 与规格卡 {hex} 不符",
board.name(),
role.name()
);
}
}
}
#[test]
fn scheme_flavor_follows_the_board() {
for board in BrandBoard::ALL {
assert_eq!(board.scheme().flavor(), board.flavor());
}
// 深板确实深、浅板确实浅(不是两个同义词)。
let ink = BrandBoard::Ink.get(Role::Surface).relative_luminance();
let silver = BrandBoard::Silver.get(Role::Surface).relative_luminance();
assert!(ink < 0.02, "Ink 的窗口底太亮:{ink}");
assert!(silver > 0.9, "Silver 的窗口底太暗:{silver}");
}
#[test]
fn the_two_boards_are_actually_different() {
let ink = BrandBoard::Ink.colors();
let silver = BrandBoard::Silver.colors();
let same = Role::ALL
.iter()
.filter(|role| ink[**role as usize] == silver[**role as usize])
.count();
// 只有 shadow / scrim(纯黑,M3 定值)该跨板同值——其余都得变。
assert_eq!(same, 2, "跨板同值的角色数应从 2(shadow/scrim)开始算");
}
}
+105
View File
@@ -0,0 +1,105 @@
//! **Silver Cat 浅色板**(规格卡 `SILVER CAT / LIGHT SCHEME` 一节 + 同页浅色字段/按钮)。
//!
//! 与 [`super::ink`] 同一套口径:49 行按 [`Role`] 声明序排列,`[spec]` = 规格卡直写值
//! (与 [`ANCHORS`] 同源,测试逐条比对 hex),`[derive]` = 按同色相 + M3 tone 关系推导后固化。
use crate::color::{Color, Role};
/// 规格卡直接给出的浅色色值,逐条对账用。
///
/// 出处分三处:SILVER CAT / LIGHT SCHEME 的色板行、同页 Filled/Tonal/Outlined 按钮与两个字段
/// (`on-primary` 取自 Filled 的文字 `#f6f9ff`、`on-secondary-container` 取自 Tonal 的文字
/// `#0d1d2a`、`outline-variant` 取自色板方块的描边 `#bec8cd`)。
///
/// ⚠️ 字段底 `#dcddde` 落在 **`surface-container-highest`**(不是 high):它与 Ink 的字段底
/// `#262b25` 是同一层角色(`component_map` 的 `TextField` 口径 = 最高容器层)。浅色板的
/// `surface-container-high` 因此是**推导值** `#e4e4e5`(tone 90),只为让阶梯在 94.1 → 88.1
/// 之间保持单调、步长 ≥1.0 L\\*;若把 `#dcddde` 定在 high,Silver 的字段底会比 Ink 低一层,
/// 单一角色接法(只读 Highest)会读到 4.38:1 的 `Error` 标签(掉 AA)。
pub const ANCHORS: [(Role, &str); 13] = [
(Role::Primary, "#006499"),
(Role::OnPrimary, "#F6F9FF"),
(Role::PrimaryContainer, "#87C7FF"),
(Role::SecondaryContainer, "#D4E4F6"),
(Role::OnSecondaryContainer, "#0D1D2A"),
(Role::Surface, "#F9F9FA"),
(Role::SurfaceContainer, "#EEEEEF"),
(Role::SurfaceContainerHighest, "#DCDDDE"),
(Role::OnSurface, "#1A1C1D"),
(Role::OnSurfaceVariant, "#434D51"),
(Role::Outline, "#6F797D"),
(Role::OutlineVariant, "#BEC8CD"),
(Role::Error, "#AC3434"),
];
/// Silver 板 49 个角色(下标 = `Role as usize`)。
pub const COLORS: [Color; 49] = [
Color::rgb(0x00, 0x64, 0x99), // 01 primary [spec] 实心按钮底、文字按钮标签
Color::rgb(0xF6, 0xF9, 0xFF), // 02 on-primary [spec] Filled 按钮文字
Color::rgb(0x87, 0xC7, 0xFF), // 03 primary-container [spec]
Color::rgb(0x00, 0x34, 0x4E), // 04 on-primary-container [derive] container @ tone 20
Color::rgb(0x4A, 0x60, 0x76), // 05 secondary [derive] tone 40,蓝灰
Color::rgb(0xF6, 0xF9, 0xFF), // 06 on-secondary [derive] tone 98
Color::rgb(0xD4, 0xE4, 0xF6), // 07 secondary-container [spec] Tonal 按钮底、选中 chip
Color::rgb(0x0D, 0x1D, 0x2A), // 08 on-secondary-container [spec] Tonal 按钮文字
Color::rgb(0x7C, 0x52, 0x25), // 09 tertiary [derive] tone 40,暖棕(与主色互补)
Color::rgb(0xFF, 0xFF, 0xFF), // 10 on-tertiary [derive] tone 100
Color::rgb(0xFF, 0xDC, 0xC1), // 11 tertiary-container [derive] tone 90
Color::rgb(0x2B, 0x17, 0x00), // 12 on-tertiary-container [derive] tone 10
Color::rgb(0xAC, 0x34, 0x34), // 13 error [spec] 错误文字
Color::rgb(0xFF, 0xFF, 0xFF), // 14 on-error [derive] tone 100
Color::rgb(0xFF, 0xDA, 0xD6), // 15 error-container [derive] error @ tone 90
Color::rgb(0x41, 0x00, 0x02), // 16 on-error-container [derive] error @ tone 10
Color::rgb(0xF9, 0xF9, 0xFA), // 17 surface [spec] 窗口底
Color::rgb(0x1A, 0x1C, 0x1D), // 18 on-surface [spec] 正文/标题
Color::rgb(0xD9, 0xDE, 0xE2), // 19 surface-variant [derive] neutral-variant tone 88
Color::rgb(0x43, 0x4D, 0x51), // 20 on-surface-variant [spec] 次要文字、字段标签
Color::rgb(0xF9, 0xF9, 0xFA), // 21 background [spec](= surface)
Color::rgb(0x1A, 0x1C, 0x1D), // 22 on-background [spec](= on-surface)
Color::rgb(0xD8, 0xDA, 0xDA), // 23 surface-dim [derive] tone 87
Color::rgb(0xF9, 0xF9, 0xFA), // 24 surface-bright [derive](= surface,M3 浅色同 tone 98)
Color::rgb(0xFF, 0xFF, 0xFF), // 25 surface-container-lowest [derive] tone 100
Color::rgb(0xF3, 0xF3, 0xF4), // 26 surface-container-low [derive] tone 96
Color::rgb(0xEE, 0xEE, 0xEF), // 27 surface-container [spec] 卡片/列表行底
Color::rgb(0xE4, 0xE4, 0xE5), // 28 surface-container-high [derive] tone 90(比 highest 高 2.5 L*,阶梯步长 3.5)
Color::rgb(0xDC, 0xDD, 0xDE), // 29 surface-container-highest [spec] 输入框底(与 Ink 的 #262B25 同位:字段底就是最高容器层)
Color::rgb(0x6F, 0x79, 0x7D), // 30 outline [spec] 字段描边
Color::rgb(0xBE, 0xC8, 0xCD), // 31 outline-variant [spec] 分隔线、chip 描边
Color::rgb(0x2F, 0x31, 0x33), // 32 inverse-surface [derive] tone 20
Color::rgb(0xF0, 0xF0, 0xF1), // 33 inverse-on-surface [derive] tone 95
Color::rgb(0x87, 0xC7, 0xFF), // 34 inverse-primary [derive](= primary-container)
Color::rgb(0x00, 0x00, 0x00), // 35 shadow M3 定值:纯黑 + 令牌 alpha
Color::rgb(0x00, 0x00, 0x00), // 36 scrim M3 定值:纯黑 + 令牌 alpha
Color::rgb(0x00, 0x64, 0x99), // 37 surface-tint = primary(M3 旧 tint 口径)
Color::rgb(0x87, 0xC7, 0xFF), // 38 primary-fixed [derive] tone 78(= container)
Color::rgb(0x5F, 0xAE, 0xE6), // 39 primary-fixed-dim [derive] tone 68
Color::rgb(0x00, 0x1E, 0x2E), // 40 on-primary-fixed [derive] tone 10
Color::rgb(0x00, 0x3A, 0x57), // 41 on-primary-fixed-variant [derive] tone 23
Color::rgb(0xD4, 0xE4, 0xF6), // 42 secondary-fixed [derive] tone 90
Color::rgb(0xB8, 0xC8, 0xDA), // 43 secondary-fixed-dim [derive] tone 80
Color::rgb(0x0D, 0x1D, 0x2A), // 44 on-secondary-fixed [derive] tone 10
Color::rgb(0x33, 0x48, 0x5A), // 45 on-secondary-fixed-variant [derive] tone 30
Color::rgb(0xFF, 0xDC, 0xC1), // 46 tertiary-fixed [derive] tone 90
Color::rgb(0xF0, 0xBD, 0x8E), // 47 tertiary-fixed-dim [derive] tone 80
Color::rgb(0x2B, 0x17, 0x00), // 48 on-tertiary-fixed [derive] tone 10
Color::rgb(0x5E, 0x3F, 0x1A), // 49 on-tertiary-fixed-variant [derive] tone 29
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn anchors_pin_the_spec() {
for (role, hex) in ANCHORS {
let expected = Color::from_hex(hex).expect("规格卡色值");
assert_eq!(
COLORS[role as usize],
expected,
"{} 的色值与规格卡({hex})不符:表里是 {}",
role.name(),
COLORS[role as usize].to_hex()
);
}
}
}
+6
View File
@@ -53,6 +53,10 @@
//!
//! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半)
//!
//! > **口径更新(M228)**:出厂外观的入口是 `DesignTokens::brand`(Ink / Silver 两套固化色板,
//! > 见 [`crate::color::boards`])。本节描述的「浅冷天蓝」**仍是 D38 取色链的中性锚点**
//! > (`Scheme::baseline` / `from_accent` 的表面色基准),不再是「产品出厂看起来长什么样」。
//!
//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**
//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在
//! `tests/skyseed_candidates.rs`):
@@ -105,6 +109,7 @@
//! - [`Role::SurfaceVariant`]:它是**容器**色(不是前景),不参与对比度门禁。
//! - 禁用态按 M3 的 alpha 叠加([`state::DISABLED_CONTENT_ALPHA`]),刻意低于 AA。
pub mod boards;
pub mod hct;
pub mod palettes;
pub mod roles;
@@ -119,6 +124,7 @@ mod hct_solver;
pub(crate) mod space;
mod viewing_conditions;
pub use boards::BrandBoard;
pub use hct::Hct;
pub use palettes::{
PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED, NEUTRAL_HUE_DEG,
+11 -11
View File
@@ -9,12 +9,12 @@
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
//!
//! ## 两个入口:官方口径 vs 产品默认(M70)
//! ## 两个入口:官方口径 vs 取色链锚点(M70 / M228 口径更新)
//!
//! | 入口 | 种子 | 中性彩度 | 用途 |
//! |---|---|---|---|
//! | [`Palettes::official_baseline`] / [`super::Scheme::official_baseline`] | [`BASELINE_SEED`](`#6750A4`) | 6(官方) | 与官方 Java 实现逐角色对齐的**锚点**,`tests/official_baseline.rs` 用它 |
//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **产品出厂默认**(消费方拿到的就是它) |
//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **D38 取色链的中性锚点**(`from_accent` 的表面色基准)。M228 起**出厂外观**的入口是 `DesignTokens::brand`(Ink / Silver 固化板),不是这里 |
//!
//! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行),
//! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。
@@ -26,12 +26,12 @@
use super::tonal::TonalPalette;
use super::Color;
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
///
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值);**不是**产品色——
/// 产品外观见 `DesignTokens::brand`,取色链锚点见 [`DEFAULT_SEED`]。
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
/// 取色链的锚点种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**;M228 起产品出厂
/// 外观走 `DesignTokens::brand`(Ink / Silver 固化板),本常量只影响 D38 取色链。
///
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
@@ -41,12 +41,11 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套;`palette::DEFAULT_CARD` 的种子即本值)。
///
/// 取色(量化 + 挑主色)、内置色卡库与三级回落都在 [`crate::palette`](M87,分层见 D40);
/// 本模块只负责「给定种子 + 中性彩度,派生出 49 个角色」。
pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
/// 取色链的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
///
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
@@ -143,10 +142,11 @@ pub struct Palettes {
}
impl Palettes {
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
/// **D38 取色链的中性锚点**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
///
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
/// 只需要改那两个常量。
/// 用途只剩一条:给「莫奈关闭时的机内色卡」与 [`super::Scheme::from_accent`] 提供中性表面色
/// 基准。**M228 起产品出厂外观走 `DesignTokens::brand`**([`super::boards::BrandBoard`] 的
/// Ink / Silver 板),改这两个常量**不再改变用户看到的默认外观**,只影响取色链。
pub fn baseline() -> Self {
Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA)
}
+19 -1
View File
@@ -13,6 +13,7 @@
//! 表错一格,对齐测试就会红。三档对比度(medium/high)不在本 crate 范围内,见
//! [`super`] 的 gap 登记。
use super::boards::BrandBoard;
use super::palettes::{PaletteSlot, Palettes};
use super::roles::Role;
use super::Color;
@@ -178,8 +179,12 @@ pub struct Scheme {
}
impl Scheme {
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
/// **D38 取色链的中性锚点**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
///
/// M228 起**产品出厂外观**走 [`Scheme::from_board`](Ink / Silver 固化色板);本入口保留给
/// 取色链与 [`Scheme::from_accent`] 的表面色基准,两者并列而不是互相替换(理由见
/// [`super::boards`] 的模块文档)。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self::from_palettes(&Palettes::baseline(), flavor)
}
@@ -212,6 +217,19 @@ impl Scheme {
Self { flavor, colors }
}
/// **品牌色板**(Ink 深 / Silver 浅)→ 完整方案:`unlucky_design::color::BrandBoard`。
///
/// 与 [`Scheme::baseline`] 的区别不是算法而是**来源**:baseline 由种子 + tone 表推导(D38
/// 取色链),本入口直接吃用户规格卡固化的 49 个色值(`color::boards`)。深浅模式由板自己
/// 决定(`BrandBoard::flavor`),所以不需要再传 [`SchemeFlavor`]——穿错了会得到「深板浅色」
/// 这种自相矛盾的方案。
pub fn from_board(board: BrandBoard) -> Self {
Self {
flavor: board.flavor(),
colors: board.colors(),
}
}
/// 从 49 个已解析色值直接构造(**底层入口**,下标必须等于 `Role as usize`)。
///
/// 只给两类调用方用:
+255
View File
@@ -0,0 +1,255 @@
//! 控件方框几何(规格卡的 `CARD / DIALOG / SNACKBAR / LIST`、`TEXT FIELDS`、`SWITCH / CHECKBOX /
//! RADIO / CHIPS`、`PROGRESS` 五节)。
//!
//! 口径与 [`crate::component_map::ComponentMetrics`] 一致:`width`/`height` 为 `0.0` 表示
//! **由内容决定**(自适应宽),不是「尺寸为零」。差别只在**来源**——这里是规格卡的值,
//! `component_map` 是官方组件令牌的值,两者不一致处已在 [`super`] 的登记表里逐条写明。
//!
//! 圆角一律引用 [`ShapeScale`],不写裸数字;`SpecBox::radius_px()` 用**高度**折算
//! (药丸按高的一半、非药丸按刻度值且不超过半高),所以同一个结构既能表达 60 高药丸行(30),
//! 也能表达 150 高圆角卡(16)。
use crate::shape::ShapeScale;
/// 一个控件方框(宽 / 高 / 圆角档)。`0.0` = 由内容决定。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpecBox {
/// 可见外框宽(px;`0.0` = 自适应)。
pub width: f32,
/// 可见外框高(px)。
pub height: f32,
/// 圆角档([`ShapeScale`] 的刻度值)。
pub shape: ShapeScale,
}
impl SpecBox {
/// 实际圆角(px):按高度折算,且不超过高的一半。
pub fn radius_px(&self) -> f32 {
self.shape.radius_px(self.height)
}
/// 是否是药丸/整圆(圆角 = 半高)。
pub fn is_pill(&self) -> bool {
(self.radius_px() - self.height / 2.0).abs() < f32::EPSILON && self.height > 0.0
}
}
/// 卡片:300×150、圆角 **16**(规格卡 `ELEVATED CARD` 的 `rx=16`)。
///
/// 与官方 `FilledCardTokens` 的 `CornerMedium(12)` 差 4 —— 规格卡口径取 16(Large)。
pub const CARD: SpecBox = SpecBox {
width: 300.0,
height: 150.0,
shape: ShapeScale::Large,
};
/// 对话框:380×190、圆角 **28**(与官方 `DialogTokens.CornerExtraLarge` 一致)。
pub const DIALOG: SpecBox = SpecBox {
width: 380.0,
height: 190.0,
shape: ShapeScale::ExtraLarge,
};
/// Snackbar:340×52、圆角 **16**(官方是 `ExtraSmall(4)`,规格卡取 16 —— 登记差异)。
pub const SNACKBAR: SpecBox = SpecBox {
width: 340.0,
height: 52.0,
shape: ShapeScale::Large,
};
/// 列表行:300×**60**、圆角 **30**(= 药丸;官方 `CornerNone`,本项目 `Small(8)`,规格卡药丸 —— 登记差异)。
pub const LIST_ROW: SpecBox = SpecBox {
width: 300.0,
height: 60.0,
shape: ShapeScale::Full,
};
/// Chip(assist / filter):高 **32**、圆角 **8**、宽度自适应(标签 13px、左右内距 16)。
///
/// 选中态的**形状不变**(规格卡 `Selected` 只是换填充),所以这里不给形变数据。
pub const CHIP: SpecBox = SpecBox {
width: 0.0,
height: 32.0,
shape: ShapeScale::Small,
};
/// Chip 左右内距(px)。规格卡 chip 自适应宽;16 与 M3 `AssistChipTokens` 的 `HorizontalPadding`
/// 同值,且实测标签订了这个量级。
pub const CHIP_PAD_X: f32 = 16.0;
/// 徽标(数字圆点):20 Ø、整圆。
pub const BADGE: SpecBox = SpecBox {
width: 20.0,
height: 20.0,
shape: ShapeScale::Full,
};
/// 列表行头像:40 Ø、整圆。
pub const AVATAR: SpecBox = SpecBox {
width: 40.0,
height: 40.0,
shape: ShapeScale::Full,
};
/// 加载指示:56 Ø,圆形态(`rx=28`)与方形态(`rx=12` = [`ShapeScale::Medium`])两种。
pub const LOADING: SpecBox = SpecBox {
width: 56.0,
height: 56.0,
shape: ShapeScale::Full,
};
/// 加载指示的方形态圆角档(规格卡 `rx=12`)。
pub const LOADING_SQUIRCLE: ShapeScale = ShapeScale::Medium;
// ---- 文本字段(TEXT FIELDS / FILLED - OUTLINED - ERROR + ROUNDED 变体)----
/// 字段高度(px;filled / outlined / error / 搜索框都是 56)。
pub const FIELD_HEIGHT: f32 = 56.0;
/// 默认圆角档:**4**(规格卡标题就写着 `CORNER 4`;与官方 `CornerExtraSmall` 一致)。
pub const FIELD_SHAPE: ShapeScale = ShapeScale::ExtraSmall;
/// 药丸变体圆角档:**28**(规格卡 `ROUNDED (CORNER 28, BRAND VARIANT)`)。
pub const FIELD_SHAPE_ROUNDED: ShapeScale = ShapeScale::ExtraLarge;
/// 默认字段的内容内距(px):标签 `x=80` − 字段 `x=64` = **16**。
pub const FIELD_INSET: f32 = 16.0;
/// 药丸字段的内容内距(px):**32**。
///
/// 规格卡专门写了一行提醒:`label inset 32 inside pills (not 16), otherwise it reads as clipped`——
/// 药丸两端是半圆,沿用 16 会让标签贴到弧上。
pub const FIELD_PILL_INSET: f32 = 32.0;
/// 字段悬浮标签字号(px)与输入值字号(px)。
pub const FIELD_LABEL_PX: f32 = 12.0;
/// 字段输入值字号(px)。
pub const FIELD_VALUE_PX: f32 = 16.0;
/// 错误提示行字号(px)。
pub const FIELD_ERROR_PX: f32 = 12.0;
/// 搜索框:560 宽是页级骨架量(这里只给高度与形状),圆角 **28** = 药丸,图标内距 **32**。
pub const SEARCH_SHAPE: ShapeScale = ShapeScale::Full;
/// 搜索框图标中心距左边(px):`cx=1048` − 框 `x=1016` = **32**。
pub const SEARCH_INSET: f32 = 32.0;
// ---- 开关 / 复选框 / 单选框 ----
/// 开关:轨道 52×32 药丸,拇指 **24 Ø**、内缩 **4**(关态拇指 16 Ø + 描边 2)。
pub const SWITCH_TRACK: SpecBox = SpecBox {
width: 52.0,
height: 32.0,
shape: ShapeScale::Full,
};
/// 开关拇指直径(px)。
pub const SWITCH_THUMB: f32 = 24.0;
/// 拇指相对轨道的内缩(px):规格卡轨道 `y=1060..1092`、拇指 `y=1064..1088`,上下各 **4**;
/// 右端 `116−112` 同为 4。
///
/// 位移行程(拇指可从左滑到右的距离)= 轨道宽 52 − 拇指 24 − 2×内缩 4 = **20**(规格卡没写行程,
/// 这是由前三项推出来的唯一值;写死 20 就是第四份副本)。
pub const SWITCH_THUMB_INSET: f32 = 4.0;
/// 关态拇指直径(px):规格卡 `16×16 rx8`。
pub const SWITCH_THUMB_OFF: f32 = 16.0;
/// 复选框:**18×18**、圆角 **4**、描边 **2**(规格卡 `rect 18×18 rx4` + 勾线 `stroke-width=2`)。
///
/// 与官方 20×20 / 2dp 差 2px 与 2dp —— 4 是 `ShapeScale` 的刻度值,所以走容器层不违反 D32。
pub const CHECKBOX: SpecBox = SpecBox {
width: 18.0,
height: 18.0,
shape: ShapeScale::ExtraSmall,
};
/// 复选框描边/勾线宽度(px)。
pub const CHECKBOX_STROKE: f32 = 2.0;
/// 单选框:**18 Ø**、环线 **2**、内点 **10 Ø**(规格卡 `r=9` 环 + `r=5` 实心)。
pub const RADIO: SpecBox = SpecBox {
width: 18.0,
height: 18.0,
shape: ShapeScale::Full,
};
/// 单选框环线宽度(px)。
pub const RADIO_RING: f32 = 2.0;
/// 单选框内点直径(px)。
pub const RADIO_DOT: f32 = 10.0;
/// 勾选控件与标签的间距(px;规格卡 `18` 框 → 标签 `x=398`,实测 8)。
pub const CHOICE_GAP: f32 = 8.0;
// ---- 进度 ----
/// 线性进度条厚度(px):`rect height=4`。
pub const PROGRESS_THICKNESS: f32 = 4.0;
/// 波形进度的描边宽度(px):`stroke-width=6`、`stroke-linecap=round`。
pub const PROGRESS_WAVY_STROKE: f32 = 6.0;
#[cfg(test)]
mod tests {
use super::*;
use crate::spacing::GRID;
#[test]
fn spec_boxes_hit_the_spec_corners() {
// 规格卡逐个比对(rx 一栏就是 `radius_px()`)。
assert_eq!(CARD.radius_px(), 16.0, "卡片 rx=16");
assert_eq!(DIALOG.radius_px(), 28.0, "对话框 rx=28");
assert_eq!(SNACKBAR.radius_px(), 16.0, "snackbar rx=16");
assert_eq!(LIST_ROW.radius_px(), 30.0, "列表行 rx=30(药丸)");
assert_eq!(CHIP.radius_px(), 8.0, "chip rx=8");
assert_eq!(BADGE.radius_px(), 10.0, "徽标是圆点");
assert_eq!(AVATAR.radius_px(), 20.0, "头像是圆");
assert_eq!(LOADING.radius_px(), 28.0, "加载圆形态");
assert_eq!(LOADING_SQUIRCLE.dp(), 12.0, "加载方形态 rx=12");
assert_eq!(CHECKBOX.radius_px(), 4.0, "复选框 rx=4");
assert_eq!(SWITCH_TRACK.radius_px(), 16.0, "开关轨道 rx=16");
}
#[test]
fn pills_are_only_the_ones_the_spec_draws_as_pills() {
assert!(LIST_ROW.is_pill());
assert!(BADGE.is_pill());
assert!(AVATAR.is_pill());
assert!(LOADING.is_pill());
assert!(RADIO.is_pill());
assert!(SWITCH_TRACK.is_pill());
assert!(!CARD.is_pill());
assert!(!DIALOG.is_pill());
assert!(!SNACKBAR.is_pill());
assert!(!CHECKBOX.is_pill());
}
#[test]
fn field_variants_keep_the_inset_rule() {
assert_eq!(FIELD_SHAPE.dp(), 4.0);
assert_eq!(FIELD_SHAPE_ROUNDED.dp(), 28.0);
// 药丸内距必须是默认内距的两倍——规格卡那行提醒就是这条。
assert_eq!(FIELD_PILL_INSET, FIELD_INSET * 2.0);
}
#[test]
fn heights_are_on_the_grid() {
for (name, value) in [
("snackbar", SNACKBAR.height),
("list-row", LIST_ROW.height),
("chip", CHIP.height),
("badge", BADGE.height),
("avatar", AVATAR.height),
("loading", LOADING.height),
("field", FIELD_HEIGHT),
("switch", SWITCH_TRACK.height),
("switch-thumb", SWITCH_THUMB),
("progress", PROGRESS_THICKNESS),
("choice-gap", CHOICE_GAP),
("search-inset", SEARCH_INSET),
] {
assert_eq!(value % GRID, 0.0, "{name} = {value} 不在 4px 栅格上");
}
// 微控件 18:规格卡实测值(M3 官方 `CheckboxTokens.ContainerWidth/Height` 也是 18),
// 它与 2dp/4dp 圆角一样属「微控件刻度」,不受 4px 栅格约束。
assert_eq!((CHECKBOX.width, CHECKBOX.height), (18.0, 18.0));
assert_eq!((RADIO.width, RADIO.height), (18.0, 18.0));
// 卡 / 对话框的 300×150 与 380×190 是规格卡画布上的**示意**尺寸(实际高由内容决定),
// 所以只钉数值、不参与栅格门禁。
assert_eq!((CARD.width, CARD.height), (300.0, 150.0));
assert_eq!((DIALOG.width, DIALOG.height), (380.0, 190.0));
// 例外:描边类不受栅格约束(它们是一像素/线宽量,不是布局尺寸),单独钉住。
assert_eq!(CHECKBOX_STROKE, 2.0);
assert_eq!(RADIO_RING, 2.0);
assert_eq!(PROGRESS_WAVY_STROKE, 6.0, "波形进度描边 stroke-width=6");
}
}
+48
View File
@@ -0,0 +1,48 @@
//! # Ink Cat 规格卡 → 令牌(形状 / 尺寸档 / 字号校核)
//!
//! 来源是用户自制的规格卡 `DevDocs/artifacts/components-ink.svg`(Ink Cat / M3 Expressive,
//! 1480×2260)。色板在 [`crate::color::boards`],**本模块只装几何与排版**:
//!
//! | 子模块 | 内容 |
//! |---|---|
//! | [`size`] | 按钮 XS~XL 五档([`size::ControlSize`])+ FAB 四态([`size::FabSize`]) |
//! | [`metrics`] | 控件方框几何:卡片 / 对话框 / snackbar / 列表行 / chip / 徽标 / 字段 / 复选框 / 单选框 / 开关 / 进度 / 加载 |
//! | [`type_scale`] | 规格卡出现的每个字号 → 最近官方档的**校核结论**(命中 / 落在两档之间 / 低于 UI 下限) |
//!
//! ## 与 [`crate::component_map`] 的关系:并列来源,不是替代
//!
//! `component_map` 的口径是**官方 M3 组件令牌**(`androidx.compose.material3.tokens`,A 级);
//! 规格卡是**用户自己的视觉决议**(也是 A 级,但是产品口径)。两者在若干处不一致,
//! 本模块把差异**逐条登记**而不是偷偷改 `component_map`:
//!
//! | 项 | 规格卡 | `component_map`(官方) | 处置 |
//! |---|---|---|---|
//! | 卡片圆角 | **16**(`rx=16`) | `FilledCard/OutlinedCard = Medium(12)` | 登记:换肤时取 [`metrics::CARD`](Large 16)。表值不动——app-iced 的 `pages/frame/tests.rs` 按 12 pin 住,改表等于连带改观感,归 M229 |
//! | Snackbar | 高 **52** / 圆角 **16** | `ExtraSmall(4)` | 登记同上([`metrics::SNACKBAR`]) |
//! | 列表行 | 高 **60** / 药丸 **30** | `Small(8)` | 登记同上([`metrics::LIST_ROW`]) |
//! | 输入框 | 圆角 **4**(默认)/ **28**(药丸变体) | `ExtraSmall(4)` | 一致 + 新增药丸变体([`metrics::FIELD_SHAPE_ROUNDED`]) |
//! | Chip | 高 **32** / 圆角 **8** | `Small(8)`、无高度 | 一致 + 补高度([`metrics::CHIP`]) |
//! | Checkbox | **18×18** / 圆角 **4** / 描边 2 | 20×20 / 圆角 2(微半径) | 差异登记([`metrics::CHECKBOX`])。4 是 `ShapeScale` 刻度值,42 号位不冲突 |
//! | Radio | **18 Ø** / 环 2 / 内点 10 | 20 Ø / 环 2 / 内点 10 | 差异登记([`metrics::RADIO`]) |
//! | 按钮档 | **XS~XL = 32/40/56/96/136** | 无档位(`LayoutMetrics::control_height = 40`) | 新增 [`size::ControlSize`] |
//! | FAB | 四态 40/56/80/160×56,圆角 12/16/20/16 | 无 | 新增 [`size::FabSize`] |
//! | 对话框 | 380×190 / 圆角 **28** | `ExtraLarge(28)` | 一致([`metrics::DIALOG`]) |
//!
//! **为什么不动 `component_map` 的表值**:那张表已经被 iced 壳层消费并 pin 住(卡片 12、
//! 搜索条 Full 等),改值等于在令牌层做了一次没人截图验收的换肤。本模块先给出规格卡口径的
//! 数据,M229 换肤时一次切换 + 出图验收,避免「一半旧一半新」。
//!
//! ## 数值口径
//!
//! - 所有尺寸单位都是**逻辑 px**(100% DPI 下 1px = 1dp),与 `ShapeScale::dp()` 同一刻度;
//! - `0.0` 一律表示「由内容决定」(与 [`crate::component_map::ComponentMetrics`] 同约定),
//! 不是「尺寸为零」;
//! - 规格卡没写、但实现需要的值(按钮水平内距)标 `[估]` 并注明推导规则。
pub mod metrics;
pub mod size;
pub mod type_scale;
pub use metrics::SpecBox;
pub use size::{ControlSize, FabSize};
pub use type_scale::{SpecFit, SpecText, UI_TEXT_FLOOR_PX};
+323
View File
@@ -0,0 +1,323 @@
//! 控件**尺寸档**:按钮 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()));
}
}
}
+288
View File
@@ -0,0 +1,288 @@
//! 规格卡字号 → 官方 type scale 的**校核**(不是又一张字号表)。
//!
//! 规格卡上每一处**文字**都写了 `font-size`,去重共 11 档:
//! 40 / 32 / 24 / 16 / 15 / 14 / 13 / 12 / 11 / 10 / 9。
//! 其中一部分**命中** M3 的 15 档(14 / 16 / 24 / 32 / 12 / 11),一部分**落在两档之间**
//! (13 / 15 / 40),还有一部分**低于本项目允许的 UI 下限**(10 / 9)。
//!
//! 规格卡上另有 20 / 26 / 34 三个字号,但它们是 **FAB 的图形符号(`+` 字形)**,不是文字——
//! 归 [`crate::inkcat::size::FabSize::icon_px`] 管,本模块不收录(`SpecText` 里没有 20 这一档)。
//!
//! 本模块的职责就是把「规格卡字级」翻译成这三类结论,让换肤时不用逐个拿卷尺量:
//!
//! | 结论 | 含义 | 处置 |
//! |---|---|---|
//! | [`SpecFit::Exact`] | 恰好是官方某档 | 直接用该 [`TypeRole`] |
//! | [`SpecFit::Between`] | 落在相邻两档之间 | 取**较大**那档(可读性优先),并在报告里登记 1px 级偏离 |
//! | [`SpecFit::BelowFloor`] | 低于 [`UI_TEXT_FLOOR_PX`](11) | **不进 UI**:只允许出现在规格卡/图注/设计稿里 |
//!
//! ## 为什么下限是 11
//!
//! [`TypeRole::LabelSmall`] 是官方最小档(11/16),且 crate 文档已经写明「中文 11px 可读性差,
//! 谨慎使用」。规格卡的 10(小节标题 `BUTTONS / 5 LEVELS`)与 9(色板角色名)是**设计稿标注**,
//! 不是界面文字——搬进 UI 就是拿中文在图上看蚂蚁。所以下限写成常量,由
//! [`SpecText::in_ui`] 与测试把它钉死。
//!
//! ## 同字号多档怎么办
//!
//! M3 有若干同字号档(14 有 title-small / body-medium / label-large;16 有 title-medium /
//! body-large;12 有 body-small / label-medium)。[`nearest_role`] 在距离相同时按
//! [`TypeRole::ALL`] 的声明序取第一个(title → body → label),这是**稳定的**、与文档一致的选择;
//! 需要别的语义(例如「这是按钮文字」)就显式指定 [`TypeRole`],不要指望 `nearest_role` 猜。
use crate::typography::{TypeRole, Weight};
/// 允许进 UI 的最小字号(px)。低于它的规格卡字号只能是设计稿标注。
pub const UI_TEXT_FLOOR_PX: f32 = 11.0;
/// 规格卡上出现的文字用途(每个用途一个 `font-size`,值取自规格卡)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SpecText {
/// 卡片大标题:40 / 500 / 0(`Ink Cat / M3 Expressive` 那行)。
PageTitle,
/// 副标题:15 / 400 / 0.3。
Subtitle,
/// 小节标注(全大写 + 字距):10 / 600 / 1.2。
SectionCaps,
/// 大按钮标签:32 / 500 / 0(XL 档)。
DisplayLabel,
/// 对话框标题:24 / 400 / 0。
DialogTitle,
/// 标准控件标签:16 / 500 / 0(M 档按钮、卡片标题)。
ControlLabel,
/// 小控件标签:14 / 500 / 0(XS/S 档按钮、对话框动作、snackbar)。
ControlLabelSmall,
/// 正文:13 / 400 / 0(卡片支持文字、chip 标签、列表副文案)。
Body,
/// 辅助文字:12 / 400 / 0(字段标签、图标按钮说明)。
Caption,
/// 徽标数字:11 / 600 / 0。
Badge,
/// 色板角色名(设计稿标注):9 / 400 / 0。
SwatchCaption,
}
impl SpecText {
/// 规格卡里出现的全部用途。
pub const ALL: [SpecText; 11] = [
SpecText::PageTitle,
SpecText::Subtitle,
SpecText::SectionCaps,
SpecText::DisplayLabel,
SpecText::DialogTitle,
SpecText::ControlLabel,
SpecText::ControlLabelSmall,
SpecText::Body,
SpecText::Caption,
SpecText::Badge,
SpecText::SwatchCaption,
];
/// 字号(px,规格卡 `font-size`)。
pub const fn px(self) -> f32 {
match self {
SpecText::PageTitle => 40.0,
SpecText::Subtitle => 15.0,
SpecText::SectionCaps => 10.0,
SpecText::DisplayLabel => 32.0,
SpecText::DialogTitle => 24.0,
SpecText::ControlLabel => 16.0,
SpecText::ControlLabelSmall => 14.0,
SpecText::Body => 13.0,
SpecText::Caption => 12.0,
SpecText::Badge => 11.0,
SpecText::SwatchCaption => 9.0,
}
}
/// 字重(规格卡 `font-weight`)。
pub const fn weight(self) -> Weight {
match self {
SpecText::SectionCaps | SpecText::Badge => Weight::SemiBold,
SpecText::PageTitle
| SpecText::DisplayLabel
| SpecText::ControlLabel
| SpecText::ControlLabelSmall => Weight::Medium,
_ => Weight::Regular,
}
}
/// 字距(px,规格卡 `letter-spacing`)。
pub const fn tracking_px(self) -> f32 {
match self {
SpecText::SectionCaps => 1.2,
SpecText::Subtitle => 0.3,
_ => 0.0,
}
}
/// 校核结论(命中 / 落在两档之间 / 低于下限)。
pub fn fit(self) -> SpecFit {
fit_of(self.px())
}
/// 能否进 UI(≥ [`UI_TEXT_FLOOR_PX`])。
pub const fn in_ui(self) -> bool {
self.px() >= UI_TEXT_FLOOR_PX
}
}
/// 一个字号与官方 type scale 的关系。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpecFit {
/// 恰好等于官方某档(同字号多档时取 `TypeRole::ALL` 序里第一个)。
Exact(TypeRole),
/// 落在相邻两档之间(小档, 大档)。
Between(TypeRole, TypeRole),
/// 低于 [`UI_TEXT_FLOOR_PX`]:只能是设计稿标注。
BelowFloor,
}
impl SpecFit {
/// 建议采用的档位(`BelowFloor` 返回 `None`:不该进 UI)。
///
/// `Between` 取**较大**那档——宁可偏大 1px,也不要偏小到中文糊成一团。
pub fn recommended(self) -> Option<TypeRole> {
match self {
SpecFit::Exact(role) => Some(role),
SpecFit::Between(_, larger) => Some(larger),
SpecFit::BelowFloor => None,
}
}
}
/// `px` 的最近官方档(距离相同取**较大**那档;同字号多档取 `TypeRole::ALL` 序里第一个)。
pub fn nearest_role(px: f32) -> TypeRole {
let mut best = TypeRole::BodyMedium;
let mut best_key = (f32::INFINITY, f32::NEG_INFINITY);
for role in TypeRole::ALL {
let size = role.style().size_px;
// 排序键:(距离, −字号);严格优于当前才换,所以同键时保留 `ALL` 序靠前的那个。
let key = ((size - px).abs(), -size);
if key.0 < best_key.0 || (key.0 == best_key.0 && key.1 < best_key.1) {
best = role;
best_key = key;
}
}
best
}
/// `px` 与官方 type scale 的关系。
pub fn fit_of(px: f32) -> SpecFit {
if px < UI_TEXT_FLOOR_PX {
return SpecFit::BelowFloor;
}
let mut exact = None;
let mut lower: Option<(f32, TypeRole)> = None;
let mut upper: Option<(f32, TypeRole)> = None;
for role in TypeRole::ALL {
let size = role.style().size_px;
if (size - px).abs() < f32::EPSILON {
// 同字号多档(14/16/12):保留 `ALL` 序里第一个(title → body → label)。
exact = exact.or(Some(role));
} else if size < px {
if lower.is_none_or(|(best, _)| size > best) {
lower = Some((size, role));
}
} else if upper.is_none_or(|(best, _)| size < best) {
upper = Some((size, role));
}
}
match (exact, lower, upper) {
(Some(role), _, _) => SpecFit::Exact(role),
(None, Some((_, lower)), Some((_, upper))) => SpecFit::Between(lower, upper),
// 超出官方区间(px > 57):取最近档,不硬造一个不存在的「之间」。
_ => SpecFit::Exact(nearest_role(px)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spec_sizes_classify_as_documented() {
// 命中:14 / 16 / 24 / 32 / 12 / 11
assert_eq!(
SpecText::ControlLabelSmall.fit(),
SpecFit::Exact(TypeRole::TitleSmall)
);
assert_eq!(
SpecText::ControlLabel.fit(),
SpecFit::Exact(TypeRole::TitleMedium)
);
assert_eq!(
SpecText::DialogTitle.fit(),
SpecFit::Exact(TypeRole::HeadlineSmall)
);
assert_eq!(
SpecText::DisplayLabel.fit(),
SpecFit::Exact(TypeRole::HeadlineLarge)
);
assert_eq!(SpecText::Caption.fit(), SpecFit::Exact(TypeRole::BodySmall));
assert_eq!(SpecText::Badge.fit(), SpecFit::Exact(TypeRole::LabelSmall));
// 落在两档之间:13(12/14)、15(14/16)、40(36/45)
assert_eq!(
SpecText::Body.fit(),
SpecFit::Between(TypeRole::BodySmall, TypeRole::TitleSmall)
);
assert_eq!(
SpecText::Subtitle.fit(),
SpecFit::Between(TypeRole::TitleSmall, TypeRole::TitleMedium)
);
assert_eq!(
SpecText::PageTitle.fit(),
SpecFit::Between(TypeRole::DisplaySmall, TypeRole::DisplayMedium)
);
// 低于下限:10 / 9
assert_eq!(SpecText::SectionCaps.fit(), SpecFit::BelowFloor);
assert_eq!(SpecText::SwatchCaption.fit(), SpecFit::BelowFloor);
}
#[test]
fn only_the_two_annotation_sizes_are_out_of_ui() {
let out: Vec<&str> = SpecText::ALL
.iter()
.filter(|text| !text.in_ui())
.map(|text| match text {
SpecText::SectionCaps => "section-caps",
SpecText::SwatchCaption => "swatch-caption",
_ => "不该出现在这里",
})
.collect();
assert_eq!(out, vec!["section-caps", "swatch-caption"]);
// 下限就是 LabelSmall 的字号(官方最小档),不是随手拍的数。
assert_eq!(UI_TEXT_FLOOR_PX, TypeRole::LabelSmall.style().size_px);
}
#[test]
fn recommended_rounds_up_not_down() {
assert_eq!(SpecFit::BelowFloor.recommended(), None);
// 13 → 取 14,不取 12。
assert_eq!(fit_of(13.0).recommended(), Some(TypeRole::TitleSmall));
// 15 → 取 16。
assert_eq!(fit_of(15.0).recommended(), Some(TypeRole::TitleMedium));
// 40 → 取 45,不取 36。
assert_eq!(fit_of(40.0).recommended(), Some(TypeRole::DisplayMedium));
}
#[test]
fn nearest_role_is_stable_for_equal_distances() {
// 15 与 14 / 16 等距 → 取较大档 16。
assert_eq!(nearest_role(15.0), TypeRole::TitleMedium);
// 官方最小/最大档之外也不越界。
assert_eq!(nearest_role(4.0), TypeRole::LabelSmall);
assert_eq!(nearest_role(80.0), TypeRole::DisplayLarge);
}
#[test]
fn spec_weights_and_tracking_are_pinned() {
assert_eq!(SpecText::SectionCaps.weight(), Weight::SemiBold);
assert_eq!(SpecText::Badge.weight(), Weight::SemiBold);
assert_eq!(SpecText::ControlLabel.weight(), Weight::Medium);
assert_eq!(SpecText::Body.weight(), Weight::Regular);
assert!((SpecText::SectionCaps.tracking_px() - 1.2).abs() < 1.0e-6);
assert!((SpecText::Subtitle.tracking_px() - 0.3).abs() < 1.0e-6);
assert_eq!(SpecText::ControlLabel.tracking_px(), 0.0);
}
}
+27 -4
View File
@@ -17,6 +17,10 @@
//!
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度
//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档)
//! - [`color::boards`]:**双主题色板**(Ink 深 / Silver 浅,用户规格卡 `components-ink.svg` 的固化值)
//! + [`BrandBoard`] 选择器;与 tone 派生的 `Scheme::baseline` 并列(理由见该模块文档)
//! - [`inkcat`]:规格卡的**形状 / 尺寸档 / 字号校核**——按钮 XS~XL、FAB 四态、控件方框几何、
//! 规格卡字号 → 官方 type scale 的命中/落档/低于下限结论
//! - [`palette`]:配色来源(D38/D40)——图片像素 → 种子 → 完整方案(Monet 式量化 + 打分)、
//! 内置色卡、三级优先与失败回落、对比度收敛。**只吃已解码的像素**,解码在 `crates/palette-extract`
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
@@ -91,13 +95,14 @@ pub mod color;
pub mod component_map;
pub mod elevation;
pub mod icons;
pub mod inkcat;
pub mod motion;
pub mod palette;
pub mod shape;
pub mod spacing;
pub mod typography;
pub use color::{Color, Role, Scheme, SchemeFlavor};
pub use color::{BrandBoard, Color, Role, Scheme, SchemeFlavor};
pub use motion::MotionScale;
pub use palette::{Card, CardKey, Level as SourceLevel, Provenance, Seed};
pub use spacing::stroke;
@@ -105,8 +110,9 @@ pub use typography::TypeRole;
/// 设计令牌的整体入口:一份「当前该长什么样」的聚合快照。
///
/// iced 适配层只持有这个结构(深浅切换 = 重新 `DesignTokens::baseline(mode)`),
/// 不要在适配层里缓存散装色值——那样深浅热切会有一半画对一半画错。
/// iced 适配层只持有这个结构(换肤 = 重新 `DesignTokens::brand(board)`,换强调色 =
/// `DesignTokens::with_accent(flavor, accent)`),不要在适配层里缓存散装色值——
/// 那样热切会有一半画对一半画错。
#[derive(Debug, Clone, Copy)]
pub struct DesignTokens {
/// 当前深浅模式。
@@ -118,9 +124,13 @@ pub struct DesignTokens {
}
impl DesignTokens {
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
/// **tone 派生**的一套令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
///
/// 定位(M228 起):这是 **D38 取色链的锚点**——「莫奈关闭时机内色卡的底色」与
/// `with_accent` 的中性基准。**产品出厂外观请走 [`DesignTokens::brand`]**(Ink / Silver
/// 两套固化色板);两条路径并列而不是互相替换,理由见 `color::boards` 的模块文档。
///
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
pub fn baseline(flavor: SchemeFlavor) -> Self {
@@ -140,6 +150,19 @@ impl DesignTokens {
}
}
/// **品牌色板**(Ink 深 / Silver 浅)的令牌:出厂外观的唯一入口。
///
/// `flavor` 由色板自己决定(`BrandBoard::flavor`),所以「跟随系统」只需
/// `DesignTokens::brand(BrandBoard::for_flavor(system_flavor))`——第三种设置态在设置层解析,
/// 令牌层只认两套板。
pub fn brand(board: BrandBoard) -> Self {
Self {
flavor: board.flavor(),
colors: board.scheme(),
motion_scale: MotionScale::STANDARD,
}
}
/// 便捷取色:`tokens.color(Role::Surface)`。
pub fn color(&self, role: Role) -> Color {
self.colors.get(role)
+7
View File
@@ -30,6 +30,8 @@
//! 挑主色在 [`score`]——**不是取平均色**,理由见 [`quantize`] 的模块文档。
//! - [`card`]:内置色卡(≥6 张,含出厂默认「天蓝」)。卡的 `key` 是**持久化键**,写进用户设置。
//! - [`wcag`]:对任意一套方案跑对比度断言;不达标就沿 tone 轴修正,并记录「改了什么」。
//! - [`visibility`]:容器层可视性裁决(状态容器对 `surface` 的 1.20:1 门禁 + 容器阶梯单调),
//! 即债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层结论。
//! - [`resolve`]:纯函数 `resolve_source(...) -> (Scheme, Provenance)`,实现三级优先与回落。
//!
//! ## D40 的两条实测事实(不是风格偏好,是物理事实)
@@ -43,6 +45,7 @@
pub mod card;
pub mod resolve;
pub mod seed;
pub mod visibility;
pub mod wcag;
mod quantize;
@@ -54,6 +57,10 @@ pub use seed::{
analyze_rgba, seed_from_rgba, Failure, Seed, MAX_SAMPLES, MIN_PIXELS, MIN_SEED_CHROMA,
MUTED_SEED_CHROMA,
};
pub use visibility::{
check_surface_hierarchy, LayerStep, SurfaceHierarchy, LAYER_ROLES, LAYER_STEP_MIN_LSTAR,
STATE_CONTAINERS, STATE_CONTAINER_MIN,
};
pub use wcag::{converge, Adjustment, WcagReport, AA_NON_TEXT, AA_TEXT, TEXT_PAIRS};
/// 一套方案的来源层级([`Level`] 的别名,读起来更清楚)。
+219
View File
@@ -0,0 +1,219 @@
//! # 容器层可视性裁决(债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层结论)
//!
//! ## 问题
//!
//! m223 视觉复审实测:选中行底(`SecondaryContainer`)与它压着的卡片只差 **1.057:1**,
//! 大色块 + 色相偏移肉眼可辨,但**数不出层级**。逐个页面补色修不完(每个用选中底色的页面都要
//! 自己调一次,而且下一套色板又得来一遍),所以裁决必须落在令牌层。
//!
//! ## 裁决(三条,按顺序适用)
//!
//! 1. **文字对比**:所有 `on-*` 对承载色 ≥ 4.5:1 —— 既有 [`super::wcag`] 那道门,不重复定义。
//! 2. **状态容器对窗口底 ≥ [`STATE_CONTAINER_MIN`] = 1.20:1**:表达「状态」的容器
//! ([`STATE_CONTAINERS`]:primary / secondary / tertiary / error 的 `*-container`)压在
//! `surface` 上时必须数得出来。这是唯一一条**数字门禁**,[`SurfaceHierarchy::state_failures`]
//! 守它。
//! 3. **容器层之间的亮度差不设门禁,改为「组合指示」要求**:M3 的容器层天生贴在一起
//! (官方口径浅色 `secondary-container` 对 `surface-container-high` 只有 1.06:1,
//! 本项目 Ink 板最低也只有 1.54:1 —— 这是配色结构的必然,不是哪套板的失误)。所以
//! **禁止**把「容器底色差异」当作唯一的选中 / 层级指示:选中态必须是
//! 「容器底(过第 2 条)+ 文字对比(第 1 条)+ 形状 / 图标 / 描边指示」三件套。
//! 另一半同理:`surface-container-*` 只表达「几乎看不见的抬升」,
//! 拿它当选中态用连第 2 条都过不了。
//!
//! 第 3 条为什么不是「把阈值降到 1.02 就完事」:那样等于承认「选中态可以看不见」,
//! 而用户抱怨的正是这个。把不可测的那一半转成**构成要求**,才是能落地的那一半。
//!
//! ## 实测(`tests/ink_cat_visibility.rs` 守住第 2 条与阶梯)
//!
//! | 板 | 最弱状态容器 / `surface` | 状态容器 / 容器层(4×5 全矩阵,最低 ~ 最高) | 阶梯 L\\* 步长 |
//! |---|---|---|---|
//! | Ink | **1.97**(secondary-container) | 1.54 ~ **16.47** | +1.4 ~ +4.4 |
//! | Silver | **1.23**(tertiary/error-container) | **1.02** ~ 1.80 | −1.7 ~ −4.1 |
//! | 旧 baseline(债务现场) | 1.23 | **1.057**(对卡片,m223 实测) | — |
//!
//! 第三列就是第 3 条的由来:它过不了任何有意义的门禁(Silver 最差的一组是
//! `tertiary-container` 压 `surface-container-high`,1.016 —— 两者几乎同亮度),
//! 所以不设门禁、只设组合要求。
//!
//! ## 阶梯本身还是要查([`SurfaceHierarchy::layer_violations`])
//!
//! `surface-container-lowest` → `highest` 必须**单调**且相邻步长 ≥ [`LAYER_STEP_MIN_LSTAR`]
//! (1.0 个 L\\* 单位)。方向由深浅模式决定:深色板递增(越靠上越亮)、浅色板递减。
//! 这条抓的是「两档写成同一个色」「顺序填反」这类事故——不抓「差距够不够好看」。
use crate::color::{Role, Scheme, SchemeFlavor};
/// 状态容器对 `surface` 的最低对比度(唯一的**数字**门禁)。
pub const STATE_CONTAINER_MIN: f32 = 1.20;
/// 层级阶梯相邻两档的最小 L\\* 步长(低于这个数就分不出「哪层更高」)。
pub const LAYER_STEP_MIN_LSTAR: f32 = 1.0;
/// 表达**状态**的容器角色(必须过 [`STATE_CONTAINER_MIN`])。
pub const STATE_CONTAINERS: [Role; 4] = [
Role::PrimaryContainer,
Role::SecondaryContainer,
Role::TertiaryContainer,
Role::ErrorContainer,
];
/// 表达**层级**的容器角色(由低到高;只查阶梯单调与步长)。
pub const LAYER_ROLES: [Role; 5] = [
Role::SurfaceContainerLowest,
Role::SurfaceContainerLow,
Role::SurfaceContainer,
Role::SurfaceContainerHigh,
Role::SurfaceContainerHighest,
];
/// 阶梯上的一步:相对**前一档**的 L\\* 变化量(带符号;深色板应为正、浅色板为负)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LayerStep {
/// 这一档是谁。
pub role: Role,
/// 相对前一档的 L\\* 差。
pub delta_lstar: f32,
}
/// 一套方案的容器层可视性体检结果(纯数据,不改方案)。
#[derive(Debug, Clone, PartialEq)]
pub struct SurfaceHierarchy {
/// 方案自己的深浅模式(决定阶梯方向)。
pub flavor: SchemeFlavor,
/// `surface` 的 L\\*(报告与对账用)。
pub surface_lstar: f32,
/// 每个状态容器对 `surface` 的实测对比度(顺序同 [`STATE_CONTAINERS`])。
pub state: [(Role, f32); 4],
/// 层级阶梯的实测 L\\*(顺序同 [`LAYER_ROLES`])。
pub layers: [(Role, f32); 5],
}
impl SurfaceHierarchy {
/// 状态容器里最低的一档(观察「刚好卡线」用)。
pub fn worst_state(&self) -> (Role, f32) {
self.state.iter().copied().fold(
self.state[0],
|acc, item| if item.1 < acc.1 { item } else { acc },
)
}
/// 未达 [`STATE_CONTAINER_MIN`] 的状态容器。
pub fn state_failures(&self) -> Vec<(Role, f32)> {
self.state
.iter()
.copied()
.filter(|(_, ratio)| *ratio < STATE_CONTAINER_MIN)
.collect()
}
/// 阶梯的每一步(4 步:lowest→low→container→high→highest)。
pub fn layer_steps(&self) -> [LayerStep; 4] {
let mut out = [LayerStep {
role: LAYER_ROLES[1],
delta_lstar: 0.0,
}; 4];
for i in 1..self.layers.len() {
out[i - 1] = LayerStep {
role: self.layers[i].0,
delta_lstar: self.layers[i].1 - self.layers[i - 1].1,
};
}
out
}
/// 方向反了或步长不足的阶梯步(深色板要递增、浅色板要递减)。
pub fn layer_violations(&self) -> Vec<LayerStep> {
let sign = if self.flavor == SchemeFlavor::Dark {
1.0
} else {
-1.0
};
self.layer_steps()
.into_iter()
.filter(|step| step.delta_lstar * sign < LAYER_STEP_MIN_LSTAR)
.collect()
}
/// 第 2 条与阶梯都过。
pub fn is_clean(&self) -> bool {
self.state_failures().is_empty() && self.layer_violations().is_empty()
}
}
/// 跑一遍容器层体检(纯函数)。
pub fn check_surface_hierarchy(scheme: &Scheme) -> SurfaceHierarchy {
let surface = scheme.get(Role::Surface);
SurfaceHierarchy {
flavor: scheme.flavor(),
surface_lstar: surface.lstar(),
state: std::array::from_fn(|i| {
let role = STATE_CONTAINERS[i];
(role, scheme.contrast(role, Role::Surface))
}),
layers: std::array::from_fn(|i| {
let role = LAYER_ROLES[i];
(role, scheme.get(role).lstar())
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::{Color, Scheme};
#[test]
fn dark_ladder_must_climb_and_light_ladder_must_fall() {
// 造假:深色板把 highest 设成纯黑 → 阶梯反向,必须被抓住。
let base = Scheme::baseline(SchemeFlavor::Dark);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::SurfaceContainerHighest as usize] = Color::BLACK;
let broken = Scheme::from_colors(SchemeFlavor::Dark, colors);
let report = check_surface_hierarchy(&broken);
assert!(
report
.layer_violations()
.iter()
.any(|step| step.role == Role::SurfaceContainerHighest),
"{:?}",
report.layer_steps()
);
assert!(!report.is_clean());
}
#[test]
fn state_container_below_the_floor_is_reported() {
// 造假:secondary-container 直接等于 surface(1:1)→ 必须报。
let base = Scheme::baseline(SchemeFlavor::Light);
let mut colors = [Color::BLACK; 49];
for (role, color) in base.iter() {
colors[role as usize] = color;
}
colors[Role::SecondaryContainer as usize] = base.get(Role::Surface);
let broken = Scheme::from_colors(SchemeFlavor::Light, colors);
let report = check_surface_hierarchy(&broken);
assert_eq!(
report.state_failures(),
vec![(Role::SecondaryContainer, 1.0)]
);
}
#[test]
fn the_official_baseline_shows_why_the_debt_exists() {
// 官方口径(tone 表)浅色板:状态容器对 surface 只有 ~1.23,对容器层只有 ~1.05。
// 前半条过不了 1.20 的是「白银下界」,后半条根本不可门禁化——这就是裁决第 3 条。
let report = check_surface_hierarchy(&Scheme::official_baseline(SchemeFlavor::Light));
let (role, ratio) = report.worst_state();
// 官方浅色的四个 container 都是 tone 90、surface 是 tone 98 ⇒ 四个值几乎相同,
// 所以只断言量级(1.23 上下),不断言「哪一族最弱」。
assert!(
(1.15..1.30).contains(&ratio),
"官方 baseline 的 {} 对 surface = {ratio:.3},与文档的 1.23 不符",
role.name()
);
}
}
+285
View File
@@ -0,0 +1,285 @@
//! Ink Cat 双主题的**消费方视角**门禁(M228):色板完整性、双主题切换、对比度。
//!
//! 只用公开 API(`unlucky_design::...`),所以它同时是「iced 适配层能拿到什么」的样本:
//! 换肤时只需要 `BrandBoard` → `Scheme` → `Role`,一个色值字面量都不用抄。
//! 几何与字级校核在 `ink_cat_geometry.rs`,容器层可视性在 `ink_cat_visibility.rs`
//! (拆开是单文件 ≤400 行的硬约束)。
use unlucky_design::color::BrandBoard::{Ink, Silver};
use unlucky_design::color::{BrandBoard, Color, Role, SchemeFlavor};
use unlucky_design::palette::wcag::{check, AA_TEXT};
use unlucky_design::DesignTokens;
// ---------------------------------------------------------------- 色板完整性
#[test]
fn every_board_covers_all_49_roles_and_reports_its_flavor() {
for board in BrandBoard::ALL {
let scheme = board.scheme();
assert_eq!(scheme.flavor(), board.flavor());
assert_eq!(scheme.iter().count(), Role::ALL.len());
for role in Role::ALL {
let color = scheme.get(role);
assert!(
color.is_opaque(),
"{} 的 {} 不是不透明色",
board.name(),
role.name()
);
assert_eq!(color, board.get(role), "两个入口必须同源");
}
}
}
#[test]
fn spec_anchors_survive_the_public_api() {
// 规格卡上写死的色值(两套板各自的一批),一份都不许漂。
let cases: [(BrandBoard, Role, &str); 12] = [
(BrandBoard::Ink, Role::Primary, "#E8FFEF"),
(BrandBoard::Ink, Role::OnPrimary, "#256B4B"),
(BrandBoard::Ink, Role::Surface, "#0F150F"),
(BrandBoard::Ink, Role::SurfaceContainerHighest, "#262B25"),
(BrandBoard::Ink, Role::Error, "#FF716C"),
(BrandBoard::Ink, Role::Outline, "#7B8A7B"),
(BrandBoard::Silver, Role::Primary, "#006499"),
(BrandBoard::Silver, Role::PrimaryContainer, "#87C7FF"),
(BrandBoard::Silver, Role::Surface, "#F9F9FA"),
(BrandBoard::Silver, Role::SecondaryContainer, "#D4E4F6"),
(BrandBoard::Silver, Role::OnSurface, "#1A1C1D"),
(BrandBoard::Silver, Role::Error, "#AC3434"),
];
for (board, role, hex) in cases {
assert_eq!(
board.scheme().get(role),
Color::from_hex(hex).expect("规格卡色值"),
"{} / {} 与规格卡的 {hex} 不符",
board.name(),
role.name()
);
}
}
// ---------------------------------------------------------------- 双主题切换
#[test]
fn follow_system_resolves_to_the_right_board() {
// 「跟随系统」是设置层的第三种态,令牌层只提供映射。
assert_eq!(
BrandBoard::for_flavor(SchemeFlavor::Dark),
BrandBoard::Ink,
"系统深色 → Ink"
);
assert_eq!(
BrandBoard::for_flavor(SchemeFlavor::Light),
BrandBoard::Silver,
"系统浅色 → Silver"
);
for board in BrandBoard::ALL {
assert_eq!(
BrandBoard::for_flavor(board.flavor()),
board,
"映射必须可逆"
);
}
}
#[test]
fn switching_boards_swaps_all_the_surface_roles() {
let ink = BrandBoard::Ink.scheme();
let silver = BrandBoard::Silver.scheme();
// 窗口底 / 正文 / 字段底 / 分层容器:切板后全都得换。
for role in [
Role::Surface,
Role::OnSurface,
Role::SurfaceContainerLow,
Role::SurfaceContainerHighest,
Role::Primary,
Role::SecondaryContainer,
Role::Error,
Role::InverseSurface,
] {
assert_ne!(
ink.get(role),
silver.get(role),
"{} 在两套板之间没变——换肤会露出旧颜色",
role.name()
);
}
// 深浅判定:Ink 是真的深、Silver 是真的浅。
assert!(ink.get(Role::Surface).relative_luminance() < 0.02);
assert!(silver.get(Role::Surface).relative_luminance() > 0.90);
}
#[test]
fn design_tokens_brand_entry_is_the_product_default() {
let ink = DesignTokens::brand(BrandBoard::Ink);
assert_eq!(ink.flavor, SchemeFlavor::Dark);
assert_eq!(ink.color(Role::Surface), BrandBoard::Ink.get(Role::Surface));
// 品牌板与「取色链」两条路径互不干扰:baseline 仍是 tone 派生(D38 的锚点)。
let baseline = DesignTokens::baseline(SchemeFlavor::Dark);
assert_eq!(baseline.flavor, SchemeFlavor::Dark);
assert_ne!(
baseline.color(Role::Surface),
ink.color(Role::Surface),
"baseline 若已变成品牌板,D38 取色链的表面色锚点就没了"
);
}
// ---------------------------------------------------------------- 对比度
#[test]
fn both_boards_pass_the_full_wcag_table() {
for board in BrandBoard::ALL {
let report = check(&board.scheme());
assert!(
report.is_clean(),
"{} 有 {} 项未达 AA:{:?}",
board.name(),
report.failures.len(),
report.failures
);
assert!(
report.worst >= AA_TEXT,
"{} 最紧的一对只有 {:.2}",
board.name(),
report.worst
);
}
}
/// 规格卡里真实出现的「文字压在什么色上」组合(25 组,含 `on-*-container` 全族)。
///
/// 只列规格卡上画出来的用法——不列「有可能这么用」的组合,否则就是在替页面线编需求。
const SPEC_TEXT_ON_SURFACE: [(BrandBoard, Role, Role, &str); 25] = [
(Ink, Role::OnPrimary, Role::Primary, "Filled 按钮"),
(
Ink,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
"Tonal 按钮",
),
(Ink, Role::OnSurface, Role::Surface, "Outlined/Text 按钮"),
(Ink, Role::Primary, Role::Surface, "Text 按钮标签"),
(
Ink,
Role::Primary,
Role::SurfaceContainerLowest,
"Elevated 标签",
),
(
Ink,
Role::OnPrimaryContainer,
Role::PrimaryContainer,
"FAB 图标",
),
(
Ink,
Role::OnSurfaceVariant,
Role::SurfaceContainerHighest,
"字段标签",
),
(
Ink,
Role::OnSurface,
Role::SurfaceContainerHighest,
"字段内容",
),
(
Ink,
Role::Error,
Role::SurfaceContainerHighest,
"错误字段标签",
),
(Ink, Role::Error, Role::Surface, "错误提示"),
(Ink, Role::OnError, Role::Error, "徽标数字"),
(
Ink,
Role::OnSurfaceVariant,
Role::Surface,
"chip 未选 / 次要图标",
),
(
Ink,
Role::InverseOnSurface,
Role::InverseSurface,
"snackbar 文字",
),
(
Ink,
Role::InversePrimary,
Role::InverseSurface,
"snackbar 动作",
),
(
Ink,
Role::OnSurfaceVariant,
Role::SurfaceContainer,
"卡片副文案",
),
(
Ink,
Role::OnSurfaceVariant,
Role::SurfaceContainerHigh,
"对话框正文",
),
(Silver, Role::OnPrimary, Role::Primary, "Filled 按钮"),
(
Silver,
Role::OnSecondaryContainer,
Role::SecondaryContainer,
"Tonal 按钮",
),
(Silver, Role::OnSurface, Role::Surface, "Outlined 按钮"),
(Silver, Role::Primary, Role::Surface, "Text 按钮标签"),
(
Silver,
Role::OnSurfaceVariant,
Role::SurfaceContainerHighest,
"字段标签",
),
(
Silver,
Role::OnSurface,
Role::SurfaceContainerHighest,
"字段内容",
),
(
Silver,
Role::Error,
Role::SurfaceContainerHighest,
"错误字段标签",
),
(
Silver,
Role::OnSurfaceVariant,
Role::SurfaceContainer,
"卡片副文案",
),
(
Silver,
Role::InverseOnSurface,
Role::InverseSurface,
"snackbar 文字",
),
];
#[test]
fn spec_text_pairs_meet_aa() {
let mut failures = Vec::new();
for (board, fg, bg, usage) in SPEC_TEXT_ON_SURFACE {
let ratio = board.scheme().contrast(fg, bg);
if ratio < AA_TEXT {
failures.push(format!(
"{} / {usage}:{} on {} = {ratio:.2}",
board.name(),
fg.name(),
bg.name()
));
}
}
assert!(
failures.is_empty(),
"规格卡用法未达 AA(4.5:1):\n{}",
failures.join("\n")
);
}
+100
View File
@@ -0,0 +1,100 @@
//! Ink Cat 规格卡的**几何与字级**门禁(M228):按钮 XS~XL、FAB 四态、控件方框、字号校核。
//!
//! 与 `ink_cat_boards.rs` 分开是单文件 ≤400 行的硬约束;两侧都只用公开 API。
use unlucky_design::inkcat::metrics;
use unlucky_design::inkcat::size::{ControlSize, FabSize};
use unlucky_design::inkcat::type_scale::{SpecFit, SpecText, UI_TEXT_FLOOR_PX};
use unlucky_design::shape::ShapeScale;
use unlucky_design::typography::{TypeRole, Weight};
#[test]
fn buttons_and_fabs_hit_the_spec_geometry() {
// 按钮五档:32/40/56/96/136,圆角一律半高(药丸)。
let heights: Vec<f32> = ControlSize::ALL.iter().map(|s| s.height_px()).collect();
assert_eq!(heights, vec![32.0, 40.0, 56.0, 96.0, 136.0]);
for size in ControlSize::ALL {
assert_eq!(
size.radius_px(),
size.height_px() / 2.0,
"{size:?} 该是药丸"
);
}
// M 档就是规格卡 `BUTTONS / 5 LEVELS` 那一行的 56 高。
assert_eq!(ControlSize::M.height_px(), 56.0);
assert_eq!(ControlSize::M.label_px(), 16.0);
// FAB 四态圆角 12/16/20/16(都不是半高,所以不用 Full)。
let radii: Vec<f32> = FabSize::ALL.iter().map(|s| s.radius_px()).collect();
assert_eq!(radii, vec![12.0, 16.0, 20.0, 16.0]);
let sizes: Vec<f32> = FabSize::ALL.iter().map(|s| s.height_px()).collect();
assert_eq!(sizes, vec![40.0, 56.0, 80.0, 56.0]);
}
#[test]
fn component_boxes_hit_the_spec_corners() {
assert_eq!(metrics::CARD.radius_px(), 16.0, "ELEVATED CARD rx=16");
assert_eq!(metrics::DIALOG.radius_px(), 28.0, "对话框 rx=28");
assert_eq!(metrics::SNACKBAR.radius_px(), 16.0, "snackbar rx=16");
assert_eq!(metrics::SNACKBAR.height, 52.0);
assert_eq!(metrics::LIST_ROW.radius_px(), 30.0, "列表行药丸");
assert_eq!(metrics::LIST_ROW.height, 60.0);
assert_eq!(metrics::CHIP.height, 32.0);
assert_eq!(metrics::CHIP.radius_px(), 8.0);
// 输入框:默认圆角 4、药丸变体 28,内距 16 / 32(规格卡专门提醒过)。
assert_eq!(metrics::FIELD_SHAPE, ShapeScale::ExtraSmall);
assert_eq!(metrics::FIELD_SHAPE_ROUNDED, ShapeScale::ExtraLarge);
assert_eq!(metrics::FIELD_HEIGHT, 56.0);
assert_eq!(metrics::FIELD_INSET, 16.0);
assert_eq!(metrics::FIELD_PILL_INSET, 32.0);
// 开关 / 勾选 / 进度。
assert_eq!(
(metrics::SWITCH_TRACK.width, metrics::SWITCH_TRACK.height),
(52.0, 32.0)
);
assert_eq!(metrics::SWITCH_THUMB, 24.0);
assert_eq!(metrics::CHECKBOX.radius_px(), 4.0);
assert_eq!(metrics::PROGRESS_THICKNESS, 4.0);
}
#[test]
fn spec_font_sizes_map_onto_the_official_scale() {
// 命中官方档的六档。
for (text, role) in [
(SpecText::ControlLabelSmall, TypeRole::TitleSmall),
(SpecText::DialogTitle, TypeRole::HeadlineSmall),
(SpecText::DisplayLabel, TypeRole::HeadlineLarge),
(SpecText::Caption, TypeRole::BodySmall),
(SpecText::Badge, TypeRole::LabelSmall),
] {
assert_eq!(text.fit(), SpecFit::Exact(role), "{text:?}");
}
// 落在两档之间的三档:取较大那档,登记 1px 级偏离。
for (text, lower, upper) in [
(SpecText::Body, TypeRole::BodySmall, TypeRole::TitleSmall),
(
SpecText::Subtitle,
TypeRole::TitleSmall,
TypeRole::TitleMedium,
),
(
SpecText::PageTitle,
TypeRole::DisplaySmall,
TypeRole::DisplayMedium,
),
] {
assert_eq!(text.fit(), SpecFit::Between(lower, upper), "{text:?}");
assert_eq!(text.fit().recommended(), Some(upper));
}
// 低于 UI 下限的两档:只能是设计稿标注。
for text in [SpecText::SectionCaps, SpecText::SwatchCaption] {
assert!(!text.in_ui(), "{text:?} 不该进 UI");
assert_eq!(text.fit(), SpecFit::BelowFloor);
assert_eq!(text.fit().recommended(), None);
}
assert_eq!(UI_TEXT_FLOOR_PX, 11.0);
// 按钮标签字重:规格卡是 500(官方 body/headline 是 400,这是按钮语义)。
for size in ControlSize::ALL {
assert_eq!(size.label().weight, Weight::Medium);
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Ink Cat 容器层可视性门禁(M228 债务裁决的可测一半):状态容器 1.20 门禁、阶梯单调、
//! 以及「容器层不能当状态指示」的实测钉子。
//!
//! 与 `ink_cat_boards.rs` / `ink_cat_geometry.rs` 分开是单文件 ≤400 行的硬约束。
use unlucky_design::color::{BrandBoard, Color, Role, SchemeFlavor};
use unlucky_design::palette::visibility::{
check_surface_hierarchy, LAYER_ROLES, STATE_CONTAINERS, STATE_CONTAINER_MIN,
};
use unlucky_design::palette::wcag::AA_TEXT;
#[test]
fn state_containers_clear_the_visibility_floor() {
// 债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:状态容器 ≥1.20:1。
for board in BrandBoard::ALL {
let report = check_surface_hierarchy(&board.scheme());
assert!(
report.state_failures().is_empty(),
"{} 的状态容器未达 {STATE_CONTAINER_MIN}:1:{:?}",
board.name(),
report.state_failures()
);
let (role, ratio) = report.worst_state();
assert!(
ratio >= STATE_CONTAINER_MIN,
"{} 最弱的状态容器 {} 只有 {ratio:.3}",
board.name(),
role.name()
);
// 旧 baseline 的现场值 1.057 必须被显著甩开,否则「修了但没修好」。
assert!(
ratio > 1.20,
"{} 只是刚好卡线({ratio:.3}),等于没修",
board.name()
);
}
// 两套板覆盖 4 个状态容器角色,一个不落。
assert_eq!(STATE_CONTAINERS.len(), 4);
}
#[test]
fn surface_container_ladder_is_monotone_with_visible_steps() {
for board in BrandBoard::ALL {
let report = check_surface_hierarchy(&board.scheme());
assert!(
report.layer_violations().is_empty(),
"{} 的容器阶梯有断层或方向错:{:?}",
board.name(),
report.layer_violations()
);
assert!(report.is_clean(), "{} 的容器层体检没过", board.name());
// 方向:深色板越靠上越亮、浅色板越靠上越暗。
let steps = report.layer_steps();
for step in steps {
let expect_positive = board.flavor() == SchemeFlavor::Dark;
assert_eq!(
step.delta_lstar > 0.0,
expect_positive,
"{} 的 {} 阶梯方向错",
board.name(),
step.role.name()
);
}
assert_eq!(LAYER_ROLES.len(), 5);
}
}
#[test]
fn container_layers_are_not_state_indicators() {
// 裁决第 3 条的可测化一半:把「状态容器压在容器层上」的最差值钉住,提醒后来者
// 别把它当唯一指示(浅色板必然贴到 1.02,深色板 1.54)。
let worst_over_layers = |board: BrandBoard| {
let scheme = board.scheme();
STATE_CONTAINERS
.iter()
.flat_map(|role| {
LAYER_ROLES
.iter()
.map(move |layer| (*role, *layer, scheme.contrast(*role, *layer)))
})
.map(|(_, _, ratio)| ratio)
.fold(f32::INFINITY, f32::min)
};
let silver = worst_over_layers(BrandBoard::Silver);
assert!(
(1.0..1.1).contains(&silver),
"Silver 的状态容器对容器层最低 = {silver:.3},与「浅色 pale-on-pale 必然贴近」的登记(1.02)不符"
);
// Ink 相反:深色板的状态容器与容器层拉得开,所以深色可以靠底色差做弱指示。
let ink = worst_over_layers(BrandBoard::Ink);
assert!(
ink >= 1.5,
"Ink 最弱的状态容器对容器层 = {ink:.3},应明显高于浅色板"
);
// 两条一起说明「为什么这不是调色问题而是构成问题」:同一套算法下深色能做的弱指示,
// 浅色做不到——所以门禁不能按容器层设,只能按 surface 设。
assert!(silver < ink);
}
#[test]
fn field_backdrop_is_the_same_role_on_both_boards() {
// P2-1 的回归钉子:字段底在两套板都是 `surface-container-highest`。
// 若把 Silver 的字段底挪回 `surface-container-high`(#E4E4E5),错误标签会从 4.69 掉到 4.38(AA 失守)。
for board in BrandBoard::ALL {
let scheme = board.scheme();
for (fg, usage) in [
(Role::OnSurfaceVariant, "字段标签"),
(Role::OnSurface, "字段内容"),
(Role::Error, "错误字段标签"),
] {
let ratio = scheme.contrast(fg, Role::SurfaceContainerHighest);
assert!(
ratio >= AA_TEXT,
"{} 的{usage}({} on surface-container-highest)= {ratio:.3}",
board.name(),
fg.name()
);
}
}
// 浅色板的 high 必须比 highest 更亮、且是另一个色(阶梯方向 + 不许两档同值)。
let silver = BrandBoard::Silver.scheme();
let high = silver.get(Role::SurfaceContainerHigh);
let highest = silver.get(Role::SurfaceContainerHighest);
assert_ne!(high, highest);
assert!(
high.lstar() > highest.lstar(),
"Silver 的 surface-container-high 应比 highest 更亮(高 {} vs 最高 {})",
high.lstar(),
highest.lstar()
);
// 规格卡给的字段底就是 highest 那个值。
assert_eq!(highest, Color::from_hex("#DCDDDE").expect("规格卡字段底"));
}