diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index 73af9ad..ec027ef 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -120,7 +120,9 @@ pub(crate) mod space; mod viewing_conditions; pub use hct::Hct; -pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED}; +pub use palettes::{ + PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED, NEUTRAL_HUE_DEG, +}; pub use roles::Role; pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA}; pub use state::StateLayer; diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 08da0aa..e2a492e 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -58,9 +58,33 @@ pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// /// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。 /// 内置多套色卡落地时每张卡各带自己的中性彩度(`palette::Card::neutral_chroma`),八张卡 -/// 当前都取本值——换卡只换色相,不让窗口底色的冷度跟着跳。 +/// 当前都取本值;再加上中性**色相**固定为 [`NEUTRAL_HUE_DEG`](D43),八张卡的表面色因此 +/// 逐值相同——切卡只换强调色。 pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0; +/// 中性调色板的**固定色相**(度)——内置色卡共用它,动态取色不用它(D43)。 +/// +/// ## 出处:这就是「天蓝」档当前用的那个中性色相 +/// +/// 值等于 [`DEFAULT_SEED`](`#3C8DBC`)的 CAM16 色相 **239.51420577630955°**。天蓝档的中性 +/// 调色板本来就是这个色相(`from_seed_with_neutral_chroma` 让中性色跟着种子走),所以它是 +/// **沿用**,不是「另选一个冷调」:`Card::scheme` 换用它之后天蓝卡的 49 个角色必须逐值不变 +/// (`tests/card_neutral.rs` 钉着)。 +/// +/// ## 为什么要有这个常量(D43) +/// +/// 八张卡原先走 TonalSpot 的「中性色也跟种子染色」,切卡连窗口底色一起换调子:浅色档 +/// `surface` 的实测 CAM16 彩度从 **1.29(rose)跨到 12.03(teal)**——tone 98 处暖相色域装 +/// 不下彩度 12,「请求值 12」在暖相上只能兑现 1.29,9 倍跳变让用户觉得「每张卡都像换了个 +/// 应用」。D43 的处置:**色卡固定中性色相、只换强调色**;动态取色(背景图 / 壁纸)保留逐色 +/// 染色,那才是用户要的 Monet 体验。所以本常量只服务色卡路径,**不要**传给 `Seed::scheme`。 +/// +/// ⚠️ 换 [`DEFAULT_SEED`] 时本常量会「过期」:`neutral_hue_is_the_default_seed_hue` 用 +/// 1e-9° 容差钉住两者的绑定(**不用 `to_bits` 位相等**——色相来自跨平台有末位差的 `atan2`), +/// 改了种子它就红——那时要想清楚是「色卡跟着新默认色走」(照抄新色相)还是「色卡保持这条 +/// 冷调」(改成显式例外),别静默漂移。 +pub const NEUTRAL_HUE_DEG: f64 = 239.514_205_776_309_55; + /// 调色板槽位(M3 的六条 tonal palette)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaletteSlot { @@ -70,9 +94,10 @@ pub enum PaletteSlot { Secondary, /// 第三调色板(种子色相 +60°,彩度 24)。 Tertiary, - /// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。 + /// 中性调色板(色相跟种子;**色卡路径**例外——固定为 [`NEUTRAL_HUE_DEG`],D43。 + /// 官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。 Neutral, - /// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。 + /// 中性变体调色板(色相同 neutral,彩度 = neutral + 2;官方 8,产品默认 14)。 NeutralVariant, /// 错误调色板(固定红相 25°,彩度 84)。 Error, @@ -171,14 +196,44 @@ impl Palettes { } } + /// **内置色卡的派生入口(D43)**:强调三族跟 `accent` 的色相走,中性族(含 + /// neutral-variant)**固定在 `neutral_hue_deg`**。 + /// + /// 与 [`Palettes::from_seed_with_neutral_chroma`](TonalSpot,中性色跟着种子染色)的唯一 + /// 差别是中性族色相;与 [`Palettes::with_accent`] 的差别是中性族可以整体搬到一个不属于 + /// 基线的色相上(`with_accent` 只能沿用 `self` 的中性族)。 + /// + /// 产品用法:色卡路径传 [`NEUTRAL_HUE_DEG`](= 出厂默认种子的中性色相),于是八张卡的 + /// 表面色完全相同,只有强调色在换。**取色路径不要用本函数**——D43 明确保留「按图像种子 + /// 逐色染色」的 Monet 体验(见 [`crate::palette::resolve`])。 + pub fn accent_on_fixed_neutral( + accent: Color, + neutral_hue_deg: f64, + neutral_chroma: f64, + ) -> Self { + let hue = super::Hct::from_color(accent).hue; + Self::tonal_spot_with_neutral(hue, neutral_hue_deg, neutral_chroma) + } + /// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。 fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self { + Self::tonal_spot_with_neutral(hue, hue, neutral_chroma) + } + + /// 同一套 Tonal Spot,但**中性族(neutral / neutral-variant)的色相可以与种子不同**。 + /// + /// 这是 D43「内置色卡固定中性色相」的实现核心:强调三族跟 `accent_hue`,表面族跟 + /// `neutral_hue`。其余彩度常数、error 族、tone 表一行未动。 + fn tonal_spot_with_neutral(accent_hue: f64, neutral_hue: f64, neutral_chroma: f64) -> Self { Self { - primary: TonalPalette::new(hue, PRIMARY_CHROMA), - secondary: TonalPalette::new(hue, SECONDARY_CHROMA), - tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA), - neutral: TonalPalette::new(hue, neutral_chroma), - neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET), + primary: TonalPalette::new(accent_hue, PRIMARY_CHROMA), + secondary: TonalPalette::new(accent_hue, SECONDARY_CHROMA), + tertiary: TonalPalette::new(accent_hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA), + neutral: TonalPalette::new(neutral_hue, neutral_chroma), + neutral_variant: TonalPalette::new( + neutral_hue, + neutral_chroma + NEUTRAL_VARIANT_OFFSET, + ), error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA), } } @@ -300,4 +355,46 @@ mod tests { assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg()); assert_eq!(swapped.primary.chroma(), 36.0); } + + #[test] + fn neutral_hue_is_the_default_seed_hue() { + // D43 的绑定器。用 1e-9° 容差而非 `to_bits()` 位相等:色相来自 `atan2()`,glibc 与 + // UCRT 理论可差 1 ulp(~1e-14°),位相等会在另一个 OS 的 CI 上红;这条要抓的是「换了 + // `DEFAULT_SEED` 却没复核常量」(偏几十度)。「逐值不变」的位相等钉子在同平台的 + // `card_neutral.rs::sky_card_is_bit_identical_to_the_product_baseline`(两个算出的值 + // 互比,不涉及跨平台字面量)。 + let seed_hue = super::super::Hct::from_color(DEFAULT_SEED).hue; + assert!( + (NEUTRAL_HUE_DEG - seed_hue).abs() < 1.0e-9, + "NEUTRAL_HUE_DEG = {NEUTRAL_HUE_DEG:?} 与 DEFAULT_SEED 的色相 {seed_hue:?} \ + 相差超过 1e-9°(换默认种子时:要么照抄新色相,要么把这里改成显式例外)" + ); + } + + #[test] + fn accent_on_fixed_neutral_only_moves_the_neutral_hue() { + let official = Palettes::official_baseline(); + // 中性色相 = 基线色相时,与官方的 from_seed 完全等价(不放宽算法)。 + let same = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, NEUTRAL_HUE_DEG, 12.0); + let per_seed = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, 12.0); + assert_eq!( + same, per_seed, + "中性色相取种子色相时必须与 TonalSpot 逐项相同" + ); + + // 换一个中性色相:只有中性两族的色相动,彩度与强调族一律不动。 + let moved = Palettes::accent_on_fixed_neutral(DEFAULT_SEED, 120.0, 12.0); + assert_eq!(moved.primary, per_seed.primary); + assert_eq!(moved.secondary, per_seed.secondary); + assert_eq!(moved.tertiary, per_seed.tertiary); + assert_eq!(moved.error, per_seed.error); + assert_eq!(moved.neutral.chroma(), per_seed.neutral.chroma()); + assert_eq!( + moved.neutral_variant.chroma(), + per_seed.neutral_variant.chroma() + ); + assert_eq!(moved.neutral.hue_deg(), 120.0); + assert_eq!(moved.neutral_variant.hue_deg(), 120.0); + assert_eq!(moved.primary.chroma(), official.primary.chroma()); + } } diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs index 8781ca7..30104aa 100644 --- a/crates/design/src/palette/card.rs +++ b/crates/design/src/palette/card.rs @@ -12,25 +12,39 @@ //! - `card.rs` 的 `card_keys_are_stable_and_unique` 把 8 个 key 逐字钉住——谁改谁红 //! (测试就在本文件末尾的 `tests` 模块里,没有独立的 `palette/tests.rs`)。 //! -//! ## 每张卡自带中性彩度 +//! ## 换卡只换强调色:中性色相固定(D43) //! -//! 八张卡都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)而不是官方 6: -//! 换卡只该换**色相**,不该让窗口底色的「冷度」跟着跳——那种跳变会让用户在设置页里 -//! 逐张试色时觉得「每张卡都像换了个应用」。 +//! 八张卡共用**一个**中性色相 [`NEUTRAL_HUE_DEG`](= 出厂默认种子 `#3C8DBC` 的 CAM16 色相 +//! 239.51°,出处见该常量的文档),中性彩度都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)。 +//! 于是这份契约是逐值可验的: //! -//! ⚠️ **这只是「请求值」相同**:中性调色板的色相目前仍 = 种子色相(`Palettes::from_seed_*`), -//! 所以八张卡的实际表面色是逐卡染色的(teal 薄荷白 / forest、olive 偏绿 / amber、rose 暖白), -//! 浅色档实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处暖相色域装不下 -//! chroma 12。tower 已定案(D43)改成「色卡固定冷调中性色相、只换强调色」,**不在 M87 做**; -//! 在 D43 落地前,别把上面那段当成已经成立的行为。要偏离这条得单独论证。 +//! - **表面与描边**(`surface` / `surface-container-*` / `background` / `inverse-surface` / +//! `outline` / `outline-variant` / `surface-variant`)**八张卡逐值相同**——切卡不动窗口底色; +//! - **只有强调族**(`primary` / `secondary` / `tertiary` 及其 container、`on-*`、`inverse-primary`、 +//! `*-fixed*`、`surface-tint`)跟着卡走; +//! - **天蓝卡(出厂默认)与 [`crate::color::Scheme::baseline`] 逐值相同**:固定的中性色相本来 +//! 就等于天蓝档的中性色相,所以这是「沿用」而非「另选一个冷调」(M70 定案的不动点)。 +//! +//! 为什么不再让中性色跟着种子染色:TonalSpot 下八张卡的中性色相 = 种子色相,浅色档 `surface` +//! 实测 CAM16 彩度从 **1.29(rose)跨到 12.03(teal)**——tone 98 处暖相色域装不下彩度 12, +//! 同一个「请求值」在暖相上只兑现 1.29。9 倍的底色跳变让用户在设置页逐张试色时觉得「每张卡 +//! 都像换了个应用」(M87 实测,D43 据此定案)。现在八张卡的 `surface` 是同一个色值, +//! 那 9 倍差由 `tests/card_neutral.rs` 断言为 1 倍。 +//! +//! ⚠️ **只对色卡路径成立**:动态取色(启动器背景图 / 桌面壁纸)仍然让中性色跟着图像种子染色, +//! 那是用户要的 Monet 体验(D38)。见 [`super::resolve`] 的 `try_level`:两条路径刻意不同, +//! 别「统一处理」(`tests/card_neutral.rs` 有反证用例)。 //! //! ## 卡与取色的关系(D38) //! -//! 取色成功时,**当前选中的卡仍然参与生成**:它的中性彩度决定取色方案的表面色温 -//! (见 [`super::resolve`])。所以卡片不是「只在第三级才用到」的备胎。 +//! 取色成功时,**当前选中的卡仍然参与生成**,但只贡献中性**彩度**(表面色的浓淡); +//! 色相由图像种子决定。所以卡片不是「只在第三级才用到」的备胎,它的固定中性色相 +//! **不会**出现在取色方案里。 use super::wcag; -use crate::color::{Color, Palettes, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA}; +use crate::color::{ + Color, Palettes, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, NEUTRAL_HUE_DEG, +}; /// 一张内置色卡。 #[derive(Debug, Clone, Copy, PartialEq)] @@ -41,15 +55,16 @@ pub struct Card { pub name: &'static str, /// 种子色(只贡献色相;Tonal Spot 会把彩度收到 36)。 pub seed: Color, - /// 中性调色板彩度(表面色的「冷度」)。 + /// 中性调色板彩度(表面色的浓淡;色相固定为 [`NEUTRAL_HUE_DEG`],见模块文档的 D43 契约)。 pub neutral_chroma: f64, } impl Card { - /// 用这张卡生成一套方案(与取色路径同一条生成器,只差种子来源)。 + /// 用这张卡生成一套方案(与取色路径同一条生成器,只差两处入参:中性色相固定为 + /// [`NEUTRAL_HUE_DEG`]、强调色来自卡而不是图像)。 pub fn scheme(self, flavor: SchemeFlavor) -> Scheme { Scheme::from_palettes( - &Palettes::from_seed_with_neutral_chroma(self.seed, self.neutral_chroma), + &Palettes::accent_on_fixed_neutral(self.seed, NEUTRAL_HUE_DEG, self.neutral_chroma), flavor, ) } diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index ceddb60..917b6da 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -178,7 +178,11 @@ fn try_level(input: Option>, card: Card, flavor: SchemeFlavor) -> Err(failure) => return Attempt::Failed(Skip::from(failure)), }; - // 取色方案的表面色温沿用当前色卡的中性彩度:换壁纸不该让窗口底色「冷热乱跳」。 + // 取色方案的表面色**跟着图像种子染色**,只借当前色卡的中性彩度定浓淡(D43): + // 这是与色卡路径刻意不同的一处——色卡固定中性色相、只换强调色,取色保留整幅染色 + // (那是用户要的 Monet 体验)。别把两条路径「统一处理」:这条接线的守护测试是 + // `tests/card_neutral.rs::resolved_image_scheme_keeps_tinting_from_the_seed`(走的就是 + // 本函数所在的 `resolve_source`;只测 `Seed::scheme` 的那条覆盖不到这里,复审 r1 p1-1)。 let (scheme, adjustments) = converge(seed.scheme(flavor, card.neutral_chroma)); let report = check(&scheme); if !report.is_clean() { diff --git a/crates/design/tests/card_neutral.rs b/crates/design/tests/card_neutral.rs new file mode 100644 index 0000000..fe858b7 --- /dev/null +++ b/crates/design/tests/card_neutral.rs @@ -0,0 +1,400 @@ +//! D43:内置色卡的**固定中性色相**——切卡只换强调色,窗口底色的冷暖不跟着换。 +//! +//! ## 这条测试要钉住的五件事 +//! +//! 1. **天蓝卡(出厂默认)逐值不变**:固定的中性色相等于天蓝档的中性色相,所以天蓝卡必须与 +//! [`Scheme::baseline`] 的 49 个角色**逐个相等**(M70 定案的不动点,p1 级硬约束)。 +//! 2. **八张卡的表面色完全相同**:`surface` 的 CAM16 色相一致(±1°)、实测彩度落在窄带内 +//! (±1.5),且改前那条「9 倍差」被同时量出来当反证(见 `the_old_per_card_tinting...`)。 +//! 3. **动态路径不许被顺手改成固定中性**:这条必须走 **`resolve_source` 公开入口**测—— +//! `Card::scheme` 那一层根本没有「取色」这回事,测它等于测空气(复审 r1 p1-1 就是这个: +//! 把 `resolve::try_level` 的取色分支改成固定中性,8 条测试全绿)。见 +//! `resolved_image_scheme_keeps_tinting_from_the_seed`。 +//! 4. **WCAG 不因换中性色相而掉档**:8 张卡 × 浅/深两套 × 26 对文字/图标对全部 ≥ 4.5:1。 +//! 5. 想让「八卡同底色」的带宽松掉,`the_old_per_card_tinting...` 会红(它量的是改前路径)。 +//! +//! ## 为什么容差是这个带宽(不是随手取整) +//! +//! - **色相 ±1°**:八张卡现在共用一个中性调色板,`surface` 是**同一个 hex**,跨卡差实测 0.00°。 +//! 容差留 1° 是为了挡住「将来某张卡偷偷换回 TonalSpot」——那种回退的最小偏离是 **43°** +//! (dusk 280.31° vs 236.86°),1° 的带宽足以把 0 与 43° 分开,同时不误报 8 bit 量化。 +//! - **彩度 ±1.5(CAM16,单位与请求值 12 同刻度)**:tone 98 的 8 bit 量化会把「请求 12」 +//! 读回成 6.41(浅)/11.70(深),这层压缩是**同一个 hex 的常数偏移**,跨卡比较时抵消; +//! 真正的危险是暖相回退——rose 1.29、amber 1.89,比基线低 4.5~5.1,远超 1.5 的带宽; +//! 紫相(lavender 5.87)只低 0.54,靠上面的色相断言抓(298° 偏 61°)。带宽取 1.5 = 请求 +//! 彩度的 1/8,比「改前最小的一张卡偏离基线」还小,所以「9 倍差回来了」一定红。 +//! +//! 改前/改后的 `surface` 实测表在子模块 [`dump`](那里同时是验收图的数据源)。 + +// 子模块放在 `tests/card_neutral/` 目录里(cargo 只把 `tests/*.rs` 顶层文件当一个 target, +// 目录里的文件不会被当成独立 target;crate 根的子模块默认在 `tests/` 下找,所以显式 `#[path]`)。 +#[path = "card_neutral/common.rs"] +mod common; +#[path = "card_neutral/dump.rs"] +mod dump; + +use common::{before_d43, flavors, hue_gap}; +use unlucky_design::color::{ + Hct, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, NEUTRAL_HUE_DEG, +}; +use unlucky_design::palette::card::card_scheme_is_readable; +use unlucky_design::palette::{ + resolve_source, Card, CardKey, ImageInput, Level, CARDS, DEFAULT_CARD, TEXT_PAIRS, +}; + +/// 天蓝卡就是产品默认色:固定的中性色相**沿用**它,所以 49 个角色必须一位不差。 +/// +/// 这条是 M88 的 p1 级硬约束。它红了只说明一件事:把「沿用天蓝的中性」实现成了「另选一个中性」。 +#[test] +fn sky_card_is_bit_identical_to_the_product_baseline() { + for flavor in flavors() { + let baseline = Scheme::baseline(flavor); + let sky = DEFAULT_CARD.scheme(flavor); + for (role, got) in sky.iter() { + assert_eq!( + got, + baseline.get(role), + "天蓝卡(出厂默认)的 {} / {} 变了:{} → {}", + role.name(), + flavor.name(), + baseline.get(role).to_hex(), + got.to_hex() + ); + } + } +} + +/// 八张卡的 `surface` 共用同一个中性调色板:色相一致(±1°)、彩度落在窄带(±1.5)。 +#[test] +fn preset_cards_share_one_neutral_hue_and_chroma() { + for flavor in flavors() { + let hcts: Vec<(&str, Hct)> = CARDS + .iter() + .map(|card| { + ( + card.key, + Hct::from_color(card.scheme(flavor).get(Role::Surface)), + ) + }) + .collect(); + let reference = hcts[0].1; + for (key, hct) in &hcts { + assert!( + hue_gap(hct.hue, reference.hue) <= 1.0, + "{key} / {} 的 surface 色相 {:.2}° 偏离基线 {:.2}° 超过 ±1°", + flavor.name(), + hct.hue, + reference.hue + ); + assert!( + (hct.chroma - reference.chroma).abs() <= 1.5, + "{key} / {} 的 surface 实测彩度 {:.2} 偏离基线 {:.2} 超过 ±1.5", + flavor.name(), + hct.chroma, + reference.chroma + ); + } + // 更强的口径:八张卡的 surface 是**同一个色值**(不只是「相近」)。 + let surfaces: Vec = CARDS + .iter() + .map(|card| card.scheme(flavor).get(Role::Surface).to_hex()) + .collect(); + assert!( + surfaces.iter().all(|hex| hex == &surfaces[0]), + "{} 模式下八张卡的 surface 不再逐值相同:{surfaces:?}", + flavor.name() + ); + } +} + +/// 反证:上面那条断言**量得出**问题。改前的路径浅色档彩度差 9 倍,改成固定中性后只剩 1 倍。 +/// +/// 没有这条的话,「±1.5 的带宽」只是一句自我声明:谁把断言写松(比如改成 ±20), +/// `preset_cards_share_one_neutral_hue_and_chroma` 照样绿,而窗口又开始逐卡染色。 +/// +/// ⚠️ **9 倍差只出现在浅色档**:tone 98(近白)的暖相色域装不下彩度 12,rose 只兑现到 1.29; +/// 深色档的 tone 6 反而每张卡都装得下(11.63~12.45,只差 1.07 倍)。深色档的问题不是彩度, +/// 而是**色相**(14.66°~300.57°,整整一个色环)——它在深底上表现为「有的卡偏紫、有的偏绿」。 +/// 所以两个模式的反证分开断言,别用一条 `> 8` 覆盖两个模式(那样深色档会红,且说不清为什么)。 +#[test] +fn the_old_per_card_tinting_really_was_a_ninefold_gap() { + let surface_hct = |scheme: &dyn Fn(Card) -> Scheme| { + CARDS + .iter() + .map(|card| Hct::from_color(scheme(*card).get(Role::Surface))) + .collect::>() + }; + let chroma_ratio = |hcts: &[Hct]| { + let max = hcts.iter().map(|h| h.chroma).fold(f64::MIN, f64::max); + let min = hcts.iter().map(|h| h.chroma).fold(f64::MAX, f64::min); + max / min + }; + let hue_span = |hcts: &[Hct]| { + // 色相是环:这里只关心「跨了多少」,用最大凸包跨度(简单排序后首尾差)就够粗判。 + let mut hues: Vec = hcts.iter().map(|h| h.hue).collect(); + hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相")); + hues[hues.len() - 1] - hues[0] + }; + + // 浅色档:彩度 9 倍差(改前)→ 1 倍(改后)。 + let old_light = surface_hct(&|card| before_d43(card, SchemeFlavor::Light)); + assert!( + chroma_ratio(&old_light) > 8.0, + "改前(浅色)的 surface 彩度差应当能量出「9 倍级」跳变,实测 {:.2} 倍——\ + 这条反证已经失效,上面的 ±1.5 带宽就不可信了", + chroma_ratio(&old_light) + ); + let new_light = surface_hct(&|card| card.scheme(SchemeFlavor::Light)); + assert!( + chroma_ratio(&new_light) < 1.5, + "固定中性后(浅色)八张卡的 surface 彩度差还有 {:.2} 倍", + chroma_ratio(&new_light) + ); + + // 深色档:彩度本来就一致,问题是色相跨了整个环 → 改后色相跨度收敛到 0。 + let old_dark = surface_hct(&|card| before_d43(card, SchemeFlavor::Dark)); + assert!( + hue_span(&old_dark) > 180.0, + "改前(深色)的 surface 色相应当横跨大半个色环,实测 {:.2}°", + hue_span(&old_dark) + ); + assert!( + chroma_ratio(&old_dark) < 1.5, + "改前(深色)的彩度本来就一致(tone 6 装得下彩度 12),实测 {:.2} 倍", + chroma_ratio(&old_dark) + ); + let new_dark = surface_hct(&|card| card.scheme(SchemeFlavor::Dark)); + assert!( + hue_span(&new_dark) < 1.0, + "改后(深色)色相跨度 {:.2}°", + hue_span(&new_dark) + ); + assert!(chroma_ratio(&new_dark) < 1.5); +} + +/// 换卡只换强调族:不随强调色变的角色必须逐值相同,强调族必须真的换。 +/// +/// 「哪些角色属于强调族」不靠手抄清单,而是**实测**:拿另一个强调色再生成一套方案,值会变的 +/// 那些角色就是强调族(primary/secondary/tertiary 全族 + `surface-tint` + `inverse-primary` + +/// 12 个 `*-fixed*` = 26 个),不随强调色变的 23 个(中性表面、描边、error、shadow/scrim) +/// 则必须留在基线——这样将来往 tone 表里加角色也不会漏掉分类。 +/// +/// ⚠️ 浅色档只有 **23** 个会变:`on-primary` / `on-secondary` / `on-tertiary` 的 tone 是 100, +/// 任何色相都解析成纯白,所以强调色换了它们也不变(深色档的 tone 是 20,会变)。 +#[test] +fn cards_only_change_the_accent_families() { + for flavor in flavors() { + let baseline = Scheme::baseline(flavor); + let recolored = Scheme::from_accent(flavor, CardKey::Teal.card().seed); + let (mut accent_roles, mut neutral_roles) = (Vec::new(), Vec::new()); + for role in Role::ALL { + if recolored.get(role) == baseline.get(role) { + neutral_roles.push(role); + } else { + accent_roles.push(role); + } + } + assert_eq!( + (accent_roles.len(), neutral_roles.len()), + if flavor.is_dark() { (26, 23) } else { (23, 26) }, + "强调族 / 中性族的划分变了({} 模式下换强调色会动 {} 个)", + flavor.name(), + accent_roles.len() + ); + + let sky = DEFAULT_CARD.scheme(flavor); + for card in CARDS { + let scheme = card.scheme(flavor); + for role in &neutral_roles { + assert_eq!( + scheme.get(*role), + sky.get(*role), + "{} / {} 的 {}(不随强调色变的角色)不该跟着卡变", + card.key, + flavor.name(), + role.name() + ); + } + if card.key != DEFAULT_CARD.key { + assert_ne!( + scheme.get(Role::Primary), + sky.get(Role::Primary), + "{} / {} 的主色与天蓝卡相同:卡没生效", + card.key, + flavor.name() + ); + } + } + } +} + +/// 卡方案 = 用该卡种子做「自定义强调色」:中性表面留在基线,强调族与官方 `from_seed` 对得上。 +/// +/// 这条把 D43 接到已有的 `tests/official_accent.rs`(192 项官方对账)上:色卡的强调族不是 +/// 另一套算法,就是那条已经对过账的路径。 +#[test] +fn card_scheme_matches_from_accent_when_chroma_is_the_default() { + for card in CARDS { + if card.neutral_chroma != DEFAULT_NEUTRAL_CHROMA { + continue; // 将来的卡可以带别的中性彩度;那时它的表面色与 baseline 不同是有意的 + } + for flavor in flavors() { + assert_eq!( + card.scheme(flavor), + Scheme::from_accent(flavor, card.seed), + "{} / {} 的色卡方案与 from_accent 不一致", + card.key, + flavor.name() + ); + } + } +} + +/// **生成器层**:同一张图,两张不同色卡下强调色相同、底色由图像 seed 决定。 +/// +/// ⚠️ 只测 `Seed::scheme` 这一层,**不覆盖接线**:取色分支在实际入口 +/// `palette::resolve::try_level` 里,接线由 `resolved_image_scheme_keeps_tinting_from_the_seed` +/// 守(复审 r1 p1-1 的病根就是这条用例看着像守护、其实测不到接线)。 +#[test] +fn image_scheme_ignores_the_preset_neutral_hue() { + // 4×4 纯 `#B23A48`(rose 卡的种子色)——色相远离固定的 239.5°,好坏都能分辨。 + let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16); + let seed = unlucky_design::palette::seed_from_rgba(&pixels, 4, 4).expect("纯色图有种子"); + + let cards = [CardKey::Sky.card(), CardKey::Teal.card()]; + let schemes: Vec = cards + .iter() + .map(|card| seed.scheme(SchemeFlavor::Light, card.neutral_chroma)) + .collect(); + + assert_eq!( + schemes[0].get(Role::Primary), + schemes[1].get(Role::Primary), + "取色方案的主色只由图像种子决定,不该跟着当前色卡变" + ); + assert_eq!( + schemes[0].get(Role::Surface), + schemes[1].get(Role::Surface), + "取色方案的底色由图像种子决定(当前八张卡的中性彩度相同,所以两套方案的表面色相同)" + ); + let surface_hue = Hct::from_color(schemes[0].get(Role::Surface)).hue; + // 精确口径:中性**调色板**的色相必须来自图像种子(不是量化后的表面色——tone 98 的 + // 8 bit 量化会让实测色相漂 2.7°~7°,见模块文档;拿它做等式断言只会引入假红)。 + for card in cards { + let neutral_hue = seed.palettes(card.neutral_chroma).neutral.hue_deg(); + assert!( + (neutral_hue - seed.hue).abs() < 1.0e-9, + "取色路径的中性色相 {neutral_hue:.2}° 没有跟着图像种子 {:.2}° 走", + seed.hue + ); + assert!( + hue_gap(neutral_hue, NEUTRAL_HUE_DEG) > 100.0, + "取色路径的中性色相被拉到了色卡的固定中性色相——动态路径的逐色染色被弄丢了(D43 保留它)" + ); + } + // 观感层:实测的表面色相明显靠近图像种子,而不是靠色卡的冷调中性色相。 + assert!( + hue_gap(surface_hue, seed.hue) < hue_gap(surface_hue, NEUTRAL_HUE_DEG), + "取色方案的 surface 色相 {surface_hue:.2}° 更接近色卡中性色相而不是图像种子 {:.2}°", + seed.hue + ); + for card in cards { + assert_ne!( + schemes[0].get(Role::Surface), + card.scheme(SchemeFlavor::Light).get(Role::Surface), + "取色方案的表面色与色卡路径相同,说明取色路径没在按图染色" + ); + } +} + +/// **接线的**守护测试(复审 r1 p1-1):走产品公开入口 [`resolve_source`],不是 `Card::scheme`。 +/// +/// 上一版只有生成器层那条用例:实测把 `palette::resolve::try_level` 的取色分支改成固定中性 +/// 时**8 条测试全绿**,而同一张 `#B23A48` 图的 `surface` 已从 `#FFF8F7`(暖白,跟着图走) +/// 退化成 `#F6FAFF`(色卡冷白)。断言测 A 层、接线在 B 层——这条把整条链跑通。 +#[test] +fn resolved_image_scheme_keeps_tinting_from_the_seed() { + // 4×4 纯 `#B23A48`:种子色相 15.5°(底色 8 bit 量化后读回 22.09°),离 239.51° 很远。 + let pixels = [0xB2u8, 0x3A, 0x48, 0xFF].repeat(16); + let flavor = SchemeFlavor::Light; + let mut resolved: Vec<(CardKey, Scheme)> = Vec::new(); + for key in [CardKey::Sky, CardKey::Teal] { + let (scheme, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 4, 4)), + None, + Some(key), + flavor, + ); + assert_eq!( + provenance.level, + Level::BackgroundImage, + "{key:?}:背景图这一级没被用上(回落链本身变了)" + ); + resolved.push((key, scheme)); + } + + let surface = resolved[0].1.get(Role::Surface); + // ① 底色在按图染色:必须是暖白,而不是色卡那条冷调中性。这一格就是「变异检测器」。 + assert_eq!( + surface.to_hex(), + "#FFF8F7", + "取色方案的 surface 被拉回了色卡中性色相" + ); + let surface_hue = Hct::from_color(surface).hue; + assert!( + hue_gap(surface_hue, 22.0) < 5.0, + "surface 色相 {surface_hue:.2}° 没跟着图走" + ); + assert!( + hue_gap(surface_hue, NEUTRAL_HUE_DEG) > 100.0, + "surface 色相 {surface_hue:.2}° 贴到了色卡固定的 {NEUTRAL_HUE_DEG:.2}°" + ); + // ② 两张卡下强调色与底色都相同:底色只由图像决定,色卡只贡献中性彩度。 + assert_eq!( + (resolved[0].1.get(Role::Primary), surface), + ( + resolved[1].1.get(Role::Primary), + resolved[1].1.get(Role::Surface) + ), + "同一张图在两张卡下的主色与底色都该相同(八张卡的中性彩度都是 12)" + ); + // ③ 与色卡路径明确不同:否则「取色」就等于「色卡 + 换强调色」。 + assert_ne!( + surface, + DEFAULT_CARD.scheme(flavor).get(Role::Surface), + "取色方案的 surface 与天蓝卡的相同——取色路径退化成色卡路径了" + ); +} + +/// 8 张卡 × 浅/深 × 26 对:换中性色相后仍全部过 WCAG AA(`wcag::check` 全表), +/// 并把最受中性色相影响的一对(on-surface / surface)钉在实测量级上。 +#[test] +fn every_card_scheme_meets_wcag_aa_under_the_fixed_neutral() { + for card in CARDS { + for flavor in flavors() { + let scheme = card.scheme(flavor); + assert!( + card_scheme_is_readable(card, flavor), + "{} / {} 未过 WCAG AA 全表({} 对逐对断言在 `palette::wcag::check`)", + card.key, + flavor.name(), + TEXT_PAIRS.len() + ); + // 中性色相变了,最受影响的一对是 on-surface / surface(改前逐卡 16.23~16.44 浅 / + // 14.30~14.42 深)。改后八张卡同一个底色,这一对也逐卡相同,钉住量级免得漂移。 + // 区间按模式分开:写成 `(浅区间) || (深区间)` 会让浅/深两个值互换也照样绿。 + let body = scheme.contrast(Role::OnSurface, Role::Surface); + let band = match flavor { + SchemeFlavor::Light => 16.30..=16.40, + SchemeFlavor::Dark => 14.28..=14.36, + }; + assert!( + band.contains(&body), + "{} / {}:on-surface/surface = {body:.3},超出 {} 模式的实测区间 {band:?}", + card.key, + flavor.name(), + flavor.name() + ); + } + } +} diff --git a/crates/design/tests/card_neutral/common.rs b/crates/design/tests/card_neutral/common.rs new file mode 100644 index 0000000..f3a0c8e --- /dev/null +++ b/crates/design/tests/card_neutral/common.rs @@ -0,0 +1,27 @@ +//! `card_neutral` 测试组共用的帮手:改前路径的复原、模式列表、色相环距离。 +//! +//! 拆成子模块是为了单文件 ≤400 行的纪律(`card_neutral.rs` 是 crate 根,`common` / `dump` +//! 只是它的子模块;cargo 只把 `tests/card_neutral.rs` 当一个 test target)。 + +use unlucky_design::color::{Palettes, Scheme, SchemeFlavor}; +use unlucky_design::palette::Card; + +/// 改前的路径(M87 及之前):中性调色板跟着种子染色(官方 TonalSpot)。 +/// +/// 产品代码已经不用它了,但公开 API 仍在(取色路径就靠它逐色染色),所以这里能原样复现「改前」。 +pub fn before_d43(card: Card, flavor: SchemeFlavor) -> Scheme { + Scheme::from_palettes( + &Palettes::from_seed_with_neutral_chroma(card.seed, card.neutral_chroma), + flavor, + ) +} + +pub fn flavors() -> [SchemeFlavor; 2] { + [SchemeFlavor::Light, SchemeFlavor::Dark] +} + +/// 色相环上的最短距离(度)。 +pub fn hue_gap(a: f64, b: f64) -> f64 { + let raw = (a - b).abs(); + raw.min(360.0 - raw) +} diff --git a/crates/design/tests/card_neutral/dump.rs b/crates/design/tests/card_neutral/dump.rs new file mode 100644 index 0000000..8ff0339 --- /dev/null +++ b/crates/design/tests/card_neutral/dump.rs @@ -0,0 +1,68 @@ +//! 出图脚本读的 hex 行(无断言,纯打印)+ D43 的实测表(本文件就是那张表的来源)。 +//! +//! 跑法:`cargo test -p unlucky-design --test card_neutral -- --nocapture` +//! +//! ## 改前 / 改后 `surface` 实测(2026-11) +//! +//! | 卡 | 改前 `surface`(浅) | 改后 `surface`(浅) | 改前 HCT(浅) | 改后 HCT(浅) | +//! |---|---|---|---|---| +//! | sky | `#F6FAFF` | `#F6FAFF` | 236.86° / 6.41 | 236.86° / 6.41 | +//! | lavender | `#FDF7FF` | `#F6FAFF` | 298.10° / 5.87 | 236.86° / 6.41 | +//! | forest | `#EEFDEE` | `#F6FAFF` | 157.01° / 11.77 | 236.86° / 6.41 | +//! | amber | `#FFF8F4` | `#F6FAFF` | 71.48° / 1.89 | 236.86° / 6.41 | +//! | rose | `#FFF8F7` | `#F6FAFF` | 22.09° / 1.29 | 236.86° / 6.41 | +//! | teal | `#EBFEF8` | `#F6FAFF` | 182.42° / 12.03 | 236.86° / 6.41 | +//! | dusk | `#FBF8FF` | `#F6FAFF` | 280.31° / 5.44 | 236.86° / 6.41 | +//! | olive | `#F9FBE1` | `#F6FAFF` | 120.99° / 11.84 | 236.86° / 6.41 | +//! +//! 深色档同理:改前 `#09151C`~`#1F0F10`、彩度 11.63~12.45,改后八张卡统一 `#09151C` +//! (235.70° / 11.70)。 +//! +//! ## 这条用例为什么没有断言 +//! +//! 它是**数据导出**(`CARD|` 行给 `/d/tmp/m88/render.py` 排版成验收图),断言在同目录的 +//! `card_neutral.rs` 里。放这里是为了让根文件留在 ≤400 行以内。 + +use super::common::{before_d43, flavors}; +use unlucky_design::color::Role; +use unlucky_design::palette::CARDS; + +/// 出图脚本读的 hex 行(改前 + 改后并排),跑法: +/// `cargo test -p unlucky-design --test card_neutral -- --nocapture`。 +/// +/// 格式 `CARD|key|flavor|before|role=hex;role=hex;…`(承接那张「8 张卡 surface 色块并排 + +/// 切 sky→teal 窗口底色」的验收图;渲染脚本 `/d/tmp/m88/render.py` 只做排版,数据全在这里)。 +#[test] +fn dump_card_palette_for_the_color_card() { + const SHOWN: [Role; 10] = [ + Role::Surface, + Role::OnSurface, + Role::OnSurfaceVariant, + Role::SurfaceVariant, + Role::SurfaceContainer, + Role::SurfaceContainerHigh, + Role::Outline, + Role::Primary, + Role::OnPrimary, + Role::PrimaryContainer, + ]; + for card in CARDS { + for flavor in flavors() { + for (tag, scheme) in [ + ("before", before_d43(card, flavor)), + ("after", card.scheme(flavor)), + ] { + let pairs: Vec = SHOWN + .iter() + .map(|role| format!("{}={}", role.name(), scheme.get(*role).to_hex())) + .collect(); + println!( + "CARD|{}|{}|{tag}|{}", + card.key, + flavor.name(), + pairs.join(";") + ); + } + } + } +}