From de9d5337aca094c3c63c46c1ba79bbadb0f8c027 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 11:18:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(design):=20=E5=86=85=E7=BD=AE=E8=89=B2?= =?UTF-8?q?=E5=8D=A1=E6=94=B9=E7=94=A8=E5=9B=BA=E5=AE=9A=E4=B8=AD=E6=80=A7?= =?UTF-8?q?=E8=89=B2=E7=9B=B8=EF=BC=8C=E5=88=87=E5=8D=A1=E5=8F=AA=E6=8D=A2?= =?UTF-8?q?=E5=BC=BA=E8=B0=83=E8=89=B2=EF=BC=88D43=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 色卡路径原先走 TonalSpot「中性色也跟种子染色」,八张卡的中性色相 = 种子色相, 浅色档 surface 的实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处 暖相色域装不下彩度 12,同一个请求值在暖相上只兑现 1.29。9 倍的底色跳变让用户在 设置页逐张试色时觉得「每张卡都像换了个应用」。 - `color::palettes::NEUTRAL_HUE_DEG`(新常量)= 出厂默认种子 `#3C8DBC` 的 CAM16 色相 239.51420577630955°,出处与「换默认色时要复核什么」写在常量文档里; `neutral_hue_is_the_default_seed_hue` 用位相等钉住它与 `DEFAULT_SEED` 的绑定。 - `Palettes::accent_on_fixed_neutral(accent, neutral_hue, neutral_chroma)`:强调三族 跟 accent 色相,中性两族固定在给定色相;其余常数(36/16/24/25°/84、tone 表、 gamut 映射)一行未动。私有 `tonal_spot_with_neutral` 承载实现,`tonal_spot` 只是 它的「中性色相 = 种子色相」特例。 - `Card::scheme` 改走上述入口(`NEUTRAL_HUE_DEG` + 卡自己的中性彩度)。默认档天蓝的 49 个角色**逐值不变**(固定在等于它自己中性色相的那个值上,属「沿用」而非「另选」)。 - 动态取色路径**刻意不动**:`resolve::try_level` 仍按图像种子逐色染色、只借色卡的中性 彩度定浓淡;两处注释都写明「两条路径不同,别统一处理」。 - 订正 `palette/card.rs` 的模块文档:原文把「请求值相同」当成「观感相同」,现在改成 可直接当契约读的三条(表面与描边八卡逐值相同 / 只有强调族跟卡走 / 天蓝卡 == baseline)。 门禁:fmt 干净、clippy -D warnings 通过、`cargo test -p unlucky-design` 全绿、 `cargo tree -p unlucky-design --edges normal` 仍是单行(零第三方依赖)。 --- crates/design/src/color/mod.rs | 4 +- crates/design/src/color/palettes.rs | 112 +++++++++++++++++++++-- crates/design/src/palette/card.rs | 45 ++++++--- crates/design/src/palette/resolve/mod.rs | 4 +- 4 files changed, 140 insertions(+), 25 deletions(-) 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..c1aa192 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -58,9 +58,34 @@ 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°**。天蓝档的 +/// 中性调色板本来就是这个色相(`Palettes::from_seed_with_neutral_chroma` 让中性色跟着种子走), +/// 所以它是**沿用**,不是「另选一个冷调」:`Card::scheme` 换用它之后,天蓝卡的 49 个角色 +/// 必须逐值不变(`palette::card` 与 `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` 用位相等 +/// (`to_bits`)钉住两者的绑定,改了种子它就当场红——那时要想清楚是「色卡跟着新默认色走」 +/// (照抄新色相)还是「色卡保持这条冷调」(把测试改成显式例外),别静默漂移。 +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 +95,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 +197,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 +356,44 @@ 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 的绑定器:`NEUTRAL_HUE_DEG` 必须是**出厂默认种子的中性色相**,一位不差 + // (位相等,不是近似)。天蓝卡的 49 个角色因此逐值不变;这条一旦红,说明种子换了而 + // 常量没跟着复核——要么照抄新色相(色卡跟着新默认色走),要么把这里改成显式例外。 + let seed_hue = super::super::Hct::from_color(DEFAULT_SEED).hue; + assert_eq!( + NEUTRAL_HUE_DEG.to_bits(), + seed_hue.to_bits(), + "NEUTRAL_HUE_DEG = {NEUTRAL_HUE_DEG:?} 与 DEFAULT_SEED 的色相 {seed_hue:?} 不再相等" + ); + } + + #[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..226f02e 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -178,7 +178,9 @@ fn try_level(input: Option>, card: Card, flavor: SchemeFlavor) -> Err(failure) => return Attempt::Failed(Skip::from(failure)), }; - // 取色方案的表面色温沿用当前色卡的中性彩度:换壁纸不该让窗口底色「冷热乱跳」。 + // 取色方案的表面色**跟着图像种子染色**,只借当前色卡的中性彩度定浓淡(D43): + // 这是与色卡路径刻意不同的一处——色卡固定中性色相、只换强调色,取色保留整幅染色 + // (那是用户要的 Monet 体验)。别把两条路径「统一处理」,`tests/card_neutral.rs` 有反证。 let (scheme, adjustments) = converge(seed.scheme(flavor, card.neutral_chroma)); let report = check(&scheme); if !report.is_clean() {