From f4c48752c24ff258cc249dbd5cb93920cb9466d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:01:26 +0800 Subject: [PATCH] =?UTF-8?q?fix(design):=20Outcome::NotReached=20/=20?= =?UTF-8?q?=E8=89=B2=E7=9B=B8=E7=8E=AF=E5=9B=9E=E7=BB=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20/=20LowChroma=20=E5=8F=A3=E5=BE=84=E7=BB=9F=E4=B8=80=20/=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=8C=87=E5=90=91=EF=BC=88=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20r1=20p2-4=20+=20=E8=89=B2=E5=BD=A9=20r1=20p2-2/3/4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 去向 --- crates/design/src/palette/card.rs | 28 +++++++++++-- crates/design/src/palette/resolve/mod.rs | 4 +- .../design/src/palette/resolve/provenance.rs | 10 +++++ crates/design/src/palette/resolve/tests.rs | 42 ++++++++++++++++++- crates/design/src/palette/score.rs | 25 +++++++---- crates/design/src/palette/seed/mod.rs | 41 +++++++++++------- 6 files changed, 122 insertions(+), 28 deletions(-) diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs index 6d0a580..fb803d6 100644 --- a/crates/design/src/palette/card.rs +++ b/crates/design/src/palette/card.rs @@ -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); diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index f8c328e..ceddb60 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -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), diff --git a/crates/design/src/palette/resolve/provenance.rs b/crates/design/src/palette/resolve/provenance.rs index c7734f9..d3ea5ad 100644 --- a/crates/design/src/palette/resolve/provenance.rs +++ b/crates/design/src/palette/resolve/provenance.rs @@ -51,7 +51,17 @@ pub enum Outcome { /// 用了这一级。 Used, /// 用户没提供这一级(没设背景图 / 没开莫奈取色)。 + /// + /// 注意与 [`Outcome::NotReached`] 的区别:`Absent` 是「用户没有这一级的输入」, + /// 而「用户提供了、但更高级已经成功所以没轮到」是 `NotReached`。两者对 UI 的含义完全不同—— + /// 把后者说成「未选择」,用户明明选了卡却被告知「色卡:未选择」。 Absent, + /// 有输入、但因为更高级已经成功,**没轮到**这一级。 + /// + /// 只可能出现在低优先级那一侧:背景图成功 → 壁纸与色卡都是 `NotReached`。 + /// 色卡即使 `NotReached` 也可能参与生成(它的中性彩度决定表面色温),所以 UI 要看 + /// [`Provenance::card`] 而不是把 `NotReached` 当成「没用它」。 + NotReached, /// 有输入但不合格,已跳过。 Failed(Skip), } diff --git a/crates/design/src/palette/resolve/tests.rs b/crates/design/src/palette/resolve/tests.rs index ee34e38..36ef697 100644 --- a/crates/design/src/palette/resolve/tests.rs +++ b/crates/design/src/palette/resolve/tests.rs @@ -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(), + "没提供 / 没轮到都不是「失败」,不该进原因码" + ); +} diff --git a/crates/design/src/palette/score.rs b/crates/design/src/palette/score.rs index d0222f3..20ccff7 100644 --- a/crates/design/src/palette/score.rs +++ b/crates/design/src/palette/score.rs @@ -115,12 +115,22 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec { 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] diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index 3184184..7721a39 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -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 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 { 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` 的像素,整块没有有效像素