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:
1 parent
948a3685cd
commit
f4c48752c2
6 files changed
+122
-28
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
"没提供 / 没轮到都不是「失败」,不该进原因码"
|
||||
);
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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` 的像素,整块没有有效像素
|
||||
|
||||
Reference in new issue
Block a user