feat(design): 内置色卡改用固定中性色相,切卡只换强调色(D43)

色卡路径原先走 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` 仍是单行(零第三方依赖)。
This commit is contained in:
AskaEth committed 2026-11-02 13:12:41 +08:00
1 parent e377989327
commit de9d5337ac
4 files changed
+140 -25

No files matched your search

+3 -1
View File
@@ -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;
+104 -8
View File
@@ -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());
}
}