From 79404fa9d17288a5b9ba00f9aa9aaaa899d90ea4 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 17:43:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(design):=20M70=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E8=89=B2=E5=80=99=E9=80=89=20=E2=80=94=E2=80=94=20=E6=B5=85?= =?UTF-8?q?=E5=86=B7=E5=A4=A9=E8=93=9D=E8=90=BD=E6=88=90=E4=B8=A4=E8=A1=8C?= =?UTF-8?q?=E5=B8=B8=E9=87=8F=20+=20=E4=B8=89=E6=A1=A3=E5=80=99=E9=80=89?= =?UTF-8?q?=E8=89=B2=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 默认色从官方紫 `#6750A4` 换成「浅冷天蓝」,并把「换默认只改哪一行」做成常量: - `palettes::DEFAULT_SEED` = `#87CEEB`(CSS skyblue)+ `palettes::DEFAULT_NEUTRAL_CHROMA` = 12 - `Palettes::baseline()` / `Scheme::baseline()` = 产品默认(消费方拿到的就是它) - 官方口径锚点独立保留:`Palettes::official_baseline()` / `Scheme::official_baseline()` (种子 `#6750A4` + 中性 6),官方对齐测试改指向它,98 项仍每通道误差 0 - 新增 `Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma)`:中性彩度从写死 常数变成入参(neutral-variant 按官方 +2 跟随),算法、tone 表、gamut 映射一行未动 有意偏离登记(已写进 color 模块文档):中性彩度 6 → 12 是对官方 Tonal Spot 的偏离, 理由=产品要冷调表面;primary 的 tone 被官方钉在 40,种子再浅也变不成浅蓝, 「浅」只能落在中性色上。 三档候选(同一条 HCT 管道,只差种子与中性彩度)逐个 pin 在 tests/skyseed_candidates.rs: ① 保守蓝 `#2196F3`/6、② 冷调蓝 `#2196F3`/12、③ 浅冷蓝 `#87CEEB`/12(临时默认,待用户确认)。 WCAG AA 扩到候选档:26 组 × 3 档 × 2 模式 = 156 项全绿,最紧 4.99:1。 --- crates/design/src/color/mod.rs | 30 ++- crates/design/src/color/palettes.rs | 120 ++++++++-- crates/design/src/color/scheme.rs | 12 +- crates/design/tests/contrast_aa.rs | 55 +++++ crates/design/tests/official_baseline.rs | 15 +- crates/design/tests/skyseed_candidates.rs | 278 ++++++++++++++++++++++ 6 files changed, 476 insertions(+), 34 deletions(-) create mode 100644 crates/design/tests/skyseed_candidates.rs diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index e7b1264..c2e6119 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -27,7 +27,8 @@ //! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 | //! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) | //! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 | -//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同 | 逐值一致 | +//! | 调色板彩度 | Tonal Spot 变体:36 / 16 / 24 / 6 / 8,error 25°/84 | 同(primary/secondary/tertiary/neutral-variant/error 逐值一致) | 逐值一致 | +//! | 中性彩度 | 6(几乎无色) | 官方口径 6;**产品默认用 12**(有意偏离,见下节「出厂默认色」) | 仅此处偏离 | //! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 | //! //! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由 @@ -50,11 +51,34 @@ //! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上 //! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。 //! +//! ## 出厂默认色(M70:浅冷天蓝)——含一处**有意偏离** +//! +//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**: +//! +//! | 常量 | 当前值 | 官方值 | 说明 | +//! |---|---|---|---| +//! | [`palettes::DEFAULT_SEED`] | `#87CEEB`(CSS `skyblue`) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 ≈ 226°);种子彩度会被 Tonal Spot 丢掉 | +//! | [`palettes::DEFAULT_NEUTRAL_CHROMA`] | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 | +//! +//! > ⚠️ **有意偏离登记**:中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,理由=产品要「浅冷」的冷调表面。 +//! > 官方 6 的表面几乎无色,单靠换种子做不到「浅」——因为 primary 的 tone 被官方钉在 40, +//! > 种子再浅主色也还是中蓝;**「浅」只能落在中性色上**。除这一处外全部走官方常数 +//! > (primary 36 / secondary 16 / tertiary 24 且色相 +60° / neutral-variant = neutral + 2 / +//! > error 25°·84),tone 表与 gamut 映射一行未动。 +//! +//! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6) +//! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1; +//! `tests/skyseed_candidates.rs` 把三档候选(保守蓝 / 冷调蓝 / 浅冷蓝)的色值逐个 pin 住, +//! 换默认时它会指着要改的行。 +//! +//! ⚠️ 当前默认色是「待用户确认」的**临时默认**(推荐档③ 浅冷蓝)。 +//! //! ## 对比度纪律(WCAG AA) //! //! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**; //! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立** -//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`)。 +//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理, +//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的三档候选)。 //! - 例外(显式登记,非缺陷): //! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边), //! 目标 3:1,实测 light 4.26:1 / dark 5.89:1; @@ -76,7 +100,7 @@ mod space; mod viewing_conditions; pub use hct::Hct; -pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED}; +pub use palettes::{PaletteSlot, Palettes, BASELINE_SEED, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED}; 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 732d2e3..0d8e27b 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -8,13 +8,43 @@ //! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。 //! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体, //! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。 +//! +//! ## 两个入口:官方口径 vs 产品默认(M70) +//! +//! | 入口 | 种子 | 中性彩度 | 用途 | +//! |---|---|---|---| +//! | [`Palettes::official_baseline`] / [`super::Scheme::official_baseline`] | [`BASELINE_SEED`](`#6750A4`) | 6(官方) | 与官方 Java 实现逐角色对齐的**锚点**,`tests/official_baseline.rs` 用它 | +//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **产品出厂默认**(消费方拿到的就是它) | +//! +//! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行), +//! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。 +//! 官方锚点不受影响——**算法没动,动的只是入参**。 +//! +//! ⚠️ 中性彩度 6 → 12 是**有意偏离**官方 Tonal Spot(理由:产品要「浅冷」的冷调表面), +//! 偏离登记见 [`super`] 模块文档的「出厂默认色」小节。 use super::tonal::TonalPalette; use super::Color; /// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。 +/// +/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。 pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); +/// 产品出厂默认种子色(M70 候选③「浅冷蓝」,**待用户确认**)。 +/// +/// `#87CEEB` 就是 CSS 的 `skyblue`。注意它的彩度(36)会被 Tonal Spot 丢掉—— +/// 主色板的彩度恒为 36,种子只贡献**色相**(这里 CAM16 hue ≈ 226°,偏 cyanish 的蓝)。 +pub const DEFAULT_SEED: Color = Color::rgb(0x87, 0xCE, 0xEB); + +/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 +/// +/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调, +/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40, +/// 种子再浅也变不成浅蓝)。安全区间 6..=12:neutral-variant = neutral + 2, +/// 超过 13 会顶到 secondary 的 16,破坏「neutral < neutral-variant < secondary」。 +pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0; + /// 调色板槽位(M3 的六条 tonal palette)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PaletteSlot { @@ -24,9 +54,9 @@ pub enum PaletteSlot { Secondary, /// 第三调色板(种子色相 +60°,彩度 24)。 Tertiary, - /// 中性调色板(同色相,彩度 6)。 + /// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。 Neutral, - /// 中性变体调色板(同色相,彩度 8)。 + /// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。 NeutralVariant, /// 错误调色板(固定红相 25°,彩度 84)。 Error, @@ -38,10 +68,10 @@ const PRIMARY_CHROMA: f64 = 36.0; const SECONDARY_CHROMA: f64 = 16.0; /// tertiary 彩度。 const TERTIARY_CHROMA: f64 = 24.0; -/// neutral 彩度(几乎是灰)。 -const NEUTRAL_CHROMA: f64 = 6.0; -/// neutral-variant 彩度。 -const NEUTRAL_VARIANT_CHROMA: f64 = 8.0; +/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。 +const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0; +/// neutral-variant 相对 neutral 的彩度偏移(官方 6 → 8,即 +2)。 +const NEUTRAL_VARIANT_OFFSET: f64 = 2.0; /// error 的色相(HCT 红相)。 const ERROR_HUE_DEG: f64 = 25.0; /// error 的彩度。 @@ -67,15 +97,33 @@ pub struct Palettes { } impl Palettes { - /// 出厂基线(种子 `#6750A4`)。 + /// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。 + /// + /// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色 + /// 只需要改那两个常量。 pub fn baseline() -> Self { + Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA) + } + + /// **官方基线的锚点**:种子 [`BASELINE_SEED`] + 官方中性彩度 6(等价官方 + /// `SchemeTonalSpot(0xFF6750A4)`)。只给对齐测试与文档表格用。 + pub fn official_baseline() -> Self { Self::from_seed(BASELINE_SEED) } - /// 从一个种子色派生全套调色板(官方 Tonal Spot 变体)。 + /// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。 pub fn from_seed(seed: Color) -> Self { + Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA) + } + + /// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。 + /// + /// 除 neutral 外**全部走官方常数**:primary 恒定 36、secondary 16、tertiary 24(色相 +60°)、 + /// neutral-variant = neutral + 2、error 固定 25°/84。所以这条入口不绕算法, + /// 只是把「官方写死在 `getNeutralPalette` 里的那一个数」变成参数。 + 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) + Self::tonal_spot(hue, neutral_chroma) } /// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走, @@ -94,14 +142,14 @@ impl Palettes { } } - /// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数)。 - fn tonal_spot(hue: f64) -> Self { + /// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。 + fn tonal_spot(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_VARIANT_CHROMA), + neutral: TonalPalette::new(hue, neutral_chroma), + neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET), error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA), } } @@ -129,8 +177,8 @@ mod tests { use super::*; #[test] - fn baseline_palettes_use_the_official_tonal_spot_chromas() { - let palettes = Palettes::baseline(); + fn official_baseline_uses_the_official_tonal_spot_chromas() { + let palettes = Palettes::official_baseline(); assert_eq!(palettes.primary.chroma(), 36.0); assert_eq!(palettes.secondary.chroma(), 16.0); assert_eq!(palettes.tertiary.chroma(), 24.0); @@ -140,9 +188,42 @@ mod tests { assert_eq!(palettes.error.chroma(), 84.0); } + #[test] + fn product_default_only_moves_the_named_knobs() { + // 产品默认与官方锚点的差别必须**只**在种子色相与中性彩度两处: + // primary/secondary/tertiary/error 的取法一模一样(算法没动)。 + let official = Palettes::official_baseline(); + let product = Palettes::baseline(); + assert_eq!(product.primary.chroma(), official.primary.chroma()); + assert_eq!(product.secondary.chroma(), official.secondary.chroma()); + assert_eq!(product.tertiary.chroma(), official.tertiary.chroma()); + assert_eq!(product.error, official.error); + assert_eq!(product.neutral.chroma(), DEFAULT_NEUTRAL_CHROMA); + assert_eq!( + product.neutral_variant.chroma(), + DEFAULT_NEUTRAL_CHROMA + NEUTRAL_VARIANT_OFFSET + ); + let seed = super::super::Hct::from_color(DEFAULT_SEED); + assert!((product.primary.hue_deg() - seed.hue).abs() < 1.0e-9); + } + + #[test] + fn neutral_chroma_knob_keeps_the_official_ordering() { + // 官方关系 neutral(6) < neutral-variant(8) < secondary(16) 在安全区间里必须一直成立, + // 否则「表面比次色还艳」或两族撞色。 + for neutral in [6.0, 8.0, 10.0, 12.0] { + let p = Palettes::from_seed_with_neutral_chroma(DEFAULT_SEED, neutral); + assert!(p.neutral.chroma() < p.neutral_variant.chroma()); + assert!(p.neutral_variant.chroma() < p.secondary.chroma()); + // 中性彩度不影响 tone 轴:tone 98 的 surface 仍是最亮的那个。 + let surface = p.tone(PaletteSlot::Neutral, 98.0); + assert!(surface.relative_luminance() > 0.9); + } + } + #[test] fn baseline_hue_comes_from_the_seed() { - let palettes = Palettes::baseline(); + let palettes = Palettes::official_baseline(); let seed = super::super::Hct::from_color(BASELINE_SEED); assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9); assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9); @@ -168,11 +249,4 @@ mod tests { assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg()); assert_eq!(swapped.primary.chroma(), 36.0); } - - #[test] - fn neutral_variant_sits_between_neutral_and_secondary() { - let palettes = Palettes::baseline(); - assert!(palettes.neutral.chroma() < palettes.neutral_variant.chroma()); - assert!(palettes.neutral_variant.chroma() < palettes.secondary.chroma()); - } } diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index 0259a15..1d8a008 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -178,11 +178,21 @@ pub struct Scheme { } impl Scheme { - /// 出厂基线方案(种子 `#6750A4`,官方 Tonal Spot 输出)。 + /// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度 + /// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。 pub fn baseline(flavor: SchemeFlavor) -> Self { Self::from_palettes(&Palettes::baseline(), flavor) } + /// **官方基线锚点**(种子 `#6750A4` + 官方中性彩度 6,等价官方 + /// `SchemeTonalSpot(0xFF6750A4)`)。 + /// + /// 与 [`Scheme::baseline`] 的区别只有入参:算法、tone 表、gamut 映射完全同一条路径, + /// 所以 `tests/official_baseline.rs` 拿它跟官方 Java 输出逐角色对账。 + pub fn official_baseline(flavor: SchemeFlavor) -> Self { + Self::from_palettes(&Palettes::official_baseline(), flavor) + } + /// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。 pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self { Self::from_palettes(&Palettes::from_seed(seed), flavor) diff --git a/crates/design/tests/contrast_aa.rs b/crates/design/tests/contrast_aa.rs index e0f365e..45a4b0e 100644 --- a/crates/design/tests/contrast_aa.rs +++ b/crates/design/tests/contrast_aa.rs @@ -157,6 +157,61 @@ fn baseline_light_and_dark_both_pass() { } } +#[test] +fn sky_seed_candidates_meet_wcag_aa() { + // M70:三档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。 + // 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过, + // 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。 + let candidates = [ + ("① 保守蓝", "#2196F3", 6.0_f64), + ("② 冷调蓝", "#2196F3", 12.0), + ("③ 浅冷蓝", "#87CEEB", 12.0), + ]; + let mut failures = Vec::new(); + let mut worst = (f32::MAX, String::new()); + let mut checked = 0; + for (title, seed_hex, neutral_chroma) in candidates { + let seed = Color::from_hex(seed_hex).expect("候选种子"); + let palettes = + unlucky_design::color::Palettes::from_seed_with_neutral_chroma(seed, neutral_chroma); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::from_palettes(&palettes, flavor); + for (fg, bg) in TEXT_PAIRS { + let ratio = scheme.contrast(fg, bg); + checked += 1; + if ratio < worst.0 { + worst = ( + ratio, + format!( + "{title}/{} 的 {} on {}", + flavor.name(), + fg.name(), + bg.name() + ), + ); + } + if ratio < AA_TEXT { + failures.push(format!( + "{title}({seed_hex} / 中性 {neutral_chroma})/ {} / {} on {} = {ratio:.2}", + flavor.name(), + fg.name(), + bg.name() + )); + } + } + } + } + println!( + "候选 WCAG:{checked} 项,最紧的一对 {} = {:.2}:1(阈值 {AA_TEXT})", + worst.1, worst.0 + ); + assert!( + failures.is_empty(), + "以下候选未达 WCAG AA(4.5:1),该档不可用:\n{}", + failures.join("\n") + ); +} + #[test] fn accent_override_cannot_break_contrast() { // 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。 diff --git a/crates/design/tests/official_baseline.rs b/crates/design/tests/official_baseline.rs index 44b0bf6..2f15715 100644 --- a/crates/design/tests/official_baseline.rs +++ b/crates/design/tests/official_baseline.rs @@ -94,8 +94,9 @@ fn worst_channel(got: Color, want: Color) -> i16 { #[test] fn baseline_matches_official_on_all_forty_nine_roles() { - let light = Scheme::baseline(SchemeFlavor::Light); - let dark = Scheme::baseline(SchemeFlavor::Dark); + // 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。 + let light = Scheme::official_baseline(SchemeFlavor::Light); + let dark = Scheme::official_baseline(SchemeFlavor::Dark); let mut worst = 0; let mut off_by_one = 0; let mut failures = Vec::new(); @@ -146,7 +147,7 @@ fn hct_solver_matches_official_color_spec_2021() { #[test] fn baseline_primary_is_not_the_seed_and_that_is_official() { - // 常见误解:baseline() 的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把 + // 常见误解:官方基线的 primary 应该等于种子 #6750A4。官方不是——Tonal Spot 变体把 // primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。 // 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。 let seed = Hct::from_color(BASELINE_SEED); @@ -155,7 +156,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() { "种子彩度 {}", seed.chroma ); - let primary = Scheme::baseline(SchemeFlavor::Light).get(Role::Primary); + let primary = Scheme::official_baseline(SchemeFlavor::Light).get(Role::Primary); assert_eq!(primary.to_hex(), "#65558F"); assert_ne!(primary, BASELINE_SEED); } @@ -164,7 +165,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() { fn baseline_tone_axis_is_still_exact_lstar() { // gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。 for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { - let scheme = Scheme::baseline(flavor); + let scheme = Scheme::official_baseline(flavor); for (role, color) in scheme.iter() { let tone = Scheme::tone_of(role, flavor); let got = f64::from(color.lstar()); @@ -182,7 +183,7 @@ fn baseline_tone_axis_is_still_exact_lstar() { fn same_family_containers_are_separated_by_hct_chroma() { // 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。 // 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。 - let light = Scheme::baseline(SchemeFlavor::Light); + let light = Scheme::official_baseline(SchemeFlavor::Light); let primary = light.get(Role::PrimaryContainer); let secondary = light.get(Role::SecondaryContainer); assert_ne!(primary, secondary); @@ -192,6 +193,6 @@ fn same_family_containers_are_separated_by_hct_chroma() { c_secondary < c_primary, "secondary-container 彩度 {c_secondary} 应低于 primary-container {c_primary}" ); - // 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅紫」而不是被裁到灰。 + // 深层原因:tone 90 上彩度 36 装得下,所以容器色是「真的有彩度的浅色」而不是被裁到灰。 assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}"); } diff --git a/crates/design/tests/skyseed_candidates.rs b/crates/design/tests/skyseed_candidates.rs new file mode 100644 index 0000000..6419fc0 --- /dev/null +++ b/crates/design/tests/skyseed_candidates.rs @@ -0,0 +1,278 @@ +//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝)——三档的**唯一真源**与 pin。 +//! +//! ## 这档候选到底改了什么 +//! +//! 三档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021` +//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参: +//! +//! | 档 | 种子 | 中性调色板彩度 | 表面 | +//! |---|---|---|---| +//! | ① 保守蓝 | `#2196F3` | 6(官方) | 近白/近黑,几乎无色 | +//! | ② 冷调蓝 | `#2196F3` | 12 | 冷白/冷黑,带淡蓝调 | +//! | ③ 浅冷蓝 | `#87CEEB`(CSS `skyblue`) | 12 | 浅天蓝 | +//! +//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」): +//! +//! 1. **种子彩度是废参数**:Tonal Spot 把 primary 彩度钉在 36,种子只贡献色相。 +//! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同** +//! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。 +//! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。 +//! 2. **primary 的 tone 被钉在 40**,所以三档主色都是「中蓝」,不会更浅。 +//! 「浅」只能落在中性色上——这就是②/③提高中性彩度的原因,也是本项目唯一一处 +//! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。 +//! +//! ## 换默认色要动哪里 +//! +//! `palettes::DEFAULT_SEED` / `palettes::DEFAULT_NEUTRAL_CHROMA` 两行常量, +//! 然后重跑本文件:pin 表会逐角色告诉你哪几个 hex 变了,照抄即可(pin 是**抄本**,不是推导)。 + +use unlucky_design::color::{ + Color, Hct, Palettes, Role, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA, DEFAULT_SEED, +}; + +/// 一档候选:只有两个变量(种子 + 中性彩度),其余全是官方常数。 +struct Candidate { + /// 短标签(色卡与 CSV 用)。 + tag: &'static str, + /// 中文档名。 + title: &'static str, + /// 种子 hex。 + seed_hex: &'static str, + /// 中性调色板彩度(官方 Tonal Spot 是 6)。 + neutral_chroma: f64, + /// 9 个展示角色的 **(角色, light, dark)** 抄本。 + pins: [(Role, &'static str, &'static str); 9], +} + +/// 三档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。 +const CANDIDATES: [Candidate; 3] = [ + Candidate { + tag: "C1", + title: "① 保守蓝(中性彩度 6 = 官方)", + seed_hex: "#2196F3", + neutral_chroma: 6.0, + pins: [ + (Role::Primary, "#36618E", "#A0CAFD"), + (Role::OnPrimary, "#FFFFFF", "#003258"), + (Role::PrimaryContainer, "#D1E4FF", "#194975"), + (Role::Secondary, "#535F70", "#BBC7DB"), + (Role::Tertiary, "#6B5778", "#D6BEE4"), + (Role::Surface, "#F8F9FF", "#111418"), + (Role::SurfaceContainerLow, "#F2F3FA", "#191C20"), + (Role::SurfaceContainerHighest, "#E1E2E8", "#32353A"), + (Role::Outline, "#73777F", "#8D9199"), + ], + }, + Candidate { + tag: "C2", + title: "② 冷调蓝(中性彩度 12)", + seed_hex: "#2196F3", + neutral_chroma: 12.0, + pins: [ + (Role::Primary, "#36618E", "#A0CAFD"), + (Role::OnPrimary, "#FFFFFF", "#003258"), + (Role::PrimaryContainer, "#D1E4FF", "#194975"), + (Role::Secondary, "#535F70", "#BBC7DB"), + (Role::Tertiary, "#6B5778", "#D6BEE4"), + (Role::Surface, "#F8F9FF", "#0C141E"), + (Role::SurfaceContainerLow, "#EEF4FF", "#141C26"), + (Role::SurfaceContainerHighest, "#DBE3F1", "#2D3540"), + (Role::Outline, "#6D7887", "#8791A1"), + ], + }, + Candidate { + tag: "C3", + title: "③ 浅冷蓝(中性彩度 12 + 偏青天蓝种子)", + seed_hex: "#87CEEB", + neutral_chroma: 12.0, + pins: [ + (Role::Primary, "#0C6780", "#89D0ED"), + (Role::OnPrimary, "#FFFFFF", "#003544"), + (Role::PrimaryContainer, "#BAEAFF", "#004D62"), + (Role::Secondary, "#4C626B", "#B3CAD5"), + (Role::Tertiary, "#5B5B7E", "#C4C3EB"), + (Role::Surface, "#F3FAFF", "#07151B"), + (Role::SurfaceContainerLow, "#E7F6FE", "#0F1D23"), + (Role::SurfaceContainerHighest, "#D6E5ED", "#29373D"), + (Role::Outline, "#687A82", "#81949C"), + ], + }, +]; + +/// 三档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。 +fn seed_hct(candidate: &Candidate) -> Hct { + Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex")) +} + +/// 按候选参数走**产品同一条**派生路径。 +fn scheme(candidate: &Candidate, flavor: SchemeFlavor) -> Scheme { + Scheme::from_palettes( + &Palettes::from_seed_with_neutral_chroma( + seed_hct(candidate).to_color(), + candidate.neutral_chroma, + ), + flavor, + ) +} + +#[test] +fn candidates_match_their_pins() { + let mut drift = Vec::new(); + for candidate in &CANDIDATES { + for (role, want_light, want_dark) in candidate.pins { + for (flavor, want_hex) in [ + (SchemeFlavor::Light, want_light), + (SchemeFlavor::Dark, want_dark), + ] { + let got = scheme(candidate, flavor).get(role).to_hex(); + if got != want_hex { + drift.push(format!( + "{}{} / {}/{} 实测 {got},pin {want_hex}", + candidate.tag, + if flavor.is_dark() { "-dark" } else { "" }, + candidate.title, + role.name() + )); + } + } + } + } + assert!( + drift.is_empty(), + "候选色值漂了(换默认色后请照抄下面这些 hex 到 pin 表):\n{}", + drift.join("\n") + ); +} + +#[test] +fn candidates_only_move_the_two_declared_inputs() { + // 「不绕算法」的证据:三档的调色板彩度与官方 Tonal Spot 逐项一致, + // 唯一的差别是中性彩度的入参(以及种子色相)。 + let official = Palettes::official_baseline(); + for candidate in &CANDIDATES { + let p = Palettes::from_seed_with_neutral_chroma( + seed_hct(candidate).to_color(), + candidate.neutral_chroma, + ); + assert_eq!(p.primary.chroma(), official.primary.chroma()); + assert_eq!(p.secondary.chroma(), official.secondary.chroma()); + assert_eq!(p.tertiary.chroma(), official.tertiary.chroma()); + assert_eq!(p.error, official.error, "error 族不该被候选影响"); + assert_eq!(p.neutral.chroma(), candidate.neutral_chroma); + assert!( + (p.tertiary.hue_deg() - (p.primary.hue_deg() + 60.0)).abs() < 1.0e-9, + "{} 的 tertiary 不再是 +60°", + candidate.tag + ); + } +} + +#[test] +fn the_three_tiers_are_actually_distinguishable() { + // 「三档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶, + // 中性彩度 6→12 会量化到同一个 #F8F9FF——所以判据落在 container 系与 dark 模式上, + // 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。 + let light = |c: &Candidate| scheme(c, SchemeFlavor::Light); + let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark); + let (c1, c2, c3) = ( + light(&CANDIDATES[0]), + light(&CANDIDATES[1]), + light(&CANDIDATES[2]), + ); + for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] { + assert_ne!( + c1.get(role), + c2.get(role), + "①与②的 {} 相同:中性彩度没起作用", + role.name() + ); + assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name()); + } + assert_ne!( + dark(&CANDIDATES[0]).get(Role::Surface), + dark(&CANDIDATES[1]).get(Role::Surface), + "深色模式的 surface 应当能看出中性彩度差别" + ); + let hue_gap = |a: &Candidate, b: &Candidate| { + let (x, y) = (seed_hct(a).hue, seed_hct(b).hue); + (x - y).abs().min(360.0 - (x - y).abs()) + }; + assert!( + hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01, + "①② 应当只差中性彩度" + ); + assert!( + (10.0..=40.0).contains(&hue_gap(&CANDIDATES[1], &CANDIDATES[2])), + "②③ 的色相差应当明显(10°~40°),实测 {}", + hue_gap(&CANDIDATES[1], &CANDIDATES[2]) + ); +} + +#[test] +fn product_default_is_the_recommended_candidate() { + // ③ 是 M70 的**临时默认**(推荐档,待用户确认)。用户拍板后改这里会立刻红—— + // 那条红就是在提醒你同步 `DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA` 与上面的 pin。 + let recommended = &CANDIDATES[2]; + assert_eq!( + DEFAULT_SEED, + Color::from_hex(recommended.seed_hex).expect("合法 hex"), + "DEFAULT_SEED 与推荐档 {}", + recommended.tag + ); + assert_eq!(DEFAULT_NEUTRAL_CHROMA, recommended.neutral_chroma); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + assert_eq!( + Scheme::baseline(flavor), + scheme(recommended, flavor), + "产品默认(Scheme::baseline)必须等于推荐档的派生结果({})", + flavor.name() + ); + } +} + +#[test] +fn surface_tint_stays_a_tint() { + // 门禁:中性彩度提高是「淡蓝调」不是「把窗口染成蓝纸」。surface 的 HCT 彩度必须 + // 明显低于 primary 的 36,且 surface 的 Y 仍 > 0.85(浅色模式还是浅底)。 + for candidate in &CANDIDATES { + let light = scheme(candidate, SchemeFlavor::Light); + let surface = light.get(Role::Surface); + let chroma = Hct::from_color(surface).chroma; + assert!( + chroma <= 1.2 * candidate.neutral_chroma, + "{} 的 surface 彩度 {chroma} 超过中性彩度入参的两倍", + candidate.tag + ); + assert!(surface.relative_luminance() > 0.85); + } +} + +#[test] +fn dump_candidates_for_the_color_card() { + // 色卡脚本读的就是这两行格式(`SEED|` / `ROLE|`)。跑法: + // cargo test -p unlucky-design --test skyseed_candidates -- --nocapture + for candidate in &CANDIDATES { + let hct = seed_hct(candidate); + println!( + "SEED|{}|{}|{}|{}|hue={:.2}|chroma={:.2}", + candidate.tag, + candidate.title, + candidate.seed_hex, + candidate.neutral_chroma, + hct.hue, + hct.chroma + ); + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let s = scheme(candidate, flavor); + for role in Role::ALL { + println!( + "ROLE|{}|{}|{}|{}", + candidate.tag, + flavor.name(), + role.name(), + s.get(role).to_hex().trim_start_matches('#') + ); + } + } + } +} From 9519a89472bda299f8567f801439635b745bf88c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 17:47:06 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(design):=20M70=20r1=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=A1=A5=E7=AC=AC=204=20=E6=A1=A3=E3=80=8C?= =?UTF-8?q?=E5=A4=A9=E8=93=9D=E3=80=8D=E5=80=99=E9=80=89=20`#3C8DBC`?= =?UTF-8?q?=EF=BC=88=E5=9B=9B=E6=A1=A3=E5=AF=B9=E6=AF=94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ③ 的 primary `#0C6780` 偏青(teal),不是「天蓝」。新增 ④ 天蓝:种子 `#3C8DBC` (CAM16 hue 239.5°,夹 ③ 与 ①② 之间)+ 中性彩度 12 → primary `#236488`。 - `tests/skyseed_candidates.rs`:候选表 3 → 4 档,④ 的 9 角色 × light/dark 全部进 pin; `the_three_tiers_are_actually_distinguishable` 改为 `the_candidates_are_actually_distinguishable`, 增加「③ 与 ④ 中性彩度相同、主色/容器必须靠色相区分」的断言 - `tests/contrast_aa.rs`:候选 WCAG 门禁 3 → 4 档(26 × 4 × 2 = 208 项) - 默认值**不动**:仍为 ③ `#87CEEB`(临时默认,待用户看完四档再拍); `product_default_is_the_recommended_candidate` 保持原样,专门在默认与推荐不一致时报警 - 文档:`color/mod.rs` 与 `palettes.rs` 的候选清单补上 ④ 与「改一行即可切 ④」的说明 --- crates/design/src/color/mod.rs | 5 +- crates/design/src/color/palettes.rs | 3 + crates/design/tests/contrast_aa.rs | 3 +- crates/design/tests/skyseed_candidates.rs | 83 ++++++++++++++++------- 4 files changed, 67 insertions(+), 27 deletions(-) diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index c2e6119..2d89cca 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -68,10 +68,11 @@ //! //! 官方口径的锚点仍然完整保留:[`Scheme::official_baseline`](种子 `#6750A4` + 中性 6) //! 是 `tests/official_baseline.rs` 的对账入口,98 项仍是每通道 ≤1; -//! `tests/skyseed_candidates.rs` 把三档候选(保守蓝 / 冷调蓝 / 浅冷蓝)的色值逐个 pin 住, +//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住, //! 换默认时它会指着要改的行。 //! -//! ⚠️ 当前默认色是「待用户确认」的**临时默认**(推荐档③ 浅冷蓝)。 +//! ⚠️ 当前默认色是「待用户确认」的**临时默认**(③ 浅冷蓝 `#87CEEB`)。用户看过四档色卡后若选 +//! ④ 天蓝(`#3C8DBC`,主色 `#236488`,比 ③ 更偏蓝),只需改 [`palettes::DEFAULT_SEED`] 一行。 //! //! ## 对比度纪律(WCAG AA) //! diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 0d8e27b..445772a 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -35,6 +35,9 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); /// /// `#87CEEB` 就是 CSS 的 `skyblue`。注意它的彩度(36)会被 Tonal Spot 丢掉—— /// 主色板的彩度恒为 36,种子只贡献**色相**(这里 CAM16 hue ≈ 226°,偏 cyanish 的蓝)。 +/// +/// 第 4 档 ④「天蓝」`#3C8DBC`(CAM16 hue 239.5°,主色 `#236488`)在 +/// `tests/skyseed_candidates.rs` 里有完整 pin 与 WCAG 覆盖;用户若选 ④,改这一行常量的值即可。 pub const DEFAULT_SEED: Color = Color::rgb(0x87, 0xCE, 0xEB); /// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 diff --git a/crates/design/tests/contrast_aa.rs b/crates/design/tests/contrast_aa.rs index 45a4b0e..f4bc124 100644 --- a/crates/design/tests/contrast_aa.rs +++ b/crates/design/tests/contrast_aa.rs @@ -159,13 +159,14 @@ fn baseline_light_and_dark_both_pass() { #[test] fn sky_seed_candidates_meet_wcag_aa() { - // M70:三档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。 + // M70:四档默认色候选(种子 + 中性彩度)必须过同一条 26 组门禁。 // 中性彩度改变只影响色相/彩度,不影响 tone 轴,所以理论上恒过——这里就是要**证明**它恒过, // 而不是相信它。任何一档不达标,该档直接判为不可用(不许为了好看放宽阈值)。 let candidates = [ ("① 保守蓝", "#2196F3", 6.0_f64), ("② 冷调蓝", "#2196F3", 12.0), ("③ 浅冷蓝", "#87CEEB", 12.0), + ("④ 天蓝", "#3C8DBC", 12.0), ]; let mut failures = Vec::new(); let mut worst = (f32::MAX, String::new()); diff --git a/crates/design/tests/skyseed_candidates.rs b/crates/design/tests/skyseed_candidates.rs index 6419fc0..51f429f 100644 --- a/crates/design/tests/skyseed_candidates.rs +++ b/crates/design/tests/skyseed_candidates.rs @@ -1,15 +1,19 @@ -//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝)——三档的**唯一真源**与 pin。 +//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。 //! //! ## 这档候选到底改了什么 //! -//! 三档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021` +//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021` //! tone 表 → `HctSolver` gamut 映射),差别只有两个入参: //! -//! | 档 | 种子 | 中性调色板彩度 | 表面 | -//! |---|---|---|---| -//! | ① 保守蓝 | `#2196F3` | 6(官方) | 近白/近黑,几乎无色 | -//! | ② 冷调蓝 | `#2196F3` | 12 | 冷白/冷黑,带淡蓝调 | -//! | ③ 浅冷蓝 | `#87CEEB`(CSS `skyblue`) | 12 | 浅天蓝 | +//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 | +//! |---|---|---|---|---|---| +//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 | +//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 | +//! | ③ 浅冷蓝 | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) | +//! | ④ 天蓝 | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 | +//! +//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝; +//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。 //! //! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」): //! @@ -17,8 +21,8 @@ //! 所以「低彩度天蓝种子」得到的色板与「高彩度天蓝种子」**逐像素相同** //! (`seed_chroma_never_leaks_into_the_primary_ramp` 在 `palettes.rs` 里也钉了这条)。 //! ③ 与 ② 的真正差别是**色相**(226° vs 254°),不是彩度。 -//! 2. **primary 的 tone 被钉在 40**,所以三档主色都是「中蓝」,不会更浅。 -//! 「浅」只能落在中性色上——这就是②/③提高中性彩度的原因,也是本项目唯一一处 +//! 2. **primary 的 tone 被钉在 40**,所以四档主色都是「中蓝」,不会更浅。 +//! 「浅」只能落在中性色上——这就是②/③/④提高中性彩度的原因,也是本项目唯一一处 //! 对官方 Tonal Spot 的**有意偏离**(登记在 `color` 模块文档的「出厂默认色」小节)。 //! //! ## 换默认色要动哪里 @@ -44,8 +48,8 @@ struct Candidate { pins: [(Role, &'static str, &'static str); 9], } -/// 三档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。 -const CANDIDATES: [Candidate; 3] = [ +/// 四档候选(色卡 `DevDocs/ops/skyseed-candidates/` 用的就是这份数据)。 +const CANDIDATES: [Candidate; 4] = [ Candidate { tag: "C1", title: "① 保守蓝(中性彩度 6 = 官方)", @@ -97,9 +101,26 @@ const CANDIDATES: [Candidate; 3] = [ (Role::Outline, "#687A82", "#81949C"), ], }, + Candidate { + tag: "C4", + title: "④ 天蓝(中性彩度 12 + 夹在中间的天蓝种子)", + seed_hex: "#3C8DBC", + neutral_chroma: 12.0, + pins: [ + (Role::Primary, "#236488", "#93CDF6"), + (Role::OnPrimary, "#FFFFFF", "#00344C"), + (Role::PrimaryContainer, "#C8E6FF", "#004C6D"), + (Role::Secondary, "#4F616E", "#B7C9D8"), + (Role::Tertiary, "#63597C", "#CDC0E9"), + (Role::Surface, "#F6FAFF", "#09151C"), + (Role::SurfaceContainerLow, "#EBF5FF", "#111D25"), + (Role::SurfaceContainerHighest, "#D8E4EF", "#2B363F"), + (Role::Outline, "#6A7985", "#84939F"), + ], + }, ]; -/// 三档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。 +/// 四档共同点:种子的 HCT 坐标(色相是候选之间**唯一**有效的差别)。 fn seed_hct(candidate: &Candidate) -> Hct { Hct::from_color(Color::from_hex(candidate.seed_hex).expect("候选种子必须是合法 hex")) } @@ -146,7 +167,7 @@ fn candidates_match_their_pins() { #[test] fn candidates_only_move_the_two_declared_inputs() { - // 「不绕算法」的证据:三档的调色板彩度与官方 Tonal Spot 逐项一致, + // 「不绕算法」的证据:四档的调色板彩度与官方 Tonal Spot 逐项一致, // 唯一的差别是中性彩度的入参(以及种子色相)。 let official = Palettes::official_baseline(); for candidate in &CANDIDATES { @@ -168,16 +189,17 @@ fn candidates_only_move_the_two_declared_inputs() { } #[test] -fn the_three_tiers_are_actually_distinguishable() { - // 「三档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶, - // 中性彩度 6→12 会量化到同一个 #F8F9FF——所以判据落在 container 系与 dark 模式上, +fn the_candidates_are_actually_distinguishable() { + // 「四档不像同一档」的机器判据。注意 light 的 surface(tone 98)在 8 bit 下已经贴顶, + // 中性彩度 6→12 会量化到同一个 #F8F9FF——所以 ①② 的判据落在 container 系与 dark 模式上, // 这也是色卡上 ①② 的差别主要体现在卡片/浮层与深色模式的原因。 let light = |c: &Candidate| scheme(c, SchemeFlavor::Light); let dark = |c: &Candidate| scheme(c, SchemeFlavor::Dark); - let (c1, c2, c3) = ( + let (c1, c2, c3, c4) = ( light(&CANDIDATES[0]), light(&CANDIDATES[1]), light(&CANDIDATES[2]), + light(&CANDIDATES[3]), ); for role in [Role::SurfaceContainerLow, Role::SurfaceContainerHighest] { assert_ne!( @@ -187,7 +209,14 @@ fn the_three_tiers_are_actually_distinguishable() { role.name() ); assert_ne!(c2.get(role), c3.get(role), "②与③的 {} 相同", role.name()); + // ③④ 的中性彩度相同,差别只允许来自色相 + assert_ne!(c3.get(role), c4.get(role), "③与④的 {} 相同", role.name()); } + assert_ne!( + c3.get(Role::Primary), + c4.get(Role::Primary), + "③与④的主色必须真的不同(否则第 4 档白加)" + ); assert_ne!( dark(&CANDIDATES[0]).get(Role::Surface), dark(&CANDIDATES[1]).get(Role::Surface), @@ -201,17 +230,23 @@ fn the_three_tiers_are_actually_distinguishable() { hue_gap(&CANDIDATES[0], &CANDIDATES[1]) < 0.01, "①② 应当只差中性彩度" ); - assert!( - (10.0..=40.0).contains(&hue_gap(&CANDIDATES[1], &CANDIDATES[2])), - "②③ 的色相差应当明显(10°~40°),实测 {}", - hue_gap(&CANDIDATES[1], &CANDIDATES[2]) - ); + // ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。 + for (i, j) in [(1, 2), (2, 3)] { + assert!( + (10.0..=40.0).contains(&hue_gap(&CANDIDATES[i], &CANDIDATES[j])), + "{}→{} 的色相差应当明显(10°~40°),实测 {}", + CANDIDATES[i].tag, + CANDIDATES[j].tag, + hue_gap(&CANDIDATES[i], &CANDIDATES[j]) + ); + } } #[test] fn product_default_is_the_recommended_candidate() { - // ③ 是 M70 的**临时默认**(推荐档,待用户确认)。用户拍板后改这里会立刻红—— - // 那条红就是在提醒你同步 `DEFAULT_SEED` / `DEFAULT_NEUTRAL_CHROMA` 与上面的 pin。 + // ③ 是 M70 的**临时默认**(待用户确认)。第 4 档 ④ 加进来之后默认**故意不动**—— + // 由用户看完四档再拍。拍板后改 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 这里会立刻红, + // 那条红就是在提醒你同步 pin 表(本用例就是那个提醒器)。 let recommended = &CANDIDATES[2]; assert_eq!( DEFAULT_SEED, From e37b452345f3a81e25b68fc0ff0517a2947939ef Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 17:55:00 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(design):=20M70=20r2=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=87=BA=E5=8E=82=E9=BB=98=E8=AE=A4=E5=AE=9A?= =?UTF-8?q?=E4=B8=BA=20=E2=91=A3=20=E5=A4=A9=E8=93=9D=20`#3C8DBC`=EF=BC=88?= =?UTF-8?q?=E7=94=A8=E6=88=B7=202026-11-01=20=E6=8B=8D=E6=9D=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `palettes::DEFAULT_SEED`:`#87CEEB`(③ 浅冷蓝)→ `#3C8DBC`(④ 天蓝,主色 `#236488`); `DEFAULT_NEUTRAL_CHROMA` 保持 12 - `tests/skyseed_candidates.rs`:`product_default_is_the_recommended_candidate` → `product_default_is_the_adopted_candidate`,绑定 `CANDIDATES[3]`(④);四档 pin 不变 - 文档:`color/mod.rs` / `palettes.rs` 的「临时默认 ③ / 待用户确认」全部换成 「出厂默认 ④(用户 2026-11-01 拍板)」,③ 标注为备选(值仍 pin 住,可一行换回) - 文档新增定位(D38):这两个常量只定义「**莫奈关闭时的出厂预选色卡**」(配色来源第三级), 不再代表唯一配色来源;背景图/壁纸取色与内置色卡库都不在本 crate、尚未实现 --- crates/design/src/color/mod.rs | 15 ++++++++++---- crates/design/src/color/palettes.rs | 19 +++++++++++------ crates/design/tests/skyseed_candidates.rs | 25 +++++++++++------------ 3 files changed, 36 insertions(+), 23 deletions(-) diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index 2d89cca..4e14e9d 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -53,13 +53,20 @@ //! //! ## 出厂默认色(M70:浅冷天蓝)——含一处**有意偏离** //! -//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行**: +//! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行** +//! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在 +//! `tests/skyseed_candidates.rs`): //! //! | 常量 | 当前值 | 官方值 | 说明 | //! |---|---|---|---| -//! | [`palettes::DEFAULT_SEED`] | `#87CEEB`(CSS `skyblue`) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 ≈ 226°);种子彩度会被 Tonal Spot 丢掉 | +//! | [`palettes::DEFAULT_SEED`] | `#3C8DBC`(CAM16 hue ≈ 240°) | `#6750A4`([`palettes::BASELINE_SEED`]) | 种子只贡献**色相**(CAM16 240°);种子彩度会被 Tonal Spot 丢掉 | //! | [`palettes::DEFAULT_NEUTRAL_CHROMA`] | `12` | `6` | 让 surface / surface-container-* 带淡蓝调 | //! +//! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时 +//! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet) +//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate, +//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。 +//! //! > ⚠️ **有意偏离登记**:中性彩度 6 → 12 是对官方 Tonal Spot 的偏离,理由=产品要「浅冷」的冷调表面。 //! > 官方 6 的表面几乎无色,单靠换种子做不到「浅」——因为 primary 的 tone 被官方钉在 40, //! > 种子再浅主色也还是中蓝;**「浅」只能落在中性色上**。除这一处外全部走官方常数 @@ -71,8 +78,8 @@ //! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住, //! 换默认时它会指着要改的行。 //! -//! ⚠️ 当前默认色是「待用户确认」的**临时默认**(③ 浅冷蓝 `#87CEEB`)。用户看过四档色卡后若选 -//! ④ 天蓝(`#3C8DBC`,主色 `#236488`,比 ③ 更偏蓝),只需改 [`palettes::DEFAULT_SEED`] 一行。 +//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB` +//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。 //! //! ## 对比度纪律(WCAG AA) //! diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 445772a..818da50 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -31,14 +31,18 @@ use super::Color; /// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。 pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); -/// 产品出厂默认种子色(M70 候选③「浅冷蓝」,**待用户确认**)。 +/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。 /// -/// `#87CEEB` 就是 CSS 的 `skyblue`。注意它的彩度(36)会被 Tonal Spot 丢掉—— -/// 主色板的彩度恒为 36,种子只贡献**色相**(这里 CAM16 hue ≈ 226°,偏 cyanish 的蓝)。 +/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot +/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。 /// -/// 第 4 档 ④「天蓝」`#3C8DBC`(CAM16 hue 239.5°,主色 `#236488`)在 -/// `tests/skyseed_candidates.rs` 里有完整 pin 与 WCAG 覆盖;用户若选 ④,改这一行常量的值即可。 -pub const DEFAULT_SEED: Color = Color::rgb(0x87, 0xCE, 0xEB); +/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源 +/// +/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭 +/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里 +/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 / +/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。 +pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 /// @@ -46,6 +50,9 @@ pub const DEFAULT_SEED: Color = Color::rgb(0x87, 0xCE, 0xEB); /// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40, /// 种子再浅也变不成浅蓝)。安全区间 6..=12:neutral-variant = neutral + 2, /// 超过 13 会顶到 secondary 的 16,破坏「neutral < neutral-variant < secondary」。 +/// +/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」, +/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。 pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0; /// 调色板槽位(M3 的六条 tonal palette)。 diff --git a/crates/design/tests/skyseed_candidates.rs b/crates/design/tests/skyseed_candidates.rs index 51f429f..4c78944 100644 --- a/crates/design/tests/skyseed_candidates.rs +++ b/crates/design/tests/skyseed_candidates.rs @@ -9,8 +9,8 @@ //! |---|---|---|---|---|---| //! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 | //! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 | -//! | ③ 浅冷蓝 | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) | -//! | ④ 天蓝 | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 | +//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) | +//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 | //! //! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝; //! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。 @@ -243,23 +243,22 @@ fn the_candidates_are_actually_distinguishable() { } #[test] -fn product_default_is_the_recommended_candidate() { - // ③ 是 M70 的**临时默认**(待用户确认)。第 4 档 ④ 加进来之后默认**故意不动**—— - // 由用户看完四档再拍。拍板后改 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 这里会立刻红, - // 那条红就是在提醒你同步 pin 表(本用例就是那个提醒器)。 - let recommended = &CANDIDATES[2]; +fn product_default_is_the_adopted_candidate() { + // 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器: + // 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。 + let adopted = &CANDIDATES[3]; assert_eq!( DEFAULT_SEED, - Color::from_hex(recommended.seed_hex).expect("合法 hex"), - "DEFAULT_SEED 与推荐档 {}", - recommended.tag + Color::from_hex(adopted.seed_hex).expect("合法 hex"), + "DEFAULT_SEED 与已采用的档 {}", + adopted.tag ); - assert_eq!(DEFAULT_NEUTRAL_CHROMA, recommended.neutral_chroma); + assert_eq!(DEFAULT_NEUTRAL_CHROMA, adopted.neutral_chroma); for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { assert_eq!( Scheme::baseline(flavor), - scheme(recommended, flavor), - "产品默认(Scheme::baseline)必须等于推荐档的派生结果({})", + scheme(adopted, flavor), + "产品默认(Scheme::baseline)必须等于已采用档的派生结果({})", flavor.name() ); } From 40864d93d041118abdb1b3c3fddf090ed2631bd5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 1 Nov 2026 18:08:26 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(design):=20M70=20r4=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=81=8F=E7=A6=BB=E7=99=BB=E8=AE=B0=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E3=80=8Cneutral-variant=208=E2=86=9214=E3=80=8D+=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84=E4=B8=8E=20pin=20=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审 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、所有色值一个没动。 --- crates/design/src/color/mod.rs | 28 +++++++++---- crates/design/src/color/palettes.rs | 50 +++++++++++++++++++--- crates/design/src/lib.rs | 6 ++- crates/design/tests/skyseed_candidates.rs | 51 ++++++++++++++++++----- 4 files changed, 108 insertions(+), 27 deletions(-) diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index 4e14e9d..c0309d4 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -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; diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 818da50..6b78e49 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -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(); diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs index c4c2388..0eeb1de 100644 --- a/crates/design/src/lib.rs +++ b/crates/design/src/lib.rs @@ -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, diff --git a/crates/design/tests/skyseed_candidates.rs b/crates/design/tests/skyseed_candidates.rs index 4c78944..22744f3 100644 --- a/crates/design/tests/skyseed_candidates.rs +++ b/crates/design/tests/skyseed_candidates.rs @@ -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() + ); + } } }