diff --git a/crates/app-iced/src/app/tests/theme.rs b/crates/app-iced/src/app/tests/theme.rs index b626c5f..198b0f2 100644 --- a/crates/app-iced/src/app/tests/theme.rs +++ b/crates/app-iced/src/app/tests/theme.rs @@ -84,3 +84,104 @@ fn the_theme_message_switches_the_board_and_persists_it() { assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型"); std::fs::remove_dir_all(&dir).ok(); } + +/// **截图通道把主题档钉死在 `--screenshot-theme`**,不读机器上的 `launcher.json`(D30 可复现)。 +/// +/// 复审 r2 P2-a 指出:`construct.rs` 里新加的这条分支没有回归测试——于是「哪天有人把它改回 +/// `bridge.load().1`」不会有人发现,两次不同配置的机器又会出不同颜色的图。 +/// +/// 判据:**同一份截图参数 + 两份不同的机器配置 ⇒ 同一个档位**,且等于参数(浅→`Light`、深→`Dark`); +/// 反证:非截图通道下同样两份配置**确实**分出两个档(否则这条测试等于没测到分支)。 +#[test] +fn the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file() { + use std::path::{Path, PathBuf}; + + /// 写一份只含 `appearance.theme_mode` 的临时配置,并用它构造 App。 + fn with_mode( + dir_name: &str, + json: &str, + options: Options, + flavor: SchemeFlavor, + ) -> (App, PathBuf) { + let dir = + std::env::temp_dir().join(format!("ull-shot-theme-{dir_name}-{}", std::process::id())); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + std::fs::write(&path, json).expect("写配置文件"); + let app = App::with_presenter( + options, + FAMILIES, + flavor, + Box::new(MockBackend::new()), + SettingsBridge::with_path(&path), + ); + (app, dir) + } + + fn cleanup(dir: &Path) { + std::fs::remove_dir_all(dir).ok(); + } + + let dark_file = r#"{"appearance":{"theme_mode":"dark"}}"#; + let light_file = r#"{"appearance":{"theme_mode":"light"}}"#; + + // ① 截图参数给浅色:机器配置是 dark 也一样出 Light(钉死值赢) + let shot_light = Options { + screenshot: Some(PathBuf::from("unused.png")), + screenshot_theme: Some(SchemeFlavor::Light), + ..Options::default() + }; + let (from_dark_file, d1) = with_mode( + "light-a", + dark_file, + shot_light.clone(), + SchemeFlavor::Light, + ); + let (from_light_file, d2) = with_mode("light-b", light_file, shot_light, SchemeFlavor::Light); + assert_eq!( + from_dark_file.theme_mode(), + ThemeMode::Light, + "截图模式下档位必须由 --screenshot-theme 决定,不许读文件里的 dark" + ); + assert_eq!( + from_dark_file.theme_mode(), + from_light_file.theme_mode(), + "两份机器配置必须出同一个档位" + ); + assert_eq!(from_dark_file.board_name(), "silver"); + assert_eq!(from_light_file.board_name(), "silver"); + + // ② 反方向:截图参数给深色 ⇒ 即使文件写 light 也是 Dark/Ink + let shot_dark = Options { + screenshot: Some(PathBuf::from("unused.png")), + screenshot_theme: Some(SchemeFlavor::Dark), + ..Options::default() + }; + let (from_light_file_dark, d3) = with_mode("dark-a", light_file, shot_dark, SchemeFlavor::Dark); + assert_eq!(from_light_file_dark.theme_mode(), ThemeMode::Dark); + assert_eq!(from_light_file_dark.board_name(), "ink"); + + // ③ 反证:非截图通道下,同样的两份配置**确实**分出两个档(证明这条分支真的存在) + let (interactive_dark, d4) = + with_mode("plain-a", dark_file, Options::default(), SchemeFlavor::Dark); + let (interactive_light, d5) = with_mode( + "plain-b", + light_file, + Options::default(), + SchemeFlavor::Light, + ); + assert_eq!( + interactive_dark.theme_mode(), + ThemeMode::Dark, + "产品路径要读文件" + ); + assert_eq!( + interactive_light.theme_mode(), + ThemeMode::Light, + "产品路径要读文件" + ); + + for dir in [d1, d2, d3, d4, d5] { + cleanup(&dir); + } +} diff --git a/crates/app-iced/src/components/gallery/buttons.rs b/crates/app-iced/src/components/gallery/buttons.rs index 19ec5bb..8e661a0 100644 --- a/crates/app-iced/src/components/gallery/buttons.rs +++ b/crates/app-iced/src/components/gallery/buttons.rs @@ -9,13 +9,16 @@ //! elevated | //! outlined | //! text | -//! icon·standard | [ico] | ... -//! icon·filled | +//! icon·filled | [ico] | ... //! icon·tonal | +//! icon·outlined | ← M229 第四式(1px 环;此前与 plain 逐像素相同) +//! icon·plain | //! ``` //! -//! 40 个单元格共 5 列 × 8 行,一屏(1180×760)装不下,靠 `gallery/mod.rs` 的 scrollable 滚动; -//! 截图取的是**整窗**,所以首屏能看到按钮家族的全部 5 行(图标行可能需要滚动,报告里会说明)。 +//! 45 个单元格共 5 列 × **9 行**(5 个文字变体 + **4 个图标变体**),一屏(1180×760)装不下, +//! 靠 `gallery/mod.rs` 的 scrollable 滚动;截图取的是**整窗**,所以首屏能看到前 8 行左右 +//! (最下面一行可能要滚动)。行序 = `Variant::ALL` 之后 `IconVariant::ALL`,`probe/rows.rs` +//! 的逐行表按同一顺序排。 //! //! ## 为什么每格都强制状态 //! diff --git a/crates/app-iced/src/components/gallery/mod.rs b/crates/app-iced/src/components/gallery/mod.rs index ea4f54b..5b5cec3 100644 --- a/crates/app-iced/src/components/gallery/mod.rs +++ b/crates/app-iced/src/components/gallery/mod.rs @@ -9,7 +9,7 @@ //! //! ## 并行地基(后续四条线怎么加东西) //! -//! 五个家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`: +//! 各家族文件在下面 `mod` 里**全部预先声明**了,每个都有自己的 `section()`: //! //! ```text //! gallery/buttons.rs ← COMPBASE(本 mission) @@ -38,12 +38,13 @@ //! //! ## 段落过滤:环境变量 `ULL_GALLERY_SECTION`(M76 起) //! -//! 五段拼成一列、外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族 +//! **七段**(buttons / sizes / divider / selection / inputs / feedback / navigation)拼成一列、 +//! 外面套 `scrollable`,而截图只截**首屏** `1180×760`。段数一多,后面的家族 //! 就落到折叠线以下(实测:selection 段从 y≈735 起,只剩标题一行)——那条线的 //! 「渲染层像素断言」就没有一个像素可量。所以这里加一个**段落选择器**: //! //! ```text -//! ULL_GALLERY_SECTION=all # 默认:五段全渲染(行为与加开关之前逐像素一致) +//! ULL_GALLERY_SECTION=all # 默认:七段全渲染(行为与加开关之前逐像素一致) //! ULL_GALLERY_SECTION=buttons # 只渲染 buttons 段 //! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它) //! ``` @@ -105,7 +106,7 @@ struct Sections { } impl Sections { - /// 五段全渲染(默认)。 + /// 七段全渲染(默认)。 const ALL: Self = Self { buttons: true, sizes: true, @@ -333,7 +334,7 @@ fn divider_proof<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { /// 一个家族小节的标题(`index.title`)+ 一行判据说明。 /// -/// 五个家族文件共用(`pub(super)`),保证五段的节奏一致——这也是 rubric D4 +/// 各段共用(`pub(super)`),保证七段的节奏一致——这也是 rubric D4 /// 「一屏字号档 ≤4」的落点:小节只有 `TitleMedium` + `BodySmall` 两档。 pub(crate) fn heading<'a>(ui: &'a Ui, title: &'a str, note: &'a str) -> Element<'a, DemoMessage> { let note_style = ui.style(TypeRole::BodySmall); diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index 93b2037..f948b02 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -92,11 +92,6 @@ fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 { /// `ULL_GALLERY_SECTION=buttons`,不满足就明确失败——「跳过」会把「指错了图」变成静默通过。 fn shot_path() -> Option { let raw = std::env::var_os("ULL_GALLERY_SHOT")?; - let section = std::env::var("ULL_GALLERY_SECTION").unwrap_or_default(); - assert!( - !section.trim().is_empty(), - "这份 probe 只认 `--screen=gallery` 出的 sheet:请同时给 `ULL_GALLERY_SECTION` (`all` 或 `buttons`)。整页图(页面自己的 chip / 卡片,实心块高 21)会被当成按钮量, 得到的是误导性的红。" - ); let path = std::path::PathBuf::from(raw); if path.is_absolute() || path.exists() { return Some(path); @@ -132,6 +127,31 @@ fn gallery_geometry_matches_the_tokens() { }; let ui = ui(flavor); + // 这份 probe **只认两种 sheet**(复审 r2 P2-b 的结论): + // + // - `ULL_GALLERY_SECTION=buttons`:按钮矩阵(五式 × 五态 + 图标四式)——本 probe 的主判据; + // - `ULL_GALLERY_SECTION=divider`:分隔线的两条判据(1px / 墨量覆盖率)。 + // + // **为什么不支持 `all`**:gallery 整页比 1180×760 的视口高,`all` 的 sheet 首屏里根本看不到 + // 分隔线段(实测 0 条长横线),而 sizes 段的实心块又会被「按钮 40 高」的判据量到。一张图装不下 + // 两段判据,所以按段出图、按段断言。 + // + // **为什么必须给 SECTION**:整页图(下载页/设置页)的 chip 与卡片会被当成按钮量, + // 那是误导性的红,不是缺陷。 + let section = std::env::var("ULL_GALLERY_SECTION") + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + assert!( + section == "buttons" || section == "divider", + "这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)或 `divider`(分隔线); 当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。" + ); + // 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。 + if section == "divider" { + check_divider(&shot, &ui); + return; + } + // ---- 12b 1.1 / 1.2:实心按钮 40 高、药丸圆角 20 ------------------------------ let primary = shot.bbox_of(rgb(ui.color(Role::Primary))); assert!( @@ -140,12 +160,32 @@ fn gallery_geometry_matches_the_tokens() { primary.len() ); + // **只判「按钮矩阵那一段」**:`--screen=gallery` 默认(`ULL_GALLERY_SECTION=all`)会把 + // sizes / divider / selection / inputs / feedback / navigation 也画进来,它们各自的实心块 + // (chips 底、卡片、stop indicator…)不该被「按钮 40 高」这条判据量到 —— 复审 r2 P2-b 的现场 + // 就是 `all` 模式下在 y≈715 撞到一块 21 高的实心块,probe 红在一个根本不在量程里的东西上。 + // + // 矩阵区的上下界由**第一块文字按钮**(宽 > 60、高 40)的顶边决定,往下 9 行 + 8px 余量。 + let matrix_top = primary + .iter() + .filter(|bbox| { + bbox.area() >= 800 && bbox.width() > 60 && (39..=41).contains(&bbox.height()) + }) + .map(|bbox| bbox.top) + .min() + .expect("没找到文字按钮的实心块(矩阵不在画面里?)"); + let matrix_bottom = matrix_top + rows::ROW_COUNT * rows::ROW_STEP + 8; + let mut label_button = None; for bbox in &primary { // 字形(OnPrimary)会把实心块割出小碎片,只判「成块的」(40×40 = 1600) if bbox.area() < 800 { continue; } + // 矩阵之外(后续段)的实心块不在这条判据的量程里。 + if bbox.top >= matrix_bottom { + continue; + } assert!( (39..=41).contains(&bbox.height()), "实心块高 {}(参考 40,12b 1.1)@ {:?}", @@ -263,52 +303,6 @@ fn gallery_geometry_matches_the_tokens() { } assert!(measured_glyph, "没量到 IconButton 的字形"); - // ---- 22.1:分隔线 1px(按「非底色的长横线」判,不按精确色:线被抗锯齿摊到两行)---- - // - // **只在分隔线段被渲染时判**:`ULL_GALLERY_SECTION=buttons` 的 sheet 里没有分隔线, - // 那一段是「没画」而不是「画错了」,断言只会误报(复审 P2 的同一类问题)。 - let section = std::env::var("ULL_GALLERY_SECTION") - .unwrap_or_default() - .trim() - .to_ascii_lowercase(); - let has_divider = section.is_empty() || section == "all" || section == "divider"; - let surface = rgb(ui.color(Role::Surface)); - let mut runs = 0usize; - let mut coverage = f32::NAN; - if has_divider { - for y in 0..shot.height { - let mut longest = 0; - let mut current = 0; - for x in 0..shot.width { - if !matches(shot.at(x, y), surface) { - current += 1; - longest = longest.max(current); - } else { - current = 0; - } - } - if longest > 300 { - runs += 1; - } - } - // 两条线 × 各摊 1~2 行 ⇒ 2..=4 行 - assert!( - (2..=4).contains(&runs), - "分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行" - ); - // **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。 - // 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。 - let full = rgb(ui.color(Role::OutlineVariant)); - // 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」 - coverage = line_coverage(&shot, surface, full) / 2.0; - assert!( - (0.8..=1.3).contains(&coverage), - "单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)" - ); - } else { - eprintln!("[probe] section={section:?} 未渲染 divider 段,跳过分隔线判据"); - } - // ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)---------------- // // 上一版把两个 reviewer 都指出的重复块留在这里(`disabled_colors` 被调两次、 @@ -350,18 +344,53 @@ fn gallery_geometry_matches_the_tokens() { Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58) 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) - 分隔线长行 {} 行(每条 1px;未渲染 divider 段时为 0) 禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过 - 分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0) 字形墨迹 {}px(24 盒应 16~26)", flavor, label_button.width(), true_height, top_inset, primary.len(), - runs, rows::ROW_COUNT, - coverage, glyph_width ); } + +/// `ULL_GALLERY_SECTION=divider` 的两条判据:分隔线 2 条(各摊 1~2 行)与单条墨量覆盖率 ≈1.0。 +/// +/// 单独成函数:`divider` 段的 sheet 没有按钮矩阵,走不到主流程的按钮判据;而主流程也不需要 +/// 分隔线(`buttons` 段的 sheet 里根本没画线,断言只会误报)。 +fn check_divider(shot: &Shot, ui: &Ui) { + let surface = rgb(ui.color(Role::Surface)); + let mut runs = 0usize; + for y in 0..shot.height { + let mut longest = 0; + let mut current = 0; + for x in 0..shot.width { + if !matches(shot.at(x, y), surface) { + current += 1; + longest = longest.max(current); + } else { + current = 0; + } + } + if longest > 300 { + runs += 1; + } + } + // 两条线 × 各摊 1~2 行 ⇒ 2..=4 行 + assert!( + (2..=4).contains(&runs), + "分隔线应 2 条(每条 1px,抗锯齿最多摊 2 行)⇒ 2..=4 行,实测 {runs} 行" + ); + // **墨量覆盖率**:一条 1dp 的线,其各行相对底色的不透明覆盖率之和应 ≈1.0。 + // 1.5dp 的线会到 ≈1.5,2dp 会到 ≈2.0 —— 这是「容忍 1.5 倍粗」的补丁。 + let full = rgb(ui.color(Role::OutlineVariant)); + // 屏上正好 2 条线(通栏 + 列表内缩),所以把总墨量除以 2 得到「单条线的覆盖率」 + let coverage = line_coverage(shot, surface, full) / 2.0; + assert!( + (0.8..=1.3).contains(&coverage), + "单条分隔线墨量覆盖率应 ≈1.0(= 1dp),实测 {coverage:.2}(1.5dp 会到 ~1.5)" + ); + eprintln!("[probe] OK section=divider:分隔线 {runs} 行、单条覆盖率 {coverage:.2}"); +} diff --git a/crates/app-iced/src/components/gallery/probe/rows.rs b/crates/app-iced/src/components/gallery/probe/rows.rs index c6572dc..dfd3b28 100644 --- a/crates/app-iced/src/components/gallery/probe/rows.rs +++ b/crates/app-iced/src/components/gallery/probe/rows.rs @@ -10,7 +10,7 @@ use crate::app::components::interaction; use crate::theme::Ui; /// 矩阵行距(40 高按钮 + 8 间距)。 -const ROW_STEP: usize = 48; +pub(super) const ROW_STEP: usize = 48; /// 行内扫描高度(可见 40 + 上下各 2 的余量)。 const ROW_HEIGHT: usize = 44; /// 矩阵行数(5 个文字变体 + 4 个图标变体):probe 的汇总日志用它。 diff --git a/crates/app-iced/src/components/surfaces/elevated.rs b/crates/app-iced/src/components/surfaces/elevated.rs index a8876b6..33e0391 100644 --- a/crates/app-iced/src/components/surfaces/elevated.rs +++ b/crates/app-iced/src/components/surfaces/elevated.rs @@ -34,7 +34,8 @@ use crate::theme::Ui; /// ELEVATED CARD 的内边距:**20**(= `LayoutMetrics::card_padding`)。 /// /// 为什么不是规格卡量到的 **24**(卡片文字 `x=88` − 框 `x=64`):本组件唯一的消费者是实例页的 -/// 元信息卡,外框是 `META_CARD_MIN_WIDTH`(160),卡内必须放下最长的值串(14px 下实测 118px)。 +/// 元信息卡,外框是 `META_CARD_MIN_WIDTH`(160),卡内必须放下最长的值串(**12px 下实测 118px**; +/// 同一个串在 14px 下是 138px,见 [`TITLE_ROLE`])。 /// 24 ⇒ 内宽 112 ⇒ 值串折行 ⇒ 同排卡底不齐(M229 复审 P1-2 的现场);20 ⇒ 内宽 120 ≥ 118, /// 且**不动卡片宽度契约**(`pages/layout.rs` 的 160 一个字没改)。 /// @@ -77,13 +78,13 @@ pub fn card<'a, M: 'a>(ui: &'a Ui, content: impl Into>) -> Elemen pub struct Texts<'a> { /// 上标注(`ELEVATED CARD` 那种小字标签)。 pub overline: Option<&'a str>, - /// 值 / 标题(字级是 [`TITLE_ROLE`] = 14/400)。 + /// 值 / 标题(字级是 [`TITLE_ROLE`] = **12/400**)。 pub title: &'a str, /// 副文案(`BodyMedium` 14/400)。 pub support: Option<&'a str>, } -/// 三段式内容(上标注 11/600 + 值 [`TITLE_ROLE`] 14/400 + 副文案 14/400,行间 4)。 +/// 三段式内容(上标注 11/600 + 值 [`TITLE_ROLE`] **12/400** + 副文案 14/400,行间 4)。 pub fn texts<'a, M: 'a>(ui: &'a Ui, parts: Texts<'a>) -> Element<'a, M> { let mut stack = column![].spacing(ui.space(Space::X1)); diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index c3af8f4..9e64361 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -231,7 +231,8 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); for cell in chunk { // M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、16 圆角)。 - // 内距 20 + 值 14/400(见 `elevated::PAD` 的登记):外框 160 − 2×20 = 120 必须放下 + // 内距 20 + 值 **12/400**(见 `elevated::PAD` / `elevated::TITLE_ROLE` 的登记): + // 外框 160 − 2×20 = 120 必须放下 // 最长值串,24 + 16 会让时戳折行、同排卡底不齐(复审 P1-2)。 let card = elevated::card( ui,