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:
1 parent
958ced2d7e
commit
29f65b8e6f
16 files changed
+1786
-373
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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}°");
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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,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}"
|
||||
|
||||
Reference in new issue
Block a user