diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index e621322..c8589cd 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -12,7 +12,8 @@ //! | 1.6 | 图标 18 + 图标↔文字 8 | **18** = `IconSize::Button`(AAR `ButtonDefaults.IconSize`);间距 `Space::X2` | [`Geometry`] | //! | 1.7 | 最小宽 58 | **G15**:iced 的 `Length` 无 min-width,不施加(实测见报告) | — | //! | 1.8 | 状态层 + 禁用 | hover 8 / focus 10 / pressed 10;容器 **10%**(官方 `DisabledContainerOpacity`;面状控件的 12% 是另一类)& 内容 38%;**内容角色按变体**([`Variant::disabled_content`]) | [`super::interaction`] | -//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated `SurfaceContainerLow`/`Primary`;Outlined 透明 + 1dp `OutlineVariant` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] | +//! | 1.9 | 五式色彩角色 | Filled `Primary`/`OnPrimary`;Tonal `SecondaryContainer`/`OnSecondaryContainer`;Elevated = **`Elevation::Level1.surface_role()`**(= `SurfaceContainerLow`)/`Primary`;Outlined 透明 + 1dp `Outline` + `OnSurfaceVariant`;Text 透明 + `OnSurfaceVariant` | [`Variant::roles`] | +//! | 1.9b | 与 A1 的登记差异(产品口径,M248) | Outlined 描边 A1 是 `OutlineVariant`,本项目取 **`Outline`**:1dp 边界属于**非文字元素**(目标 3:1,`design/color/mod.rs` 的对比度纪律),`OutlineVariant` 是装饰分隔线的角色;且 `IconVariant::Outlined` 一直是 `Outline`,两族由此统一 | [`Variant::roles`] | //! | 2.1 | IconButton 容器 | **40×40**、圆角 full | [`IconGeometry`] | //! | 2.2 | IconButton 图标 / 边距 | 图标 **24**、边距 **8** | [`IconGeometry`] | //! | 2.4 | Standard 各态**图标色不变** | 只有状态层变化 | [`Variant::roles`] 与态无关 | @@ -77,9 +78,9 @@ pub enum Variant { Filled, /// 次强调(tonal):`SecondaryContainer`。 Tonal, - /// 带高程:`SurfaceContainerLow` + `Primary` 文本(L1 用色调差,不画阴影)。 + /// 带高程:L1 的容器面(`Elevation::Level1.surface_role()` = `SurfaceContainerLow`)+ `Primary` 文本。 Elevated, - /// 描边:透明容器 + 1dp `OutlineVariant`。 + /// 描边:透明容器 + 1dp `Outline`(非文字元素角色;装饰分隔线才用 `OutlineVariant`)。 Outlined, /// 纯文本:最低权重。 Text, @@ -158,8 +159,12 @@ impl Variant { Role::OnSecondaryContainer, None, ), - Variant::Elevated => (Some(Role::SurfaceContainerLow), Role::Primary, None), - Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::OutlineVariant)), + // 容器面由**高程令牌**推导(L1 → `SurfaceContainerLow`):不许在组件里再抄一遍角色名, + // 换层级口径只改 `elevation.rs` 一处(M248:P2 的落地方式是改来源,不是改色值)。 + Variant::Elevated => (Some(self.elevation().surface_role()), Role::Primary, None), + // 1dp 边界取 `Outline`(非文字元素、目标 3:1);`OutlineVariant` 是装饰分隔线的角色, + // 拿它画边界在 Ink/Silver 上都到不了 3:1,也和 `IconVariant::Outlined` 不一致(M248:P3)。 + Variant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)), Variant::Text => (None, Role::OnSurfaceVariant, None), } } @@ -270,12 +275,12 @@ pub fn tonal<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a assemble(ui, Variant::Tonal, button) } -/// Elevated 按钮(`SurfaceContainerLow` + `Primary` 文本,L1 用色调差)。 +/// Elevated 按钮(L1 的容器面 + `Primary` 文本,用色调差表达高程、不画阴影)。 pub fn elevated<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> { assemble(ui, Variant::Elevated, button) } -/// Outlined 按钮(透明 + 1dp `OutlineVariant`)。 +/// Outlined 按钮(透明 + 1dp `Outline`)。 pub fn outlined<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, M> { assemble(ui, Variant::Outlined, button) } diff --git a/crates/app-iced/src/components/buttons/tests.rs b/crates/app-iced/src/components/buttons/tests.rs index 505608f..efe0122 100644 --- a/crates/app-iced/src/components/buttons/tests.rs +++ b/crates/app-iced/src/components/buttons/tests.rs @@ -124,6 +124,7 @@ fn icon_button_geometry_matches_the_reference() { mod base; mod icon; +mod roles; /// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。 /// @@ -188,8 +189,9 @@ fn five_variants_have_distinct_colour_roles() { ( None, unlucky_design::color::Role::OnSurfaceVariant, - Some(unlucky_design::color::Role::OutlineVariant) - ) + Some(unlucky_design::color::Role::Outline) + ), + "描边取 Outline(M248:非文字元素角色;OutlineVariant 是装饰分隔线的)" ); assert_eq!( Variant::Text.roles(), @@ -275,7 +277,7 @@ fn outlined_style_draws_hairline_border_and_full_radius() { assert_eq!(idle.border.radius.top_left, 20.0, "full 圆角"); assert_eq!( idle.border.color, - ui.color(unlucky_design::color::Role::OutlineVariant) + ui.color(unlucky_design::color::Role::Outline) ); assert_eq!(idle.shadow, Shadow::default(), "L0 不许有阴影"); diff --git a/crates/app-iced/src/components/buttons/tests/roles.rs b/crates/app-iced/src/components/buttons/tests/roles.rs new file mode 100644 index 0000000..6a46393 --- /dev/null +++ b/crates/app-iced/src/components/buttons/tests/roles.rs @@ -0,0 +1,190 @@ +//! 角色语义 + 「可主题化」断言(M248:Elevated 底 / Outlined 描边)。 +//! +//! **判据不是「等于某个 hex」**:品牌色板本来就会被换(D38 的取色方案会整表接管 49 个角色), +//! 钉死色值等于把「配色可自定义」这条路焊上。所以这里钉两件互相补位的事: +//! +//! 1. [`role_table_is_named_roles_not_colours`]:`Variant::roles()` 吐的是**角色名**;Elevated 的 +//! 容器还必须由**高程令牌**推导(`Elevation::Level1.surface_role()`),不许在组件里手抄一遍; +//! 2. [`styles_follow_the_scheme_across_boards_and_custom_accents`]:同一变体在 Ink / Silver / +//! 自定义种子方案(`Scheme::from_seed`)三套方案下,`button::Style` 里解出的颜色**分别等于 +//! 各自方案的该角色色值**,且三者互不相同。把「取角色」改成「取死值(`Role::Surface`)」 +//! 或「写死某套方案」都会被这条打红。 +//! +//! 抓不住什么: +//! - **令牌表里两个角色同值**时,换角色的变异只有第 1 条会红; +//! - **像素**:角色写对了、图上却是别的颜色(样式闭包被别处覆盖)——那是 `gallery/probe` 与出图的事。 + +use iced::widget::button; +use iced::Background; + +use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor}; +use unlucky_design::elevation::Elevation; +use unlucky_design::typography::Platform; + +use super::super::{style_closure, IconVariant, Variant}; +use super::spec_of; +use crate::theme::{Ui, STROKE_PX}; + +/// 三套配色来源:两块出厂品牌板 + 一套 `Scheme::from_seed` 自定义种子方案。 +/// +/// 第三套是关键:品牌板是固化数据,**换不了**;只有「同一份组件代码吃进另一套方案就换色」 +/// 才叫可自定义(D38 的取色链就是这么接管 49 个角色的)。种子用 design 里的 +/// [`DEFAULT_SEED`](取色链默认的天蓝)——它是令牌,不是本文件写下的色值字面量 +/// (`tests/no_hardcoded_tokens.rs` 拦 `#RRGGBB`,这条门禁是对的)。 +fn themes() -> Vec<(&'static str, Ui)> { + vec![ + ( + "ink", + Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES), + ), + ( + "silver", + Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES), + ), + ( + "custom", + Ui::from_scheme( + Scheme::from_seed(SchemeFlavor::Dark, unlucky_design::color::DEFAULT_SEED), + super::FAMILIES, + ), + ), + ] +} + +#[test] +fn role_table_is_named_roles_not_colours() { + // ---- P2:Elevated 的容器是**高程令牌的推论**,不是一个手抄的角色名 ---------- + assert_eq!(Variant::Elevated.elevation(), Elevation::Level1, "L1"); + assert_eq!( + Variant::Elevated.roles().0, + Some(Elevation::Level1.surface_role()), + "Elevated 的底必须由高程令牌推导(L1 → surface_role = SurfaceContainerLow),\ + 组件里不许再写一遍这个角色名" + ); + assert_ne!( + Elevation::Level1.surface_role(), + Role::Surface, + "L1 的容器面必须比画布高一档(否则 Elevated 与画布同色)" + ); + + // ---- P3:1dp 边界取 `Outline`;`OutlineVariant` 是装饰分隔线的角色 ---------- + assert_eq!( + Variant::Outlined.roles(), + (None, Role::OnSurfaceVariant, Some(Role::Outline)), + "Outlined 的 1dp 描边取 Outline(`design/color/mod.rs` 的对比度纪律:\ + Outline 是「非文字元素」角色、目标 3:1;OutlineVariant 是纯装饰分隔线)" + ); + // 同族一致性:图标按钮那一式一直是 Outline,此前文字按钮是 OutlineVariant ⇒ 全站两套描边 + assert_eq!( + IconVariant::Outlined.roles().2, + Some(Role::Outline), + "图标按钮 Outlined 与文字按钮 Outlined 必须是同一个描边角色" + ); +} + +/// 非文字元素的对比度下限 3:1(WCAG 1.4.11;`design/color/mod.rs` 的对比度纪律同一口径)。 +/// +/// 这条是 P3 的**理由**而不只是判据:`OutlineVariant` 在深/浅两块板上都到不了 3:1, +/// 拿它画按钮边界等于「看得见形状、量不出边界」。别把下限写死成某个角色的实测值—— +/// 换品牌板或换取色方案时,比例是**算出来的**,不是抄出来的。 +#[test] +fn outlined_boundary_meets_the_non_text_contrast_bar() { + for (name, ui) in themes() { + let scheme = ui.scheme(); + let boundary = scheme.contrast(Role::Outline, Role::Surface); + assert!( + boundary >= 3.0, + "{name}: Outline/Surface = {boundary:.2},非文字元素下限是 3:1" + ); + // 报告用:装饰角色差多少(`--nocapture` 可见),不改判据 + eprintln!( + "[{name}] Outline/Surface={boundary:.2} · OutlineVariant/Surface={:.2}", + scheme.contrast(Role::OutlineVariant, Role::Surface) + ); + } +} + +#[test] +fn styles_follow_the_scheme_across_boards_and_custom_accents() { + let elevated = spec_of(Variant::Elevated, Some(super::Interaction::Enabled)); + let outlined = spec_of(Variant::Outlined, Some(super::Interaction::Enabled)); + + let mut elevated_fills = Vec::new(); + let mut outlined_strokes = Vec::new(); + + for (name, ui) in themes() { + let style = style_closure(&ui, elevated); + let idle = style(&ui.theme(), button::Status::Active); + let expected = ui.color(Elevation::Level1.surface_role()); + assert_eq!( + idle.background, + Some(Background::Color(expected)), + "{name}: Elevated 的底 = 本方案的 L1 容器面" + ); + assert_ne!( + idle.background, + Some(Background::Color(ui.color(Role::Surface))), + "{name}: Elevated 退化成画布色了(取死值 Surface 的变异会打红这条)" + ); + assert_eq!( + idle.text_color, + ui.color(Role::Primary), + "{name}: Elevated 标签 = Primary" + ); + + let style = style_closure(&ui, outlined); + let idle = style(&ui.theme(), button::Status::Active); + assert_eq!( + idle.border.color, + ui.color(Role::Outline), + "{name}: Outlined 描边 = Outline" + ); + assert_eq!(idle.border.width, STROKE_PX, "{name}: 描边 1dp"); + assert_eq!( + idle.text_color, + ui.color(Role::OnSurfaceVariant), + "{name}: Outlined 标签 = OnSurfaceVariant" + ); + + elevated_fills.push(expected); + outlined_strokes.push(idle.border.color); + } + + // 三套方案两两不同 ⇒「跟着配色来源走」这件事有实据(不是三套恰好同色) + for (index, (name, _)) in themes().iter().enumerate() { + for other in index + 1..elevated_fills.len() { + assert_ne!( + elevated_fills[index], elevated_fills[other], + "{name} 与第 {other} 套方案的 Elevated 底同色,可主题化没有实据" + ); + assert_ne!( + outlined_strokes[index], outlined_strokes[other], + "{name} 与第 {other} 套方案的 Outlined 描边同色" + ); + } + } +} + +/// 基线条:Ink(深)与 Silver(浅)两块**出厂**板必须给出不同的 Elevated 底与 Outlined 描边。 +/// +/// 与上一条分开写:上一条的三套方案里有一套是自定义的,这条只钉出厂外观—— +/// 哪怕有人把 `themes()` 改成只跑自定义方案,出厂这块的回归也还在。 +#[test] +fn ink_and_silver_resolve_the_same_roles_to_different_colours() { + let ink = Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES); + let silver = Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES); + for role in [ + Elevation::Level1.surface_role(), + Role::Outline, + Role::OnSurfaceVariant, + ] { + assert_ne!( + ink.color(role), + silver.color(role), + "{role:?} 在 Ink 与 Silver 上同色,说明它没跟着色板走" + ); + } + // 浅色板的 L1 容器必须比画布暗、深色板必须比画布亮(层级方向不能反) + assert!(silver.color(Role::Surface).r > silver.color(Role::SurfaceContainerLow).r); + assert!(ink.color(Role::Surface).r < ink.color(Role::SurfaceContainerLow).r); +} diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs index e65ccff..1a2fae5 100644 --- a/crates/app-iced/src/components/feedback/geometry.rs +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -5,7 +5,7 @@ //! | # | 量 | 参考值 | 落点 | //! |---|---|---|---| //! | 12.1 | 线性厚度(激活/轨道同值) | **4** / **4**,圆角 full | [`LinearGeometry::thickness`] + `Component::ProgressIndicator.spec().shape` | -//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 | +//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 **`SurfaceContainerHighest`**(A1 `TrackColor` 是 `SecondaryContainer`;M248 按「轨道是背景尺」改口径,登记差异;旧值留作对照) | 组件层取角色 | //! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] | //! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 **0→1440°** = 全局 1080° + 关键帧 360°) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] | //! | 14.1 | Snackbar 圆角 | 规格卡 **16**(`inkcat::metrics::SNACKBAR`;官方 `CornerExtraSmall` 是 4,M229 换肤按规格卡) | `panel::SnackbarGeometry::radius` | diff --git a/crates/app-iced/src/components/feedback/pixel.rs b/crates/app-iced/src/components/feedback/pixel.rs index 704de58..2bf9f99 100644 --- a/crates/app-iced/src/components/feedback/pixel.rs +++ b/crates/app-iced/src/components/feedback/pixel.rs @@ -60,6 +60,29 @@ impl Shot { pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] { self.pixels[y * self.width + x] } + + /// **合成图**(不读盘):按 `(x, y) → RGB` 造一张 `width × height` 的图。 + /// + /// 只给回归用例用:有些几何错误在真截图上**不会自然出现**(例如「轨道被宿主裁短」—— + /// 真实渲染里轨道永远占满宿主,只有把像素涂掉才看得到,那是复审的一次性动作)。 + /// 合成图让这类形状变成可复现的夹具,见 `tests/bands.rs`。 + pub(super) fn from_rows( + width: usize, + height: usize, + pixel: impl Fn(usize, usize) -> [u8; 3], + ) -> Self { + let mut pixels = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + pixels.push(pixel(x, y)); + } + } + Self { + width, + height, + pixels, + } + } } /// 一个外接框(闭区间)。 @@ -209,6 +232,50 @@ pub(super) fn band_width(shot: &Shot, band: (usize, usize), fill: Color, under: .unwrap_or(0) } +/// 按「**每行最长连续段** ≥ `min_run`」聚成的竖带。 +/// +/// 与 [`bands`] 的区别只在「一行算不算墨」的判法:`bands` 数**覆盖率 ≥50% 的像素个数**, +/// 本函数要求这些像素连成一长段。M248 换了进度轨道的角色之后,这个区别变成了分水岭: +/// +/// - 轨道从强调色族(`SecondaryContainer`)改成中性容器面(`SurfaceContainerHighest`)后, +/// 「画布 → 轨道」这条混色轴**正好穿过中性文字与中性容器面**:文字像素的投影 `t` 会 clamp 到 +/// 1.0、残差只剩 <8,于是整行文字都能被数成「轨道墨」(浅色板实测:命中行数从 2 条涨到 5 条, +/// 多出来的三条是图注文字,高度 6 / 2 / 6 行); +/// - 按**连续段**判就不受影响:一行 12px 的字形边缘凑不出 200px 的连续同色段,而 360px 的轨道 +/// 一眼就能认出来。 +/// +/// 阈值的取值口径:轨道带最窄的整段是 200px 以上(线性进度宿主 320/360 宽,指示条压在轨道上 +/// 会把轨道切成几段,最长的那段仍 ≥200——不加指示条的确定态那一格是整条 360)。 +pub(super) fn bands_by_run( + shot: &Shot, + fill: Color, + under: Color, + min_run: usize, +) -> Vec<(usize, usize)> { + let mut found: Vec<(usize, usize)> = Vec::new(); + let mut current: Option = None; + for y in 0..shot.height { + let longest = runs(shot, y, fill, under) + .into_iter() + .map(|(_, len)| len) + .max() + .unwrap_or(0); + let hit = longest >= min_run; + match (hit, current) { + (true, None) => current = Some(y), + (false, Some(top)) => { + found.push((top, y - 1)); + current = None; + } + _ => {} + } + } + if let Some(top) = current { + found.push((top, shot.height - 1)); + } + found +} + /// 某种颜色的**精确色**连通块(只用于「找着一大块实心」这种粗定位)。 pub(super) fn blobs(shot: &Shot, color: Color, min_side: usize) -> Vec { let target = rgb(color); diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index b130802..cd54a15 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -31,12 +31,13 @@ mod circular; mod panels; +pub(super) mod track; use unlucky_design::color::{Role, SchemeFlavor}; use unlucky_design::typography::Platform; use super::geometry::PHASE_055; -use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot}; +use super::pixel::{band_width, bands, bands_by_run, matches, near, rgb, runs, shot_path, Shot}; use crate::theme::{Families, Ui}; const FAMILIES: Families = Families { @@ -54,6 +55,11 @@ const TRACK3_X0: usize = 688; /// 单条轨道宽(gallery 的 `TRACK_HOST`)。 const TRACK3_W: usize = 320; +/// 认定为「主色进度行」所需的连续主色段长度。 +/// +/// phase 0.55 的两段分别是 ≈24px 与 ≈165px,取 60 既收得住第二段、又挡得住文字行(实测 ≤22px)。 +const INDETERMINATE_MIN_RUN: usize = 60; + /// 一条测试覆盖当前截图对应的那一段(段由 `ULL_FEEDBACK_PART` 指定,默认 `all`)。 #[test] fn feedback_geometry_matches_the_reference() { @@ -106,37 +112,10 @@ fn check_linear(shot: &Shot, ui: &Ui) { // 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后 // 「实测 6 == 期望 6」会照样绿,等于没有断言。 let surface = ui.color(Role::Surface); - let track = ui.color(Role::SecondaryContainer); let active = ui.color(Role::Primary); - // ---- 12.1 厚度:轨道带恰好 4 行(宿主 320 宽 ⇒ 每行 ~320 个轨道墨点)-------- - let bar_bands = bands(shot, track, surface, 300); - assert!( - !bar_bands.is_empty(), - "没找到线性进度轨道(宿主宽 320,应有 ≥300 个墨点的行)" - ); - for (top, bottom) in &bar_bands { - let height = bottom - top + 1; - assert!( - near(height, 4), - "12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})" - ); - } - - // ---- 12.1 圆角 full:条的四角必须透出底色(直角会把角填满)------------------ - let (top, _) = bar_bands[0]; - let (left, len) = runs(shot, top + 1, track, surface) - .into_iter() - .find(|(_, len)| *len >= 300) - .expect("轨道那一行必须有 ≥300 的长段"); - assert!( - !matches(shot.at(left, top), rgb(track)), - "12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)" - ); - assert!( - matches(shot.at(left + len / 2, top + 1), rgb(track)), - "条的中间必须有墨(否则是没画出来,不是圆角)" - ); + // §12.1(厚度 / 宽度 / 圆角)搬去 `probe/track.rs`:那边撞了 400 行红线,见该文件文档。 + track::check(shot, ui); // ---- 12.4 不定态:phase 0.55 那一列必须**数出两段**(复审 P1-A 的回归断言)---- // @@ -174,21 +153,19 @@ fn check_linear(shot: &Shot, ui: &Ui) { ); } - eprintln!( - "[feedback-probe] progress:轨道带 {} 条(高 {:?})· phase 0.55 两段 {found:?}", - bar_bands.len(), - bar_bands - .iter() - .map(|(top, bottom)| bottom - top + 1) - .collect::>(), - ); + eprintln!("[feedback-probe] progress·12.4:phase 0.55 两段 {found:?}(行 {row})"); } /// 线性不定态那一行的 y:主色墨迹在**第一条轨道**里出现、且不在确定态行的最上一组。 /// /// 不想硬编码坐标(gallery 一改间距就失准),所以按「主色横带 + 排除最高那条带」找。 +/// +/// **按连续段判,不按计数判**(M248 修):标题与图注的行里主色像素能凑到 20 个以上, +/// 按旧口径 `bands(.., 20)` 会把标题行(深色板实测 y≈64)当成第一组主色带,`found[1]` +/// 于是落在文字上、`runs` 只返回一个 11px 的段 —— 这条在**改前**就红(用改前的图 + 改前的 +/// 探针复现过,与换轨道颜色无关)。轨道行是 60px 以上的**连续**主色段,文字行不是。 fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> usize { - let found = bands(shot, active, surface, 20); + let found = bands_by_run(shot, active, surface, INDETERMINATE_MIN_RUN); assert!( found.len() >= 2, "至少要找到确定态行与不定态行两组主色横带,实测 {found:?}" diff --git a/crates/app-iced/src/components/feedback/probe/circular.rs b/crates/app-iced/src/components/feedback/probe/circular.rs index 42e7a7a..466f9d3 100644 --- a/crates/app-iced/src/components/feedback/probe/circular.rs +++ b/crates/app-iced/src/components/feedback/probe/circular.rs @@ -44,7 +44,8 @@ fn is_ink(pixel: [u8; 3], fill: Color, under: Color) -> bool { /// 环形那一排的判据:逐窗测「有没有一圈 40×40 的墨迹」。 pub(super) fn check(shot: &Shot, ui: &Ui) { let active = ui.color(Role::Primary); - let track = ui.color(Role::SecondaryContainer); + // M248:轨道改成容器面角色(与线性同源),参考值写字面角色,不从 `progress_roles()` 读回。 + let track = ui.color(Role::SurfaceContainerHighest); let surface = ui.color(Role::Surface); // 环带的 y:以第一个窗(0% 环,只有轨道色)的**圆环签名**定位 —— 中间那几十行在窗的 diff --git a/crates/app-iced/src/components/feedback/probe/track.rs b/crates/app-iced/src/components/feedback/probe/track.rs new file mode 100644 index 0000000..8b28748 --- /dev/null +++ b/crates/app-iced/src/components/feedback/probe/track.rs @@ -0,0 +1,112 @@ +//! 线性进度**轨道本身**的判据(§12.1:厚度 / 宽度 / 圆角)。 +//! +//! 从 `probe.rs` 拆出来(M248 r1 复审时那边正好 401 行,撞 400 红线):顺着 `circular` / +//! `panels` 的同一先例,一族几何一个文件。 +//! +//! ## 取带规则(M248 r1 P2-1 / P2-2 的教训) +//! +//! 轨道的配色从强调色族换成中性容器面(`SurfaceContainerHighest`)之后,「画布 → 轨道」这条 +//! 混色轴正好穿过中性文字:按**计数**判(`bands`,一行里有多少个覆盖率 ≥50% 的像素)会把 +//! 图注行也数成轨道(浅色板实测多出 3 条假带:6/2/6 行)。所以这里改成按**连续段**判 +//! (`pixel::bands_by_run`)。 +//! +//! 复审判过这条改动:「换判据」的方向对,但第一版把阈值定成 200,**顺手丢了半个守卫**—— +//! 把 after 图里 0% 那条轨道右半截涂回画布色(连续段 160px = 轨道被宿主裁短的形态)之后, +//! 那一行直接不再成带,`check_linear` 靠另一条带照样绿。现在分两条判据: +//! +//! | 判据 | 常量 | 决定什么 | 实测口径(两套主题一致) | +//! |---|---|---|---| +//! | 成带 | [`TRACK_MIN_RUN`] = **100** | 这一行算不算轨道行 | 确定态行最长段 318~320、不定态行 306~308、文字行 **22** | +//! | 宽度 | [`TRACK_MIN_WIDTH`] = **300** | 这条轨道的宽度对不对 | 宿主 320/360,砍半(160)必须判不合格 | +//! +//! ⚠️ **别按「单条轨道」心算阈值**:`bands_by_run` 是按**整行**取最长连续段(三个宿主在同一 +//! y 上,取其中最长那一段),不是按单条 320 轨道取。按单条轨道算会得到 ~88px 的错值, +//! 于是把阈值定到 200,160px 的窄轨道就整行掉出判据——复审就是这么抓到第一版的漏洞的。 +//! +//! 取值区间由 `tests/bands.rs` 的合成图钉住(成带阈值调大到 >160、或小到 ≤22,那里都红)。 + +use unlucky_design::color::Role; + +use super::super::pixel::{band_width, bands_by_run, matches, near, rgb, runs, Shot}; +use crate::theme::Ui; + +/// 认定为「轨道行」所需的**连续**同色段长度。 +/// +/// `pub(crate)`:`tests/bands.rs` 的合成图要断言这两个阈值本身(`probe` 是 `#[cfg(test)]` 模块)。 +pub(crate) const TRACK_MIN_RUN: usize = 100; + +/// 轨道必须占满宿主带来的宽度(线性进度宿主 320/360,下限取 300)。 +pub(crate) const TRACK_MIN_WIDTH: usize = 300; + +/// `check` 的**取带规则**(`pub(crate)` 是为了让 `tests/bands.rs` 的合成图跑同一段逻辑)。 +/// +/// 抽函数而不是让用例直接调 `bands_by_run`:用例要能在「调用点被改回计数判据」时变红, +/// 那就得覆盖调用点本身。 +pub(crate) fn track_bands( + shot: &Shot, + track: iced::Color, + surface: iced::Color, +) -> Vec<(usize, usize)> { + bands_by_run(shot, track, surface, TRACK_MIN_RUN) +} + +/// §12.1:线性轨道的厚度(4 行)、宽度(占满宿主)、圆角(full)。 +/// +/// 参考值写字面量(A1):**不能**从 `LinearGeometry` 读回期望值——那样把 4 改成 6 之后 +/// 「实测 6 == 期望 6」会照样绿,等于没有断言。轨道的**角色**同理,由调用方给字面角色 +/// (`SurfaceContainerHighest`),不从 `progress::progress_roles()` 读回。 +pub(super) fn check(shot: &Shot, ui: &Ui) { + let surface = ui.color(Role::Surface); + let track = ui.color(Role::SurfaceContainerHighest); + + let bar_bands = track_bands(shot, track, surface); + assert!( + bar_bands.len() >= 2, + "线性进度应有两条轨道带(确定态 + 不定态),实测 {bar_bands:?}:\ + 某一条整行掉出判据 = 它被裁到 <{TRACK_MIN_RUN}px 连不成段了" + ); + for (top, bottom) in &bar_bands { + let height = bottom - top + 1; + assert!( + near(height, 4), + "12.1:线性进度厚必须 4(A1 Height;实测 {height} @ 行 {top})" + ); + // 宽度**单独**判(M248 r1 P2-1):阈值 100 会把被裁短的轨道也认成一条带, + // 所以「窄了」不能再靠「找不到带」兜——复审反例(砍到 160px)走的就是这条路。 + let widest = band_width(shot, (*top, *bottom), track, surface); + assert!( + widest >= TRACK_MIN_WIDTH, + "12.1:轨道必须占满宿主(≥{TRACK_MIN_WIDTH}px 的连续段),实测最宽段 {widest}px @ 行 {top}" + ); + } + + // 圆角 full:条的四角必须透出底色(直角会把角填满)。 + let (top, _) = bar_bands[0]; + let (left, len) = runs(shot, top + 1, track, surface) + .into_iter() + .find(|(_, len)| *len >= TRACK_MIN_WIDTH) + .expect("上面刚判过宽度 ≥300,这一行必须能找到长段"); + assert!( + !matches(shot.at(left, top), rgb(track)), + "12.1:圆角 full(2) 的左上角必须透出底色(直角会填满)" + ); + assert!( + matches(shot.at(left + len / 2, top + 1), rgb(track)), + "条的中间必须有墨(否则是没画出来,不是圆角)" + ); + + // 覆盖面账(复审可对 `--nocapture` 输出核):两条带**都在**厚度/宽度判据里; + // 不定态那条 5 行是含 1 行抗锯齿(`near(·, 4)` 容 ±1)。12.4 只量主色两端点,不量厚度。 + eprintln!( + "[feedback-probe] 轨道带 {} 条(高 {:?})· 最宽段 {:?}", + bar_bands.len(), + bar_bands + .iter() + .map(|(top, bottom)| bottom - top + 1) + .collect::>(), + bar_bands + .iter() + .map(|band| band_width(shot, *band, track, surface)) + .collect::>(), + ); +} diff --git a/crates/app-iced/src/components/feedback/progress.rs b/crates/app-iced/src/components/feedback/progress.rs index 765dc42..fbf5b57 100644 --- a/crates/app-iced/src/components/feedback/progress.rs +++ b/crates/app-iced/src/components/feedback/progress.rs @@ -4,9 +4,16 @@ //! //! | 形态 | 确定态 | 不定态 | //! |---|---|---| -//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) | +//! | [`linear`] | 轨道 `SurfaceContainerHighest` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) | //! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 **0→1440°**:全局 1080° 线性 + 关键帧 360°,[`CircularGeometry::indeterminate`]) | //! +//! **轨道角色为什么是容器面(M248,与 A1 的登记差异)**:A1 的 +//! `LinearProgressIndicatorTokens.TrackColor` 是 `SecondaryContainer`,本项目取 +//! `SurfaceContainerHighest`。口径是「轨道 = 进度还剩多少的**背景尺**,属于表面层级; +//! 强调色只属于指示条」。取角色而不是色值,所以换品牌板 / 换 D38 取色方案它跟着变 +//! (判据见 `tests/progress.rs` 的 `track_is_the_highest_container_step_and_follows_the_scheme`)。 +//! 12b §12.2 的旧值仍留在 `geometry.rs` 的参考值表里当对照。 +//! //! ## 为什么不定态要调用方给 `phase` //! //! 契约 §2:组件不持有时钟。`phase`(0..1 的一个周期)由页面按 `Subscription` 推进; @@ -158,14 +165,36 @@ pub fn portion_shares(items: &[(bool, f32)]) -> Vec<(bool, u16)> { shares } +/// 进度家族的取色角色 `(轨道, 指示条)`。 +/// +/// **为什么要这个函数**:`Element` 是黑盒、`container::Style` 与 `svg::Style` 都是闭包, +/// 两者都不可内省(同 `buttons::icon_style_spec` 的教训:把 `stroke` 写死成 0 时纯函数测试照样绿)。 +/// 取色点收成这一对角色,单测才能钉住「取的是哪个角色」而不只是「渲染没 panic」。 +/// +/// 轨道 = `SurfaceContainerHighest`(比画布高一档以上的容器面,与输入框底同层);指示条 = `Primary`。 +/// 环形与线性共用同一对:一根进度条两套配色口径就没人说得清哪个才是「进度」。 +pub(crate) const fn progress_roles() -> (Role, Role) { + (Role::SurfaceContainerHighest, Role::Primary) +} + +/// `linear()` 真正塞进样式闭包的两个颜色(单测断言的就是它,口径同 `StyleSpec`)。 +pub(crate) fn linear_colors(ui: &Ui) -> (iced::Color, iced::Color) { + let (track, active) = progress_roles(); + (ui.color(track), ui.color(active)) +} + +/// 环形进度的取色(与 [`linear_colors`] 同源)。 +pub(crate) fn circular_colors(ui: &Ui) -> (iced::Color, iced::Color) { + linear_colors(ui) +} + /// 线性进度条(轨道 + 指示条,厚 4、圆角 full)。 /// /// 轨道宽 `Fill`(由宿主决定),指示条按占比分到 `FillPortion` 上——**不需要知道轨道像素宽**, /// 所以 50% 在任何宿主宽度下都精确是半条。 pub fn linear<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { let geometry = LinearGeometry::of(ui); - let track_color = ui.color(Role::SecondaryContainer); - let active_color = ui.color(Role::Primary); + let (track_color, active_color) = linear_colors(ui); let mut bar = row![] .width(Length::Fill) @@ -218,26 +247,19 @@ pub fn circular<'a, M: 'a>(ui: &'a Ui, progress: Progress) -> Element<'a, M> { } }; - let track = glyph( - &geometry, - ring_source(&geometry), - ui.color(Role::SecondaryContainer), - ); + let (track_color, active_color) = circular_colors(ui); + let track = glyph(&geometry, ring_source(&geometry), track_color); // 0% 只画轨道(不画弧,也不画圆头小点);100% 画整环(`A` 命令的首尾重合画不出整圆) let active = if sweep < MIN_SWEEP_DEG { None } else if sweep >= FULL_SWEEP_DEG { - Some(glyph( - &geometry, - ring_source(&geometry), - ui.color(Role::Primary), - )) + Some(glyph(&geometry, ring_source(&geometry), active_color)) } else { Some(glyph( &geometry, arc_source(&geometry, sweep, rotation), - ui.color(Role::Primary), + active_color, )) }; diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index 6e3b635..7d74cf4 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -18,6 +18,7 @@ use super::progress::Progress; use super::snackbar::{Placement, Snackbar}; use super::{circular, dialog, empty_state, linear, snackbar, Dialog, EmptyState}; +mod bands; mod progress; const FAMILIES: Families = Families { diff --git a/crates/app-iced/src/components/feedback/tests/bands.rs b/crates/app-iced/src/components/feedback/tests/bands.rs new file mode 100644 index 0000000..12102aa --- /dev/null +++ b/crates/app-iced/src/components/feedback/tests/bands.rs @@ -0,0 +1,123 @@ +//! 探针**取带规则**的回归(M248 r1 复审 P2-1 / P2-2)——用合成图,不依赖真实截图。 +//! +//! ## 为什么必须用合成图 +//! +//! 真实渲染里轨道**永远占满宿主**,「轨道被裁短」这类几何回归在图上不会自然出现。 +//! 复审是手工把 after 图里 0% 那条轨道右半截涂回画布色才造出反例的(一次性动作), +//! 而那一涂正好打中第一版的漏洞:阈值 200 时该行**直接不再被认成轨道行**, +//! `check_linear` 靠另一条带照样绿——「判据被放宽」在真实图上永远不露馅。 +//! 合成图把这个形状变成可复现夹具。 +//! +//! ## 两条用例各钉住什么 +//! +//! | 用例 | 钉住的取值区间 | 打红它的变异 | +//! |---|---|---| +//! | [`narrowed_track_is_still_a_band`] | `TRACK_MIN_RUN` ≤ 160(窄轨道必须仍成带),且 `TRACK_MIN_WIDTH` > 160(它必须过不了宽度判据) | ① 调用点换回计数判据 `bands(.., 200)`(窄轨道那条带消失 → `bands.len()` 变 1);② `TRACK_MIN_WIDTH` 调到 ≤160(宽度判据失去区分力) | +//! | [`text_rows_are_never_track_bands`] | `TRACK_MIN_RUN` > 22(文字行最长连续段就是 22) | 阈值调到 ≤22(图注行会被数成轨道,正是第一版的假红) | +//! +//! **抓不住**:真实截图里轨道被裁/被折线的具体像素形态(那仍要复审涂图或用真实 probe), +//! 也抓不住厚度那一条(厚度仍由真实截图的 `track::check` 判)。 + +use unlucky_design::color::{BrandBoard, Role}; +use unlucky_design::typography::Platform; + +use super::super::pixel::{band_width, rgb, Shot}; +use super::super::probe::track::{track_bands, TRACK_MIN_RUN, TRACK_MIN_WIDTH}; +use super::FAMILIES; +use crate::theme::Ui; + +/// 合成图的配色来源:用 Ink 板(深色)——轨道 `SurfaceContainerHighest` 与画布 `Surface` +/// 在这里是**两个不同的中性色**,正好复现「混色轴穿过中性文字」那一幕的坐标轴。 +fn strip_ui() -> Ui { + Ui::with_board(BrandBoard::Ink, Platform::Windows, FAMILIES) +} + +/// 一段横线:`y0..=y1` 行里前 `len` 个像素涂 `color`,其余留底色。 +fn strip( + width: usize, + height: usize, + rows: &[(usize, usize, usize)], + color: [u8; 3], + under: [u8; 3], +) -> Shot { + Shot::from_rows(width, height, |x, y| { + let hit = rows + .iter() + .any(|(top, bottom, len)| (*top..=*bottom).contains(&y) && x < *len); + if hit { + color + } else { + under + } + }) +} + +/// 复审反例的形态:**轨道比宿主窄一半**(160 = 320 宿主的一半)。 +/// +/// 阈值 200 的版本在这里会**只找到一条带**(窄的那条整行掉出判据)——这正是那条 p2 的漏判, +/// 所以 `bands.len() == 2` 是这条用例的主断言之一。 +#[test] +fn narrowed_track_is_still_a_band() { + let ui = strip_ui(); + let track = rgb(ui.color(Role::SurfaceContainerHighest)); + let surface = rgb(ui.color(Role::Surface)); + + // 360×20:y=5..8 是**被裁到 160px** 的轨道;y=12..15 是占满宿主的轨道。 + let shot = strip(360, 20, &[(5, 8, 160), (12, 15, 360)], track, surface); + + let bands = track_bands( + &shot, + ui.color(Role::SurfaceContainerHighest), + ui.color(Role::Surface), + ); + assert_eq!( + bands, + vec![(5, 8), (12, 15)], + "窄轨道也必须被认成一条带(TRACK_MIN_RUN={TRACK_MIN_RUN};调大到 ≥160 时它会消失,\ + 于是 `check_linear` 只能看见另一条带、漏判——复审就是在这里抓到第一版的)" + ); + + let widths: Vec = bands + .iter() + .map(|band| { + band_width( + &shot, + *band, + ui.color(Role::SurfaceContainerHighest), + ui.color(Role::Surface), + ) + }) + .collect(); + assert_eq!(widths, vec![160, 360], "两条带的最宽连续段"); + assert!( + widths[0] < TRACK_MIN_WIDTH, + "窄轨道必须过不了宽度判据(TRACK_MIN_WIDTH={TRACK_MIN_WIDTH}),否则「轨道被裁短」\ + 这类回归会绿着过" + ); + assert!(widths[1] >= TRACK_MIN_WIDTH, "占满宿主的轨道必须过宽度判据"); +} + +/// 文字行永远不能进判据:图注行的最长连续段实测就是 **22px**(`after/gallery-progress-*`)。 +/// +/// 这条是「阈值别调得太小」的那一侧:阈值 ≤22 时,图注行会被数成轨道带, +/// 浅色板那次假红(`12.1:线性进度厚必须 4(实测 6 @ 行 63)`)就会回来。 +#[test] +fn text_rows_are_never_track_bands() { + let ui = strip_ui(); + let track = rgb(ui.color(Role::SurfaceContainerHighest)); + let surface = rgb(ui.color(Role::Surface)); + + // 6 行「文字」:每段 22px、段间隔 12px(字形与字距的粗略形态),整行铺满。 + let shot = Shot::from_rows(360, 6, |x, _y| if x % 34 < 22 { track } else { surface }); + + let bands = track_bands( + &shot, + ui.color(Role::SurfaceContainerHighest), + ui.color(Role::Surface), + ); + assert!( + bands.is_empty(), + "22px 的文字段不能算轨道行(TRACK_MIN_RUN={TRACK_MIN_RUN};调到 ≤22 时这里会红,\ + 那正是浅色板假红 `实测 6 @ 行 63` 的来源),实测 {bands:?}" + ); +} diff --git a/crates/app-iced/src/components/feedback/tests/progress.rs b/crates/app-iced/src/components/feedback/tests/progress.rs index 60f0f53..b32e7f0 100644 --- a/crates/app-iced/src/components/feedback/tests/progress.rs +++ b/crates/app-iced/src/components/feedback/tests/progress.rs @@ -7,13 +7,17 @@ //! - [`circular_indeterminate_rotation_is_four_turns_and_continuous`]:每周期 4 圈、跨周期不跳变 //! (代码 r1 P1-B:旧实现只有 1 圈,且末尾倒跳 270°)。 -use unlucky_design::color::SchemeFlavor; +use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor}; use unlucky_design::shape::ShapeScale; +use unlucky_design::typography::Platform; use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry, PHASE_055}; use super::super::keyframes::{KeyPoint, Keyframes}; -use super::super::progress::{layout_spans, portion_shares, Amount, Progress}; +use super::super::progress::{ + circular_colors, layout_spans, linear_colors, portion_shares, progress_roles, Amount, Progress, +}; use super::ui; +use crate::theme::Ui; // ---- §12 进度 --------------------------------------------------------------- @@ -279,3 +283,79 @@ fn keyframes_hold_ends_and_wrap() { ); assert_eq!(LINE.sample(f32::NAN), 0.0, "NaN 不 panic,给首值"); } + +// ---- M248:进度轨道的角色语义与「可主题化」 --------------------------------- + +/// 轨道的配色判据**不是某个 hex**:两块出厂品牌板 + 一套自定义种子方案(`Scheme::from_seed`),轨道都必须解出 +/// 「本方案的容器面角色」,且三套互不相同。把取角色改成取死值(`Role::Surface`)或写死 +/// `SecondaryContainer`,这条都会红。 +/// +/// 抓不住什么:**像素**(角色对、图上却画错了)——那是 `super::super::probe` 的事; +/// 也抓不住「两个角色在令牌表里恰好同值」的情形,那条由这一个用例里的角色 pin 兜。 +#[test] +fn track_is_the_highest_container_step_and_follows_the_scheme() { + // 角色 pin:轨道 = 比画布高一档的容器面;指示条 = 强调色。 + // (A1 `LinearProgressIndicatorTokens.TrackColor` 是 `SecondaryContainer`,M248 按 + // 「轨道是背景尺、强调色只属于指示条」改口径,差异登记在 `geometry.rs` 的 12.2 行。) + assert_eq!( + progress_roles(), + (Role::SurfaceContainerHighest, Role::Primary), + "进度:轨道 SurfaceContainerHighest + 指示条 Primary" + ); + + // 第三套方案用**取色链的默认种子**(`DEFAULT_SEED`,天蓝):design 的令牌,不是色值字面量。 + let themes = [ + ( + "ink", + Ui::with_board(BrandBoard::Ink, Platform::Windows, super::FAMILIES), + ), + ( + "silver", + Ui::with_board(BrandBoard::Silver, Platform::Windows, super::FAMILIES), + ), + ( + "custom", + Ui::from_scheme( + Scheme::from_seed(SchemeFlavor::Dark, unlucky_design::color::DEFAULT_SEED), + super::FAMILIES, + ), + ), + ]; + + let mut tracks = Vec::new(); + for (name, theme) in &themes { + let (track, active) = linear_colors(theme); + assert_eq!( + track, + theme.color(Role::SurfaceContainerHighest), + "{name}: 轨道 = 本方案的最高容器面" + ); + assert_ne!( + track, + theme.color(Role::Surface), + "{name}: 轨道退化成画布色了(取死值 Surface 的变异会打红这条)" + ); + assert_ne!( + track, + theme.color(Role::SecondaryContainer), + "{name}: 轨道又借回强调色族了" + ); + assert_eq!( + active, + theme.color(Role::Primary), + "{name}: 指示条 = Primary" + ); + assert_eq!( + circular_colors(theme), + (track, active), + "{name}: 环形取色与线性同源" + ); + tracks.push(track); + } + + assert_ne!(tracks[0], tracks[1], "Ink 与 Silver 的轨道必须不同色"); + assert_ne!( + tracks[0], tracks[2], + "换一套种子方案,轨道必须跟着变(可主题化的证据)" + ); +} diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 3edc214..207618f 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -190,7 +190,7 @@ fn progress_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { column![ caption( ui, - "Linear · determinate 0% / 50% / 100%(轨道 SecondaryContainer · 指示条 Primary)" + "Linear · determinate 0% / 50% / 100%(轨道 SurfaceContainerHighest · 指示条 Primary)" ), determinate, caption(