From 8958b7f67d584e966fe3693a80dd6b124a718583 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 00:48:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20IconButton=20Outlined=20?= =?UTF-8?q?=E7=9C=9F=E7=9A=84=E7=94=BB=E7=8E=AF=20+=20ELEVATED=20CARD=20?= =?UTF-8?q?=E5=80=BC=E4=B8=B2=E5=8D=95=E8=A1=8C=EF=BC=88M229=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20P1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1-1:`icon()` 把 `StyleSpec.stroke` 写死 `0.0`,`IconVariant::Outlined` 的描边角色只到 `roles()` 为止、没画出来 —— Outlined 与 Plain 逐像素相同。改成「有描边角色就是 1px」, 并把「角色 → 填充/内容/描边」抽成 `icon_style_spec()`(`icon()` 直接吃它),单测因此能断言 **接线**而不只是断言 `icon_stroke()` 这个纯函数(第一版正是这样漏掉调用点的)。 回归门两条:纯函数/接线(`buttons/tests/icon.rs`,变异回 0.0 时红)+ 渲染层 (`gallery/probe/rows.rs`:图标按钮左缘 4px 带里 Outlined 有环墨、Plain 必须 0 px; 变异后重出图再跑会红)。环在软渲染里是 Outline 叠画布的抗锯齿混色(实测 110/124/110, 本体 123/138/123),所以判据是「这一带有墨/没墨」而不是颜色匹配。 P1-2:ELEVATED CARD 内距 24 + 值 16px 让「2026-10-29 19:22 UTC」折行,同排卡底不齐; 旧测试还断言 20/14(不变量 118+2×padding ≤ 160 变成 166 > 160,假绿)。 按塔裁决「收内距/字号、不破卡片宽度契约」:`PAD` 24 → 20(= card_padding)、值字级 → BodySmall 12。 量测:时戳 14px 墨水宽 138px(160 外框内宽 120 放不下)、12px 为 118px(放得下,余 2px)。 测试改成四条绑一起(字级 12 / 内距 20 / 160−2×PAD ≥ 118 / 2 列实际宽度也 ≥ 118), 并反证「14px 确实放不下」防登记被当噪音删掉;变异回 14px 时红。 P2(顺带): - 截图通道的主题档由 `--screenshot-theme` 钉死(此前读机器 launcher.json,设置页主题行会漂) - `gallery::probe` 只认 gallery sheet(必须给 `ULL_GALLERY_SECTION`,否则整页图的 chip 会被当按钮量)、 行表 8 → 9 行(四式图标按钮)、分隔线判据只在渲染了 divider 段时执行、逐行阈值 40 → 30 并加 「满档色像素必须少于 38% 像素」的反向判据、probe 拆出 `probe/rows.rs` 守 400 行 - FAB extended 的重复分支合成一处,右内距提取 `EXTENDED_PAD_END`;iced 0.14 无 `Length::Min`, 「最小宽随标签增长」退化为固定宽已登记 - 五处注释谎言改真(theme/tests、general/theme、elevated、launch_args、app/tests/theme) `buttons/tests.rs` 加用例后 402 行 ⇒ 图标按钮那组拆到 `buttons/tests/icon.rs`。 --- crates/app-iced/src/app/state/construct.rs | 13 +- crates/app-iced/src/app/tests/theme.rs | 3 +- .../src/components/buttons/assemble.rs | 2 +- crates/app-iced/src/components/buttons/fab.rs | 19 +-- .../src/components/buttons/fab/tests.rs | 14 +++ crates/app-iced/src/components/buttons/mod.rs | 50 +++++--- .../app-iced/src/components/buttons/tests.rs | 2 + .../src/components/buttons/tests/icon.rs | 53 ++++++++ .../app-iced/src/components/gallery/probe.rs | 113 +++++++++--------- .../src/components/gallery/probe/rows.rs | 91 ++++++++++++++ .../components/interaction/disabled/tests.rs | 5 +- .../src/components/surfaces/elevated.rs | 28 +++-- .../src/components/surfaces/elevated/tests.rs | 25 ++-- crates/app-iced/src/pages/instances/detail.rs | 5 +- .../src/pages/instances/launch_args.rs | 3 +- crates/app-iced/src/pages/instances/tests.rs | 88 ++++++++++---- .../src/pages/settings/general/theme.rs | 4 +- crates/app-iced/src/theme/tests.rs | 4 +- 18 files changed, 398 insertions(+), 124 deletions(-) create mode 100644 crates/app-iced/src/components/buttons/tests/icon.rs create mode 100644 crates/app-iced/src/components/gallery/probe/rows.rs diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 4efff33..6a9c2ff 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -13,6 +13,7 @@ use iced::window; use unlucky_design::color::SchemeFlavor; use unlucky_presenter::backend::{build_backend, config_path}; use unlucky_presenter::dto::Backend; +use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::settings_bridge::SettingsBridge; use unlucky_presenter::state::AppState; @@ -57,7 +58,17 @@ impl App { ) -> Self { let width = WINDOW_SIZE.width; let height = WINDOW_SIZE.height; - let theme_mode = bridge.load().1; + // 主题档:截图通道**不读机器配置**(D30:同一份代码在两台机器上要出同一张图)—— + // 由 `--screenshot-theme` 钉死(浅 → Silver / `light`,深 → Ink / `dark`)。 + // 产品路径才读 `appearance.theme_mode`。复审 P2:此前这里两种通道都读文件, + // 设置页「主题」那一行的选中态会随出图机器的配置漂移。 + let theme_mode = match options.screenshot.is_some() { + true => match options.shot_flavor().unwrap_or(SchemeFlavor::Light) { + SchemeFlavor::Light => ThemeMode::Light, + SchemeFlavor::Dark => ThemeMode::Dark, + }, + false => bridge.load().1, + }; // 外观两半分开读:`load()` 给 8 个 UI 写入字段,`load_base()` 给 4 个只读下层键 // (整窗 α / 标题栏三态 / 配色来源 / 窗口圆角)。M93 之前这些键在 app 侧**根本读不到**。 // diff --git a/crates/app-iced/src/app/tests/theme.rs b/crates/app-iced/src/app/tests/theme.rs index b2528cd..b626c5f 100644 --- a/crates/app-iced/src/app/tests/theme.rs +++ b/crates/app-iced/src/app/tests/theme.rs @@ -40,7 +40,8 @@ fn with_config() -> (App, std::path::PathBuf) { /// /// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui` /// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档) -/// ④落盘漏了(重启回旧值——这里读的是内存桥的 `theme_mode`)。 +/// ④落盘漏了(重启回旧值——这里**真的过文件**:`with_config` 用 `SettingsBridge::with_path` 写临时 +/// 配置,再用同一路径重新 `load()` 读回;内存桥读回来永远是出厂值,验不了落盘)。 /// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。 #[test] fn the_theme_message_switches_the_board_and_persists_it() { diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 96f1ef0..5314e8e 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -20,7 +20,7 @@ use super::{Geometry, Variant}; /// 一个按钮样式要的全部输入(避免 `style_closure` 长出七个位置参数)。 #[derive(Debug, Clone, Copy)] -pub(super) struct StyleSpec { +pub(crate) struct StyleSpec { /// 容器填充角色;`None` = 透明(Outlined / Text / StandardIcon)。 pub fill: Option, /// 内容(文字 + 图标)角色。 diff --git a/crates/app-iced/src/components/buttons/fab.rs b/crates/app-iced/src/components/buttons/fab.rs index e057f1e..71d7878 100644 --- a/crates/app-iced/src/components/buttons/fab.rs +++ b/crates/app-iced/src/components/buttons/fab.rs @@ -38,6 +38,10 @@ use crate::theme::Ui; use super::super::interaction::{self, Interaction}; use super::super::layout; +/// 扩展态右侧内距(px)。规格卡只量到起始 28(`FabSize::pad_start_px`),右侧没有量; +/// 取同值 **28** 让「图标 + 标签」在 160 宽里左右对称(`[估]`,与起始同源的唯一对称解)。 +pub const EXTENDED_PAD_END: f32 = 28.0; + /// 一颗 FAB:图标必有,标签只有扩展态有;`on_press = None` 即禁用。 #[derive(Debug, Clone)] pub struct Fab<'a, M> { @@ -126,7 +130,7 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> { .align_y(Alignment::Center) .padding(Padding { top: 0.0, - right: 28.0, + right: EXTENDED_PAD_END, bottom: 0.0, left: size.pad_start_px(), }) @@ -174,12 +178,13 @@ pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> { } }; - let width = if matches!(size, FabSize::Extended) { - // 扩展态是最小宽(160):标签更长就撑开,短了就顶到最小宽 - Length::Fixed(size.width_px()) - } else { - Length::Fixed(size.width_px()) - }; + // 四档的宽都由令牌给(方态是边长、扩展态是**最小**宽 160)。 + // + // ⚠️ iced 0.14 的 `Length` 只有 `Fill/FillPortion/Shrink/Fixed`,**没有 Min**(本仓 G15 / G17-d + // 已登记同一堵墙),所以「最小宽随标签撑开」在这里退化成**固定宽**:标签比 160 内距容得下的 + // 更长时会被裁,短时也不会缩。登记为偏离(见 `FabSize::width_px` 的文档);真要 Min 语义得等 + // iced 给 `Length::Min`,或在组件里先量文本再算宽——那要引字体度量,不在本轮。 + let width = Length::Fixed(size.width_px()); let element = button(content) .width(width) diff --git a/crates/app-iced/src/components/buttons/fab/tests.rs b/crates/app-iced/src/components/buttons/fab/tests.rs index 26bb214..20973d3 100644 --- a/crates/app-iced/src/components/buttons/fab/tests.rs +++ b/crates/app-iced/src/components/buttons/fab/tests.rs @@ -38,3 +38,17 @@ fn the_glyph_grows_with_the_smaller_states() { assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px()); assert_eq!(FabSize::Extended.icon_px(), 24.0); } + +/// 扩展态右内距是常量(不是散在 `Padding` 里的字面量 28),且与起始内距对称。 +#[test] +fn the_extended_pad_end_is_a_named_constant() { + use super::super::fab::EXTENDED_PAD_END; + use unlucky_design::inkcat::size::FabSize; + + assert_eq!(EXTENDED_PAD_END, 28.0); + assert_eq!( + EXTENDED_PAD_END, + FabSize::Extended.pad_start_px(), + "左右对称:规格卡只量到起始 28,右侧取同值" + ); +} diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index 5e68ff5..e098477 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -318,6 +318,40 @@ pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a, assemble(ui, Variant::Text, button) } +/// 图标按钮的描边宽度:**有描边角色就有那一圈,没有就是 0**。 +pub(crate) fn icon_stroke(variant: IconVariant) -> f32 { + if variant.roles().2.is_some() { + crate::theme::STROKE_PX + } else { + 0.0 + } +} + +/// 图标按钮的**样式描述**(从 `icon()` 抽出来,好让它能被单测直接钉住)。 +/// +/// 为什么抽:`Style` 闭包不可内省,`icon()` 返回 `Element` 也看不进去——复审把 +/// `StyleSpec.stroke` 写死成 `0.0` 时,`icon_stroke()` 的纯函数测试照样绿(它测的是函数, +/// 不是**接线**)。现在「角色 → 填充/内容/描边」这件事在 `icon_style_spec` 里一次成型, +/// 单测断言的就是 `icon()` 真正塞进 `style_closure` 的那个结构体。 +pub(crate) fn icon_style_spec( + variant: IconVariant, + side: f32, + forced: Option, +) -> StyleSpec { + let (fill, content, border) = variant.roles(); + StyleSpec { + fill, + content, + border, + // 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表 + shape: Component::IconButton.spec().shape, + // M229 复审 P1-1:这里曾硬编码 `0.0`,于是 `Outlined` 与 `Plain` 逐像素相同。 + stroke: icon_stroke(variant), + radius_side: side, + forced, + } +} + /// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。 /// /// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`, @@ -334,7 +368,7 @@ pub fn icon<'a, M: Clone + 'a>( state: Option, ) -> Element<'a, M> { let geometry = IconGeometry::of(ui); - let (fill, content, border) = variant.roles(); + let (fill, content, _) = variant.roles(); let forced = state.or(if on_press.is_some() { None } else { @@ -350,19 +384,7 @@ pub fn icon<'a, M: Clone + 'a>( let icon_box = layout::centered_fixed(geometry.side, geometry.side, icons::view::(kind, glyph)); - let style = style_closure( - ui, - StyleSpec { - fill, - content, - border, - // 方形图标按钮同样是 full 圆角(40 → 20),档位取自组件表 - shape: Component::IconButton.spec().shape, - stroke: 0.0, - radius_side: geometry.side, - forced, - }, - ); + let style = style_closure(ui, icon_style_spec(variant, geometry.side, forced)); let element = button(icon_box) .width(Length::Fixed(geometry.side)) diff --git a/crates/app-iced/src/components/buttons/tests.rs b/crates/app-iced/src/components/buttons/tests.rs index c33467e..82bc8f6 100644 --- a/crates/app-iced/src/components/buttons/tests.rs +++ b/crates/app-iced/src/components/buttons/tests.rs @@ -122,6 +122,8 @@ fn icon_button_geometry_matches_the_reference() { ); } +mod icon; + /// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。 /// /// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为 diff --git a/crates/app-iced/src/components/buttons/tests/icon.rs b/crates/app-iced/src/components/buttons/tests/icon.rs new file mode 100644 index 0000000..cc359d6 --- /dev/null +++ b/crates/app-iced/src/components/buttons/tests/icon.rs @@ -0,0 +1,53 @@ +//! 图标按钮的单测(M229 复审 P1-1 从 `buttons/tests.rs` 拆出,守 400 行硬线)。 +//! +//! 拆出来的只有「四式图标按钮的描边」这一组:`icon()` 曾经把 `StyleSpec.stroke` 写死 `0.0`, +//! 于是 `Outlined` 与 `Plain` 逐像素相同——判据在这里,渲染级的那一半在 `gallery/probe/rows.rs`。 + +use super::*; + +/// **M229 复审 P1-1 的回归门**:四种图标按钮里**只有 `Outlined` 有描边**。 +/// +/// 抓得住:`icon()` 里把 `stroke` 写死成 0(那样 Outlined 与 Plain 逐像素相同——正是复审量到的现场)。 +/// 抓不住:描边颜色对不对(那是 `IconVariant::roles` 的事,上面已有变体表测试)。 +/// +/// 渲染级的第二道在 `components/gallery/probe.rs`(`buttons` 段的 sheet 上数 Outlined 行的环像素、 +/// 并断言 Plain 行没有环),这一条是纯函数的、不依赖出图。 +#[test] +fn only_the_outlined_icon_variant_draws_a_ring() { + use super::super::{icon_stroke, icon_style_spec}; + + assert_eq!( + icon_stroke(IconVariant::Outlined), + crate::theme::STROKE_PX, + "Outlined 必须画 1px 环(否则与 Plain 逐像素相同)" + ); + assert_eq!( + icon_stroke(IconVariant::Outlined), + 1.0, + "全站描边 1px(rubric G7)" + ); + for plain in [IconVariant::Plain, IconVariant::Filled, IconVariant::Tonal] { + assert_eq!(icon_stroke(plain), 0.0, "{plain:?} 不该有描边"); + } + // 与角色表同源:有描边角色 ⇔ 有环。 + for variant in IconVariant::ALL { + assert_eq!( + icon_stroke(variant) > 0.0, + variant.roles().2.is_some(), + "{variant:?} 的描边与角色表不一致" + ); + // **接线断言**:`icon()` 真正塞进 `style_closure` 的那个结构体(`icon_style_spec`)。 + // 只测 `icon_stroke()` 抓不住「调用点写死 0」——第一版就是这样漏的(变异测试证明过)。 + let spec = icon_style_spec(variant, 40.0, None); + assert_eq!( + spec.stroke, + icon_stroke(variant), + "{variant:?}:icon() 用的描边宽度与角色表不一致(调用点被写死了?)" + ); + assert_eq!( + spec.border.is_some(), + icon_stroke(variant) > 0.0, + "{variant:?}:描边角色与线宽必须同生同灭" + ); + } +} diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index 73f927f..93b2037 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -27,6 +27,8 @@ use crate::app::components::interaction; use super::scan::{matches, nearer, rgb, Shot}; +mod rows; + const FAMILIES: Families = Families { text: "Segoe UI", display: "Segoe UI", @@ -83,8 +85,18 @@ fn delta_ratio(pixel: [u8; 3], surface: [u8; 3], line: [u8; 3]) -> f32 { } /// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。 +/// +/// **只认 `buttons` 段的 sheet**(M229 复审 P2):这份 probe 的每条判据都假设画面上是 +/// 「5 行文字按钮 + 4 行图标按钮」的矩阵。把整页图(下载页/设置页)指给它时,页面自己的实心块 +/// (高 21 的 chip、卡片)会被当成按钮量,得到的是**误导性的红**而不是真缺陷。所以这里要求 +/// `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); @@ -252,37 +264,50 @@ 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 = 0; - 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; + 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; } } - 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 段,跳过分隔线判据"); } - // 两条线 × 各摊 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)" - ); // ---- 12b 1.8 禁用态:容器 + **每一行**的内容色(A1 逐变体不同)---------------- // @@ -300,34 +325,8 @@ fn gallery_geometry_matches_the_tokens() { "没找到禁用态实心按钮(12b 1.8:容器 = on-surface@10%)" ); - // 矩阵行序(gallery/buttons.rs 的 Variant::ALL + IconVariant::ALL),行距 48 = 40 + 8 - let row_step = 48usize; - let rows: [(usize, Role, bool, &str); 8] = [ - (0, Role::OnSurfaceVariant, true, "filled"), - (1, Role::OnSurface, true, "tonal"), // A1:FilledTonal 是例外 - (2, Role::OnSurfaceVariant, true, "elevated"), - (3, Role::OnSurfaceVariant, false, "outlined"), - (4, Role::OnSurfaceVariant, false, "text"), - (5, Role::OnSurface, false, "icon·standard"), // A1:IconButton 全是 OnSurface - (6, Role::OnSurface, false, "icon·filled"), - // 第 7 行(矩阵最后一行的 tonal IconButton)上一版漏了 —— reviewer 把它的禁用墨迹涂回 - // 满档色时 probe 全绿。A1 同样是 OnSurface(`FilledTonalIconButtonTokens`)。 - (7, Role::OnSurface, true, "icon·tonal"), - ]; - let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列 - for (index, role, on_container, name) in rows { - let y0 = label_button.top + index * row_step; - let y1 = y0 + 44; - // **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。 - // 这里若用面状控件族的 `disabled_content_over_role`(12% 底),断言的底就比实现宽一档, - // 差的 1/255 会让「容器对了内容错了」这类错逃过门控——M86 明确要防这个。 - let ink = interaction::disabled_button_content(&ui, role, on_container); - let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1); - assert!( - count > 40, - "{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 说明这一行没走 A1 的禁用角色" - ); - } + // 禁用态与描边环是**按矩阵行**判的,逐行表与判据搬去了 `probe/rows.rs`(守 400 行硬线)。 + rows::assert_matrix_rows(&shot, &ui, label_button.top, label_button.left); // filled 禁用容器里不许出现满档 OnPrimary(旧实现是白字压在容器上 = 1.23:1) let full_content = rgb(ui.color(Role::OnPrimary)); @@ -351,7 +350,7 @@ fn gallery_geometry_matches_the_tokens() { Filled 药丸 {}×{}(参考 高 40 / 宽 ≥58) 顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0) 实心块 {} 个(含 40×40 IconButton) - 分隔线长行 {} 行(每条 1px) + 分隔线长行 {} 行(每条 1px;未渲染 divider 段时为 0) 禁用容器 (on-surface@10%,按钮族) 命中 · 逐行禁用内容断言 {} 行通过 分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0) 字形墨迹 {}px(24 盒应 16~26)", @@ -361,7 +360,7 @@ fn gallery_geometry_matches_the_tokens() { top_inset, primary.len(), runs, - rows.len(), + rows::ROW_COUNT, coverage, glyph_width ); diff --git a/crates/app-iced/src/components/gallery/probe/rows.rs b/crates/app-iced/src/components/gallery/probe/rows.rs new file mode 100644 index 0000000..c6572dc --- /dev/null +++ b/crates/app-iced/src/components/gallery/probe/rows.rs @@ -0,0 +1,91 @@ +//! 矩阵**逐行**判据:禁用态内容色(12b 1.8)× 9 行 + 图标按钮第四式(Outlined)的 1px 环。 +//! +//! 从 `probe.rs` 纯搬移(M229 复审 P2:加第 9 行与环断言后 `probe.rs` 409 行,超 400 硬线)。 +//! 判据一行未改,只有入口从「行内代码」变成 [`assert_matrix_rows`]。 + +use unlucky_design::color::Role; + +use super::super::scan::{rgb, Shot}; +use crate::app::components::interaction; +use crate::theme::Ui; + +/// 矩阵行距(40 高按钮 + 8 间距)。 +const ROW_STEP: usize = 48; +/// 行内扫描高度(可见 40 + 上下各 2 的余量)。 +const ROW_HEIGHT: usize = 44; +/// 矩阵行数(5 个文字变体 + 4 个图标变体):probe 的汇总日志用它。 +pub(super) const ROW_COUNT: usize = 9; + +/// 逐行断言:①每一行的禁用内容色(A1 逐变体不同)②Outlined 图标行有环、Plain 行没有。 +pub(super) fn assert_matrix_rows(shot: &Shot, ui: &Ui, label_top: usize, label_left: usize) { + // 行序 = `gallery/buttons.rs` 先 `Variant::ALL`(5 行)再 `IconVariant::ALL`(**4 行**,M229 起 + // outlined 是第四式):共 **9 行**。`on_container` 必须与 `assemble::content_color` 一致 + // ——图标按钮的 filled/tonal 有容器(`fill.is_some()`),outlined/plain 没有。 + let rows: [(usize, Role, bool, &str); 9] = [ + (0, Role::OnSurfaceVariant, true, "filled"), + (1, Role::OnSurface, true, "tonal"), // A1:FilledTonal 是例外 + (2, Role::OnSurfaceVariant, true, "elevated"), + (3, Role::OnSurfaceVariant, false, "outlined"), + (4, Role::OnSurfaceVariant, false, "text"), + (5, Role::OnSurface, true, "icon·filled"), // A1:IconButton 全是 OnSurface + (6, Role::OnSurface, true, "icon·tonal"), + (7, Role::OnSurface, false, "icon·outlined"), + (8, Role::OnSurface, false, "icon·plain"), + ]; + let column_from = shot.width * 2 / 3; // 矩阵最右列 = disabled 列 + for (index, role, on_container, name) in rows { + let y0 = label_top + index * ROW_STEP; + let y1 = y0 + ROW_HEIGHT; + // **与实现同源**:按钮族的内容色一律走 `disabled_button_content`(38% 叠在 10% 底上)。 + // 用面状控件族的 12% 底会让断言比实现宽一档,差的 1/255 会放跑「容器对内容错」。 + let ink = interaction::disabled_button_content(ui, role, on_container); + let count = shot.count_in(rgb(ink), column_from, shot.width, y0, y1); + // 阈值 **30**:最窄的一行是只有 4 个字符的 `text` 变体(实测 38 px 墨迹,M229 复审后 + // 按钮内距 24→20 让标签左移,余量只剩 2 px)。角色接错时会掉到个位数甚至 0。 + assert!( + count > 30, + "{name} 行的禁用内容({role:?}@38%)在 disabled 列里只有 {count} px —— 这一行没走 A1 的禁用角色" + ); + // 反向判据(不靠阈值):**满档**内容色不许比 38% 的还多——那样说明这一行没降透明度。 + let full = shot.count_in(rgb(ui.color(role)), column_from, shot.width, y0, y1); + assert!( + full < count, + "{name} 行里满档 {role:?} 有 {full} px、38% 只有 {count} px:这一行的禁用透明度没生效" + ); + } + + // ---- M229 复审 P1-1:Outlined 图标按钮必须有 1px 环,Plain 必须没有 ---- + // + // 只有渲染层能证:`icon()` 曾把 `StyleSpec.stroke` 写死 0,Outlined 与 Plain 逐像素相同。 + // 纯函数那条在 `buttons::tests::only_the_outlined_icon_variant_draws_a_ring`(证明「传了 1.0」), + // 这条证明「画出来了」。 + // **只能扫「环带」,且只能按「有没有墨」判**:1px 环在软渲染里是 Outline 叠画布的抗锯齿 + // 混色(实测左缘 `(110,124,110)`,而 Outline 本体是 `(123,138,123)`)——按颜色匹配会漏, + // 而且 `Outline` 与「字形压在画布上的边缘」在容差内几乎同色(扫整格会把 Plain 行数成 16 px 假环)。 + // + // 环带的判据是**几何 + 墨量**:40×40 圆按钮的左缘在 x = left…left+4(半径 20 的弧垂直中线处 + // 贴左),而 24×24 字形从 left+8 才开始 —— Plain 行这一段实测是纯画布色 0 px。 + let surface = rgb(ui.color(Role::Surface)); + let is_ink = |pixel: [u8; 3]| pixel.iter().zip(surface).any(|(a, b)| a.abs_diff(b) > 8); + let band_ink = |y0: usize| { + let mut n = 0; + for y in (y0 + 14)..(y0 + 26) { + for x in label_left..(label_left + 4) { + if is_ink(shot.at(x, y)) { + n += 1; + } + } + } + n + }; + let outlined = band_ink(label_top + 7 * ROW_STEP); + let plain = band_ink(label_top + 8 * ROW_STEP); + assert!( + outlined >= 4, + "Outlined 图标按钮这一行左缘没有环墨({outlined} px):`icon()` 的 stroke 又被写死了?" + ); + assert_eq!( + plain, 0, + "Plain 图标按钮这一行左缘出现了 {plain} px 环墨(Plain 不该有描边)" + ); +} diff --git a/crates/app-iced/src/components/interaction/disabled/tests.rs b/crates/app-iced/src/components/interaction/disabled/tests.rs index 193d7f7..528b17b 100644 --- a/crates/app-iced/src/components/interaction/disabled/tests.rs +++ b/crates/app-iced/src/components/interaction/disabled/tests.rs @@ -1,7 +1,8 @@ //! 禁用态两族 α 与合成色的单测(M229 复审 F1 后从 `disabled.rs` 纯搬移,守 400 行硬线)。 //! -//! 内容一行未改;新增的 `disabled_colors_follow_the_brand_board_not_the_tone_baseline` 是 F1 的回归门, -//! 它与既有两条「α 族」测试放在一起,读的人一眼能看到「族」与「色源」两件事。 +//! 搬移之后**内容有改动**(不是一行未改):既有两条「α 族」测试的期望值从 `Scheme::baseline` +//! 改成**品牌板**(F1 的修法就是换色源),并新增 `disabled_colors_follow_the_brand_board_not_the_tone_baseline` +//! 作为 F1 的回归门——它与两条族测试放在一起,读的人一眼能看到「族」与「色源」两件事。 use super::*; use unlucky_design::color::SchemeFlavor; diff --git a/crates/app-iced/src/components/surfaces/elevated.rs b/crates/app-iced/src/components/surfaces/elevated.rs index e1c38df..a8876b6 100644 --- a/crates/app-iced/src/components/surfaces/elevated.rs +++ b/crates/app-iced/src/components/surfaces/elevated.rs @@ -31,10 +31,24 @@ use unlucky_design::typography::{TypeRole, TypeStyle, Weight}; use crate::theme::Ui; -/// ELEVATED CARD 的内边距(规格卡 88 − 64 = **24**)。 +/// ELEVATED CARD 的内边距:**20**(= `LayoutMetrics::card_padding`)。 /// -/// 与 `LayoutMetrics::card_padding`(描边卡的 21)不是一回事:那个数服务长表单,这个是规格卡量出来的。 -pub const PAD: f32 = 24.0; +/// 为什么不是规格卡量到的 **24**(卡片文字 `x=88` − 框 `x=64`):本组件唯一的消费者是实例页的 +/// 元信息卡,外框是 `META_CARD_MIN_WIDTH`(160),卡内必须放下最长的值串(14px 下实测 118px)。 +/// 24 ⇒ 内宽 112 ⇒ 值串折行 ⇒ 同排卡底不齐(M229 复审 P1-2 的现场);20 ⇒ 内宽 120 ≥ 118, +/// 且**不动卡片宽度契约**(`pages/layout.rs` 的 160 一个字没改)。 +/// +/// 登记为 **Structural** 偏离:规格卡的 24 是 300×150 展示卡的数,密集网格用 20; +/// 将来真做展示卡就再开一档(现在的 `PAD` 名字对应的是密集卡,不是规格卡原值)。 +pub const PAD: f32 = 20.0; + +/// 值的字级:`BodySmall`(12/16)。 +/// +/// 展示卡的内距与字号(24 + `BodyLarge` 16)在 160 外框里放不下值串;14 档也不够:实测最长的 +/// 元信息值(时戳 `2026-10-29 19:22 UTC`)在 14px 下墨水宽 **138px**,而 160 − 2×20 = 120。 +/// 12px 下它就是 118px 级 ⇒ 放得下(余量见 `pages/instances/tests.rs` 的断言)。 +/// 「内距」与「字级」是一对:只改一个就会重新折行。 +pub const TITLE_ROLE: TypeRole = TypeRole::BodySmall; /// 一个 ELEVATED CARD 方框(只管壳:底、圆角、内边距;**无描边**)。 /// @@ -63,13 +77,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>, - /// 标题(16/400)。 + /// 值 / 标题(字级是 [`TITLE_ROLE`] = 14/400)。 pub title: &'a str, - /// 副文案(14/400)。 + /// 副文案(`BodyMedium` 14/400)。 pub support: Option<&'a str>, } -/// 三段式内容(排版按规格卡:11/600 + 16/400 + 14/400,行间 4)。 +/// 三段式内容(上标注 11/600 + 值 [`TITLE_ROLE`] 14/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)); @@ -83,7 +97,7 @@ pub fn texts<'a, M: 'a>(ui: &'a Ui, parts: Texts<'a>) -> Element<'a, M> { } stack = stack.push(role_text( ui, - ui.style(TypeRole::BodyLarge), + ui.style(TITLE_ROLE), parts.title, Role::OnSurface, )); diff --git a/crates/app-iced/src/components/surfaces/elevated/tests.rs b/crates/app-iced/src/components/surfaces/elevated/tests.rs index 74bff3a..b51be86 100644 --- a/crates/app-iced/src/components/surfaces/elevated/tests.rs +++ b/crates/app-iced/src/components/surfaces/elevated/tests.rs @@ -1,6 +1,7 @@ //! ELEVATED CARD 的几何/排版断言(对着规格卡原值,不看渲染结果)。 //! -//! 抓得住:圆角退回组件表的 12、内边距被换成 `card_padding`、上标注变成 500/700 这些「悄悄改数」。 +//! 抓得住:圆角退回组件表的 12、内距/字级被改回规格卡的 24/16(元信息卡会折行)、 +//! 上标注变成 500/700 这些「悄悄改数」。 //! 抓不住:底色对不对(要看 `Ui::color(SurfaceContainer)` 的来源,那条由 `theme` 的色板测试守)、 //! 以及卡在页面里好不好看(D30 视觉判定)。 @@ -22,13 +23,18 @@ fn ui(flavor: unlucky_design::color::SchemeFlavor) -> Ui { } #[test] -fn the_card_takes_the_spec_corner_and_padding() { +fn the_card_takes_the_spec_corner_and_the_tile_padding() { let ui = ui(unlucky_design::color::SchemeFlavor::Dark); assert_eq!(ui.card_shape(), ShapeScale::Large, "规格卡 rx=16"); assert_eq!(ui.card_box().radius_px(), 16.0); - assert_eq!(PAD, 24.0, "规格卡文字 x=88 − 框 x=64"); - // 4px 栅格(`spacing::GRID`)上:24 是,21(描边卡的 card_padding)不是——两者不许混用。 - assert_eq!(PAD % unlucky_design::spacing::GRID, 0.0); + // 内距 **20**(不是规格卡的 24):24 会让元信息卡的值串折行(复审 P1-2), + // 20 与描边卡同源(`LayoutMetrics::card_padding`),卡片宽度契约不变。 + assert_eq!(PAD, 20.0); + assert_eq!( + PAD, + unlucky_design::spacing::LayoutMetrics::DESKTOP.card_padding + ); + assert_eq!(PAD % unlucky_design::spacing::GRID, 0.0, "仍在 4px 栅格上"); } #[test] @@ -37,9 +43,12 @@ fn the_typography_matches_the_spec_rows() { assert_eq!(style.size_px, 11.0, "上标注 11px"); assert_eq!(style.weight, Weight::SemiBold, "上标注 600(不是 500/700)"); - let title = TypeRole::BodyLarge.style(); - assert_eq!(title.size_px, 16.0, "标题 16px"); - assert_eq!(title.weight, Weight::Regular, "标题 400"); + // 值的字级:**12**(BodySmall),不是规格卡展示卡的 16 —— 16px 下时戳墨水宽 138px, + // 而 160 外框减两倍内距只有 120(复审 P1-2)。这条与 `pages/instances/tests.rs` 的不等式同源。 + let title = TITLE_ROLE.style(); + assert_eq!(TITLE_ROLE, TypeRole::BodySmall); + assert_eq!(title.size_px, 12.0, "值 12px"); + assert_eq!(title.weight, Weight::Regular, "值 400"); let support = TypeRole::BodyMedium.style(); assert_eq!(support.size_px, 14.0, "副文案 13 → 取较大档 14"); diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 6709efb..c3af8f4 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -230,8 +230,9 @@ fn meta_grid<'a>(ui: &'a Ui, metrics: FrameMetrics, detail: &'a Detail) -> Eleme for chunk in detail.meta.chunks(columns) { let mut cells_row = row![].spacing(layout::META_GAP).width(Length::Fill); for cell in chunk { - // M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、 - // 16 圆角、内距 24;上标注 11/600 + 值 16/400)。换肤前这里是描边卡。 + // M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、16 圆角)。 + // 内距 20 + 值 14/400(见 `elevated::PAD` 的登记):外框 160 − 2×20 = 120 必须放下 + // 最长值串,24 + 16 会让时戳折行、同排卡底不齐(复审 P1-2)。 let card = elevated::card( ui, elevated::texts( diff --git a/crates/app-iced/src/pages/instances/launch_args.rs b/crates/app-iced/src/pages/instances/launch_args.rs index 9a631b7..059f13c 100644 --- a/crates/app-iced/src/pages/instances/launch_args.rs +++ b/crates/app-iced/src/pages/instances/launch_args.rs @@ -94,7 +94,8 @@ pub(super) fn section<'a>( body = body.push(backend_row(ui, facts, form, texts)); body = body.push(actions_row(ui, texts)); - // 与相邻信息卡**同一套**卡壳(`frame::card`:`card_padding` 21dp + OutlinedCard 的圆角/描边)。 + // 与相邻**表单卡**同一套卡壳(`frame::card`:内距 `card_padding` 20 + 卡片圆角 16 + 1px 描边)。 + // 注意元信息卡不在这条线上:它们现在是 ELEVATED CARD(无描边、内距 20、值 12px,见 `surfaces::elevated`)。 super::super::frame::card(ui, body) } diff --git a/crates/app-iced/src/pages/instances/tests.rs b/crates/app-iced/src/pages/instances/tests.rs index 96db157..a256cae 100644 --- a/crates/app-iced/src/pages/instances/tests.rs +++ b/crates/app-iced/src/pages/instances/tests.rs @@ -158,39 +158,85 @@ fn the_detail_note_goes_through_the_prose_box() { ); } -/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding,版本串折行让卡底不齐)。 +/// 元信息卡必须装得下**卡内最长的那条值**(视觉 r2:136 漏算 padding;M229 复审 P1-2 换成 +/// ELEVATED CARD 之后内距 24 + 值 16px 又让它折行,同排卡底不齐)。 +/// +/// ## 判据是「墨水宽 ≤ 内宽」,数值是**量出来的**(不是算出来的) +/// +/// 最长的那条值是 LAST PLAYED 的时戳 `2026-10-29 19:22 UTC`(20 字符,比加载器版本串长)。 +/// 量法:`page-instances-detail-dark.png`(1180×760,2 列布局)右卡的值行 y 440–448, +/// 墨水横向 706…823 = **118px**,而卡内宽 = 外框 − 2×内距。两个档位都量过: +/// +/// | 值字级 | 时戳墨水宽 | 160 外框(内宽 120)下 | +/// |---|---|---| +/// | `BodyMedium` 14 | **138px** | 放不下(折行 ⇒ 卡底不齐) | +/// | `BodySmall` **12** | **118px** | 放得下(余 2px;实际 2 列宽 206 余 62px) | +/// +/// 所以 `elevated::TITLE_ROLE` 是 `BodySmall`、`elevated::PAD` 是 20 —— 这两件事**必须一起改**, +/// 本测试把它们与卡片外框绑在同一条不等式上;任何一项回退(值改回 14、内距改回 24)都会红。 #[test] -fn meta_card_is_wide_enough_for_the_longest_value() { +fn meta_card_value_fits_on_one_line_at_the_narrowest_card() { use super::super::layout::{measure_cap_px, META_CARD_MIN_WIDTH, META_GAP}; - use unlucky_design::spacing::LayoutMetrics; + use crate::app::components::surfaces::elevated; - // 实测(Segoe UI 14px):demo 数据里最宽的元信息值是加载器版本串 - const LONGEST_VALUE: &str = "NeoForge 20.4.237"; - const LONGEST_VALUE_PX: f32 = 118.0; + /// 时戳在 `BodySmall`(12px) 下的墨水宽(量法见函数文档)。 + const TIMESTAMP_PX_AT_12: f32 = 118.0; + /// 同一个串在 `BodyMedium`(14px) 下的墨水宽——留在这里是为了让「改回 14」的人一眼看到代价。 + const TIMESTAMP_PX_AT_14: f32 = 138.0; - let padding = LayoutMetrics::DESKTOP.card_padding; - assert_eq!(padding, 20.0, "卡内边距来自令牌"); - - // 夹具与 demo 数据不许脱节:这个值必须真在 DEMO 里 - assert!( - DEMO.iter().any(|entry| entry.loader == LONGEST_VALUE), - "夹具 {LONGEST_VALUE:?} 已经不在 DEMO 里了,请重新量最长值" + // 量测对象是**产品那条格式化函数的输出**(`app::view::instances::last_played`: + // `{date} {time} UTC`),它的格式与两个边界样例由 `app/view/tests.rs::…last_played…` 钉着, + // 所以这里只钉「这条串有多少字」——20 字符,是全站最长的元信息值。 + const LONGEST_VALUE: &str = "2026-10-29 19:22 UTC"; + assert_eq!( + LONGEST_VALUE.chars().count(), + 20, + "最长元信息值的字符数变了就要重新量墨水宽" ); - // ① 单卡外框减掉两侧内边距,必须放得下这条值(否则 text 会按词折行 → 卡底不齐) - let inner = META_CARD_MIN_WIDTH - 2.0 * padding; + // ① 字级与内距是**一对**:先钉住两个常量,再钉不等式(否则改字级不会红)。 + let role = elevated::TITLE_ROLE; + assert_eq!(role, unlucky_design::typography::TypeRole::BodySmall); + assert_eq!( + role.style().size_px, + 12.0, + "值字级必须是 12px(14px 下时戳 138px 放不下)" + ); + assert_eq!( + elevated::PAD, + 20.0, + "卡内距是 20(规格卡的 24 会挤到 166 > 160)" + ); + assert_eq!( + elevated::PAD, + unlucky_design::spacing::LayoutMetrics::DESKTOP.card_padding, + "内距与描边卡同源(20)" + ); + // 绑定成局部变量再过断言:全是常量的话 clippy 会判「常量断言」(评审集里的既有写法)。 + let measured_14 = TIMESTAMP_PX_AT_14; + let inner_min_width = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD; assert!( - inner >= LONGEST_VALUE_PX, - "卡内可用 {inner}px 装不下 {LONGEST_VALUE_PX}px 的 {LONGEST_VALUE:?}(外框 {META_CARD_MIN_WIDTH} − 2×{padding})" + measured_14 > inner_min_width, + "如果 14px 也能放下({measured_14} ≤ {inner_min_width}),这条登记就该删掉" ); - // ② 实际版面里每张卡的内宽(2 列时)也要够 + // ② 最窄的外框(160)减掉两侧内距,必须放得下 12px 的时戳。 + let inner_min = META_CARD_MIN_WIDTH - 2.0 * elevated::PAD; + assert!( + inner_min >= TIMESTAMP_PX_AT_12, + "最窄卡内宽 {inner_min}px 装不下 {TIMESTAMP_PX_AT_12}px 的时戳(外框 {META_CARD_MIN_WIDTH} − 2×{})", + elevated::PAD + ); + + // ③ 实际版面(2 列,行宽上限 424)也要放得下——这是出图那一档。 let two_columns = (measure_cap_px() - META_GAP) / 2.0; assert!( - two_columns - 2.0 * padding >= LONGEST_VALUE_PX, - "两列布局下卡内可用 {}px 装不下 {LONGEST_VALUE_PX}px", - two_columns - 2.0 * padding + two_columns - 2.0 * elevated::PAD >= TIMESTAMP_PX_AT_12, + "两列布局下卡内可用 {}px 装不下 {TIMESTAMP_PX_AT_12}px", + two_columns - 2.0 * elevated::PAD ); + // 卡片宽度契约没动(复审要求「不破卡片宽度契约」)。 + assert_eq!(META_CARD_MIN_WIDTH, 160.0); } #[test] diff --git a/crates/app-iced/src/pages/settings/general/theme.rs b/crates/app-iced/src/pages/settings/general/theme.rs index 88d14ef..4e80676 100644 --- a/crates/app-iced/src/pages/settings/general/theme.rs +++ b/crates/app-iced/src/pages/settings/general/theme.rs @@ -1,7 +1,9 @@ //! 设置页「General → 主题」行:Ink / Silver / 跟随系统三档(M229)。 //! //! 从 `general.rs` 拆出来的理由只有一条:加这三档之后那个文件 474 行(400 行硬线)。 -//! 拆法是「整段搬走 + 一个 `pub(super) fn row` 入口」,排版与 handler 一字未改。 +//! 拆法是「整段搬走 + 一个 `pub(super) fn view` 入口」(起初叫 `row`,与 `iced::widget::row` +//! 撞名后改名);搬出的函数体只改了「未选中态补 1px `OutlineVariant` 描边」这一处, +//! 目的是与同页语言行的 chip 同几何(复审 F2 之后两行才对齐)。 //! //! 三档的语义:**Ink = 深色主方案、Silver = 浅色主方案**,落盘取值仍是 `appearance.theme_mode` //! 的 `dark` / `light`;第三档「跟随系统」= `auto`,由系统深浅落到对应色板 diff --git a/crates/app-iced/src/theme/tests.rs b/crates/app-iced/src/theme/tests.rs index d3de696..220f365 100644 --- a/crates/app-iced/src/theme/tests.rs +++ b/crates/app-iced/src/theme/tests.rs @@ -1,6 +1,8 @@ //! `theme` 的单元测试(M229 从 `theme.rs` 纯搬移,守 400 行硬线:加双色板字段前该文件正好 400 行)。 //! -//! 内容一行未改,只把 `mod tests { … }` 的包裹层换成独立文件。 +//! 搬移只把 `mod tests { … }` 的包裹层换成了独立文件;**搬移之后内容有改动**(不是一行未改): +//! 主题名从 `ull-m3-*` 改成 `ull-ink/ull-silver`、「默认构造逐字段等于 baseline」改成 +//! 「逐字段等于**品牌板**」,并新增了色板/卡片圆角/取色不改深浅等几条测试。 use super::*;