默认色从官方紫 `#6750A4` 换成「浅冷天蓝」,并把「换默认只改哪一行」做成常量: - `palettes::DEFAULT_SEED` = `#87CEEB`(CSS skyblue)+ `palettes::DEFAULT_NEUTRAL_CHROMA` = 12 - `Palettes::baseline()` / `Scheme::baseline()` = 产品默认(消费方拿到的就是它) - 官方口径锚点独立保留:`Palettes::official_baseline()` / `Scheme::official_baseline()` (种子 `#6750A4` + 中性 6),官方对齐测试改指向它,98 项仍每通道误差 0 - 新增 `Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma)`:中性彩度从写死 常数变成入参(neutral-variant 按官方 +2 跟随),算法、tone 表、gamut 映射一行未动 有意偏离登记(已写进 color 模块文档):中性彩度 6 → 12 是对官方 Tonal Spot 的偏离, 理由=产品要冷调表面;primary 的 tone 被官方钉在 40,种子再浅也变不成浅蓝, 「浅」只能落在中性色上。 三档候选(同一条 HCT 管道,只差种子与中性彩度)逐个 pin 在 tests/skyseed_candidates.rs: ① 保守蓝 `#2196F3`/6、② 冷调蓝 `#2196F3`/12、③ 浅冷蓝 `#87CEEB`/12(临时默认,待用户确认)。 WCAG AA 扩到候选档:26 组 × 3 档 × 2 模式 = 156 项全绿,最紧 4.99:1。
199 lines
8.5 KiB
Rust
199 lines
8.5 KiB
Rust
//! 与官方 `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() {
|
||
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
|
||
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||
let dark = Scheme::official_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() {
|
||
// 常见误解:官方基线的 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::official_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::official_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::official_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}");
|
||
}
|