CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
220 lines
9.2 KiB
Rust
220 lines
9.2 KiB
Rust
//! # 容器层可视性裁决(债务台账「SecondaryContainer 对 Surface 仅 1.06」的令牌层结论)
|
||
//!
|
||
//! ## 问题
|
||
//!
|
||
//! m223 视觉复审实测:选中行底(`SecondaryContainer`)与它压着的卡片只差 **1.057:1**,
|
||
//! 大色块 + 色相偏移肉眼可辨,但**数不出层级**。逐个页面补色修不完(每个用选中底色的页面都要
|
||
//! 自己调一次,而且下一套色板又得来一遍),所以裁决必须落在令牌层。
|
||
//!
|
||
//! ## 裁决(三条,按顺序适用)
|
||
//!
|
||
//! 1. **文字对比**:所有 `on-*` 对承载色 ≥ 4.5:1 —— 既有 [`super::wcag`] 那道门,不重复定义。
|
||
//! 2. **状态容器对窗口底 ≥ [`STATE_CONTAINER_MIN`] = 1.20:1**:表达「状态」的容器
|
||
//! ([`STATE_CONTAINERS`]:primary / secondary / tertiary / error 的 `*-container`)压在
|
||
//! `surface` 上时必须数得出来。这是唯一一条**数字门禁**,[`SurfaceHierarchy::state_failures`]
|
||
//! 守它。
|
||
//! 3. **容器层之间的亮度差不设门禁,改为「组合指示」要求**:M3 的容器层天生贴在一起
|
||
//! (官方口径浅色 `secondary-container` 对 `surface-container-high` 只有 1.06:1,
|
||
//! 本项目 Ink 板最低也只有 1.54:1 —— 这是配色结构的必然,不是哪套板的失误)。所以
|
||
//! **禁止**把「容器底色差异」当作唯一的选中 / 层级指示:选中态必须是
|
||
//! 「容器底(过第 2 条)+ 文字对比(第 1 条)+ 形状 / 图标 / 描边指示」三件套。
|
||
//! 另一半同理:`surface-container-*` 只表达「几乎看不见的抬升」,
|
||
//! 拿它当选中态用连第 2 条都过不了。
|
||
//!
|
||
//! 第 3 条为什么不是「把阈值降到 1.02 就完事」:那样等于承认「选中态可以看不见」,
|
||
//! 而用户抱怨的正是这个。把不可测的那一半转成**构成要求**,才是能落地的那一半。
|
||
//!
|
||
//! ## 实测(`tests/ink_cat_visibility.rs` 守住第 2 条与阶梯)
|
||
//!
|
||
//! | 板 | 最弱状态容器 / `surface` | 状态容器 / 容器层(4×5 全矩阵,最低 ~ 最高) | 阶梯 L\\* 步长 |
|
||
//! |---|---|---|---|
|
||
//! | Ink | **1.97**(secondary-container) | 1.54 ~ **16.47** | +1.4 ~ +4.4 |
|
||
//! | Silver | **1.23**(tertiary/error-container) | **1.02** ~ 1.80 | −1.7 ~ −4.1 |
|
||
//! | 旧 baseline(债务现场) | 1.23 | **1.057**(对卡片,m223 实测) | — |
|
||
//!
|
||
//! 第三列就是第 3 条的由来:它过不了任何有意义的门禁(Silver 最差的一组是
|
||
//! `tertiary-container` 压 `surface-container-high`,1.016 —— 两者几乎同亮度),
|
||
//! 所以不设门禁、只设组合要求。
|
||
//!
|
||
//! ## 阶梯本身还是要查([`SurfaceHierarchy::layer_violations`])
|
||
//!
|
||
//! `surface-container-lowest` → `highest` 必须**单调**且相邻步长 ≥ [`LAYER_STEP_MIN_LSTAR`]
|
||
//! (1.0 个 L\\* 单位)。方向由深浅模式决定:深色板递增(越靠上越亮)、浅色板递减。
|
||
//! 这条抓的是「两档写成同一个色」「顺序填反」这类事故——不抓「差距够不够好看」。
|
||
|
||
use crate::color::{Role, Scheme, SchemeFlavor};
|
||
|
||
/// 状态容器对 `surface` 的最低对比度(唯一的**数字**门禁)。
|
||
pub const STATE_CONTAINER_MIN: f32 = 1.20;
|
||
|
||
/// 层级阶梯相邻两档的最小 L\\* 步长(低于这个数就分不出「哪层更高」)。
|
||
pub const LAYER_STEP_MIN_LSTAR: f32 = 1.0;
|
||
|
||
/// 表达**状态**的容器角色(必须过 [`STATE_CONTAINER_MIN`])。
|
||
pub const STATE_CONTAINERS: [Role; 4] = [
|
||
Role::PrimaryContainer,
|
||
Role::SecondaryContainer,
|
||
Role::TertiaryContainer,
|
||
Role::ErrorContainer,
|
||
];
|
||
|
||
/// 表达**层级**的容器角色(由低到高;只查阶梯单调与步长)。
|
||
pub const LAYER_ROLES: [Role; 5] = [
|
||
Role::SurfaceContainerLowest,
|
||
Role::SurfaceContainerLow,
|
||
Role::SurfaceContainer,
|
||
Role::SurfaceContainerHigh,
|
||
Role::SurfaceContainerHighest,
|
||
];
|
||
|
||
/// 阶梯上的一步:相对**前一档**的 L\\* 变化量(带符号;深色板应为正、浅色板为负)。
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct LayerStep {
|
||
/// 这一档是谁。
|
||
pub role: Role,
|
||
/// 相对前一档的 L\\* 差。
|
||
pub delta_lstar: f32,
|
||
}
|
||
|
||
/// 一套方案的容器层可视性体检结果(纯数据,不改方案)。
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub struct SurfaceHierarchy {
|
||
/// 方案自己的深浅模式(决定阶梯方向)。
|
||
pub flavor: SchemeFlavor,
|
||
/// `surface` 的 L\\*(报告与对账用)。
|
||
pub surface_lstar: f32,
|
||
/// 每个状态容器对 `surface` 的实测对比度(顺序同 [`STATE_CONTAINERS`])。
|
||
pub state: [(Role, f32); 4],
|
||
/// 层级阶梯的实测 L\\*(顺序同 [`LAYER_ROLES`])。
|
||
pub layers: [(Role, f32); 5],
|
||
}
|
||
|
||
impl SurfaceHierarchy {
|
||
/// 状态容器里最低的一档(观察「刚好卡线」用)。
|
||
pub fn worst_state(&self) -> (Role, f32) {
|
||
self.state.iter().copied().fold(
|
||
self.state[0],
|
||
|acc, item| if item.1 < acc.1 { item } else { acc },
|
||
)
|
||
}
|
||
|
||
/// 未达 [`STATE_CONTAINER_MIN`] 的状态容器。
|
||
pub fn state_failures(&self) -> Vec<(Role, f32)> {
|
||
self.state
|
||
.iter()
|
||
.copied()
|
||
.filter(|(_, ratio)| *ratio < STATE_CONTAINER_MIN)
|
||
.collect()
|
||
}
|
||
|
||
/// 阶梯的每一步(4 步:lowest→low→container→high→highest)。
|
||
pub fn layer_steps(&self) -> [LayerStep; 4] {
|
||
let mut out = [LayerStep {
|
||
role: LAYER_ROLES[1],
|
||
delta_lstar: 0.0,
|
||
}; 4];
|
||
for i in 1..self.layers.len() {
|
||
out[i - 1] = LayerStep {
|
||
role: self.layers[i].0,
|
||
delta_lstar: self.layers[i].1 - self.layers[i - 1].1,
|
||
};
|
||
}
|
||
out
|
||
}
|
||
|
||
/// 方向反了或步长不足的阶梯步(深色板要递增、浅色板要递减)。
|
||
pub fn layer_violations(&self) -> Vec<LayerStep> {
|
||
let sign = if self.flavor == SchemeFlavor::Dark {
|
||
1.0
|
||
} else {
|
||
-1.0
|
||
};
|
||
self.layer_steps()
|
||
.into_iter()
|
||
.filter(|step| step.delta_lstar * sign < LAYER_STEP_MIN_LSTAR)
|
||
.collect()
|
||
}
|
||
|
||
/// 第 2 条与阶梯都过。
|
||
pub fn is_clean(&self) -> bool {
|
||
self.state_failures().is_empty() && self.layer_violations().is_empty()
|
||
}
|
||
}
|
||
|
||
/// 跑一遍容器层体检(纯函数)。
|
||
pub fn check_surface_hierarchy(scheme: &Scheme) -> SurfaceHierarchy {
|
||
let surface = scheme.get(Role::Surface);
|
||
SurfaceHierarchy {
|
||
flavor: scheme.flavor(),
|
||
surface_lstar: surface.lstar(),
|
||
state: std::array::from_fn(|i| {
|
||
let role = STATE_CONTAINERS[i];
|
||
(role, scheme.contrast(role, Role::Surface))
|
||
}),
|
||
layers: std::array::from_fn(|i| {
|
||
let role = LAYER_ROLES[i];
|
||
(role, scheme.get(role).lstar())
|
||
}),
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::color::{Color, Scheme};
|
||
|
||
#[test]
|
||
fn dark_ladder_must_climb_and_light_ladder_must_fall() {
|
||
// 造假:深色板把 highest 设成纯黑 → 阶梯反向,必须被抓住。
|
||
let base = Scheme::baseline(SchemeFlavor::Dark);
|
||
let mut colors = [Color::BLACK; 49];
|
||
for (role, color) in base.iter() {
|
||
colors[role as usize] = color;
|
||
}
|
||
colors[Role::SurfaceContainerHighest as usize] = Color::BLACK;
|
||
let broken = Scheme::from_colors(SchemeFlavor::Dark, colors);
|
||
let report = check_surface_hierarchy(&broken);
|
||
assert!(
|
||
report
|
||
.layer_violations()
|
||
.iter()
|
||
.any(|step| step.role == Role::SurfaceContainerHighest),
|
||
"{:?}",
|
||
report.layer_steps()
|
||
);
|
||
assert!(!report.is_clean());
|
||
}
|
||
|
||
#[test]
|
||
fn state_container_below_the_floor_is_reported() {
|
||
// 造假:secondary-container 直接等于 surface(1:1)→ 必须报。
|
||
let base = Scheme::baseline(SchemeFlavor::Light);
|
||
let mut colors = [Color::BLACK; 49];
|
||
for (role, color) in base.iter() {
|
||
colors[role as usize] = color;
|
||
}
|
||
colors[Role::SecondaryContainer as usize] = base.get(Role::Surface);
|
||
let broken = Scheme::from_colors(SchemeFlavor::Light, colors);
|
||
let report = check_surface_hierarchy(&broken);
|
||
assert_eq!(
|
||
report.state_failures(),
|
||
vec![(Role::SecondaryContainer, 1.0)]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn the_official_baseline_shows_why_the_debt_exists() {
|
||
// 官方口径(tone 表)浅色板:状态容器对 surface 只有 ~1.23,对容器层只有 ~1.05。
|
||
// 前半条过不了 1.20 的是「白银下界」,后半条根本不可门禁化——这就是裁决第 3 条。
|
||
let report = check_surface_hierarchy(&Scheme::official_baseline(SchemeFlavor::Light));
|
||
let (role, ratio) = report.worst_state();
|
||
// 官方浅色的四个 container 都是 tone 90、surface 是 tone 98 ⇒ 四个值几乎相同,
|
||
// 所以只断言量级(1.23 上下),不断言「哪一族最弱」。
|
||
assert!(
|
||
(1.15..1.30).contains(&ratio),
|
||
"官方 baseline 的 {} 对 surface = {ratio:.3},与文档的 1.23 不符",
|
||
role.name()
|
||
);
|
||
}
|
||
}
|