Files
Unlucky-Launcher/crates/design/tests/skyseed_candidates.rs
T
AskaEth 40864d93d0 fix(design): M70 r4 —— 偏离登记补齐「neutral-variant 8→14」+ 文档口径与 pin 可读性
复审 P2-1:neutral-variant 的那一半偏离漏登记,且文档把 nv 说成官方相对式。
- `color/mod.rs`:标题改「两处有意偏离(同一处偏离的两半)」;偏离登记把
  neutral 6→12 与 neutral-variant 8→14 分列,写明官方 `getNeutralVariantPalette`
  是**绝对值 8**(不是 neutral+2)、影响角色含 outline/outline-variant/surface-variant,
  以及「nv 钉回 8 会让表面(12)比中性变体(8)更有色」的倒挂理由
- `palettes.rs`:`NEUTRAL_VARIANT_OFFSET` 文档改述为「官方绝对值 8,本实现相对式=有意偏离」;
  `DEFAULT_NEUTRAL_CHROMA` 的矛盾区间改正为「数值安全区间 6..=13(14 才撞 secondary),产品取 12」;
  `from_seed_with_neutral_chroma` 加调用方约束(越界静默退化:<0/NaN → 彩度 0;≥14 → nv 撞 secondary);
  新增 `product_default_neutral_chroma_is_a_registered_deviation`(钉 neutral 12 / nv 14,官方锚点仍 6/8)
- nit1:`sky_seed_candidates_meet_wcag_aa` 的「三档」→「四档(208 项)」
- nit2:`product_default_is_the_adopted_candidate` 改成比 hex 文案,失败时可直读「期望 #3C8DBC、实测 #87CEEB(③)」
- nit4:`lib.rs` 的 `DesignTokens::baseline` 文档改准(产品默认 ④ 天蓝,官方口径走 `Scheme::official_baseline`)

无物理行为变更:算法、tone 表、gamut、所有色值一个没动。
2026-11-01 18:09:14 +08:00

342 lines
14 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.
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
//!
//! ## 这档候选到底改了什么
//!
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
//!
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
//! |---|---|---|---|---|---|
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
//!
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
//!
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
//!
//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。
//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同**
//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。
//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。
//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。
//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处
//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。
//!
//! ## 换默认色要动哪里
//!
//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量,
//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。
use unlucky_design::color::{
Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED,
};
/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。
struct Candidate {
/// 短标签(色卡与 CSV 用)。
tag: &'static str,
/// 中文档名。
title: &'static str,
/// 种子 hex。
seed_hex: &'static str,
/// 中性调色板彩度(官方 Tonal Spot 是 6)。
neutral_chroma: f64,
/// 9 个展示角色的 **(角色, light, dark)** 抄本。
pins: [(Role, &'static str, &'static str); 9],
}
/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。
const CANDIDATES: [Candidate; 4] = [
Candidate {
tag: "C1",
title: "① 保守蓝(中性彩度 6 = 官方)",
seed_hex: "#2196F3",
neutral_chroma: 6.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#111418"),
(Role::SurfaceContainerLow, "#F2F3FA", "#191C20"),
(Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"),
(Role::Outline, "#73777F", "#8D9199"),
],
},
Candidate {
tag: "C2",
title: "② 冷调蓝(中性彩度 12)",
seed_hex: "#2196F3",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#36618E", "#A0CAFD"),
(Role::OnPrimary, "#FFFFFF", "#003258"),
(Role::PrimaryContainer, "#D1E4FF", "#194975"),
(Role::Secondary, "#535F70", "#BBC7DB"),
(Role::Tertiary, "#6B5778", "#D6BEE4"),
(Role::Surface, "#F8F9FF", "#0C141E"),
(Role::SurfaceContainerLow, "#EEF4FF", "#141C26"),
(Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"),
(Role::Outline, "#6D7887", "#8791A1"),
],
},
Candidate {
tag: "C3",
title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)",
seed_hex: "#87CEEB",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#0C6780", "#89D0ED"),
(Role::OnPrimary, "#FFFFFF", "#003544"),
(Role::PrimaryContainer, "#BAEAFF", "#004D62"),
(Role::Secondary, "#4C626B", "#B3CAD5"),
(Role::Tertiary, "#5B5B7E", "#C4C3EB"),
(Role::Surface, "#F3FAFF", "#07151B"),
(Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"),
(Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"),
(Role::Outline, "#687A82", "#81949C"),
],
},
Candidate {
tag: "C4",
title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)",
seed_hex: "#3C8DBC",
neutral_chroma: 12.0,
pins: [
(Role::Primary, "#236488", "#93CDF6"),
(Role::OnPrimary, "#FFFFFF", "#00344C"),
(Role::PrimaryContainer, "#C8E6FF", "#004C6D"),
(Role::Secondary, "#4F616E", "#B7C9D8"),
(Role::Tertiary, "#63597C", "#CDC0E9"),
(Role::Surface, "#F6FAFF", "#09151C"),
(Role::SurfaceContainerLow, "#EBF5FF", "#111D25"),
(Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"),
(Role::Outline, "#6A7985", "#84939F"),
],
},
];
/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。
fn seed_hct(candidate: &Candidate) -> Hct {
Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex"))
}
/// 按候选参数走**产品同一条**派生路径。
fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
),
flavor,
)
}
#[test]
fn candidates_match_their_pins() {
let mut drift = Vec::new();
for candidate in &CANDIDATES {
for (role, want_light, want_dark) in candidate.pins {
for (flavor, want_hex) in [
(SchemeFlavor::Light, want_light),
(SchemeFlavor::Dark, want_dark),
] {
let got = scheme(candidate, flavor).get(role).to_hex();
if got != want_hex {
drift.push(format!(
"{}{} / {}/{} 实测 {got},pin {want_hex}",
candidate.tag,
if flavor.is_dark() { "-dark" } else { "" },
candidate.title,
role.name()
));
}
}
}
}
assert!(
drift.is_empty(),
"候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}",
drift.join("\n")
);
}
#[test]
fn candidates_only_move_the_two_declared_inputs() {
// 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致,
// 唯一的差别是中性彩度的入参(以及种子色相)。
let official = Palettes::official_baseline();
for candidate in &CANDIDATES {
let p = Palettes::from_seed_with_neutral_chroma(
seed_hct(candidate).to_color(),
candidate.neutral_chroma,
);
assert_eq!(p.primary.chroma(), official.primary.chroma());
assert_eq!(p.secondary.chroma(), official.secondary.chroma());
assert_eq!(p.tertiary.chroma(), official.tertiary.chroma());
assert_eq!(p.error, official.error, "error 族不该被候选影响");
assert_eq!(p.neutral.chroma(), candidate.neutral_chroma);
assert!(
(p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9,
"{} 的 tertiary 不再是 +60°",
candidate.tag
);
}
}
#[test]
fn the_candidates_are_actually_distinguishable() {
// 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶,
// 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上,
// 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。
let light = |c: &Candidate| scheme(c, SchemeFlavor::Light);
let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark);
let (c1, c2, c3, c4) = (
light(&CANDIDATES[0]),
light(&CANDIDATES[1]),
light(&CANDIDATES[2]),
light(&CANDIDATES[3]),
);
for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] {
assert_ne!(
c1.get(role),
c2.get(role),
"①与②的 {} 相同:中性彩度没起作用",
role.name()
);
assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name());
// ③④ 的中性彩度相同,差别只允许来自色相
assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name());
}
assert_ne!(
c3.get(Role::Primary),
c4.get(Role::Primary),
"③与④的主色必须真的不同(否则第 4 档白加)"
);
assert_ne!(
dark(&CANDIDATES[0]).get(Role::Surface),
dark(&CANDIDATES[1]).get(Role::Surface),
"深色模式的 surface 应当能看出中性彩度差别"
);
let hue_gap = |a: &Candidate, b: &Candidate| {
let (x, y) = (seed_hct(a).hue, seed_hct(b).hue);
(x - y).abs().min(360.0 - (x - y).abs())
};
assert!(
hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01,
"①② 应当只差中性彩度"
);
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
for (i, j) in [(1, 2), (2, 3)] {
assert!(
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])),
"{}→{} 的色相差应当明显(10°~40°),实测 {}",
CANDIDATES[i].tag,
CANDIDATES[j].tag,
hue_gap(&CANDIDATES[i], &CANDIDATES[j])
);
}
}
#[test]
fn product_default_is_the_adopted_candidate() {
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
let adopted = &CANDIDATES[3];
assert_eq!(
DEFAULT_SEED.to_hex(),
"#3C8DBC",
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
DEFAULT_SEED.to_hex(),
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
} else {
"不是候选表里的任何一个种子"
}
);
assert_eq!(
DEFAULT_SEED.to_hex(),
adopted.seed_hex,
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
DEFAULT_SEED.to_hex(),
adopted.tag,
adopted.seed_hex
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, 12.0,
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
DEFAULT_NEUTRAL_CHROMA
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let product = Scheme::baseline(flavor);
let expected = scheme(adopted, flavor);
for (role, got) in product.iter() {
assert_eq!(
got.to_hex(),
expected.get(role).to_hex(),
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
adopted.tag,
role.name(),
flavor.name()
);
}
}
}
#[test]
fn surface_tint_stays_a_tint() {
// 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须
// 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。
for candidate in &CANDIDATES {
let light = scheme(candidate, SchemeFlavor::Light);
let surface = light.get(Role::Surface);
let chroma = Hct::from_color(surface).chroma;
assert!(
chroma <= 1.2 * candidate.neutral_chroma,
"{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍",
candidate.tag
);
assert!(surface.relative_luminance() > 0.85);
}
}
#[test]
fn dump_candidates_for_the_color_card() {
// 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法:
// cargo test -p unlucky-design --test skyseed_candidates -- --nocapture
for candidate in &CANDIDATES {
let hct = seed_hct(candidate);
println!(
"SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}",
candidate.tag,
candidate.title,
candidate.seed_hex,
candidate.neutral_chroma,
hct.hue,
hct.chroma
);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
let s = scheme(candidate, flavor);
for role in Role::ALL {
println!(
"ROLE|{}|{}|{}|{}",
candidate.tag,
flavor.name(),
role.name(),
s.get(role).to_hex().trim_start_matches('#')
);
}
}
}
}