Compare commits
5
Commits
c20c742b60
...
cf52751124
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf52751124 | ||
|
|
40864d93d0 | ||
|
|
e37b452345 | ||
|
|
9519a89472 | ||
|
|
79404fa9d1 |
No files matched your search
@@ -27,7 +27,8 @@
|
|||||||
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
|
//! | tone | CIELAB `L*` | CIELAB `L*`(同一个 `Y` 轴) | 逐值一致 |
|
||||||
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
|
//! | 色相 / 彩度 | CAM16 | CAM16(D65 默认观察条件) | 逐值一致(误差 < 1e-4) |
|
||||||
//! | gamut 映射 | `HctSolver` | 同一套两段求解器,关键平面表原样搬运 | 逐通道一致 |
|
//! | 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) | 逐值一致 |
|
//! | 角色 tone 表 | ColorSpec2021 | 同(49 角色 × light/dark) | 逐值一致 |
|
||||||
//!
|
//!
|
||||||
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
|
//! 对齐证据在 `tests/official_baseline.rs` 与 `tests/official_accent.rs`:期望值全部由
|
||||||
@@ -50,11 +51,52 @@
|
|||||||
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
|
//! tertiary 三族,中性色与 error 保持基线。强调族的色值与官方 `from_seed(accent)` 逐个对得上
|
||||||
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
|
//! (`tests/official_accent.rs`,8 个种子 × 12 个强调族角色 × 2 模式 = 192 项)。
|
||||||
//!
|
//!
|
||||||
|
//! ## 出厂默认色(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`](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 个角色」。
|
||||||
|
//!
|
||||||
|
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
|
||||||
|
//! >
|
||||||
|
//! > 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;
|
||||||
|
//! `tests/skyseed_candidates.rs` 把四档候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)的色值逐个 pin 住,
|
||||||
|
//! 换默认时它会指着要改的行。
|
||||||
|
//!
|
||||||
|
//! ✅ 默认色已由用户 2026-11-01 拍板:**④ 天蓝 `#3C8DBC`**。备选 ③ 浅冷蓝 `#87CEEB`
|
||||||
|
//! (主色偏青 `#0C6780`)仍在候选表里,换回去只需改 [`palettes::DEFAULT_SEED`] 一行。
|
||||||
|
//!
|
||||||
//! ## 对比度纪律(WCAG AA)
|
//! ## 对比度纪律(WCAG AA)
|
||||||
//!
|
//!
|
||||||
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
//! - 所有 `on-*` 与其承载色(`*-container`、`primary`、`surface`)都满足 **≥ 4.5:1**;
|
||||||
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
//! 这一条由 `tests/contrast_aa.rs` 全表验证,且因为 tone 轴固定,**换任何种子色都成立**
|
||||||
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`)。
|
//! (WCAG 对比度只取决于相对亮度,而 tone 直接钉住 `L*`;中性彩度同理,
|
||||||
|
//! `sky_seed_candidates_meet_wcag_aa` 逐档验证了 M70 的四档候选:4 档 × 2 模式 × 26 对 = 208 项)。
|
||||||
//! - 例外(显式登记,非缺陷):
|
//! - 例外(显式登记,非缺陷):
|
||||||
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
//! - [`Role::Outline`] 对 [`Role::Surface`]:M3 定义的**非文字**元素(输入框描边),
|
||||||
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
|
//! 目标 3:1,实测 light 4.26:1 / dark 5.89:1;
|
||||||
@@ -76,7 +118,7 @@ mod space;
|
|||||||
mod viewing_conditions;
|
mod viewing_conditions;
|
||||||
|
|
||||||
pub use hct::Hct;
|
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 roles::Role;
|
||||||
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
pub use scheme::{Scheme, SchemeFlavor, SCRIM_ALPHA, SHADOW_ALPHA};
|
||||||
pub use state::StateLayer;
|
pub use state::StateLayer;
|
||||||
|
|||||||
@@ -8,13 +8,56 @@
|
|||||||
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
|
//! (色相 +60°)、neutral **6**、neutral-variant **8**、error 固定色相 **25** 彩度 **84**。
|
||||||
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
|
//! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体,
|
||||||
//! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。
|
//! 再鲜艳的种子也会被收到 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::tonal::TonalPalette;
|
||||||
use super::Color;
|
use super::Color;
|
||||||
|
|
||||||
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
|
/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。
|
||||||
|
///
|
||||||
|
/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。
|
||||||
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4);
|
||||||
|
|
||||||
|
/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。
|
||||||
|
///
|
||||||
|
/// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot
|
||||||
|
/// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。
|
||||||
|
///
|
||||||
|
/// ## 定位(D38):这是「莫奈关闭时的出厂预选色卡」,不是唯一的配色来源
|
||||||
|
///
|
||||||
|
/// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭
|
||||||
|
/// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里
|
||||||
|
/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 /
|
||||||
|
/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。
|
||||||
|
pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC);
|
||||||
|
|
||||||
|
/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。
|
||||||
|
///
|
||||||
|
/// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调,
|
||||||
|
/// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40,
|
||||||
|
/// 种子再浅也变不成浅蓝)。
|
||||||
|
///
|
||||||
|
/// **数值安全区间 `6..=13`,产品取 12**:neutral-variant 跟 neutral 联动(`nv = neutral + 2`),
|
||||||
|
/// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改
|
||||||
|
/// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。
|
||||||
|
///
|
||||||
|
/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」,
|
||||||
|
/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。
|
||||||
|
pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0;
|
||||||
|
|
||||||
/// 调色板槽位(M3 的六条 tonal palette)。
|
/// 调色板槽位(M3 的六条 tonal palette)。
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum PaletteSlot {
|
pub enum PaletteSlot {
|
||||||
@@ -24,9 +67,9 @@ pub enum PaletteSlot {
|
|||||||
Secondary,
|
Secondary,
|
||||||
/// 第三调色板(种子色相 +60°,彩度 24)。
|
/// 第三调色板(种子色相 +60°,彩度 24)。
|
||||||
Tertiary,
|
Tertiary,
|
||||||
/// 中性调色板(同色相,彩度 6)。
|
/// 中性调色板(同色相;官方彩度 6,产品默认 12——见 [`DEFAULT_NEUTRAL_CHROMA`])。
|
||||||
Neutral,
|
Neutral,
|
||||||
/// 中性变体调色板(同色相,彩度 8)。
|
/// 中性变体调色板(同色相,彩度 = neutral + 2;官方 8)。
|
||||||
NeutralVariant,
|
NeutralVariant,
|
||||||
/// 错误调色板(固定红相 25°,彩度 84)。
|
/// 错误调色板(固定红相 25°,彩度 84)。
|
||||||
Error,
|
Error,
|
||||||
@@ -38,10 +81,15 @@ const PRIMARY_CHROMA: f64 = 36.0;
|
|||||||
const SECONDARY_CHROMA: f64 = 16.0;
|
const SECONDARY_CHROMA: f64 = 16.0;
|
||||||
/// tertiary 彩度。
|
/// tertiary 彩度。
|
||||||
const TERTIARY_CHROMA: f64 = 24.0;
|
const TERTIARY_CHROMA: f64 = 24.0;
|
||||||
/// neutral 彩度(几乎是灰)。
|
/// 官方 Tonal Spot 的 neutral 彩度(几乎是灰)。产品默认用 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||||
const NEUTRAL_CHROMA: f64 = 6.0;
|
const TONAL_SPOT_NEUTRAL_CHROMA: f64 = 6.0;
|
||||||
/// neutral-variant 彩度。
|
/// neutral-variant 相对 neutral 的彩度偏移。
|
||||||
const NEUTRAL_VARIANT_CHROMA: f64 = 8.0;
|
///
|
||||||
|
/// ⚠️ 官方 `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 红相)。
|
/// error 的色相(HCT 红相)。
|
||||||
const ERROR_HUE_DEG: f64 = 25.0;
|
const ERROR_HUE_DEG: f64 = 25.0;
|
||||||
/// error 的彩度。
|
/// error 的彩度。
|
||||||
@@ -67,15 +115,41 @@ pub struct Palettes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Palettes {
|
impl Palettes {
|
||||||
/// 出厂基线(种子 `#6750A4`)。
|
/// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。
|
||||||
|
///
|
||||||
|
/// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色
|
||||||
|
/// 只需要改那两个常量。
|
||||||
pub fn baseline() -> Self {
|
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)
|
Self::from_seed(BASELINE_SEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体)。
|
/// 从一个种子色派生全套调色板(官方 Tonal Spot 变体,中性彩度取官方 6)。
|
||||||
pub fn from_seed(seed: Color) -> Self {
|
pub fn from_seed(seed: Color) -> Self {
|
||||||
|
Self::from_seed_with_neutral_chroma(seed, TONAL_SPOT_NEUTRAL_CHROMA)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从一个种子色派生,并显式指定中性调色板彩度(`6.0` 即官方 Tonal Spot)。
|
||||||
|
///
|
||||||
|
/// ⚠️ **调用方负责传 `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;
|
let hue = super::Hct::from_color(seed).hue;
|
||||||
Self::tonal_spot(hue)
|
Self::tonal_spot(hue, neutral_chroma)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
|
/// 只换强调色:primary/secondary/tertiary 跟着新强调色的色相走,
|
||||||
@@ -94,14 +168,14 @@ impl Palettes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数)。
|
/// 官方 Tonal Spot 的六条调色板(色相来自种子,彩度取官方常数,neutral 可调)。
|
||||||
fn tonal_spot(hue: f64) -> Self {
|
fn tonal_spot(hue: f64, neutral_chroma: f64) -> Self {
|
||||||
Self {
|
Self {
|
||||||
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
primary: TonalPalette::new(hue, PRIMARY_CHROMA),
|
||||||
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
secondary: TonalPalette::new(hue, SECONDARY_CHROMA),
|
||||||
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
tertiary: TonalPalette::new(hue + TERTIARY_HUE_SHIFT_DEG, TERTIARY_CHROMA),
|
||||||
neutral: TonalPalette::new(hue, NEUTRAL_CHROMA),
|
neutral: TonalPalette::new(hue, neutral_chroma),
|
||||||
neutral_variant: TonalPalette::new(hue, NEUTRAL_VARIANT_CHROMA),
|
neutral_variant: TonalPalette::new(hue, neutral_chroma + NEUTRAL_VARIANT_OFFSET),
|
||||||
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,8 +203,8 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn baseline_palettes_use_the_official_tonal_spot_chromas() {
|
fn official_baseline_uses_the_official_tonal_spot_chromas() {
|
||||||
let palettes = Palettes::baseline();
|
let palettes = Palettes::official_baseline();
|
||||||
assert_eq!(palettes.primary.chroma(), 36.0);
|
assert_eq!(palettes.primary.chroma(), 36.0);
|
||||||
assert_eq!(palettes.secondary.chroma(), 16.0);
|
assert_eq!(palettes.secondary.chroma(), 16.0);
|
||||||
assert_eq!(palettes.tertiary.chroma(), 24.0);
|
assert_eq!(palettes.tertiary.chroma(), 24.0);
|
||||||
@@ -140,9 +214,64 @@ mod tests {
|
|||||||
assert_eq!(palettes.error.chroma(), 84.0);
|
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 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]
|
#[test]
|
||||||
fn baseline_hue_comes_from_the_seed() {
|
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);
|
let seed = super::super::Hct::from_color(BASELINE_SEED);
|
||||||
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
assert!((palettes.primary.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||||
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
|
assert!((palettes.neutral.hue_deg() - seed.hue).abs() < 1.0e-9);
|
||||||
@@ -168,11 +297,4 @@ mod tests {
|
|||||||
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
assert_ne!(swapped.primary.hue_deg(), base.primary.hue_deg());
|
||||||
assert_eq!(swapped.primary.chroma(), 36.0);
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -178,11 +178,21 @@ pub struct Scheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Scheme {
|
impl Scheme {
|
||||||
/// 出厂基线方案(种子 `#6750A4`,官方 Tonal Spot 输出)。
|
/// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度
|
||||||
|
/// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。
|
||||||
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||||
Self::from_palettes(&Palettes::baseline(), flavor)
|
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`)。
|
/// 指定种子色的方案(表面色与强调色同源,等价官方 `SchemeTonalSpot`)。
|
||||||
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
pub fn from_seed(flavor: SchemeFlavor, seed: Color) -> Self {
|
||||||
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
Self::from_palettes(&Palettes::from_seed(seed), flavor)
|
||||||
|
|||||||
@@ -112,7 +112,11 @@ pub struct DesignTokens {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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 {
|
pub fn baseline(flavor: SchemeFlavor) -> Self {
|
||||||
Self {
|
Self {
|
||||||
flavor,
|
flavor,
|
||||||
|
|||||||
@@ -157,6 +157,62 @@ 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),
|
||||||
|
("④ 天蓝", "#3C8DBC", 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]
|
#[test]
|
||||||
fn accent_override_cannot_break_contrast() {
|
fn accent_override_cannot_break_contrast() {
|
||||||
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
// 换了强调色之后,primary 族仍必须过 AA——这是「tone 轴固定」带来的结构性保证。
|
||||||
|
|||||||
@@ -94,8 +94,9 @@ fn worst_channel(got: Color, want: Color) -> i16 {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn baseline_matches_official_on_all_forty_nine_roles() {
|
fn baseline_matches_official_on_all_forty_nine_roles() {
|
||||||
let light = Scheme::baseline(SchemeFlavor::Light);
|
// 注意:对齐的是**官方锚点**(种子 #6750A4 + 中性彩度 6),不是产品默认(M70 起是浅冷蓝)。
|
||||||
let dark = Scheme::baseline(SchemeFlavor::Dark);
|
let light = Scheme::official_baseline(SchemeFlavor::Light);
|
||||||
|
let dark = Scheme::official_baseline(SchemeFlavor::Dark);
|
||||||
let mut worst = 0;
|
let mut worst = 0;
|
||||||
let mut off_by_one = 0;
|
let mut off_by_one = 0;
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
@@ -146,7 +147,7 @@ fn hct_solver_matches_official_color_spec_2021() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
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。
|
// primary 的彩度固定成 36,而 #6750A4 的 HCT 彩度是 47.86,所以 primary 被收到 #65558F。
|
||||||
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
// 这也是 Flutter `ColorScheme.fromSeed(0xFF6750A4)` 的 primary 值。
|
||||||
let seed = Hct::from_color(BASELINE_SEED);
|
let seed = Hct::from_color(BASELINE_SEED);
|
||||||
@@ -155,7 +156,7 @@ fn baseline_primary_is_not_the_seed_and_that_is_official() {
|
|||||||
"种子彩度 {}",
|
"种子彩度 {}",
|
||||||
seed.chroma
|
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_eq!(primary.to_hex(), "#65558F");
|
||||||
assert_ne!(primary, BASELINE_SEED);
|
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() {
|
fn baseline_tone_axis_is_still_exact_lstar() {
|
||||||
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
// gamut 映射只动彩度,不动 tone:每个角色的实测 L* 与它声明的 tone 相差 ≤1。
|
||||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
let scheme = Scheme::baseline(flavor);
|
let scheme = Scheme::official_baseline(flavor);
|
||||||
for (role, color) in scheme.iter() {
|
for (role, color) in scheme.iter() {
|
||||||
let tone = Scheme::tone_of(role, flavor);
|
let tone = Scheme::tone_of(role, flavor);
|
||||||
let got = f64::from(color.lstar());
|
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() {
|
fn same_family_containers_are_separated_by_hct_chroma() {
|
||||||
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
// 旧缺陷①:light 下 primary-container 与 secondary-container 撞色。
|
||||||
// 现在两者彩度不同(36 vs 16),在 tone 90 上都不会被色域裁到同一个上限。
|
// 现在两者彩度不同(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 primary = light.get(Role::PrimaryContainer);
|
||||||
let secondary = light.get(Role::SecondaryContainer);
|
let secondary = light.get(Role::SecondaryContainer);
|
||||||
assert_ne!(primary, secondary);
|
assert_ne!(primary, secondary);
|
||||||
@@ -192,6 +193,6 @@ fn same_family_containers_are_separated_by_hct_chroma() {
|
|||||||
c_secondary < c_primary,
|
c_secondary < c_primary,
|
||||||
"secondary-container 彩度 {c_secondary} 应低于 primary-container {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}");
|
assert!(c_primary > 12.0, "primary-container 彩度只剩 {c_primary}");
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
//! M70:默认色候选(保守蓝 / 冷调蓝 / 浅冷蓝 / 天蓝)——四档的**唯一真源**与 pin。
|
||||||
|
//!
|
||||||
|
//! ## 这档候选到底改了什么
|
||||||
|
//!
|
||||||
|
//! 四档走的是**同一条** HCT 管道(`Palettes::from_seed_with_neutral_chroma` → `ColorSpec2021`
|
||||||
|
//! tone 表 → `HctSolver` gamut 映射),差别只有两个入参:
|
||||||
|
//!
|
||||||
|
//! | 档 | 种子 | 种子 HCT 色相 | 中性调色板彩度 | 表面 | 主色观感 |
|
||||||
|
//! |---|---|---|---|---|---|
|
||||||
|
//! | ① 保守蓝 | `#2196F3` | 253.7° | 6(官方) | 近白/近黑,几乎无色 | 钢蓝 |
|
||||||
|
//! | ② 冷调蓝 | `#2196F3` | 253.7° | 12 | 冷白/冷黑,带淡蓝调 | 钢蓝 |
|
||||||
|
//! | ③ 浅冷蓝(备选) | `#87CEEB`(CSS `skyblue`) | 225.7° | 12 | 浅天蓝 | 偏青(teal) |
|
||||||
|
//! | ④ 天蓝(**出厂默认,用户 2026-11-01 拍板**) | `#3C8DBC` | 239.5° | 12 | 浅天蓝 | 偏蓝的天蓝 |
|
||||||
|
//!
|
||||||
|
//! ③ 与 ④ 是同一件事的两种「蓝」:色相越低越偏青(teal),越高越偏钢蓝;
|
||||||
|
//! 239.5° 正好夹在中间,主色从 ③ 的 `#0C6780` 变成 `#236488`。
|
||||||
|
//!
|
||||||
|
//! 两条必须知道的物理事实(写在测试里,免得下次又有人想「把主色调浅一点」):
|
||||||
|
//!
|
||||||
|
//! 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; 4] = [
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
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 坐标(色相是候选之间**唯一**有效的差别)。
|
||||||
|
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_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, c4) = (
|
||||||
|
light(&CANDIDATES[0]),
|
||||||
|
light(&CANDIDATES[1]),
|
||||||
|
light(&CANDIDATES[2]),
|
||||||
|
light(&CANDIDATES[3]),
|
||||||
|
);
|
||||||
|
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!(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),
|
||||||
|
"深色模式的 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,
|
||||||
|
"①② 应当只差中性彩度"
|
||||||
|
);
|
||||||
|
// ③ 偏青 / ④ 居中的分界靠色相:②→③ 与 ③→④ 都要肉眼可分的量级。
|
||||||
|
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_adopted_candidate() {
|
||||||
|
// 用户 2026-11-01 拍板采用 **④ 天蓝**。这条用例就是「默认值 ↔ 候选表」的绑定器:
|
||||||
|
// 谁改了 `DEFAULT_SEED`/`DEFAULT_NEUTRAL_CHROMA` 却没同步这里(或反过来),它会立刻红。
|
||||||
|
// 断言一律比 **hex / 数值**,这样失败信息里直接能读到「期望 ④、实测几」。
|
||||||
|
let adopted = &CANDIDATES[3];
|
||||||
|
assert_eq!(
|
||||||
|
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
|
||||||
|
);
|
||||||
|
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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('#')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user