From b8003177d7e25565569e35777dbdecea2385be40 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 22:25:17 +0800 Subject: [PATCH] =?UTF-8?q?fix(design):=20M228=20=E5=A4=8D=E5=AE=A1=20r1?= =?UTF-8?q?=20=E4=B8=89=E6=9D=A1=EF=BC=88=E5=AD=97=E6=AE=B5=E5=BA=95?= =?UTF-8?q?=E8=A7=92=E8=89=B2=E5=AF=B9=E9=BD=90=E3=80=81=E5=9B=9B=E5=A4=84?= =?UTF-8?q?=E5=B0=8F=E7=96=B5=E3=80=81=E4=BA=A7=E5=93=81=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2-1 字段底角色不对称(塔裁决方案 a):Silver 的 #dcddde 从 surface-container-high 改判到 surface-container-highest,与 Ink 的 #262b25 同位——component_map 的 TextField 口径就是最高 容器层,单一角色接法才不会一边掉层。Silver 的 high 改取推导值 #e4e4e5(tone 90),阶梯 94.12 → 90.61 → 88.07 保持单调、步长 ≥1.74 L*。实测:Error on 新 highest = 4.686(原按单一 角色接到 high 时的 4.379 失守已消除)、OnSurfaceVariant = 6.383、OnSurface = 12.576。 测试同步:Silver 三条字段用法 pin 从 high 改 highest(与 Ink 对称),并新增 `field_backdrop_is_the_same_role_on_both_boards` 把这个不对称钉住(顺带断言 high 比 highest 更亮、highest 就是规格卡的 #dcddde)。 P2-2 四处小疵: ① inkcat/size.rs 扩展 FAB 起始内距 16 → **28**(规格卡画的数):框 x=292 → 图标盒 320(`+` font-size 24 居中于 x=332),28 才是图上的距离;16 是 M3 官方口径,已在 doc 里登记为偏离, 并说明「要回 M3 的 16 得同时把图标中心挪回 316,那是要出截图的视觉改动」。测试补一条自洽 断言:292 + 28 + 24/2 = 332。 ② inkcat/metrics.rs 的 SWITCH_THUMB_INSET 注释重写(原句断成碎片且读起来像算错):写明 轨道 y=1060..1092 / 拇指 y=1064..1088 ⇒ 上下各 4、右端 116−112=4,以及行程 52−24−2×4 = 20 的推导。 ③ Silver「状态容器 / 容器层」数字统一成实测口径:boards/mod.rs 与 palette/visibility.rs 都写 「最低 1.02」(Ink 1.54),并点名最差的一组是 tertiary-container 压 surface-container-high = 1.016;visibility 表的 Silver 阶梯步长改写 −1.7 ~ −4.1。 ④ inkcat/type_scale.rs 正文清单只列 11 个**文字**字号(去掉 FAB 字形 20/26/34),并注明那三个 归 FabSize::icon_px、SpecText 没有 20 这一档。 P2-3 产品默认口径统一:palettes.rs 的「两个入口」表与 Palettes::baseline 文档、color/mod.rs 的「出厂默认色」小节、Scheme::baseline 文档统一写明「M228 起出厂外观入口是 DesignTokens::brand,本入口是 D38 取色链中性锚点」,与 lib.rs 同口径;顺带把 BASELINE_SEED / DEFAULT_SEED / DEFAULT_NEUTRAL_CHROMA 的「产品默认」措辞改成取色链口径。 另:tests/ink_cat_boards.rs 已 400 行贴线,把 4 条容器层可视性测试拆到新文件 tests/ink_cat_visibility.rs(纯搬移 + 补 import);palettes.rs 因新增口径说明到 403 行,按 「合并相邻段落、不删信息」压回 399 行;size.rs 现在 323 行。 五步(design 口径):fmt --all --check=0;check -p unlucky-design --all-targets=0(零 warning); test -p unlucky-design --all-targets=0(267 passed);clippy -p unlucky-design --all-targets -- -D warnings=0;400 行扫描只剩 README.md(豁免)、无 <2 字节 .rs。 --- crates/design/src/color/boards/mod.rs | 5 +- crates/design/src/color/boards/silver.rs | 12 +- crates/design/src/color/mod.rs | 4 + crates/design/src/color/palettes.rs | 22 ++-- crates/design/src/color/scheme.rs | 6 +- crates/design/src/inkcat/metrics.rs | 6 +- crates/design/src/inkcat/size.rs | 17 ++- crates/design/src/inkcat/type_scale.rs | 6 +- crates/design/src/palette/visibility.rs | 14 ++- crates/design/tests/ink_cat_boards.rs | 96 +--------------- crates/design/tests/ink_cat_visibility.rs | 133 ++++++++++++++++++++++ 11 files changed, 203 insertions(+), 118 deletions(-) create mode 100644 crates/design/tests/ink_cat_visibility.rs diff --git a/crates/design/src/color/boards/mod.rs b/crates/design/src/color/boards/mod.rs index 2f70f6a..748a1da 100644 --- a/crates/design/src/color/boards/mod.rs +++ b/crates/design/src/color/boards/mod.rs @@ -46,8 +46,9 @@ //! **1.057:1**(m223 视觉复审),Ink 板抬到 **1.97:1**、Silver 板 **1.23:1**; //! - **层级容器**(`surface-container-*`)不参与那条数字门禁,只要求 L\\* 沿阶梯单调、相邻步长 //! ≥ [`crate::palette::visibility::LAYER_STEP_MIN_LSTAR`]。M3 的容器层天生贴在一起 -//! (Silver 的状态容器对 `surface-container-high` 只有 1.05~1.13),所以裁决第 3 条是 -//! **构成要求**:选中态必须「容器底 + 文字对比 + 形状/图标/描边」三件套,不许只靠底色差。 +//! (Silver 的状态容器压在容器层上实测最低 **1.02**、Ink 最低 1.54,见 `palette::visibility` +//! 的实测表),所以裁决第 3 条是**构成要求**:选中态必须「容器底 + 文字对比 + 形状/图标/描边」 +//! 三件套,不许只靠底色差。 mod ink; mod silver; diff --git a/crates/design/src/color/boards/silver.rs b/crates/design/src/color/boards/silver.rs index 69f626d..045b66f 100644 --- a/crates/design/src/color/boards/silver.rs +++ b/crates/design/src/color/boards/silver.rs @@ -10,6 +10,12 @@ use crate::color::{Color, Role}; /// 出处分三处:SILVER CAT / LIGHT SCHEME 的色板行、同页 Filled/Tonal/Outlined 按钮与两个字段 /// (`on-primary` 取自 Filled 的文字 `#f6f9ff`、`on-secondary-container` 取自 Tonal 的文字 /// `#0d1d2a`、`outline-variant` 取自色板方块的描边 `#bec8cd`)。 +/// +/// ⚠️ 字段底 `#dcddde` 落在 **`surface-container-highest`**(不是 high):它与 Ink 的字段底 +/// `#262b25` 是同一层角色(`component_map` 的 `TextField` 口径 = 最高容器层)。浅色板的 +/// `surface-container-high` 因此是**推导值** `#e4e4e5`(tone 90),只为让阶梯在 94.1 → 88.1 +/// 之间保持单调、步长 ≥1.0 L\\*;若把 `#dcddde` 定在 high,Silver 的字段底会比 Ink 低一层, +/// 单一角色接法(只读 Highest)会读到 4.38:1 的 `Error` 标签(掉 AA)。 pub const ANCHORS: [(Role, &str); 13] = [ (Role::Primary, "#006499"), (Role::OnPrimary, "#F6F9FF"), @@ -18,7 +24,7 @@ pub const ANCHORS: [(Role, &str); 13] = [ (Role::OnSecondaryContainer, "#0D1D2A"), (Role::Surface, "#F9F9FA"), (Role::SurfaceContainer, "#EEEEEF"), - (Role::SurfaceContainerHigh, "#DCDDDE"), + (Role::SurfaceContainerHighest, "#DCDDDE"), (Role::OnSurface, "#1A1C1D"), (Role::OnSurfaceVariant, "#434D51"), (Role::Outline, "#6F797D"), @@ -55,8 +61,8 @@ pub const COLORS: [Color; 49] = [ Color::rgb(0xFF, 0xFF, 0xFF), // 25 surface-container-lowest [derive] tone 100 Color::rgb(0xF3, 0xF3, 0xF4), // 26 surface-container-low [derive] tone 96 Color::rgb(0xEE, 0xEE, 0xEF), // 27 surface-container [spec] 卡片/列表行底 - Color::rgb(0xDC, 0xDD, 0xDE), // 28 surface-container-high [spec] 输入框底 - Color::rgb(0xD5, 0xD6, 0xD7), // 29 surface-container-highest [derive] tone 86 + Color::rgb(0xE4, 0xE4, 0xE5), // 28 surface-container-high [derive] tone 90(比 highest 高 2.5 L*,阶梯步长 3.5) + Color::rgb(0xDC, 0xDD, 0xDE), // 29 surface-container-highest [spec] 输入框底(与 Ink 的 #262B25 同位:字段底就是最高容器层) Color::rgb(0x6F, 0x79, 0x7D), // 30 outline [spec] 字段描边 Color::rgb(0xBE, 0xC8, 0xCD), // 31 outline-variant [spec] 分隔线、chip 描边 Color::rgb(0x2F, 0x31, 0x33), // 32 inverse-surface [derive] tone 20 diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index e6c9bec..6109cf1 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -53,6 +53,10 @@ //! //! ## 出厂默认色(M70:浅冷天蓝)——含**两处**有意偏离(同一处偏离的两半) //! +//! > **口径更新(M228)**:出厂外观的入口是 `DesignTokens::brand`(Ink / Silver 两套固化色板, +//! > 见 [`crate::color::boards`])。本节描述的「浅冷天蓝」**仍是 D38 取色链的中性锚点** +//! > (`Scheme::baseline` / `from_accent` 的表面色基准),不再是「产品出厂看起来长什么样」。 +//! //! 产品默认不走官方紫,而是「浅冷天蓝」。默认值收在两个常量里,**换默认只改这两行** //! (用户 2026-11-01 拍板采用候选 **④ 天蓝**;③ 浅冷天蓝 `#87CEEB` 留作备选,值仍 pin 在 //! `tests/skyseed_candidates.rs`): diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index e5b2d03..799c00b 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -9,12 +9,12 @@ //! 注意 primary 的 36 与种子自身的彩度无关——这是官方口径:Tonal Spot 是「镇静」变体, //! 再鲜艳的种子也会被收到 36,再灰的种子也会被提到 36。 //! -//! ## 两个入口:官方口径 vs 产品默认(M70) +//! ## 两个入口:官方口径 vs 取色链锚点(M70 / M228 口径更新) //! //! | 入口 | 种子 | 中性彩度 | 用途 | //! |---|---|---|---| //! | [`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`] | **产品出厂默认**(消费方拿到的就是它) | +//! | [`Palettes::baseline`] / [`super::Scheme::baseline`] | [`DEFAULT_SEED`] | [`DEFAULT_NEUTRAL_CHROMA`] | **D38 取色链的中性锚点**(`from_accent` 的表面色基准)。M228 起**出厂外观**的入口是 `DesignTokens::brand`(Ink / Silver 固化板),不是这里 | //! //! 换默认色只改 [`DEFAULT_SEED`] 与 [`DEFAULT_NEUTRAL_CHROMA`] 两行常量(中性彩度不动就只改一行), //! 然后重跑 `tests/skyseed_candidates.rs` 的 pin(它会打印新值并指出哪几行要同步)。 @@ -26,12 +26,12 @@ use super::tonal::TonalPalette; use super::Color; -/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值)。 -/// -/// **不是**产品默认色——产品默认见 [`DEFAULT_SEED`]。 +/// M3 baseline 种子色(与 material-color-utilities 的默认基线同值);**不是**产品色—— +/// 产品外观见 `DesignTokens::brand`,取色链锚点见 [`DEFAULT_SEED`]。 pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); -/// 产品出厂默认种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**。 +/// 取色链的锚点种子色 —— M70 候选④「天蓝」,**用户 2026-11-01 拍板采用**;M228 起产品出厂 +/// 外观走 `DesignTokens::brand`(Ink / Silver 固化板),本常量只影响 D38 取色链。 /// /// `#3C8DBC`(CAM16 hue ≈ 239.5°,主色 `#236488`)。注意种子的彩度(42.6)会被 Tonal Spot /// 丢掉——主色板的彩度恒为 36,种子只贡献**色相**。 @@ -41,12 +41,11 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); /// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭 /// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里 /// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套;`palette::DEFAULT_CARD` 的种子即本值)。 -/// /// 取色(量化 + 挑主色)、内置色卡库与三级回落都在 [`crate::palette`](M87,分层见 D40); /// 本模块只负责「给定种子 + 中性彩度,派生出 49 个角色」。 pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); -/// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 +/// 取色链的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 /// /// 官方 6 让表面几乎无色;12 让 surface / surface-container-* 带一层淡蓝调, /// 这是「浅冷天蓝」唯一能落在表面上的手段(primary 的 tone 被官方钉在 40, @@ -143,10 +142,11 @@ pub struct Palettes { } impl Palettes { - /// **产品出厂默认**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。 + /// **D38 取色链的中性锚点**:种子 [`DEFAULT_SEED`] + 中性彩度 [`DEFAULT_NEUTRAL_CHROMA`]。 /// - /// 消费方(`DesignTokens::baseline`、iced 适配层)拿到的就是这套,所以产品默认色 - /// 只需要改那两个常量。 + /// 用途只剩一条:给「莫奈关闭时的机内色卡」与 [`super::Scheme::from_accent`] 提供中性表面色 + /// 基准。**M228 起产品出厂外观走 `DesignTokens::brand`**([`super::boards::BrandBoard`] 的 + /// Ink / Silver 板),改这两个常量**不再改变用户看到的默认外观**,只影响取色链。 pub fn baseline() -> Self { Self::from_seed_with_neutral_chroma(DEFAULT_SEED, DEFAULT_NEUTRAL_CHROMA) } diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index b849ad2..8768855 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -179,8 +179,12 @@ pub struct Scheme { } impl Scheme { - /// **产品出厂默认方案**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度 + /// **D38 取色链的中性锚点**(种子 [`super::palettes::DEFAULT_SEED`] + 中性彩度 /// [`super::palettes::DEFAULT_NEUTRAL_CHROMA`],见 [`Palettes::baseline`])。 + /// + /// M228 起**产品出厂外观**走 [`Scheme::from_board`](Ink / Silver 固化色板);本入口保留给 + /// 取色链与 [`Scheme::from_accent`] 的表面色基准,两者并列而不是互相替换(理由见 + /// [`super::boards`] 的模块文档)。 pub fn baseline(flavor: SchemeFlavor) -> Self { Self::from_palettes(&Palettes::baseline(), flavor) } diff --git a/crates/design/src/inkcat/metrics.rs b/crates/design/src/inkcat/metrics.rs index 35e529e..9e20aa1 100644 --- a/crates/design/src/inkcat/metrics.rs +++ b/crates/design/src/inkcat/metrics.rs @@ -138,7 +138,11 @@ pub const SWITCH_TRACK: SpecBox = SpecBox { }; /// 开关拇指直径(px)。 pub const SWITCH_THUMB: f32 = 24.0; -/// 拇指相对轨道的内缩(px):拇指 `x=88` − 轨道 `x=64` − 描边 …= 实测 0 起点 24 偏移、右端留 4。 +/// 拇指相对轨道的内缩(px):规格卡轨道 `y=1060..1092`、拇指 `y=1064..1088`,上下各 **4**; +/// 右端 `116−112` 同为 4。 +/// +/// 位移行程(拇指可从左滑到右的距离)= 轨道宽 52 − 拇指 24 − 2×内缩 4 = **20**(规格卡没写行程, +/// 这是由前三项推出来的唯一值;写死 20 就是第四份副本)。 pub const SWITCH_THUMB_INSET: f32 = 4.0; /// 关态拇指直径(px):规格卡 `16×16 rx8`。 pub const SWITCH_THUMB_OFF: f32 = 16.0; diff --git a/crates/design/src/inkcat/size.rs b/crates/design/src/inkcat/size.rs index e1f54bc..552eee7 100644 --- a/crates/design/src/inkcat/size.rs +++ b/crates/design/src/inkcat/size.rs @@ -219,10 +219,18 @@ impl FabSize { } } - /// 起始内距(px;只有 Extended 有:图标左边 = 16)。 + /// 起始内距(px;只有 Extended 有):**28**,规格卡画的就这个数。 + /// + /// 推导(可逐位核):框 `x=292` 起、宽 160;`+` 是 `font-size=24`、`text-anchor=middle`、 + /// 中心 `x=332` ⇒ 图标盒 = 320..344 ⇒ 内距 = 320 − 292 = **28**;标签 `x=352` 起, + /// 于是图标盒右沿 344 到标签 352 = [`FabSize::gap_px`] 的 8。 + /// + /// ⚠️ 与官方口径的偏离已登记:M3 ExtendedFAB 的起始内距是 **16**(图标盒 292+16=308)。 + /// 规格卡画得更宽松,本表按**规格卡**取值(A 级依据 = 图中坐标,不是猜的);M229 若要用 + /// M3 的 16,得同时把图标中心从 332 挪回 316 —— 那是一处视觉改动,要出截图,别悄悄改数。 pub const fn pad_start_px(self) -> f32 { match self { - FabSize::Extended => 16.0, + FabSize::Extended => 28.0, _ => 0.0, } } @@ -290,7 +298,10 @@ mod tests { assert_eq!(FabSize::Extended.width_px(), 160.0); assert_eq!(FabSize::Extended.height_px(), 56.0); assert_eq!(FabSize::Extended.label_px(), 15.0); - assert_eq!(FabSize::Extended.pad_start_px(), 16.0); + // 内距 28 = 规格卡图中「框 292 → 图标盒 320」的实测距离(不是 M3 的 16)。 + assert_eq!(FabSize::Extended.pad_start_px(), 28.0); + // 图标中心落点必须自洽:292 + 28 + 24/2 = 332,与规格卡 `x=332` 对得上。 + assert_eq!(FabSize::Extended.pad_start_px() + 24.0 / 2.0 + 292.0, 332.0); assert_eq!(FabSize::Extended.gap_px(), 8.0); // 图标字号随档递增(扩展态回到 24,因为它跟标签同一行)。 assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px()); diff --git a/crates/design/src/inkcat/type_scale.rs b/crates/design/src/inkcat/type_scale.rs index 6f88fea..bf53ac7 100644 --- a/crates/design/src/inkcat/type_scale.rs +++ b/crates/design/src/inkcat/type_scale.rs @@ -1,9 +1,13 @@ //! 规格卡字号 → 官方 type scale 的**校核**(不是又一张字号表)。 //! -//! 规格卡上每一处文字都写了 `font-size`:40 / 32 / 24 / 20 / 16 / 15 / 14 / 13 / 12 / 11 / 10 / 9。 +//! 规格卡上每一处**文字**都写了 `font-size`,去重共 11 档: +//! 40 / 32 / 24 / 16 / 15 / 14 / 13 / 12 / 11 / 10 / 9。 //! 其中一部分**命中** M3 的 15 档(14 / 16 / 24 / 32 / 12 / 11),一部分**落在两档之间** //! (13 / 15 / 40),还有一部分**低于本项目允许的 UI 下限**(10 / 9)。 //! +//! 规格卡上另有 20 / 26 / 34 三个字号,但它们是 **FAB 的图形符号(`+` 字形)**,不是文字—— +//! 归 [`crate::inkcat::size::FabSize::icon_px`] 管,本模块不收录(`SpecText` 里没有 20 这一档)。 +//! //! 本模块的职责就是把「规格卡字级」翻译成这三类结论,让换肤时不用逐个拿卷尺量: //! //! | 结论 | 含义 | 处置 | diff --git a/crates/design/src/palette/visibility.rs b/crates/design/src/palette/visibility.rs index 03af0a9..94ec6ad 100644 --- a/crates/design/src/palette/visibility.rs +++ b/crates/design/src/palette/visibility.rs @@ -14,25 +14,27 @@ //! `surface` 上时必须数得出来。这是唯一一条**数字门禁**,[`SurfaceHierarchy::state_failures`] //! 守它。 //! 3. **容器层之间的亮度差不设门禁,改为「组合指示」要求**:M3 的容器层天生贴在一起 -//! (官方口径浅色 `secondary-container` 对 `surface-container-high` 只有 1.05:1, -//! 深色也只有 1.54:1 —— 这是配色结构的必然,不是哪套板的失误)。所以 +//! (官方口径浅色 `secondary-container` 对 `surface-container-high` 只有 1.06:1, +//! 本项目 Ink 板最低也只有 1.54:1 —— 这是配色结构的必然,不是哪套板的失误)。所以 //! **禁止**把「容器底色差异」当作唯一的选中 / 层级指示:选中态必须是 //! 「容器底(过第 2 条)+ 文字对比(第 1 条)+ 形状 / 图标 / 描边指示」三件套。 //! 另一半同理:`surface-container-*` 只表达「几乎看不见的抬升」, //! 拿它当选中态用连第 2 条都过不了。 //! -//! 第 3 条为什么不是「把阈值降到 1.05 就完事」:那样等于承认「选中态可以看不见」, +//! 第 3 条为什么不是「把阈值降到 1.02 就完事」:那样等于承认「选中态可以看不见」, //! 而用户抱怨的正是这个。把不可测的那一半转成**构成要求**,才是能落地的那一半。 //! //! ## 实测(`tests/ink_cat_boards.rs` 守住第 2 条与阶梯) //! -//! | 板 | 最弱状态容器 / `surface` | 状态容器 / 最亮(深)或最暗(浅)的容器层 | 阶梯 L\\* 步长 | +//! | 板 | 最弱状态容器 / `surface` | 状态容器 / 容器层(最低 ~ 最高) | 阶梯 L\\* 步长 | //! |---|---|---|---| //! | Ink | **1.97**(secondary-container) | 1.54 ~ 12.13 | +1.4 ~ +4.4 | -//! | Silver | **1.23**(tertiary/error-container) | 1.05 ~ 1.24 | −1.8 ~ −6.1 | +//! | Silver | **1.23**(tertiary/error-container) | **1.02** ~ 1.80 | −1.7 ~ −4.1 | //! | 旧 baseline(债务现场) | 1.23 | **1.057**(对卡片,m223 实测) | — | //! -//! 第三列就是第 3 条的由来:它过不了任何有意义的门禁,所以不设门禁、只设组合要求。 +//! 第三列就是第 3 条的由来:它过不了任何有意义的门禁(Silver 最差的一组是 +//! `tertiary-container` 压 `surface-container-high`,1.016 —— 两者几乎同亮度), +//! 所以不设门禁、只设组合要求。 //! //! ## 阶梯本身还是要查([`SurfaceHierarchy::layer_violations`]) //! diff --git a/crates/design/tests/ink_cat_boards.rs b/crates/design/tests/ink_cat_boards.rs index 0d8f26b..d1c77ae 100644 --- a/crates/design/tests/ink_cat_boards.rs +++ b/crates/design/tests/ink_cat_boards.rs @@ -1,14 +1,12 @@ -//! Ink Cat 双主题的**消费方视角**门禁(M228):色板完整性、双主题切换、对比度、容器层可视性。 +//! Ink Cat 双主题的**消费方视角**门禁(M228):色板完整性、双主题切换、对比度。 //! //! 只用公开 API(`unlucky_design::...`),所以它同时是「iced 适配层能拿到什么」的样本: //! 换肤时只需要 `BrandBoard` → `Scheme` → `Role`,一个色值字面量都不用抄。 -//! 规格卡的几何与字级校核在同目录的 `ink_cat_geometry.rs`(单文件 ≤400 行)。 +//! 几何与字级校核在 `ink_cat_geometry.rs`,容器层可视性在 `ink_cat_visibility.rs` +//! (拆开是单文件 ≤400 行的硬约束)。 use unlucky_design::color::BrandBoard::{Ink, Silver}; use unlucky_design::color::{BrandBoard, Color, Role, SchemeFlavor}; -use unlucky_design::palette::visibility::{ - check_surface_hierarchy, LAYER_ROLES, STATE_CONTAINERS, STATE_CONTAINER_MIN, -}; use unlucky_design::palette::wcag::{check, AA_TEXT}; use unlucky_design::DesignTokens; @@ -236,19 +234,19 @@ const SPEC_TEXT_ON_SURFACE: [(BrandBoard, Role, Role, &str); 25] = [ ( Silver, Role::OnSurfaceVariant, - Role::SurfaceContainerHigh, + Role::SurfaceContainerHighest, "字段标签", ), ( Silver, Role::OnSurface, - Role::SurfaceContainerHigh, + Role::SurfaceContainerHighest, "字段内容", ), ( Silver, Role::Error, - Role::SurfaceContainerHigh, + Role::SurfaceContainerHighest, "错误字段标签", ), ( @@ -285,85 +283,3 @@ fn spec_text_pairs_meet_aa() { failures.join("\n") ); } - -#[test] -fn state_containers_clear_the_visibility_floor() { - // 债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:状态容器 ≥1.20:1。 - for board in BrandBoard::ALL { - let report = check_surface_hierarchy(&board.scheme()); - assert!( - report.state_failures().is_empty(), - "{} 的状态容器未达 {STATE_CONTAINER_MIN}:1:{:?}", - board.name(), - report.state_failures() - ); - let (role, ratio) = report.worst_state(); - assert!( - ratio >= STATE_CONTAINER_MIN, - "{} 最弱的状态容器 {} 只有 {ratio:.3}", - board.name(), - role.name() - ); - // 旧 baseline 的现场值 1.057 必须被显著甩开,否则「修了但没修好」。 - assert!( - ratio > 1.20, - "{} 只是刚好卡线({ratio:.3}),等于没修", - board.name() - ); - } - // 两套板覆盖 4 个状态容器角色,一个不落。 - assert_eq!(STATE_CONTAINERS.len(), 4); -} - -#[test] -fn surface_container_ladder_is_monotone_with_visible_steps() { - for board in BrandBoard::ALL { - let report = check_surface_hierarchy(&board.scheme()); - assert!( - report.layer_violations().is_empty(), - "{} 的容器阶梯有断层或方向错:{:?}", - board.name(), - report.layer_violations() - ); - assert!(report.is_clean(), "{} 的容器层体检没过", board.name()); - // 方向:深色板越靠上越亮、浅色板越靠上越暗。 - let steps = report.layer_steps(); - for step in steps { - let expect_positive = board.flavor() == SchemeFlavor::Dark; - assert_eq!( - step.delta_lstar > 0.0, - expect_positive, - "{} 的 {} 阶梯方向错", - board.name(), - step.role.name() - ); - } - assert_eq!(LAYER_ROLES.len(), 5); - } -} - -// ---------------------------------------------------------------- 规则:容器层不当状态用 - -#[test] -fn container_layers_are_not_state_indicators() { - // 裁决第 3 条的可测化一半:把「状态容器压在最亮容器层上」的实际数字钉住, - // 提醒后来者别把它当唯一指示(浅色板这里必然贴到 1.05~1.24)。 - let silver = BrandBoard::Silver.scheme(); - let worst = Role::ALL - .iter() - .filter(|role| STATE_CONTAINERS.contains(role)) - .map(|role| silver.contrast(*role, Role::SurfaceContainerHigh)) - .fold(f32::INFINITY, f32::min); - assert!( - (1.0..1.5).contains(&worst), - "Silver 的状态容器对 surface-container-high = {worst:.3},与「浅色必然贴近」的登记不符" - ); - // Ink 相反:深色板的状态容器与容器层拉得开,所以深色可以靠底色差做弱指示。 - let ink = BrandBoard::Ink.scheme(); - let ink_worst = Role::ALL - .iter() - .filter(|role| STATE_CONTAINERS.contains(role)) - .map(|role| ink.contrast(*role, Role::SurfaceContainerHighest)) - .fold(f32::INFINITY, f32::min); - assert!(ink_worst >= 1.5, "Ink 最弱的状态容器 = {ink_worst:.3}"); -} diff --git a/crates/design/tests/ink_cat_visibility.rs b/crates/design/tests/ink_cat_visibility.rs new file mode 100644 index 0000000..4b5ccff --- /dev/null +++ b/crates/design/tests/ink_cat_visibility.rs @@ -0,0 +1,133 @@ +//! Ink Cat 容器层可视性门禁(M228 债务裁决的可测一半):状态容器 1.20 门禁、阶梯单调、 +//! 以及「容器层不能当状态指示」的实测钉子。 +//! +//! 与 `ink_cat_boards.rs` / `ink_cat_geometry.rs` 分开是单文件 ≤400 行的硬约束。 + +use unlucky_design::color::{BrandBoard, Color, Role, SchemeFlavor}; +use unlucky_design::palette::visibility::{ + check_surface_hierarchy, LAYER_ROLES, STATE_CONTAINERS, STATE_CONTAINER_MIN, +}; +use unlucky_design::palette::wcag::AA_TEXT; + +#[test] +fn state_containers_clear_the_visibility_floor() { + // 债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层裁决:状态容器 ≥1.20:1。 + for board in BrandBoard::ALL { + let report = check_surface_hierarchy(&board.scheme()); + assert!( + report.state_failures().is_empty(), + "{} 的状态容器未达 {STATE_CONTAINER_MIN}:1:{:?}", + board.name(), + report.state_failures() + ); + let (role, ratio) = report.worst_state(); + assert!( + ratio >= STATE_CONTAINER_MIN, + "{} 最弱的状态容器 {} 只有 {ratio:.3}", + board.name(), + role.name() + ); + // 旧 baseline 的现场值 1.057 必须被显著甩开,否则「修了但没修好」。 + assert!( + ratio > 1.20, + "{} 只是刚好卡线({ratio:.3}),等于没修", + board.name() + ); + } + // 两套板覆盖 4 个状态容器角色,一个不落。 + assert_eq!(STATE_CONTAINERS.len(), 4); +} + +#[test] +fn surface_container_ladder_is_monotone_with_visible_steps() { + for board in BrandBoard::ALL { + let report = check_surface_hierarchy(&board.scheme()); + assert!( + report.layer_violations().is_empty(), + "{} 的容器阶梯有断层或方向错:{:?}", + board.name(), + report.layer_violations() + ); + assert!(report.is_clean(), "{} 的容器层体检没过", board.name()); + // 方向:深色板越靠上越亮、浅色板越靠上越暗。 + let steps = report.layer_steps(); + for step in steps { + let expect_positive = board.flavor() == SchemeFlavor::Dark; + assert_eq!( + step.delta_lstar > 0.0, + expect_positive, + "{} 的 {} 阶梯方向错", + board.name(), + step.role.name() + ); + } + assert_eq!(LAYER_ROLES.len(), 5); + } +} + +#[test] +fn container_layers_are_not_state_indicators() { + // 裁决第 3 条的可测化一半:把「状态容器压在容器层上」的最差值钉住,提醒后来者 + // 别把它当唯一指示(浅色板必然贴到 1.02,深色板 1.54)。 + let worst_over_layers = |board: BrandBoard| { + let scheme = board.scheme(); + STATE_CONTAINERS + .iter() + .flat_map(|role| { + LAYER_ROLES + .iter() + .map(move |layer| (*role, *layer, scheme.contrast(*role, *layer))) + }) + .map(|(_, _, ratio)| ratio) + .fold(f32::INFINITY, f32::min) + }; + let silver = worst_over_layers(BrandBoard::Silver); + assert!( + (1.0..1.1).contains(&silver), + "Silver 的状态容器对容器层最低 = {silver:.3},与「浅色 pale-on-pale 必然贴近」的登记(1.02)不符" + ); + // Ink 相反:深色板的状态容器与容器层拉得开,所以深色可以靠底色差做弱指示。 + let ink = worst_over_layers(BrandBoard::Ink); + assert!( + ink >= 1.5, + "Ink 最弱的状态容器对容器层 = {ink:.3},应明显高于浅色板" + ); + // 两条一起说明「为什么这不是调色问题而是构成问题」:同一套算法下深色能做的弱指示, + // 浅色做不到——所以门禁不能按容器层设,只能按 surface 设。 + assert!(silver < ink); +} + +#[test] +fn field_backdrop_is_the_same_role_on_both_boards() { + // P2-1 的回归钉子:字段底在两套板都是 `surface-container-highest`。 + // 若把 Silver 的字段底挪回 `surface-container-high`(#E4E4E5),错误标签会从 4.69 掉到 4.38(AA 失守)。 + for board in BrandBoard::ALL { + let scheme = board.scheme(); + for (fg, usage) in [ + (Role::OnSurfaceVariant, "字段标签"), + (Role::OnSurface, "字段内容"), + (Role::Error, "错误字段标签"), + ] { + let ratio = scheme.contrast(fg, Role::SurfaceContainerHighest); + assert!( + ratio >= AA_TEXT, + "{} 的{usage}({} on surface-container-highest)= {ratio:.3}", + board.name(), + fg.name() + ); + } + } + // 浅色板的 high 必须比 highest 更亮、且是另一个色(阶梯方向 + 不许两档同值)。 + let silver = BrandBoard::Silver.scheme(); + let high = silver.get(Role::SurfaceContainerHigh); + let highest = silver.get(Role::SurfaceContainerHighest); + assert_ne!(high, highest); + assert!( + high.lstar() > highest.lstar(), + "Silver 的 surface-container-high 应比 highest 更亮(高 {} vs 最高 {})", + high.lstar(), + highest.lstar() + ); + // 规格卡给的字段底就是 highest 那个值。 + assert_eq!(highest, Color::from_hex("#DCDDDE").expect("规格卡字段底")); +}