feat(design): 配色来源纯数学层(Monet 式量化取色 + 色卡 + 三级回落 + 对比度收敛)

D40 分层:design 保持零第三方依赖,只吃已解码的 RGBA 像素。
- palette/quantize:5bit 直方图 + CIELAB k-means(QuantizerCelebi/Score 的形状)
- palette/score:官方 Score 口径(CUTOFF_CHROMA 5 / TARGET 48 / 权重 0.7+0.3)
- palette/seed:seed_from_rgba + D40 彩度门槛 8,灰白图回落而非凭空上色
- palette/card:8 张内置色卡,key 为持久化键
- palette/wcag:26 对文字对比度门禁 + 沿 tone 轴收敛并记录修正
- palette/resolve:纯函数三级来源编排 + Provenance(原因码/提示键)
This commit is contained in:
AskaEth committed 2026-11-02 11:04:10 +08:00
1 parent c1fc5b8086
commit 18327fb038
11 files changed
+2098 -2

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
//! 内置色卡(D38 第三级):用户在「关闭莫奈取色」时手动选的那几张。
//!
//! ## `key` 是持久化键,命名就此定死
//!
//! 每张卡的 [`Card::key`] 会被写进用户设置(settings 里存字符串),**改了就等于把用户的选择
//! 抹成出厂默认**。所以:
//!
//! - 只用小写 ASCII + 单段单词,不带动词、不带颜色数值(`sky`,不是 `blue_3c8dbc`);
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
//! [`CardKey::from_key`] 的回落口径);
//! - `palette/tests.rs` 里有一条 `card_keys_are_stable` 把 8 个 key 逐字钉住——谁改谁红。
//!
//! ## 每张卡自带中性彩度
//!
//! 八张卡都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)而不是官方 6:
//! 换卡只该换**色相**,不该让窗口底色的「冷度」跟着跳——那种跳变会让用户在设置页里
//! 逐张试色时觉得「每张卡都像换了个应用」。要偏离这条得单独论证。
//!
//! ## 卡与取色的关系(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` 的实测对比度(设置页显示「对比度 8.4:1」用)。
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
);
assert!(
card.primary_contrast(flavor) >= wcag::AA_TEXT,
"{} / {flavor:?} primary 对 {} 只有 {:.2}",
card.key,
card.name,
card.primary_contrast(flavor)
);
}
}
}
#[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:?}");
}
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)
);
}
}
}