fix(design): M70 r4 —— 偏离登记补齐「neutral-variant 8→14」+ 文档口径与 pin 可读性

复审 P2-1:neutral-variant 的那一半偏离漏登记,且文档把 nv 说成官方相对式。
- `color/mod.rs`:标题改「两处有意偏离(同一处偏离的两半)」;偏离登记把
  neutral 6→12 与 neutral-variant 8→14 分列,写明官方 `getNeutralVariantPalette`
  是**绝对值 8**(不是 neutral+2)、影响角色含 outline/outline-variant/surface-variant,
  以及「nv 钉回 8 会让表面(12)比中性变体(8)更有色」的倒挂理由
- `palettes.rs`:`NEUTRAL_VARIANT_OFFSET` 文档改述为「官方绝对值 8,本实现相对式=有意偏离」;
  `DEFAULT_NEUTRAL_CHROMA` 的矛盾区间改正为「数值安全区间 6..=13(14 才撞 secondary),产品取 12」;
  `from_seed_with_neutral_chroma` 加调用方约束(越界静默退化:<0/NaN → 彩度 0;≥14 → nv 撞 secondary);
  新增 `product_default_neutral_chroma_is_a_registered_deviation`(钉 neutral 12 / nv 14,官方锚点仍 6/8)
- nit1:`sky_seed_candidates_meet_wcag_aa` 的「三档」→「四档(208 项)」
- nit2:`product_default_is_the_adopted_candidate` 改成比 hex 文案,失败时可直读「期望 #3C8DBC、实测 #87CEEB(③)」
- nit4:`lib.rs` 的 `DesignTokens::baseline` 文档改准(产品默认 ④ 天蓝,官方口径走 `Scheme::official_baseline`)

无物理行为变更:算法、tone 表、gamut、所有色值一个没动。
This commit is contained in:
AskaEth committed 2026-11-01 18:09:14 +08:00
1 parent e37b452345
commit 40864d93d0
4 files changed
+108 -27

No files matched your search

+19 -9
View File
@@ -51,27 +51,37 @@
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
//!
//! ## 出厂默认色(M70:浅冷天蓝)——含一处**有意偏离**
//! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半)
//!
//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**
//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在
//! `tests/skyseed_candidates.rs`):
//!
//! | 常量 | 当前值 | 官方值 | 说明 |
//! | 项 | 当前值 | 官方值 | 说明 |
//! |---|---|---|---|
//! | [`palettes::DEFAULT_SEED`] | `#3C8DBC`(CAM16 hue ≈ 240°) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 240°);种子彩度会被 Tonal Spot 丢掉 |
//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`] | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 |
//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`](neutral) | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 |
//! | 联动的 neutral-variant | `14` | `8`(**绝对值**;官方不是「neutral + 2」) | `outline` / `outline-variant` / `surface-variant` 一起偏蓝(light `outline` = `#6A7985` 就是 14 彩度的口径) |
//!
//! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时
//! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet)
//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate,
//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。
//!
//! > ⚠️ **有意偏离登记**:中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,理由=产品要「浅冷」的冷调表面。
//! > 官方 6 的表面几乎无色,单靠换种子做不到「浅」——因为 primary 的 tone 被官方钉在 40,
//! > 种子再浅主色也还是中蓝;**「浅」只能落在中性色上**。除这一处外全部走官方常数
//! > (primary 36 / secondary 16 / tertiary 24 且色相 +60° / neutral-variant = neutral + 2 /
//! > error 25°·84),tone 表与 gamut 映射一行未动。
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
//! >
//! > 1. **neutral 彩度 `6 → 12`**。理由=产品要「浅冷」的冷调表面:官方 6 的表面几乎无色,单靠换
//! > 种子做不到「浅」——primary 的 tone 被官方钉在 40,种子再浅主色也还是中蓝,所以「浅」只能
//! > 落在中性色上。
//! > 2. **neutral-variant 彩度 `8 → 14`**。这是第 1 条的连带结果:官方
//! > `getNeutralVariantPalette` 写死的是**绝对值 8**(不是「neutral + 2」这种相对式),
//! > 本实现为了让层次不倒挂,让它跟着 neutral 联动(`nv = neutral + 2`)。若把 nv 钉回 8,
//! > 表面(12)会比中性变体(8)更「有色」,破坏官方的「neutral < neutral-variant」层次。
//! > 影响面不只表面:`outline` / `outline-variant` / `surface-variant` 三个角色一起偏蓝。
//! >
//! > 除这两处外全部走官方常数(primary 36 / secondary 16 / tertiary 24 且色相 +60° /
//! > error 25°·84),tone 表与 gamut 映射一行未动;[`Scheme::official_baseline`] 仍是官方的
//! > 6(neutral)/ 8(neutral-variant)口径。
//!
//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6)
//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1;
@@ -86,7 +96,7 @@
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的三档候选)。
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
//! - 例外(显式登记,非缺陷):
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
+44 -6
View File
@@ -48,8 +48,11 @@ pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
///
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
/// 种子再浅也变不成浅蓝)。安全区间 6..=12:neutral-variant = neutral + 2,
/// 超过 13 会顶到 secondary 的 16,破坏「neutral < neutral-variant < secondary」。
/// 种子再浅也变不成浅蓝)。
///
/// **数值安全区间 `6..=13`,产品取 12**:neutral-variant 跟 neutral 联动(`nv = neutral + 2`),
/// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改
/// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。
///
/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」,
/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。
@@ -80,7 +83,12 @@ const SECONDARY_CHROMA: f64 = 16.0;
const TERTIARY_CHROMA: f64 = 24.0;
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
/// neutral-variant 相对 neutral 的彩度偏移(官方 6 → 8,即 +2)。
/// neutral-variant 相对 neutral 的彩度偏移。
///
/// ⚠️ 官方 `getNeutralVariantPalette` 写死的是**绝对值 8**,**不是**「neutral + 2」这种相对式;
/// 本实现选择让 neutral-variant 随 neutral 联动,于是产品默认(neutral 12)取到 **14**。
/// 这是**有意偏离**的另一半(见 `color` 模块文档的偏离登记),不是官方口径——
/// 官方的 6 / 8 由 [`Palettes::official_baseline`] 原样保留。
const NEUTRAL_VARIANT_OFFSET: f64 = 2.0;
/// error 的色相(HCT 红相)。
const ERROR_HUE_DEG: f64 = 25.0;
@@ -128,9 +136,17 @@ impl Palettes {
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
///
/// 除 neutral 外**全部走官方常数**:primary 恒定 36、secondary 16、tertiary 24(色相 +60°)、
/// neutral-variant = neutral + 2、error 固定 25°/84。所以这条入口不绕算法,
/// 只是把「官方写死在 `getNeutralPalette` 里的那一个数」变成参数。
/// ⚠️ **调用方负责传 `6.0..=13.0`(产品取 12)**。本函数**不做范围校验**,越界的表现是
/// 静默退化而不是 panic:
/// - `neutral_chroma < 0` 或 `NaN` → 彩度被夹到 0(`TonalPalette::new` 里 `.max(0.0)`),
/// 得到一套纯灰的表面(surface ≈ `#F9F9F9`),**不报错**;
/// - `neutral_chroma >= 14` → neutral-variant(= neutral + 2)够到甚至超过 secondary 的 16,
/// 「中性 < 中性变体 < 次色」的层次被破坏(描边会明显发蓝)。
///
/// 除 neutral / neutral-variant 外**全部走官方常数**:primary 恒定 36、secondary 16、
/// tertiary 24(色相 +60°)、error 固定 25°/84。所以这条入口不绕算法,只是把「官方写死在
/// `getNeutralPalette` / `getNeutralVariantPalette` 里的那两个数」变成可调入参
/// (neutral-variant 这里取相对式 `neutral + 2`,属**有意偏离**,见 `NEUTRAL_VARIANT_OFFSET`)。
pub fn from_seed_with_neutral_chroma(seed: Color, neutral_chroma: f64) -> Self {
let hue = super::Hct::from_color(seed).hue;
Self::tonal_spot(hue, neutral_chroma)
@@ -231,6 +247,28 @@ mod tests {
}
}
#[test]
fn product_default_neutral_chroma_is_a_registered_deviation() {
// 官方 neutral / neutral-variant 是 6 / 8;产品默认是 12 / 14。
// 这**不是官方口径**,是「有意偏离」(见 `color` 模块文档的偏离登记)。
// 想把 nv 拉回官方绝对值 8 的人,请先改登记再改这里:直接钉成 8 会让表面(12)
// 比中性变体(8)更「有色」,破坏官方的 neutral < neutral-variant 层次。
let product = Palettes::baseline();
assert_eq!(product.neutral.chroma(), 12.0, "产品默认 neutral 彩度");
assert_eq!(
product.neutral_variant.chroma(),
14.0,
"产品默认的 neutral-variant 彩度 14 是**有意偏离**(官方绝对值 8),改前先更新偏离登记"
);
let official = Palettes::official_baseline();
assert_eq!(official.neutral.chroma(), 6.0, "官方锚点仍是 6");
assert_eq!(
official.neutral_variant.chroma(),
8.0,
"官方锚点仍是绝对值 8(不是 neutral + 2 的相对式)"
);
}
#[test]
fn baseline_hue_comes_from_the_seed() {
let palettes = Palettes::official_baseline();
+5 -1
View File
@@ -112,7 +112,11 @@ pub struct DesignTokens {
}
impl DesignTokens {
/// 按模式给一套出厂令牌(baseline seed `#6750A4`,等价 M3 baseline 调色板)。
/// 按模式给一套**产品默认**令牌:种子 `#3C8DBC`(M70 ④ 天蓝,用户 2026-11-01 拍板)
/// + 中性彩度 12,即 `color::palettes::DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA`。
///
/// 想要**官方 M3 baseline** 口径(种子 `#6750A4` + 中性彩度 6 / neutral-variant 8)请走
/// `color::Scheme::official_baseline(flavor)`——两条路径的算法完全相同,只差入参。
pub fn baseline(flavor: SchemeFlavor) -> Self {
Self {
flavor,
+40 -11
View File
@@ -246,21 +246,50 @@ fn the_candidates_are_actually_distinguishable() {
fn product_default_is_the_adopted_candidate() {
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
let adopted = &CANDIDATES[3];
assert_eq!(
DEFAULT_SEED,
Color::from_hex(adopted.seed_hex).expect("合法 hex"),
"DEFAULT_SEED 与已采用的档 {}",
adopted.tag
DEFAULT_SEED.to_hex(),
"#3C8DBC",
"DEFAULT_SEED 必须仍是已采用的 ④ 天蓝:期望 #3C8DBC(④),实测 {}({})",
DEFAULT_SEED.to_hex(),
if DEFAULT_SEED.to_hex() == CANDIDATES[2].seed_hex {
"③ 浅冷蓝——要改回 ③ 请连偏离登记与 pin 一起改"
} else {
"不是候选表里的任何一个种子"
}
);
assert_eq!(
DEFAULT_SEED.to_hex(),
adopted.seed_hex,
"DEFAULT_SEED({})与已采用档 {} 的种子({})不一致",
DEFAULT_SEED.to_hex(),
adopted.tag,
adopted.seed_hex
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, 12.0,
"DEFAULT_NEUTRAL_CHROMA 必须仍是已采用的 12,实测 {}",
DEFAULT_NEUTRAL_CHROMA
);
assert_eq!(
DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma,
"DEFAULT_NEUTRAL_CHROMA({})与已采用档 {} 的中性彩度({})不一致",
DEFAULT_NEUTRAL_CHROMA, adopted.tag, adopted.neutral_chroma
);
assert_eq!(DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma);
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
assert_eq!(
Scheme::baseline(flavor),
scheme(adopted, flavor),
"产品默认(Scheme::baseline)必须等于已采用档的派生结果({})",
flavor.name()
);
let product = Scheme::baseline(flavor);
let expected = scheme(adopted, flavor);
for (role, got) in product.iter() {
assert_eq!(
got.to_hex(),
expected.get(role).to_hex(),
"产品默认与已采用档 {} 的 {}({} 模式)不一致",
adopted.tag,
role.name(),
flavor.name()
);
}
}
}