fix(design): Outcome::NotReached / 色相环回绕断言 / LowChroma 口径统一 / 文档指向(复审 r1 p2-4 + 色彩 r1 p2-2/3/4)

- Outcome 新增 NotReached:区分「用户没提供」与「更高级成功所以没轮到」,
  UI 不会再对着用户已选的卡说「色卡:未选择」;新增 not_reached_is_distinguishable_from_not_provided
- cards_are_visually_distinct 补色相环回绕间隔(原来只比排序相邻差,350° 的卡会静默通过)
- Failure::LowChroma 的 color 与 chroma 现在永远来自同一个簇(原来一条路径拿 A 簇的色值配 B 簇的彩度);
  两条路径的口径写进文档注释,并提醒 UI 别把数字写成「彩度 X < 门槛 8」
- card.rs:测试名指向修正(card_keys_are_stable_and_unique,测试就在本文件)、
  「对比度 8.4:1」的假示例换成实测区间(light 6.43~6.49 / dark 7.70~7.75)、
  中性彩度那段补上「请求值相同 ≠ 实际表面同冷度」的事实与 D43 去向
This commit is contained in:
AskaEth committed 2026-11-02 11:05:02 +08:00
1 parent 948a3685cd
commit f4c48752c2
6 files changed
+122 -28

No files matched your search

+25 -3
View File
@@ -9,13 +9,20 @@
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
//! [`CardKey::from_key`] 的回落口径);
//! - `palette/tests.rs` 里有一条 `card_keys_are_stable` 把 8 个 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)
//!
@@ -47,7 +54,12 @@ impl Card {
)
}
/// 这张卡的 `on-primary` 对 `primary` 的实测对比度(设置页显示「对比度 8.4:1」用)。
/// 这张卡的 `on-primary` 对 `primary` 的实测对比度。
///
/// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**:
/// 浅色档 6.43~6.49、深色档 7.70~7.75(逐张数值见 `every_card_scheme_meets_wcag_aa`
/// 的断言上限与 M87 报告表格)——两种模式都远高于 WCAG AA 的 4.5:1。
/// 别在文案里写死一个数:卡换种子后它会变,用本方法的返回值。
pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 {
let scheme = self.scheme(flavor);
scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary)
@@ -277,6 +289,16 @@ mod tests {
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);
+3 -1
View File
@@ -19,6 +19,7 @@
//! | 情形 | 结果 | `Provenance` 里留下 |
//! |---|---|---|
//! | 没有背景图 | 跳到 ② | `Outcome::Absent` |
//! | 更高级已经成功 | 不再看这一级 | `Outcome::NotReached`(**不等于**「用户没提供」) |
//! | 背景图解码失败 | 跳到 ② | `Outcome::Failed(Skip::Undecodable)` |
//! | 背景图太小 / 像素缓冲不对 / 全透明 | 跳到 ② | `… TooSmall / BadDimensions / NoOpaquePixels` |
//! | 背景图彩度不足(D40 的 `< 8`) | 跳到 ② | `… LowChroma { chroma }` |
@@ -97,7 +98,8 @@ pub fn resolve_source(
flavor: SchemeFlavor,
) -> (Scheme, Provenance) {
let card = preset.map(Card::from).unwrap_or(DEFAULT_CARD);
let mut outcomes = [Outcome::Absent; 3];
// 先假定「没轮到」,逐级评估时再改成 Absent / Failed / Used。
let mut outcomes = [Outcome::NotReached; 3];
for (level, input) in [
(Level::BackgroundImage, background),
@@ -51,7 +51,17 @@ pub enum Outcome {
/// 用了这一级。
Used,
/// 用户没提供这一级(没设背景图 / 没开莫奈取色)。
///
/// 注意与 [`Outcome::NotReached`] 的区别:`Absent` 是「用户没有这一级的输入」,
/// 而「用户提供了、但更高级已经成功所以没轮到」是 `NotReached`。两者对 UI 的含义完全不同——
/// 把后者说成「未选择」,用户明明选了卡却被告知「色卡:未选择」。
Absent,
/// 有输入、但因为更高级已经成功,**没轮到**这一级。
///
/// 只可能出现在低优先级那一侧:背景图成功 → 壁纸与色卡都是 `NotReached`。
/// 色卡即使 `NotReached` 也可能参与生成(它的中性彩度决定表面色温),所以 UI 要看
/// [`Provenance::card`] 而不是把 `NotReached` 当成「没用它」。
NotReached,
/// 有输入但不合格,已跳过。
Failed(Skip),
}
+40 -2
View File
@@ -24,7 +24,7 @@ fn level_one_wins_when_background_is_good() {
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(provenance.background(), Outcome::Used);
assert_eq!(provenance.preset(), Outcome::Absent);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert!(provenance.hint_key().is_none());
assert!(check(&scheme).is_clean());
assert!(provenance.seed.is_some());
@@ -122,7 +122,11 @@ fn background_beats_wallpaper_when_both_work() {
SchemeFlavor::Dark,
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(provenance.wallpaper(), Outcome::Absent, "② 根本没轮到");
assert_eq!(
provenance.wallpaper(),
Outcome::NotReached,
"② 有输入但根本没轮到——不等于「没提供」"
);
assert!(!provenance.did_fall_back());
}
@@ -202,3 +206,37 @@ fn a_five_percent_accent_drives_level_one() {
assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light));
assert!(crate::palette::wcag::check(&scheme).is_clean());
}
#[test]
fn not_reached_is_distinguishable_from_not_provided() {
// 复审(色彩 r1 p2-2):`Absent` 曾同时表示「用户没提供」和「更高级成功所以没轮到」,
// UI 照字面写「色卡:未选择」就会与事实相反(用户明明选了卡,而且它的中性彩度参与了生成)。
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
// 一级成功:壁纸与色卡都是「没轮到」,但 card 仍然是用户选的那张。
let (_, provenance) = resolve_source(
Some(ImageInput::pixels(&pixels, 4, 4)),
Some(ImageInput::Undecodable), // 壁纸**提供了**,只是没轮到
Some(CardKey::Forest),
SchemeFlavor::Light,
);
assert_eq!(provenance.level, Level::BackgroundImage);
assert_eq!(provenance.wallpaper(), Outcome::NotReached);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert_eq!(provenance.card, CardKey::Forest, "没轮到 ≠ 没选择");
// 真的没提供:背景图 `None` → Absent;壁纸成功 → 色卡 NotReached。
let (_, provenance) = resolve_source(
None,
Some(ImageInput::pixels(&pixels, 4, 4)),
Some(CardKey::Forest),
SchemeFlavor::Light,
);
assert_eq!(provenance.background(), Outcome::Absent);
assert_eq!(provenance.preset(), Outcome::NotReached);
assert_eq!(
provenance.reason_codes(),
Vec::<(&'static str, &'static str)>::new(),
"没提供 / 没轮到都不是「失败」,不该进原因码"
);
}
+18 -7
View File
@@ -115,12 +115,22 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec<Ranked> {
ranked
}
/// 候选里最高的 CAM16 彩度——被 `Score` 全部过滤时用它生成「这图太灰了」的提示。
pub(crate) fn max_chroma(clusters: &[Cluster]) -> f64 {
clusters
.iter()
.map(|c| Hct::from_color(c.color).chroma)
.fold(0.0_f64, f64::max)
/// 彩度最高的那个簇——被 `Score` 全部过滤时,它是「最接近候选门槛」的那一族。
///
/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对:
/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值,
/// 于是同一个 `LowChroma` 里的 `color` 与 `chroma` 可能不是一回事(复审 r1 p2-4.2)。
///
/// 同彩度时取像素多的那一个(结果确定,不受簇顺序影响)。
pub(crate) fn most_chromatic(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by(|a, b| {
let key = |cluster: &Cluster| (Hct::from_color(cluster.color).chroma, cluster.population);
let (ca, pa) = key(a);
let (cb, pb) = key(b);
ca.partial_cmp(&cb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| pa.cmp(&pb))
})
}
fn hue_bucket(hue: f64) -> usize {
@@ -157,7 +167,8 @@ mod tests {
cluster("#0A0A0A", 300),
];
assert!(rank(&clusters).is_empty());
assert!(max_chroma(&clusters) < CUTOFF_CHROMA);
let most = most_chromatic(&clusters).expect("灰图也有簇");
assert!(Hct::from_color(most.color).chroma < CUTOFF_CHROMA);
}
#[test]
+26 -15
View File
@@ -33,7 +33,7 @@
//! 与 [`super::score::CUTOFF_CHROMA`] = 5 分工不同:5 决定「能不能当候选」,8 决定
//! 「这个候选够不够格当种子」。落在这条缝里(5~8)的图会有一堆候选,最终仍然回落色卡。
use super::quantize::{self, Cluster};
use super::quantize;
use super::score;
use crate::color::{Color, Palettes, Scheme, SchemeFlavor};
@@ -103,10 +103,19 @@ pub enum Failure {
/// 全是透明/半透明像素,没有可用的不透明像素。
NoOpaquePixels,
/// 彩度不足:`chroma < MIN_SEED_CHROMA`,硬取色会凭空上色。
///
/// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径):
/// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` →
/// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」);
/// - 一个候选都没有(灰度图 / 只有零碎高彩度小面积)→ 报**彩度最高**的那一簇
/// (此时彩度必然 < 候选门槛 5)。
///
/// ⚠️ UI 别把这个数字写成「彩度 X < 门槛 8」:第二种情况(高彩度但面积不足 1%)里
/// X 可能 ≥8,真正的失败原因是「没有一族面积够大的颜色」。文案用「颜色太淡 / 太杂」即可。
LowChroma {
/// 最有希望的那个色(提示文案里当「最接近的颜色」)。
/// 最接近能用的那个色(提示文案里当「最接近的颜色」)。
color: Color,
/// 实测 CAM16 彩度。
/// 该色的实测 CAM16 彩度(与 `color` 成对)。
chroma: f64,
},
}
@@ -154,18 +163,24 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result<Seed, Fail
chroma: best.chroma,
share: best.excited,
}),
// 有候选但都不够鲜艳:带着「最鲜艳的候选」和实测彩度回去,让 UI 能说清原因。
// 有候选但都不够鲜艳:报 `Score` 榜首的色值与彩度(`Score` 已经按人气 × 彩度排过序,
// 它就是「本次能拿到的最好候选」)。
Some(best) => Err(Failure::LowChroma {
color: best.cluster.color,
chroma: best.chroma,
}),
// 一个候选都没有(灰度图):把最鲜艳的簇的彩度报回去(通常是 0~4)。
None => Err(Failure::LowChroma {
color: most_populated(&clusters)
.map(|cluster| cluster.color)
.unwrap_or(Color::rgb(0x80, 0x80, 0x80)),
chroma: score::max_chroma(&clusters),
}),
// 一个候选都没有(灰度图):报**彩度最高**的那一族,色值与彩度成对(必然 < 候选门槛 5)。
None => {
let closest = score::most_chromatic(&clusters);
Err(Failure::LowChroma {
color: closest
.map(|cluster| cluster.color)
.unwrap_or(Color::rgb(0x80, 0x80, 0x80)),
chroma: closest
.map(|cluster| crate::color::Hct::from_color(cluster.color).chroma)
.unwrap_or(0.0),
})
}
}
}
@@ -176,10 +191,6 @@ pub fn seed_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<Seed> {
analyze_rgba(pixels, width, height).ok()
}
fn most_populated(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by_key(|cluster| cluster.population)
}
/// 块平均降采样:块边长按「不超过 [`MAX_SAMPLES`] 个采样点」反推,小图不降采样(块边长 1)。
///
/// **先按 alpha 过滤、再求平均**:块内只累加 `a >= ALPHA_THRESHOLD` 的像素,整块没有有效像素