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 几何、控件圆角、字号校核)。
This commit is contained in:
1 parent
7fe38fb240
commit
0169d4c8fb
14 files changed
+2047
-4
No files matched your search
@@ -0,0 +1,369 @@
|
||||
//! 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}");
|
||||
}
|
||||
Reference in new issue
Block a user