Files
Unlucky-Launcher/crates/design/src/palette/card.rs
T
AskaEth 09b716ae79 docs(design)+test(palette-extract): r2 三条口径修正与常数绑定
- LowChroma 文档:删掉「彩度必然 < 候选门槛 5」(人气门槛也会淘汰高彩度小面积,
  实测 0.5% 纯红报 113.36),改为与契约文档 §6 一致的口径;代码臂注释与 score::most_chromatic 同步
- Seed::color / share 改成有边界的说法:≤MAX_SAMPLES 才是图上真实色、share 的分母是有效采样点
- palette-extract 新增 max_edge_stays_within_the_design_sample_budget:
  MAX_EDGE² ≤ MAX_SAMPLES(256² 当前余量为零,调大就红)
- card.rs:把 6.43~6.50 / 7.70~7.76 写成断言(原来只有 ≥4.5 下限,文档却宣称区间)
- LowChroma.color 字段注释改成「最接近可用的候选色」(113 那条并不「能用」)
2026-11-02 11:05:03 +08:00

326 lines
13 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.
//! 内置色卡(D38 第三级):用户在「关闭莫奈取色」时手动选的那几张。
//!
//! ## `key` 是持久化键,命名就此定死
//!
//! 每张卡的 [`Card::key`] 会被写进用户设置(settings 里存字符串),**改了就等于把用户的选择
//! 抹成出厂默认**。所以:
//!
//! - 只用小写 ASCII + 单段单词,不带动词、不带颜色数值(`sky`,不是 `blue_3c8dbc`);
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
//! [`CardKey::from_key`] 的回落口径);
//! - `card.rs` 的 `card_keys_are_stable_and_unique` 把 8 个 key 逐字钉住——谁改谁红
//! (测试就在本文件末尾的 `tests` 模块里,没有独立的 `palette/tests.rs`)。
//!
//! ## 每张卡自带中性彩度
//!
//! 八张卡都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)而不是官方 6:
//! 换卡只该换**色相**,不该让窗口底色的「冷度」跟着跳——那种跳变会让用户在设置页里
//! 逐张试色时觉得「每张卡都像换了个应用」。
//!
//! ⚠️ **这只是「请求值」相同**:中性调色板的色相目前仍 = 种子色相(`Palettes::from_seed_*`),
//! 所以八张卡的实际表面色是逐卡染色的(teal 薄荷白 / forest、olive 偏绿 / amber、rose 暖白),
//! 浅色档实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处暖相色域装不下
//! chroma 12。tower 已定案(D43)改成「色卡固定冷调中性色相、只换强调色」,**不在 M87 做**;
//! 在 D43 落地前,别把上面那段当成已经成立的行为。要偏离这条得单独论证。
//!
//! ## 卡与取色的关系(D38)
//!
//! 取色成功时,**当前选中的卡仍然参与生成**:它的中性彩度决定取色方案的表面色温
//! (见 [`super::resolve`])。所以卡片不是「只在第三级才用到」的备胎。
use super::wcag;
use crate::color::{Color, Palettes, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA};
/// 一张内置色卡。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Card {
/// 持久化键(写进用户设置,**不许改**)。
pub key: &'static str,
/// 显示名(可改、可本地化)。
pub name: &'static str,
/// 种子色(只贡献色相;Tonal Spot 会把彩度收到 36)。
pub seed: Color,
/// 中性调色板彩度(表面色的「冷度」)。
pub neutral_chroma: f64,
}
impl Card {
/// 用这张卡生成一套方案(与取色路径同一条生成器,只差种子来源)。
pub fn scheme(self, flavor: SchemeFlavor) -> Scheme {
Scheme::from_palettes(
&Palettes::from_seed_with_neutral_chroma(self.seed, self.neutral_chroma),
flavor,
)
}
/// 这张卡的 `on-primary` 对 `primary` 的实测对比度。
///
/// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**:
/// 浅色档 **6.43~6.49**、深色档 **7.70~7.75**——两种模式都远高于 WCAG AA 的 4.5:1。
///
/// 这两个区间**被测试钉住**(不是口头声明):`every_card_scheme_meets_wcag_aa` 对每张卡
/// 断言「下限 [`wcag::AA_TEXT`] + 落在上述区间内」,所以将来改种子/调色板常数把比值
/// 拖出区间会当场红——文档里的数字与测试同源。要改数值请连测试区间一起改。
pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 {
let scheme = self.scheme(flavor);
scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary)
}
}
/// 全部内置色卡(下标 0 = 出厂默认)。
///
/// 色相在 CAM16 色环上大致均匀铺开(蓝 240 / 紫 299 / 绿 155 / 橙 65 / 红 10 / 青 185 /
/// 暮蓝 ~275 / 橄榄绿 ~110),让用户「随便挑一张都不会撞色」。`palette::card` 的单测
/// `cards_are_visually_distinct` 钉住「相邻色相间隔 ≥ 20°」。
pub const CARDS: [Card; 8] = [
Card {
key: "sky",
name: "天蓝",
// M70 定案 ④(`palettes::DEFAULT_SEED` 同值)。改这张卡 = 改产品默认色,见 M70 文档。
seed: Color::rgb(0x3C, 0x8D, 0xBC),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "lavender",
name: "薰衣草紫",
// 官方 M3 baseline 种子,和 `Scheme::official_baseline` 同色,便于对照官方截图。
seed: Color::rgb(0x67, 0x50, 0xA4),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "forest",
name: "森林绿",
seed: Color::rgb(0x2E, 0x7D, 0x4F),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "amber",
name: "琥珀橙",
seed: Color::rgb(0xB2, 0x6A, 0x00),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "rose",
name: "玫瑰红",
seed: Color::rgb(0xB2, 0x3A, 0x48),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "teal",
name: "青碧",
seed: Color::rgb(0x00, 0x79, 0x6B),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "dusk",
name: "暮蓝",
seed: Color::rgb(0x4A, 0x5A, 0x9C),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
Card {
key: "olive",
name: "橄榄绿",
// 刻意**没有**「纯灰/石板灰」卡:D40 已实测,零彩度种子的 CAM16 色相恒为 209.49°
// (不是「无色」,而是固定的青),极低彩度段色相还是噪声放大器。所以「想要无彩色界面」
// 不能靠一张灰卡实现——那需要一个饱和度/对比度维度(未来若做,是另一条轴,不是色卡)。
seed: Color::rgb(0x5F, 0x6B, 0x2E),
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
},
];
/// 出厂默认卡(首次启动、用户没选过卡时的那一张)。
pub const DEFAULT_CARD: Card = CARDS[0];
/// 色卡的类型化键(`Card::key` 的枚举形态)。
///
/// 加了它是因为「存字符串」这件事必须只有一处口径:设置读回来时走 [`CardKey::from_key`],
/// 写出去时走 [`CardKey::key`],两边不会漂。新卡追加在末尾,**不重排**。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum CardKey {
/// 天蓝(出厂默认,M70 ④)。
Sky,
/// 薰衣草紫(官方 M3 baseline)。
Lavender,
/// 森林绿。
Forest,
/// 琥珀橙。
Amber,
/// 玫瑰红。
Rose,
/// 青碧。
Teal,
/// 暮蓝。
Dusk,
/// 橄榄绿。
Olive,
}
impl CardKey {
/// 全部键(顺序与 [`CARDS`] 一致)。
pub const ALL: [CardKey; 8] = [
CardKey::Sky,
CardKey::Lavender,
CardKey::Forest,
CardKey::Amber,
CardKey::Rose,
CardKey::Teal,
CardKey::Dusk,
CardKey::Olive,
];
/// 持久化键(与 [`Card::key`] 逐字相同)。
pub const fn key(self) -> &'static str {
CARDS[self.index()].key
}
/// 这张卡。
pub const fn card(self) -> Card {
CARDS[self.index()]
}
/// 显示名。
pub const fn name(self) -> &'static str {
CARDS[self.index()].name
}
const fn index(self) -> usize {
match self {
CardKey::Sky => 0,
CardKey::Lavender => 1,
CardKey::Forest => 2,
CardKey::Amber => 3,
CardKey::Rose => 4,
CardKey::Teal => 5,
CardKey::Dusk => 6,
CardKey::Olive => 7,
}
}
/// 从持久化键解析;**未知键返回 `None`**(调用方回落 [`DEFAULT_CARD`] 并可以提示
/// 「设置里的色卡名已失效」——绝不静默当成默认卡用掉,那样用户会以为设置没保存)。
pub fn from_key(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|card| card.key() == key)
}
}
impl From<CardKey> for Card {
fn from(key: CardKey) -> Self {
key.card()
}
}
/// 卡片方案是否过 WCAG(给测试与设置页复用;正常路径由 [`super::resolve`] 兜底)。
pub fn card_scheme_is_readable(card: Card, flavor: SchemeFlavor) -> bool {
wcag::check(&card.scheme(flavor)).is_clean()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn at_least_six_cards_and_default_is_sky() {
assert!(CARDS.len() >= 6, "只有 {} 张卡", CARDS.len());
assert_eq!(DEFAULT_CARD.key, "sky");
assert_eq!(CARDS[0].seed, crate::color::palettes::DEFAULT_SEED);
}
#[test]
fn card_keys_are_stable_and_unique() {
// ⚠️ 这些字符串是持久化键:改一个就等于把老用户的选择抹成出厂默认。
const EXPECTED: [&str; 8] = [
"sky", "lavender", "forest", "amber", "rose", "teal", "dusk", "olive",
];
for (card, expected) in CARDS.iter().zip(EXPECTED) {
assert_eq!(card.key, expected);
}
let mut keys: Vec<&str> = CARDS.iter().map(|card| card.key).collect();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), CARDS.len(), "key 有重复");
for card in CARDS {
assert!(
card.key.chars().all(|c| c.is_ascii_lowercase()),
"{} 不是小写 ASCII",
card.key
);
assert!(!card.name.is_empty());
}
}
#[test]
fn card_key_roundtrips_and_rejects_unknown() {
for key in CardKey::ALL {
assert_eq!(CardKey::from_key(key.key()), Some(key));
assert_eq!(key.card().key, key.key());
}
assert_eq!(CardKey::from_key("no-such-card"), None);
assert_eq!(CardKey::from_key("SKY"), None, "key 大小写敏感");
}
#[test]
fn every_card_scheme_meets_wcag_aa() {
for card in CARDS {
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
assert!(
card_scheme_is_readable(card, flavor),
"{} / {flavor:?} 未过门禁",
card.key
);
let ratio = card.primary_contrast(flavor);
assert!(
ratio >= wcag::AA_TEXT,
"{} / {flavor:?} primary 对 {} 只有 {ratio:.2}",
card.key,
card.name
);
// 上半区间:文档(`Card::primary_contrast`)向用户承诺的就是这两个区间,
// 只钉「≥ 4.5」等于没钉——比值掉到 5.0 也全绿,而文档会继续说 6.43~6.49。
// 边界取实测极值 ±0.01:浅色 6.4324~6.4906、深色 7.7024~7.7504。
let (low, high) = match flavor {
SchemeFlavor::Light => (6.43, 6.50),
SchemeFlavor::Dark => (7.70, 7.76),
};
assert!(
(low..=high).contains(&ratio),
"{} / {flavor:?} primary 对比度 {ratio:.4} 超出文档区间 {low}~{high}",
card.key
);
}
}
}
#[test]
fn cards_are_visually_distinct() {
// 色相间隔 ≥ 20°:八张卡的种子在两套方案里都不该撞成同一套配色。
let mut hues: Vec<f64> = CARDS
.iter()
.map(|card| crate::color::Hct::from_color(card.seed).hue)
.collect();
hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相"));
for pair in hues.windows(2) {
let gap = pair[1] - pair[0];
assert!(gap >= 20.0, "色相挤在一起:{pair:?}");
}
// 色相是环:排序后「最大 − 最小」不是真实间隔,**回绕那一侧**也要检查。
// 漏了这条的话,将来加一张 350° 的卡(与 rose 15.85° 实际只差 25.7°)会被算成
// 334.2° 而静默通过。
let wrap_gap = 360.0 - (hues[hues.len() - 1] - hues[0]);
assert!(
wrap_gap >= 20.0,
"色相环回绕处挤在一起:{:.2}° ↔ {:.2}°(间隔 {wrap_gap:.2}°)",
hues[hues.len() - 1],
hues[0]
);
for card in CARDS {
let light = card.scheme(SchemeFlavor::Light);
let dark = card.scheme(SchemeFlavor::Dark);
assert_ne!(
light.get(crate::color::Role::Primary),
dark.get(crate::color::Role::Primary)
);
}
}
}