feat(design): M65 真 HCT 色彩 —— CAM16 + ColorSpec2021 + 官方逐角色对齐

把 M61 的「OKLCh 近似 CAM16 + gamut 硬裁」换成官方的 HCT 算法,与
material-color-utilities(Apache-2.0)逐角色对上:baseline 49 角色 ×
light/dark、8 个种子 × 12 个强调族角色 × 2 模式,**实测每通道误差 0**
(判据写入测试:≤1)。

实现(逐函数对照官方 Java 重写为 Rust,f64 内部精度):
- color/cam16.rs + viewing_conditions.rs:CAM16 色相/彩度(D65、200 lux、
  背景 L* 50),默认观察条件的中间量与 Java 实测逐位对齐
- color/hct_solver.rs + critical_planes.rs:官方 gamut 映射(Newton 迭代 +
  色调平面上的色相二分,255 项关键平面表原样搬运),只压彩度不硬裁
- color/hct.rs:HCT 坐标(hue/chroma/tone),公开给工具与测试
- color/space.rs:官方 ColorUtils 的 sRGB↔线性↔XYZ↔L* 换算(矩阵、阈值、
  取整口径一致)
- color/palettes.rs:Tonal Spot 官方彩度常数 36/16/24/6/8,error 25°/84
- color/scheme.rs:tone 表换 ColorSpec2021(on-*container 10→30、
  background 99/10→98/6)

两个旧缺陷关门:
- primary-container 与 secondary-container 不再撞色(彩度 36 vs 16,
  tone 90 装得下,无需「共享基准压彩度」补丁);reference_chroma /
  tone_scaled 随之删除
- 强调色色相不再被 gamut 硬裁(只压彩度,实测偏移 <1°)

对齐证据:tests/official_baseline.rs(98 项 + 12 个 HCT 探针)、
tests/official_accent.rs(192 项)。期望值是把官方 Java 实现本机编译运行
的输出抄本,并用 Flutter 的 ColorScheme.fromSeed 用例交叉验证
(#00FF00 → #406836、#000000 → #8C4A60、#FFFFFF → #006874 一致)。

诚实口径:只做 Tonal Spot 变体 + contrastLevel 0;对比度三档、其他变体、
壁纸多色取色、非默认观察条件都在模块文档的 gap 表里登记。
注:token_pins 的 tone 值变化来自官方算法(ColorSpec2021),不是为过测试而改。

NOTICE 增「Google Material Color Utilities」归属(Apache-2.0 要求,
含逐文件映射与对齐方法)。
This commit is contained in:
AskaEth committed 2026-11-01 16:40:36 +08:00
1 parent 958ced2d7e
commit 29f65b8e6f
16 files changed
+1786 -373

No files matched your search

+2 -2
View File
@@ -54,13 +54,13 @@ const EXCEPTIONS: [(Role, Role, f32, &str); 3] = [
Role::Outline,
Role::Surface,
AA_NON_TEXT,
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.29:1、dark 6.5:1 已高于目标)",
"输入框/卡片描边是 M3 定义的非文字元素,规范目标 3:1(light 实测 4.26:1、dark 5.89:1 已高于目标)",
),
(
Role::OutlineVariant,
Role::Surface,
AA_NON_TEXT * 0.5,
"分隔线是纯装饰,M3 不要求对比度下限;1.6:1 是「几乎看不见但要能数出层级」的刻意取值",
"分隔线是纯装饰,M3 不要求对比度下限;light 1.62:1 / dark 1.99:1 是「几乎看不见但要能数出层级」的取值",
),
(
Role::SurfaceVariant,
+307
View File
@@ -0,0 +1,307 @@
//! 自定义强调色 × 官方算法的**逐角色对齐**(`Scheme::from_accent`)。
//!
//! 期望值来自官方 Java 实现:`SchemeTonalSpot(seed, isDark, 0, SPEC_2021)` 的强调族角色。
//! 判据:每通道误差 ≤1(见 `official_baseline.rs` 的说明)。
//!
//! 为什么只比强调族(primary/secondary/tertiary 共 12 个角色):本项目的产品语义是
//! 「换强调色不动底色」——中性表面与 error 刻意留在 baseline(见 `Palettes::with_accent`),
//! 而官方 `from_seed` 会把中性色也染上种子色相。中性派生的正确性由本文件末尾的
//! `from_seed_*` 用例单独验证(那一处必须与官方完全一致)。
use unlucky_design::color::{Color, Role, Scheme, SchemeFlavor};
/// 每通道允许的最大误差。
const CHANNEL_TOLERANCE: i16 = 1;
/// 一条官方期望值:角色 + light 色值 + dark 色值。
type RoleRow = (Role, &'static str, &'static str);
/// 一个种子 + 它 12 个强调族角色的官方色值。
type SeedRows = (&'static str, [RoleRow; 12]);
/// 八个种子:官方基线紫、纯红/绿/蓝、低彩度灰蓝、高彩度紫,外加纯黑与纯白两个边缘用例
/// (无彩色种子的色相是病态的,官方的处理方式见 `Hct`:黑 → 色相 0、白 → 色相 209.5)。
/// 每个种子带 12 个强调族角色的官方色值(light, dark)。
const ACCENT_SEEDS: [SeedRows; 8] = [
(
"#6750A4",
[
(Role::Primary, "#65558F", "#CFBDFE"),
(Role::OnPrimary, "#FFFFFF", "#36275D"),
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
(Role::Secondary, "#625B71", "#CBC2DB"),
(Role::OnSecondary, "#FFFFFF", "#332D41"),
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
(Role::Tertiary, "#7E5260", "#EFB8C8"),
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
],
),
(
"#FF0000",
[
(Role::Primary, "#904B40", "#FFB4A8"),
(Role::OnPrimary, "#FFFFFF", "#561E16"),
(Role::PrimaryContainer, "#FFDAD4", "#73342A"),
(Role::OnPrimaryContainer, "#73342A", "#FFDAD4"),
(Role::Secondary, "#775651", "#E7BDB6"),
(Role::OnSecondary, "#FFFFFF", "#442925"),
(Role::SecondaryContainer, "#FFDAD4", "#5D3F3B"),
(Role::OnSecondaryContainer, "#5D3F3B", "#FFDAD4"),
(Role::Tertiary, "#705C2E", "#DEC48C"),
(Role::OnTertiary, "#FFFFFF", "#3E2E04"),
(Role::TertiaryContainer, "#FBDFA6", "#564419"),
(Role::OnTertiaryContainer, "#564419", "#FBDFA6"),
],
),
(
"#00FF00",
[
(Role::Primary, "#406836", "#A5D395"),
(Role::OnPrimary, "#FFFFFF", "#11380B"),
(Role::PrimaryContainer, "#C0EFB0", "#285020"),
(Role::OnPrimaryContainer, "#285020", "#C0EFB0"),
(Role::Secondary, "#54634D", "#BBCBB2"),
(Role::OnSecondary, "#FFFFFF", "#263422"),
(Role::SecondaryContainer, "#D7E8CD", "#3C4B37"),
(Role::OnSecondaryContainer, "#3C4B37", "#D7E8CD"),
(Role::Tertiary, "#386568", "#A0CFD2"),
(Role::OnTertiary, "#FFFFFF", "#003739"),
(Role::TertiaryContainer, "#BCEBEE", "#1E4D50"),
(Role::OnTertiaryContainer, "#1E4D50", "#BCEBEE"),
],
),
(
"#0000FF",
[
(Role::Primary, "#555992", "#BEC2FF"),
(Role::OnPrimary, "#FFFFFF", "#272B60"),
(Role::PrimaryContainer, "#E0E0FF", "#3E4278"),
(Role::OnPrimaryContainer, "#3E4278", "#E0E0FF"),
(Role::Secondary, "#5C5D72", "#C5C4DD"),
(Role::OnSecondary, "#FFFFFF", "#2E2F42"),
(Role::SecondaryContainer, "#E1E0F9", "#444559"),
(Role::OnSecondaryContainer, "#444559", "#E1E0F9"),
(Role::Tertiary, "#78536B", "#E8B9D5"),
(Role::OnTertiary, "#FFFFFF", "#46263B"),
(Role::TertiaryContainer, "#FFD8EE", "#5E3C52"),
(Role::OnTertiaryContainer, "#5E3C52", "#FFD8EE"),
],
),
(
"#6E7C8A",
[
(Role::Primary, "#2B638B", "#98CCF9"),
(Role::OnPrimary, "#FFFFFF", "#003350"),
(Role::PrimaryContainer, "#CCE5FF", "#044B71"),
(Role::OnPrimaryContainer, "#044B71", "#CCE5FF"),
(Role::Secondary, "#50606F", "#B8C8D9"),
(Role::OnSecondary, "#FFFFFF", "#23323F"),
(Role::SecondaryContainer, "#D4E4F6", "#394856"),
(Role::OnSecondaryContainer, "#394856", "#D4E4F6"),
(Role::Tertiary, "#66587B", "#D1BFE7"),
(Role::OnTertiary, "#FFFFFF", "#372A4A"),
(Role::TertiaryContainer, "#ECDCFF", "#4E4162"),
(Role::OnTertiaryContainer, "#4E4162", "#ECDCFF"),
],
),
(
"#D000FF",
[
(Role::Primary, "#794F81", "#E9B5EF"),
(Role::OnPrimary, "#FFFFFF", "#472150"),
(Role::PrimaryContainer, "#FDD6FF", "#603768"),
(Role::OnPrimaryContainer, "#603768", "#FDD6FF"),
(Role::Secondary, "#6A596C", "#D6C0D6"),
(Role::OnSecondary, "#FFFFFF", "#3A2B3C"),
(Role::SecondaryContainer, "#F3DBF2", "#524153"),
(Role::OnSecondaryContainer, "#524153", "#F3DBF2"),
(Role::Tertiary, "#82524C", "#F5B7B0"),
(Role::OnTertiary, "#FFFFFF", "#4C2521"),
(Role::TertiaryContainer, "#FFDAD6", "#673B36"),
(Role::OnTertiaryContainer, "#673B36", "#FFDAD6"),
],
),
(
"#000000",
[
(Role::Primary, "#8C4A60", "#FFB1C8"),
(Role::OnPrimary, "#FFFFFF", "#541D32"),
(Role::PrimaryContainer, "#FFD9E2", "#703348"),
(Role::OnPrimaryContainer, "#703348", "#FFD9E2"),
(Role::Secondary, "#74565F", "#E3BDC6"),
(Role::OnSecondary, "#FFFFFF", "#422931"),
(Role::SecondaryContainer, "#FFD9E2", "#5A3F47"),
(Role::OnSecondaryContainer, "#5A3F47", "#FFD9E2"),
(Role::Tertiary, "#7C5635", "#EFBD94"),
(Role::OnTertiary, "#FFFFFF", "#48290B"),
(Role::TertiaryContainer, "#FFDCC1", "#623F20"),
(Role::OnTertiaryContainer, "#623F20", "#FFDCC1"),
],
),
(
"#FFFFFF",
[
(Role::Primary, "#006874", "#82D3E0"),
(Role::OnPrimary, "#FFFFFF", "#00363D"),
(Role::PrimaryContainer, "#9EEFFD", "#004F58"),
(Role::OnPrimaryContainer, "#004F58", "#9EEFFD"),
(Role::Secondary, "#4A6267", "#B1CBD0"),
(Role::OnSecondary, "#FFFFFF", "#1C3438"),
(Role::SecondaryContainer, "#CDE7EC", "#334B4F"),
(Role::OnSecondaryContainer, "#334B4F", "#CDE7EC"),
(Role::Tertiary, "#525E7D", "#BAC6EA"),
(Role::OnTertiary, "#FFFFFF", "#24304D"),
(Role::TertiaryContainer, "#DAE2FF", "#3B4664"),
(Role::OnTertiaryContainer, "#3B4664", "#DAE2FF"),
],
),
];
/// 官方 `SchemeTonalSpot(seed)` 的中性表面(surface 与 surface-container-highest,light/dark)。
const SEED_NEUTRALS: [(&str, &str, &str, &str, &str); 8] = [
("#6750A4", "#FDF7FF", "#141218", "#E6E0E9", "#36343A"),
("#FF0000", "#FFF8F6", "#1A1110", "#F1DFDC", "#3D3230"),
("#00FF00", "#F8FBF1", "#11140F", "#E1E4DA", "#32362F"),
("#0000FF", "#FBF8FF", "#131318", "#E4E1E9", "#34343A"),
("#6E7C8A", "#F7F9FF", "#101418", "#E0E2E8", "#313539"),
("#D000FF", "#FFF7FA", "#171217", "#EAE0E7", "#393338"),
("#000000", "#FFF8F8", "#191113", "#EFDFE1", "#3C3235"),
("#FFFFFF", "#F5FAFB", "#0E1415", "#DEE3E5", "#303637"),
];
fn worst_channel(got: Color, want: Color) -> i16 {
[(got.r, want.r), (got.g, want.g), (got.b, want.b)]
.iter()
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
.max()
.unwrap_or(0)
}
#[test]
fn accent_schemes_match_official_on_the_accent_families() {
let mut worst = 0;
let mut off_by_one = 0;
let mut checked = 0;
let mut failures = Vec::new();
for (seed_hex, roles) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, is_dark) in [(SchemeFlavor::Light, false), (SchemeFlavor::Dark, true)] {
let scheme = Scheme::from_accent(flavor, accent);
for (role, want_light, want_dark) in roles {
let want_hex = if is_dark { want_dark } else { want_light };
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
let delta = worst_channel(got, want);
worst = worst.max(delta);
checked += 1;
if delta > 0 {
off_by_one += 1;
}
if delta > CHANNEL_TOLERANCE {
failures.push(format!(
"{seed_hex}/{}/{} 是 {},官方 {want_hex}(差 {delta})",
flavor.name(),
role.name(),
got.to_hex()
));
}
}
}
}
assert!(
failures.is_empty(),
"{} 项强调色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
failures.len(),
failures.join("\n")
);
println!("强调色对齐:{checked} 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
}
#[test]
fn accent_scheme_keeps_baseline_surfaces() {
// 产品语义:换强调色不许让底色变脏(这是 `from_accent` 与官方 `from_seed` 的唯一差别)。
let base = Scheme::baseline(SchemeFlavor::Light);
for (seed_hex, _) in ACCENT_SEEDS {
let accent = Color::from_hex(seed_hex).expect("合法 hex");
let custom = Scheme::from_accent(SchemeFlavor::Light, accent);
assert_eq!(custom.get(Role::Surface), base.get(Role::Surface));
assert_eq!(custom.get(Role::Outline), base.get(Role::Outline));
assert_eq!(custom.get(Role::Error), base.get(Role::Error));
}
}
#[test]
fn from_seed_matches_official_neutrals() {
// 全量 `from_seed` 的中性派生必须与官方一致(否则「整窗染色」的色相就是错的)。
for (seed_hex, light_surface, dark_surface, light_highest, dark_highest) in SEED_NEUTRALS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for (flavor, want_surface, want_highest) in [
(SchemeFlavor::Light, light_surface, light_highest),
(SchemeFlavor::Dark, dark_surface, dark_highest),
] {
let scheme = Scheme::from_seed(flavor, seed);
for (role, want_hex) in [
(Role::Surface, want_surface),
(Role::SurfaceContainerHighest, want_highest),
] {
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
assert!(
worst_channel(got, want) <= CHANNEL_TOLERANCE,
"{seed_hex}/{}/{} 是 {},官方 {want_hex}",
flavor.name(),
role.name(),
got.to_hex()
);
}
}
}
}
#[test]
fn from_seed_and_from_accent_agree_on_the_accent_families() {
// 两个入口的强调族应当逐像素相同:换种子时中性色不同,强调色只取决于种子色相。
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let by_seed = Scheme::from_seed(flavor, seed);
let by_accent = Scheme::from_accent(flavor, seed);
for role in [
Role::Primary,
Role::OnPrimary,
Role::PrimaryContainer,
Role::OnPrimaryContainer,
Role::Secondary,
Role::SecondaryContainer,
Role::Tertiary,
Role::TertiaryContainer,
] {
assert_eq!(
by_seed.get(role),
by_accent.get(role),
"{seed_hex}/{} 的 {} 两个入口不一致",
flavor.name(),
role.name()
);
}
}
}
}
#[test]
fn accent_hue_is_not_clipped_away() {
// 旧缺陷②:强调色色相被 gamut 硬裁导致偏移。现在色相由 HCT 保住(只压彩度)。
use unlucky_design::color::Hct;
for (seed_hex, _) in ACCENT_SEEDS {
let seed = Color::from_hex(seed_hex).expect("合法 hex");
let seed_hue = Hct::from_color(seed).hue;
let primary = Scheme::from_accent(SchemeFlavor::Light, seed).get(Role::Primary);
let got = Hct::from_color(primary).hue;
let delta = (got - seed_hue).abs().min(360.0 - (got - seed_hue).abs());
assert!(delta < 1.0, "{seed_hex} 的 primary 色相偏了 {delta}°");
}
}
+197
View File
@@ -0,0 +1,197 @@
//! 与官方 `material-color-utilities` 的**逐角色对齐**(baseline 方案)。
//!
//! 期望值不是「我们觉得对的颜色」,而是把官方 Java 实现(Apache-2.0)编译跑出来的结果:
//! `new SchemeTonalSpot(Hct.fromInt(0xFF6750A4), isDark, 0.0, SpecVersion.SPEC_2021, Platform.PHONE)`
//! 配上 `ColorSpec2021` 的 49 个动态色。跑法见 `NOTICE` 的归属声明——期望值表就是那份输出的
//! 抄本(`seed,flavor,role,hex`)。
//!
//! 判据是**每通道误差 ≤1**:8 bit 量化本身就有半格误差,再加上 Java `double` 与 Rust `f64`
//! 的末位差异,1 是「同一个算法」的合理上限;2 以上就是实现真的不一样了。
use unlucky_design::color::{Color, Hct, Role, Scheme, SchemeFlavor, BASELINE_SEED};
/// 每通道允许的最大误差。
const CHANNEL_TOLERANCE: i16 = 1;
/// 官方 baseline 输出(seed `#6750A4`,Tonal Spot,ColorSpec2021,contrastLevel 0)。
const BASELINE: [(Role, &str, &str); 49] = [
(Role::Primary, "#65558F", "#CFBDFE"),
(Role::OnPrimary, "#FFFFFF", "#36275D"),
(Role::PrimaryContainer, "#E9DDFF", "#4D3D75"),
(Role::OnPrimaryContainer, "#4D3D75", "#E9DDFF"),
(Role::Secondary, "#625B71", "#CBC2DB"),
(Role::OnSecondary, "#FFFFFF", "#332D41"),
(Role::SecondaryContainer, "#E8DEF8", "#4A4458"),
(Role::OnSecondaryContainer, "#4A4458", "#E8DEF8"),
(Role::Tertiary, "#7E5260", "#EFB8C8"),
(Role::OnTertiary, "#FFFFFF", "#4A2532"),
(Role::TertiaryContainer, "#FFD9E3", "#633B48"),
(Role::OnTertiaryContainer, "#633B48", "#FFD9E3"),
(Role::Error, "#BA1A1A", "#FFB4AB"),
(Role::OnError, "#FFFFFF", "#690005"),
(Role::ErrorContainer, "#FFDAD6", "#93000A"),
(Role::OnErrorContainer, "#93000A", "#FFDAD6"),
(Role::Surface, "#FDF7FF", "#141218"),
(Role::OnSurface, "#1D1B20", "#E6E0E9"),
(Role::SurfaceVariant, "#E7E0EB", "#49454E"),
(Role::OnSurfaceVariant, "#49454E", "#CAC4CF"),
(Role::Background, "#FDF7FF", "#141218"),
(Role::OnBackground, "#1D1B20", "#E6E0E9"),
(Role::SurfaceDim, "#DED8E0", "#141218"),
(Role::SurfaceBright, "#FDF7FF", "#3B383E"),
(Role::SurfaceContainerLowest, "#FFFFFF", "#0F0D13"),
(Role::SurfaceContainerLow, "#F8F2FA", "#1D1B20"),
(Role::SurfaceContainer, "#F2ECF4", "#211F24"),
(Role::SurfaceContainerHigh, "#ECE6EE", "#2B292F"),
(Role::SurfaceContainerHighest, "#E6E0E9", "#36343A"),
(Role::Outline, "#7A757F", "#948F99"),
(Role::OutlineVariant, "#CAC4CF", "#49454E"),
(Role::InverseSurface, "#322F35", "#E6E0E9"),
(Role::InverseOnSurface, "#F5EFF7", "#322F35"),
(Role::InversePrimary, "#CFBDFE", "#65558F"),
(Role::Shadow, "#000000", "#000000"),
(Role::Scrim, "#000000", "#000000"),
(Role::SurfaceTint, "#65558F", "#CFBDFE"),
(Role::PrimaryFixed, "#E9DDFF", "#E9DDFF"),
(Role::PrimaryFixedDim, "#CFBDFE", "#CFBDFE"),
(Role::OnPrimaryFixed, "#201047", "#201047"),
(Role::OnPrimaryFixedVariant, "#4D3D75", "#4D3D75"),
(Role::SecondaryFixed, "#E8DEF8", "#E8DEF8"),
(Role::SecondaryFixedDim, "#CBC2DB", "#CBC2DB"),
(Role::OnSecondaryFixed, "#1E192B", "#1E192B"),
(Role::OnSecondaryFixedVariant, "#4A4458", "#4A4458"),
(Role::TertiaryFixed, "#FFD9E3", "#FFD9E3"),
(Role::TertiaryFixedDim, "#EFB8C8", "#EFB8C8"),
(Role::OnTertiaryFixed, "#31101D", "#31101D"),
(Role::OnTertiaryFixedVariant, "#633B48", "#633B48"),
];
/// 官方 `Hct.from(hue, chroma, tone)` 的色值(求解器与 gamut 映射的直接证据)。
const HCT_PROBES: [(f64, f64, f64, &str); 12] = [
(95.0, 36.0, 40.0, "#705D0D"),
(95.0, 36.0, 90.0, "#FCE186"),
(95.0, 16.0, 90.0, "#F0E2BB"),
(96.0, 24.0, 80.0, "#D7C68D"),
(25.0, 84.0, 40.0, "#BA1A1A"),
(0.0, 0.0, 50.0, "#777777"),
(200.0, 120.0, 70.0, "#00BEC3"),
(30.0, 0.5, 98.0, "#FDF8F7"),
(270.0, 200.0, 10.0, "#001847"),
(120.0, 60.0, 99.0, "#FCFFDD"),
(60.0, 6.0, 6.0, "#19120C"),
(95.0, 36.0, 30.0, "#554500"),
];
/// 三通道里最大的绝对误差。
fn worst_channel(got: Color, want: Color) -> i16 {
let pairs = [(got.r, want.r), (got.g, want.g), (got.b, want.b)];
pairs
.iter()
.map(|(a, b)| (i16::from(*a) - i16::from(*b)).abs())
.max()
.unwrap_or(0)
}
#[test]
fn baseline_matches_official_on_all_forty_nine_roles() {
let light = Scheme::baseline(SchemeFlavor::Light);
let dark = Scheme::baseline(SchemeFlavor::Dark);
let mut worst = 0;
let mut off_by_one = 0;
let mut failures = Vec::new();
for (role, want_light, want_dark) in BASELINE {
for (flavor, scheme, want_hex) in [
(SchemeFlavor::Light, light, want_light),
(SchemeFlavor::Dark, dark, want_dark),
] {
let got = scheme.get(role);
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
let delta = worst_channel(got, want);
worst = worst.max(delta);
if delta > 0 {
off_by_one += 1;
}
if delta > CHANNEL_TOLERANCE {
failures.push(format!(
"{}/{} 是 {},官方 {want_hex}(差 {delta})",
flavor.name(),
role.name(),
got.to_hex()
));
}
}
}
assert!(
failures.is_empty(),
"{} 个角色与官方不一致(容差 {CHANNEL_TOLERANCE}):\n{}",
failures.len(),
failures.join("\n")
);
// 把误差统计打出来(`cargo test -- --nocapture` 可见),报告里要引用。
println!("baseline 对齐:98 项,最大通道误差 {worst},非零误差 {off_by_one} 项");
}
#[test]
fn hct_solver_matches_official_color_spec_2021() {
for (hue, chroma, tone, want_hex) in HCT_PROBES {
let got = Hct::from_components(hue, chroma, tone).to_color();
let want = Color::from_hex(want_hex).expect("官方 hex 必须合法");
assert!(
worst_channel(got, want) <= CHANNEL_TOLERANCE,
"HCT({hue}, {chroma}, {tone}) 是 {},官方 {want_hex}",
got.to_hex()
);
}
}
#[test]
fn baseline_primary_is_not_the_seed_and_that_is_official() {
// 常见误解:baseline() 的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
let seed = Hct::from_color(BASELINE_SEED);
assert!(
(seed.chroma - 47.856_526).abs() < 1.0e-4,
"种子彩度 {}",
seed.chroma
);
let primary = Scheme::baseline(SchemeFlavor::Light).get(Role::Primary);
assert_eq!(primary.to_hex(), "#65558F");
assert_ne!(primary, BASELINE_SEED);
}
#[test]
fn baseline_tone_axis_is_still_exact_lstar() {
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let scheme = Scheme::baseline(flavor);
for (role, color) in scheme.iter() {
let tone = Scheme::tone_of(role, flavor);
let got = f64::from(color.lstar());
assert!(
(got - tone).abs() <= 1.0,
"{}/{} 的 tone 声明 {tone}、实测 {got}",
flavor.name(),
role.name()
);
}
}
}
#[test]
fn same_family_containers_are_separated_by_hct_chroma() {
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
let light = Scheme::baseline(SchemeFlavor::Light);
let primary = light.get(Role::PrimaryContainer);
let secondary = light.get(Role::SecondaryContainer);
assert_ne!(primary, secondary);
let c_primary = Hct::from_color(primary).chroma;
let c_secondary = Hct::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"
);
// 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅紫」而不是被裁到灰。
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
}
+3 -3
View File
@@ -212,11 +212,11 @@ fn design_tokens_entry_point() {
#[test]
fn tonal_palette_hits_requested_tones() {
let palette = TonalPalette::new(150.0, 0.1);
for tone in [20.0_f32, 40.0, 60.0, 80.0] {
let palette = TonalPalette::new(150.0, 36.0);
for tone in [20.0_f64, 40.0, 60.0, 80.0] {
let color = palette.tone(tone);
assert!(
(color.lstar() - tone).abs() <= 1.0,
(f64::from(color.lstar()) - tone).abs() <= 1.0,
"tone {tone} 实际 {}",
color.lstar()
);
+8 -8
View File
@@ -8,7 +8,7 @@ use unlucky_design::icons::IconKind;
use unlucky_design::motion::{DurationToken, Easing};
use unlucky_design::shape::ShapeScale;
fn expect_tone(role: Role, light: f32, dark: f32) {
fn expect_tone(role: Role, light: f64, dark: f64) {
assert_eq!(
Scheme::tone_of(role, SchemeFlavor::Light),
light,
@@ -28,24 +28,24 @@ fn all_forty_nine_tones_are_pinned() {
expect_tone(Role::Primary, 40.0, 80.0);
expect_tone(Role::OnPrimary, 100.0, 20.0);
expect_tone(Role::PrimaryContainer, 90.0, 30.0);
expect_tone(Role::OnPrimaryContainer, 10.0, 90.0);
expect_tone(Role::OnPrimaryContainer, 30.0, 90.0);
expect_tone(Role::Secondary, 40.0, 80.0);
expect_tone(Role::OnSecondary, 100.0, 20.0);
expect_tone(Role::SecondaryContainer, 90.0, 30.0);
expect_tone(Role::OnSecondaryContainer, 10.0, 90.0);
expect_tone(Role::OnSecondaryContainer, 30.0, 90.0);
expect_tone(Role::Tertiary, 40.0, 80.0);
expect_tone(Role::OnTertiary, 100.0, 20.0);
expect_tone(Role::TertiaryContainer, 90.0, 30.0);
expect_tone(Role::OnTertiaryContainer, 10.0, 90.0);
expect_tone(Role::OnTertiaryContainer, 30.0, 90.0);
expect_tone(Role::Error, 40.0, 80.0);
expect_tone(Role::OnError, 100.0, 20.0);
expect_tone(Role::ErrorContainer, 90.0, 30.0);
expect_tone(Role::OnErrorContainer, 10.0, 90.0);
expect_tone(Role::OnErrorContainer, 30.0, 90.0);
expect_tone(Role::Surface, 98.0, 6.0);
expect_tone(Role::OnSurface, 10.0, 90.0);
expect_tone(Role::SurfaceVariant, 90.0, 30.0);
expect_tone(Role::OnSurfaceVariant, 30.0, 80.0);
expect_tone(Role::Background, 99.0, 10.0);
expect_tone(Role::Background, 98.0, 6.0);
expect_tone(Role::OnBackground, 10.0, 90.0);
expect_tone(Role::SurfaceDim, 87.0, 6.0);
expect_tone(Role::SurfaceBright, 98.0, 24.0);
@@ -241,8 +241,8 @@ fn same_family_containers_do_not_collide() {
"同族容器撞色(彩度压缩没生效):{primary:?}"
);
// secondary-container 必须比 primary-container 更灰(M3 的「次级」语义)。
let c_primary = unlucky_design::color::space::Oklch::from_color(primary).chroma;
let c_secondary = unlucky_design::color::space::Oklch::from_color(secondary).chroma;
let c_primary = unlucky_design::color::Hct::from_color(primary).chroma;
let c_secondary = unlucky_design::color::Hct::from_color(secondary).chroma;
assert!(
c_secondary < c_primary,
"secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}"