- 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 那条并不「能用」)
326 lines
13 KiB
Rust
326 lines
13 KiB
Rust
//! 内置色卡(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)
|
||
);
|
||
}
|
||
}
|
||
}
|