Files
Unlucky-Launcher/crates/design/tests/skyseed_candidates.rs
T
AskaEth 79404fa9d1 feat(design): M70 默认色候选 —— 浅冷天蓝落成两行常量 + 三档候选色卡
默认色从官方紫 `#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。
2026-11-01 18:09:12 +08:00

279 lines
11 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 映射),差别只有两个入参:
//!
//! | 档 | 种子 | 中性调色板彩度 | 表面 |
//! |---|---|---|---|
//! | ① 保守蓝 | `#2196F3` | 6(官方) | 近白/近黑,几乎无色 |
//! | ② 冷调蓝 | `#2196F3` | 12 | 冷白/冷黑,带淡蓝调 |
//! | ③ 浅冷蓝 | `#87CEEB`(CSS `skyblue`) | 12 | 浅天蓝 |
//!
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
//!
//! 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; 3] = [
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"),
],
},
];
/// 三档共同点:种子的 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_three_tiers_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) = (
light(&CANDIDATES[0]),
light(&CANDIDATES[1]),
light(&CANDIDATES[2]),
);
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!(
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,
"①② 应当只差中性彩度"
);
assert!(
(10.0..=40.0).contains(&hue_gap(&CANDIDATES[1], &CANDIDATES[2])),
"②③ 的色相差应当明显(10°~40°),实测 {}",
hue_gap(&CANDIDATES[1], &CANDIDATES[2])
);
}
#[test]
fn product_default_is_the_recommended_candidate() {
// ③ 是 M70 的**临时默认**(推荐档,待用户确认)。用户拍板后改这里会立刻红——
// 那条红就是在提醒你同步 `DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA` 与上面的 pin。
let recommended = &CANDIDATES[2];
assert_eq!(
DEFAULT_SEED,
Color::from_hex(recommended.seed_hex).expect("合法 hex"),
"DEFAULT_SEED 与推荐档 {}",
recommended.tag
);
assert_eq!(DEFAULT_NEUTRAL_CHROMA, recommended.neutral_chroma);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
assert_eq!(
Scheme::baseline(flavor),
scheme(recommended, flavor),
"产品默认(Scheme::baseline)必须等于推荐档的派生结果({})",
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('#')
);
}
}
}
}