Files
Unlucky-Launcher/crates/design/tests/ink_cat_boards.rs
T
AskaEth 0169d4c8fb feat(design): 加 Ink/Silver 双主题色板 + 规格卡几何与字号令牌(M228)
规格卡 DevDocs/artifacts/components-ink.svg 的令牌化,只动 crates/design,零依赖不变。

- color::boards:BrandBoard(Ink 深 / Silver 浅)→ 49 角色 × 2 套固化色板。
  spec 直写值进 ANCHORS(测试逐条比 hex,规格卡改了代码不改会红);规格卡没给的
  角色按「同色相 + M3 tone 关系」推导后固化,规则写在 boards/ink.rs 的行注释里。
  Scheme::from_board / DesignTokens::brand 是两个新入口;Scheme::baseline 一字未改
  (它是 D38 取色链的中性锚点,改它等于同时改 app-iced 观感 + 取色语义)。
- palette::visibility:容器层可视性裁决,即债务台账「SecondaryContainer 对 Surface
  仅 1.06」的令牌层结论。状态容器(*-container)对 surface ≥1.20:1 是唯一数字门禁;
  容器层之间不设亮度门禁,改为「选中态 = 容器底 + 文字对比 + 形状/图标」三件套的构成
  要求(浅色板实测 1.05~1.24,任何有意义的门禁都过不了,详情见模块文档实测表)。
- inkcat:规格卡的尺寸与形状数据。ControlSize(按钮 XS~XL = 32/40/56/96/136,圆角
  一律半高)、FabSize(40/56/80/160×56,圆角 12/16/20/16)、控件方框(卡片 16、
  对话框 28、snackbar 52@16、列表行 60 药丸、chip 32@8、字段 4/28 + 内距 16/32、
  复选 18@4、单选 18、开关 52×32 + 拇指 24、进度 4/6)、SpecText(规格卡 11 个字号
  → 命中 / 落档 / 低于 11px 下限三类结论)。
  component_map 的表值一处未动:app-iced 的 pages/frame/tests.rs:42 与 shell/tests.rs:30
  按 Medium(12) pin 住卡片,改表等于连带改观感且破坏既有断言;差异在 inkcat 模块文档
  逐条登记,换肤时由 M229 一次切换 + 截图验收。
- 测试:186 lib(+13 条新单测)+ tests/ink_cat_boards.rs(10 条:完整性/锚点/双主题
  切换/26 对 WCAG AA/25 组规格卡用法/状态容器 1.2 门禁/阶梯单调)+ tests/ink_cat_geometry.rs
  (3 条:按钮 FAB 几何、控件圆角、字号校核)。
2026-11-10 22:34:05 +08:00

370 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Ink Cat 双主题的**消费方视角**门禁(M228):色板完整性、双主题切换、对比度、容器层可视性。
//!
//! 只用公开 API(`unlucky_design::...`),所以它同时是「iced 适配层能拿到什么」的样本:
//! 换肤时只需要 `BrandBoard` → `Scheme` → `Role`,一个色值字面量都不用抄。
//! 规格卡的几何与字级校核在同目录的 `ink_cat_geometry.rs`(单文件 ≤400 行)。
use unlucky_design::color::BrandBoard::{Ink, Silver};
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::{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::SurfaceContainerHigh,
"字段标签",
),
(
Silver,
Role::OnSurface,
Role::SurfaceContainerHigh,
"字段内容",
),
(
Silver,
Role::Error,
Role::SurfaceContainerHigh,
"错误字段标签",
),
(
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")
);
}
#[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.05~1.24)。
let silver = BrandBoard::Silver.scheme();
let worst = Role::ALL
.iter()
.filter(|role| STATE_CONTAINERS.contains(role))
.map(|role| silver.contrast(*role, Role::SurfaceContainerHigh))
.fold(f32::INFINITY, f32::min);
assert!(
(1.0..1.5).contains(&worst),
"Silver 的状态容器对 surface-container-high = {worst:.3},与「浅色必然贴近」的登记不符"
);
// Ink 相反:深色板的状态容器与容器层拉得开,所以深色可以靠底色差做弱指示。
let ink = BrandBoard::Ink.scheme();
let ink_worst = Role::ALL
.iter()
.filter(|role| STATE_CONTAINERS.contains(role))
.map(|role| ink.contrast(*role, Role::SurfaceContainerHighest))
.fold(f32::INFINITY, f32::min);
assert!(ink_worst >= 1.5, "Ink 最弱的状态容器 = {ink_worst:.3}");
}