From db541c820eb4129d9f17c9ce724706d11767199d Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 23:12:47 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat(app-iced):=20Ink=20Cat=20/=20Silver=20?= =?UTF-8?q?Cat=20=E6=8D=A2=E8=82=A4=20=E2=80=94=E2=80=94=20=E5=8F=8C?= =?UTF-8?q?=E8=89=B2=E6=9D=BF=E9=BB=98=E8=AE=A4=20+=20=E8=A7=84=E6=A0=BC?= =?UTF-8?q?=E5=8D=A1=E7=BB=84=E4=BB=B6=E5=87=A0=E4=BD=95=EF=BC=88M229?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品出厂外观从 tone 派生的 `Scheme::baseline` 切到品牌色板:深色 = Ink(淡绿)、 浅色 = Silver(钢蓝),D38 取色链(背景图 / 壁纸)仍然优先,取色失败回落到色板。 主题三档接进设置页「通用 → 主题」:Ink / Silver / 跟随系统(落盘仍是 `appearance.theme_mode` 的 dark / light / auto,不新增配置键)。 组件几何按规格卡对齐(M228 的 `inkcat` 登记项逐条落地): - 卡片圆角 12 → 16;实例元信息卡改用新的 ELEVATED CARD(无描边、SurfaceContainer 底、内距 24、上标注 11/600) - Snackbar 48/4 → 52/16(双行 72) - Checkbox 20/rx2 → 18/rx4;Radio 20 → 18 Ø(内点仍 10) - 按钮加 XS~XL 五档(产品仍只用 S=40),内距改走 ControlSize::pad_x - 新增 FAB 四态(40/56/80/160×56)与图标按钮第四式 outlined - 语言与主题两行 chips 统一成规格卡 chip(32 高 / rx8) `theme.rs` 原本正好 400 行,先把单测纯搬移到 `theme/tests.rs` 再加色板字段; `app/tests.rs`、`settings/general.rs` 同样按纯搬移拆分守 400 行硬线。 验证:cargo fmt --all --check、cargo check -p unlucky-app-iced --all-targets、 cargo test --workspace(除 clippy 前的一轮 flake,见报告)、cargo clippy --workspace --all-targets -- -D warnings、全仓 400 行扫描。视觉矩阵 42 张在 DevDocs/artifacts/m229/(gitignored,不进提交)。 --- crates/app-iced/src/app/state/construct.rs | 5 +- crates/app-iced/src/app/state/theme.rs | 66 ++++++ crates/app-iced/src/app/tests.rs | 16 +- crates/app-iced/src/app/tests/appearance.rs | 2 +- crates/app-iced/src/app/tests/theme.rs | 85 +++++++ .../app-iced/src/app/update/page_messages.rs | 3 + crates/app-iced/src/app/view.rs | 4 +- .../src/components/buttons/assemble.rs | 14 +- crates/app-iced/src/components/buttons/fab.rs | 203 ++++++++++++++++ .../src/components/buttons/fab/tests.rs | 40 ++++ .../src/components/buttons/geometry.rs | 32 ++- crates/app-iced/src/components/buttons/mod.rs | 47 +++- .../app-iced/src/components/buttons/tests.rs | 12 +- .../src/components/feedback/geometry.rs | 4 +- .../app-iced/src/components/feedback/panel.rs | 13 +- .../app-iced/src/components/feedback/probe.rs | 10 +- .../src/components/feedback/snackbar.rs | 6 +- .../app-iced/src/components/feedback/tests.rs | 13 +- .../src/components/gallery/feedback.rs | 4 +- crates/app-iced/src/components/gallery/mod.rs | 16 +- .../app-iced/src/components/gallery/sizes.rs | 123 ++++++++++ crates/app-iced/src/components/mod.rs | 2 + .../src/components/selection/geometry.rs | 36 +-- .../src/components/selection/tests.rs | 18 +- .../src/components/surfaces/elevated.rs | 126 ++++++++++ .../src/components/surfaces/elevated/tests.rs | 68 ++++++ .../app-iced/src/components/surfaces/mod.rs | 7 + crates/app-iced/src/pages/account/widgets.rs | 3 +- .../src/pages/appearance/section/mod.rs | 5 +- crates/app-iced/src/pages/frame/pieces.rs | 10 +- crates/app-iced/src/pages/frame/tests.rs | 14 +- crates/app-iced/src/pages/instances/detail.rs | 31 +-- crates/app-iced/src/pages/logs/panel.rs | 3 +- crates/app-iced/src/pages/message.rs | 6 + crates/app-iced/src/pages/settings/general.rs | 55 +++-- .../src/pages/settings/general/theme.rs | 161 +++++++++++++ .../src/pages/settings/language_appearance.rs | 12 +- crates/app-iced/src/pages/settings/mod.rs | 2 +- .../pages/settings/tests/general_section.rs | 13 +- crates/app-iced/src/shell/mod.rs | 2 +- crates/app-iced/src/theme.rs | 224 ++++++------------ crates/app-iced/src/theme/tests.rs | 207 ++++++++++++++++ crates/i18n/src/catalog/general.rs | 9 + crates/i18n/src/catalog/golden/tail_ai.rs | 7 + 44 files changed, 1439 insertions(+), 300 deletions(-) create mode 100644 crates/app-iced/src/app/tests/theme.rs create mode 100644 crates/app-iced/src/components/buttons/fab.rs create mode 100644 crates/app-iced/src/components/buttons/fab/tests.rs create mode 100644 crates/app-iced/src/components/gallery/sizes.rs create mode 100644 crates/app-iced/src/components/surfaces/elevated.rs create mode 100644 crates/app-iced/src/components/surfaces/elevated/tests.rs create mode 100644 crates/app-iced/src/components/surfaces/mod.rs create mode 100644 crates/app-iced/src/pages/settings/general/theme.rs create mode 100644 crates/app-iced/src/theme/tests.rs diff --git a/crates/app-iced/src/app/state/construct.rs b/crates/app-iced/src/app/state/construct.rs index 311104f..4efff33 100644 --- a/crates/app-iced/src/app/state/construct.rs +++ b/crates/app-iced/src/app/state/construct.rs @@ -154,7 +154,10 @@ impl App { avatars: pages::account::avatar::Cache::default(), corner_applied: None, }; - // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是 baseline。 + // M229:设置页「主题」行的选中态来自设置组自有状态(`language_appearance::State::theme`), + // 唯一写者是 `App::set_theme_mode`;这里把启动时读到的档位灌进去,首帧才不会画错档。 + app.settings.language_appearance.theme = theme_mode; + // 配色来源档可能已经指向一张背景图:那时 `Ui` 必须由解析出的方案构造,而不是色板。 app.rebuild_ui(); // M174:助手配置的首屏值(真值来自 `config/launcher.json`;截图通道走 `ULL_AI_STATE`)。 app.hydrate_ai_settings(); diff --git a/crates/app-iced/src/app/state/theme.rs b/crates/app-iced/src/app/state/theme.rs index 7094a52..b8086ab 100644 --- a/crates/app-iced/src/app/state/theme.rs +++ b/crates/app-iced/src/app/state/theme.rs @@ -6,6 +6,8 @@ //! 截图通道的判据 [`App::has_screenshot`] 也放在这儿:上面三条里两条都要「截图不读用户配置」。 use crate::app::diag::diag; +use crate::app::message::Message; +use iced::Task; use unlucky_design::color::SchemeFlavor; use unlucky_presenter::prefs::ThemeMode; @@ -73,6 +75,8 @@ impl App { } let mode = self.bridge.load().1; self.theme_mode = mode; + // M229:设置页那一行画的是这个字段(唯一写者见 [`App::set_theme_mode`])。 + self.settings.language_appearance.theme = mode; match mode { ThemeMode::Light => self.set_flavor(SchemeFlavor::Light), ThemeMode::Dark => self.set_flavor(SchemeFlavor::Dark), @@ -80,6 +84,68 @@ impl App { } } + /// 换主题档(M229:设置页「通用 → 主题」→ `pages::Message::Theme`)。 + /// + /// 四件事:①改 `theme_mode`(唯一真相)②同步设置页那一行的档位 ③重建 `Ui`(色板换了, + /// `Theme::name` 与 49 个角色一起变,iced 才会重算缓存)④落盘 `appearance.theme_mode`。 + /// `Auto` 自己没有颜色,要等系统回答——所以返回 `iced::system::theme()` 那个 Task, + /// 由既有的 `Message::SystemTheme` 分支落地(它只在 `theme_mode == Auto` 时吃这条消息)。 + pub(in crate::app) fn set_theme_mode(&mut self, mode: ThemeMode) -> Task { + self.settings.language_appearance.theme = mode; + if mode == self.theme_mode { + return Task::none(); + } + self.theme_mode = mode; + + let prefs = self.appearance_prefs.clone(); + self.bridge.persist(&prefs, mode); + match self.bridge.path() { + Some(path) => diag!("[theme] mode={} 已写回 {}", mode.as_str(), path.display()), + None => diag!( + "[theme] mode={} 未落盘(数据根解析不出来,内存态运行):重启会回到旧值", + mode.as_str() + ), + } + + match mode { + ThemeMode::Light => { + self.set_flavor(SchemeFlavor::Light); + self.after_theme_switch(); + Task::none() + } + ThemeMode::Dark => { + self.set_flavor(SchemeFlavor::Dark); + self.after_theme_switch(); + Task::none() + } + // 跟随系统:当前先按已有深浅画,紧接着由系统主题回答覆盖 + // (`set_flavor` 有「同档不重建」的保护,所以这里不必先猜一次)。 + ThemeMode::Auto => iced::system::theme().map(Message::SystemTheme), + } + } + + /// 换档之后重建视图模型 + 记一行取证日志(色板名就是证据:`ink` / `silver`)。 + fn after_theme_switch(&mut self) { + self.view_dirty = true; + self.refresh_view(); + diag!( + "[theme] mode={} board={} Theme::name={}", + self.theme_mode().as_str(), + self.board_name(), + self.ui.name() + ); + } + + /// 当前主题档(设置页那一行的选中态来自 `settings.groups`,这里是取证与测试的读数口)。 + pub(in crate::app) fn theme_mode(&self) -> ThemeMode { + self.theme_mode + } + + /// 当前色板名(日志与测试用:`ink` / `silver`;取色方案接管时仍报它所属深浅的那块)。 + pub(in crate::app) fn board_name(&self) -> &'static str { + self.ui.board().name() + } + /// 当前令牌求值器(测试与截图日志用)。 pub(in crate::app) fn ui(&self) -> &Ui { &self.ui diff --git a/crates/app-iced/src/app/tests.rs b/crates/app-iced/src/app/tests.rs index 2f10ec5..a86769e 100644 --- a/crates/app-iced/src/app/tests.rs +++ b/crates/app-iced/src/app/tests.rs @@ -54,11 +54,11 @@ fn switching_flavor_rebuilds_styles_but_keeps_ui_state() { let mut app = headless(Options::default(), SchemeFlavor::Light); app.state.navigate(Route::Logs); app.refresh_view(); - assert_eq!(app.ui().name(), "ull-m3-light"); + assert_eq!(app.ui().name(), "ull-silver"); app.set_flavor(SchemeFlavor::Dark); - assert_eq!(app.ui().name(), "ull-m3-dark", "切主题要换主题名"); + assert_eq!(app.ui().name(), "ull-ink", "切主题要换主题名"); assert_eq!(app.state.route(), &Route::Logs, "切主题不许重置 UI 状态"); assert_eq!(app.view.screen, pages::Screen::Logs, "视图模型跟着路由"); assert!(app.options.screenshot.is_none(), "切主题不许改启动参数"); @@ -78,7 +78,7 @@ fn screenshot_mode_never_waits_for_the_system_theme() { assert_eq!(options.capture_flavor(), Some(SchemeFlavor::Light)); let app = headless(options, SchemeFlavor::Light); - assert!(app.has_screenshot() && app.ui().name() == "ull-m3-light"); + assert!(app.has_screenshot() && app.ui().name() == "ull-silver"); } #[test] @@ -336,7 +336,7 @@ fn the_page_navigation_entry_also_detaches() { fn screenshot_mode_ignores_the_settings_file_theme() { let mut app = headless(screenshot_options(), SchemeFlavor::Light); app.apply_config_theme(); - assert_eq!(app.ui().name(), "ull-m3-light", "截图模式的主题只认命令行"); + assert_eq!(app.ui().name(), "ull-silver", "截图模式的主题只认命令行"); } /// 主题优先级:`--theme` > 配置文件 `appearance.theme_mode` > 系统主题。 @@ -355,7 +355,7 @@ fn the_system_theme_never_overrides_an_explicit_one() { let mut auto = headless(Options::default(), SchemeFlavor::Light); assert_eq!(auto.theme_mode, ThemeMode::Auto); let _ = auto.update(Message::SystemTheme(iced::theme::Mode::Dark)); - assert_eq!(auto.ui().name(), "ull-m3-dark", "auto 模式要跟随系统"); + assert_eq!(auto.ui().name(), "ull-ink", "auto 模式要跟随系统"); // ② 配置文件显式 dark:系统给浅色也不许改 let mut pinned = headless(Options::default(), SchemeFlavor::Dark); @@ -363,7 +363,7 @@ fn the_system_theme_never_overrides_an_explicit_one() { let _ = pinned.update(Message::SystemTheme(iced::theme::Mode::Light)); assert_eq!( pinned.ui().name(), - "ull-m3-dark", + "ull-ink", "配置文件说 dark,系统说 light:不许改" ); @@ -378,7 +378,9 @@ fn the_system_theme_never_overrides_an_explicit_one() { let _ = explicit.update(Message::SystemTheme(iced::theme::Mode::Light)); assert_eq!( explicit.ui().name(), - "ull-m3-dark", + "ull-ink", "--theme 优先级最高,系统主题不许盖" ); } + +mod theme; diff --git a/crates/app-iced/src/app/tests/appearance.rs b/crates/app-iced/src/app/tests/appearance.rs index ff7991e..5f05438 100644 --- a/crates/app-iced/src/app/tests/appearance.rs +++ b/crates/app-iced/src/app/tests/appearance.rs @@ -181,7 +181,7 @@ fn a_decodable_png_makes_the_background_and_the_palette_active() { "取色成功后 Ui 要换成解析出的方案" ); // 换 scheme 之后主题名仍是同一个(深浅没变),但 primary 必须与 baseline 不同。 - assert_eq!(app.ui().name(), "ull-m3-light"); + assert_eq!(app.ui().name(), "ull-silver"); let baseline = crate::theme::Ui::new(SchemeFlavor::Light, FAMILIES); assert_ne!( app.ui().color(unlucky_design::color::Role::Primary), diff --git a/crates/app-iced/src/app/tests/theme.rs b/crates/app-iced/src/app/tests/theme.rs new file mode 100644 index 0000000..b2528cd --- /dev/null +++ b/crates/app-iced/src/app/tests/theme.rs @@ -0,0 +1,85 @@ +//! 主题三档(Ink / Silver / 跟随系统)的落点测试(M229;从 `app/tests.rs` 拆出守 400 行)。 +//! +//! 这一份只管**用户点设置页那三枚 chip 之后**发生的事:换色板、同步设置组状态、落盘。 +//! `Auto`(跟随系统)那条路在 `app::tests::the_system_theme_never_overrides_an_explicit_one`。 + +use crate::app::message::Message; +use crate::app::state::App; +use crate::cli::Options; +use crate::pages; +use crate::theme::Families; +use unlucky_design::color::SchemeFlavor; +use unlucky_presenter::mock::MockBackend; +use unlucky_presenter::prefs::ThemeMode; +use unlucky_presenter::settings_bridge::SettingsBridge; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +/// 一个把配置写在临时文件里的 App:测「落盘」必须真的过文件(内存桥读回来永远是出厂值)。 +fn with_config() -> (App, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("ull-theme-m229-{}", std::process::id())); + std::fs::create_dir_all(dir.join("config")).expect("临时目录"); + let path = dir.join("config/launcher.json"); + std::fs::write(&path, "{}").expect("写配置文件"); + + let app = App::with_presenter( + Options::default(), + FAMILIES, + SchemeFlavor::Light, + Box::new(MockBackend::new()), + SettingsBridge::with_path(&path), + ); + (app, dir) +} + +/// M229:设置页「通用 → 主题」三档(Ink / Silver / 跟随系统)真的换色板、真的落盘。 +/// +/// 抓得住:①`pages::Message::Theme` 没接进 `on_page`(点了没反应)②接了但不换 `Ui` +/// (只有选中态变了,界面颜色还是旧的)③同步设置组状态漏了(设置页首帧/刷新后画错档) +/// ④落盘漏了(重启回旧值——这里读的是内存桥的 `theme_mode`)。 +/// 抓不住:色板好不好看、像素级差异(那要截图,见 `DevDocs/artifacts/m229/`)。 +#[test] +fn the_theme_message_switches_the_board_and_persists_it() { + let (mut app, dir) = with_config(); + assert_eq!(app.board_name(), "silver", "首帧是浅色主方案(Silver)"); + + // 选 Ink(= 深色主方案) + let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Dark))); + assert_eq!(app.theme_mode(), ThemeMode::Dark); + assert_eq!(app.board_name(), "ink", "Ink 深板要真切过去"); + assert_eq!(app.ui().flavor(), SchemeFlavor::Dark); + assert_eq!( + app.settings.language_appearance.theme, + ThemeMode::Dark, + "设置页那一行的档位要跟着改" + ); + // 落盘:真的过了文件(重新打开同一个配置读回来就是 dark) + assert_eq!( + SettingsBridge::with_path(dir.join("config/launcher.json")) + .load() + .1, + ThemeMode::Dark, + "主题档要落盘" + ); + + // 选 Silver(= 浅色主方案) + let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light))); + assert_eq!(app.board_name(), "silver"); + assert_eq!(app.ui().flavor(), SchemeFlavor::Light); + assert_eq!( + SettingsBridge::with_path(dir.join("config/launcher.json")) + .load() + .1, + ThemeMode::Light + ); + + // 同一档再点一次:不许白重建 + let refreshes = app.refreshes; + let _ = app.update(Message::Page(pages::Message::Theme(ThemeMode::Light))); + assert_eq!(app.refreshes, refreshes, "同一档不该再重建视图模型"); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/app-iced/src/app/update/page_messages.rs b/crates/app-iced/src/app/update/page_messages.rs index da352e9..5494647 100644 --- a/crates/app-iced/src/app/update/page_messages.rs +++ b/crates/app-iced/src/app/update/page_messages.rs @@ -66,6 +66,9 @@ impl App { // 这是**唯一**一处绕开 M101 投递口的页面消息:语言档位不是「某一组的状态」, // 它要落盘到 `ui.language` 并重建整棵视图模型(设置页的语言行只是它的第一个出口)。 pages::Message::Language(choice) => self.set_language(choice), + // M229:换主题档(Ink / Silver / 跟随系统)——换 `Ui` 的色板 + 落盘 + // `appearance.theme_mode`。与语言行同一条「页面消息直接改本地 + 落盘」的路。 + pages::Message::Theme(mode) => return self.set_theme_mode(mode), // M109:内容库那一套意图在自己的文件里穷尽分发(`update/content.rs`) pages::Message::Content(message) => return self.on_content(message), // M95:摘要落剪贴板(文本由页面造,这里只搬) diff --git a/crates/app-iced/src/app/view.rs b/crates/app-iced/src/app/view.rs index e3ee9bb..ff108dc 100644 --- a/crates/app-iced/src/app/view.rs +++ b/crates/app-iced/src/app/view.rs @@ -33,7 +33,7 @@ use update::update_of; /// 上一次记过的「设置页看到的组 + 主题」(`None` = 还没记过)。 /// /// 只给 [`note_screenshot_group`] 用:`view` 每帧都会跑,日志必须只在身份**变化**时落一行。 -/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-m3-light` / `ull-m3-dark`), +/// 用 `Mutex` 而不是 `AtomicUsize` 位打包:主题是字符串(`ull-ink` / `ull-silver`), /// 把两件事挤进一个整数要引入「高半位 = 深色」这种与主题命名耦合的约定,不如老实用一对值。 static SHOT_GROUP_LOGGED: std::sync::Mutex> = std::sync::Mutex::new(None); @@ -43,7 +43,7 @@ static SHOT_GROUP_LOGGED: std::sync::Mutex> = std::sync: /// /// 举证图自带不了身份。M201 的事故就是 worker 交了一张「外观」页当下载组的证据,报告里的结论 /// 建立在一个错的「这是哪个组」上,直到视觉 reviewer 自己重拍才发现。所以每张交付图都要能对上 -/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-m3-light`。 +/// 一行日志:`[shot] settings route=Settings group=2 theme=ull-silver`。 /// /// ## 为什么只在变化时打 /// diff --git a/crates/app-iced/src/components/buttons/assemble.rs b/crates/app-iced/src/components/buttons/assemble.rs index 0b22b85..96f1ef0 100644 --- a/crates/app-iced/src/components/buttons/assemble.rs +++ b/crates/app-iced/src/components/buttons/assemble.rs @@ -10,7 +10,6 @@ use iced::{Alignment, Background, Border, Element, Length, Pixels, Shadow}; use unlucky_design::color::Role; use unlucky_design::elevation::Elevation; use unlucky_design::shape::ShapeScale; -use unlucky_design::typography::TypeRole; use crate::icons; use crate::theme::Ui; @@ -48,7 +47,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>( variant: Variant, spec: super::Button<'a, M>, ) -> Element<'a, M> { - let geometry = Geometry::of(ui, variant, spec.leading.is_some()); + let geometry = Geometry::sized(ui, variant, spec.leading.is_some(), spec.size); let (fill, content, border) = variant.roles(); let forced = spec.state.or(if spec.on_press.is_none() { Some(Interaction::Disabled) @@ -70,12 +69,13 @@ pub(super) fn assemble<'a, M: Clone + 'a>( // 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。 let ink = content_color(ui, forced, content, fill, variant.disabled_content()); + // 标签排版跟尺寸档走:`ControlSize::label()` 已把「字号跟字级、字重固定 500」定死 + // (XS/S = LabelLarge 14、M = BodyLarge 16、L = HeadlineSmall 24、XL = HeadlineLarge 32)。 + let label_style = geometry.size.label(); let label = text(spec.label) - .size(ui.style(TypeRole::LabelLarge).size_px) - .line_height(LineHeight::Absolute(Pixels( - ui.style(TypeRole::LabelLarge).line_height_px, - ))) - .font(ui.font_of(ui.emphasized(TypeRole::LabelLarge))) + .size(label_style.size_px) + .line_height(LineHeight::Absolute(Pixels(label_style.line_height_px))) + .font(ui.font_of(label_style)) .color(ink); let mut row = Row::new().align_y(Alignment::Center); diff --git a/crates/app-iced/src/components/buttons/fab.rs b/crates/app-iced/src/components/buttons/fab.rs new file mode 100644 index 0000000..e057f1e --- /dev/null +++ b/crates/app-iced/src/components/buttons/fab.rs @@ -0,0 +1,203 @@ +//! FAB 四态(规格卡 `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。 +//! +//! ## 规格卡原值(`[spec]` = 图上的坐标/字号,几何全部来自 [`FabSize`] 的令牌访问器) +//! +//! | 档 | 尺寸 | 圆角 | 图标 | 标签 | 规格卡依据 | +//! |---|---|---|---|---|---| +//! | Small | 40×40 | 12(`Medium`) | 20 | — | `+` 徽章式小 FAB | +//! | Default | 56×56 | 16(`Large`) | 26 | — | M3 默认 FAB | +//! | Medium | 80×80 | 20(`LargeIncreased`) | 34 | — | 中大档 | +//! | Extended | 160×56(最小宽) | 16(`Large`) | 24 | 15 | `+ New`:起始内距 28、图标↔标签 8 | +//! +//! ## 配色:`PrimaryContainer` / `OnPrimaryContainer` +//! +//! 规格卡上四颗 FAB 都是**淡绿底 + 深绿字形**,M3 的对应角色正是 `primary-container` / +//! `on-primary-container`(`boards/ink.rs` 的 `[spec]` 行注释也这么标:`FAB 底` / `FAB 图标`)。 +//! 所以这里不引入新角色,也不画阴影——规格卡上是纯色块,M3 的 FAB 高程由容器色表达。 +//! +//! 色值不写在这份文档里:`tests/no_hardcoded_tokens.rs` 会扫 `src/**` 的十六进制色值, +//! 要核对数字请看 `boards/ink.rs` 的 `ANCHORS` 表(那是唯一真源)。 +//! +//! ## 为什么单独一个文件 +//! +//! 按钮家族(五式 × 五态 × 五档)已经在 `buttons/mod.rs` + `assemble.rs` 里贴着 400 行;FAB 的 +//! 几何与按钮**不共享**(圆角不是半高、图标随档放大、扩展态有自己的内距),硬塞进去只会让两边 +//! 的读取路径都变长。共享的只有 `interaction` 的状态层与图标烘焙工具。 + +use iced::widget::{button, container, row, text}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::inkcat::size::FabSize; +use unlucky_design::typography::{TypeRole, TypeStyle, Weight}; + +use crate::icons; +use crate::theme::Ui; + +use super::super::interaction::{self, Interaction}; +use super::super::layout; + +/// 一颗 FAB:图标必有,标签只有扩展态有;`on_press = None` 即禁用。 +#[derive(Debug, Clone)] +pub struct Fab<'a, M> { + icon: IconKind, + label: Option<&'a str>, + size: FabSize, + on_press: Option, + state: Option, +} + +impl<'a, M> Fab<'a, M> { + /// 标准 FAB(默认档 = 56)。 + pub fn new(icon: IconKind, on_press: M) -> Self { + Self { + icon, + label: None, + size: FabSize::Default, + on_press: Some(on_press), + state: None, + } + } + + /// 扩展态(药丸 + 图标 + 标签)。`label` 文案由调用方给(组件不碰 i18n)。 + pub fn extended(icon: IconKind, label: &'a str, on_press: M) -> Self { + Self { + label: Some(label), + size: FabSize::Extended, + ..Self::new(icon, on_press) + } + } + + /// 换档(Small / Default / Medium / Extended)。 + pub fn size(mut self, size: FabSize) -> Self { + self.size = size; + self + } + + /// 强制视觉态(gallery 取证用;运行期不要调)。 + pub fn state(mut self, state: Interaction) -> Self { + self.state = Some(state); + self + } +} + +/// 画一颗 FAB。 +pub fn fab<'a, M: Clone + 'a>(ui: &'a Ui, spec: Fab<'a, M>) -> Element<'a, M> { + let size = spec.size; + let forced = spec.state.or(if spec.on_press.is_none() { + Some(Interaction::Disabled) + } else { + None + }); + let ink = match forced { + // 禁用:38% 的内容色叠在容器上(与按钮族的容器档同一口径) + Some(Interaction::Disabled) => { + interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true) + } + _ => ui.color(Role::OnPrimaryContainer), + }; + + let glyph = layout::fixed( + size.icon_px(), + size.icon_px(), + icons::view_at::(spec.icon, ink, size.icon_px()), + ); + + let content: Element<'a, M> = match spec.label { + // 扩展态:图标 + 标签(起始内距 28、间距 8,规格卡实测值) + Some(label) => { + let label_style = TypeStyle { + weight: Weight::Medium, + ..TypeRole::BodyMedium.style() + }; + let label_px = size.label_px(); + row![ + glyph, + text(label) + .size(label_px) + .font(ui.font_of(TypeStyle { + size_px: label_px, + ..label_style + })) + .color(ink), + ] + .spacing(size.gap_px()) + .align_y(Alignment::Center) + .padding(Padding { + top: 0.0, + right: 28.0, + bottom: 0.0, + left: size.pad_start_px(), + }) + .into() + } + None => container(glyph) + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Center) + .align_y(Alignment::Center) + .into(), + }; + + let shape = size.shape(); + let radius = size.radius_px(); + let style = move |_theme: &iced::Theme, status: button::Status| { + let state = forced.unwrap_or_else(|| Interaction::from_status(status)); + let background = match state.layer() { + // 状态层叠在容器色上(与按钮族同一条 `overlay_color` 口径) + Some(_) => interaction::overlay_color( + ui, + state, + Role::OnPrimaryContainer, + Role::PrimaryContainer, + ) + .or(Some(ui.color(Role::PrimaryContainer))), + None => Some(ui.color(Role::PrimaryContainer)), + }; + let text_color = if state.is_disabled() { + interaction::disabled_button_content(ui, Role::OnPrimaryContainer, true) + } else { + ui.color(Role::OnPrimaryContainer) + }; + + button::Style { + background: background.map(Background::Color), + text_color, + border: Border { + radius: iced::border::Radius::new(radius), + ..Border::default() + }, + // 规格卡不画阴影;M3 的 FAB 高程靠容器色(rubric §7 的「L0/L1 不用阴影」同源) + shadow: Shadow::default(), + snap: true, + } + }; + + let width = if matches!(size, FabSize::Extended) { + // 扩展态是最小宽(160):标签更长就撑开,短了就顶到最小宽 + Length::Fixed(size.width_px()) + } else { + Length::Fixed(size.width_px()) + }; + + let element = button(content) + .width(width) + .height(Length::Fixed(size.height_px())) + .padding(Padding::default()) + .style(style); + + debug_assert!(radius > 0.0, "FAB 圆角必须来自 ShapeScale({shape:?})"); + debug_assert!( + matches!(size, FabSize::Extended) || size.pad_start_px() == 0.0, + "只有扩展态有起始内距" + ); + + match spec.on_press { + Some(message) => element.on_press(message).into(), + None => element.into(), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/buttons/fab/tests.rs b/crates/app-iced/src/components/buttons/fab/tests.rs new file mode 100644 index 0000000..26bb214 --- /dev/null +++ b/crates/app-iced/src/components/buttons/fab/tests.rs @@ -0,0 +1,40 @@ +//! FAB 的几何断言(四档尺寸/圆角/图标/内距,全部对着规格卡原值)。 +//! +//! 抓得住:档位与规格卡错位(例如把 Default 写成 48)、圆角被写成半高、扩展态内距悄悄改成 M3 的 16。 +//! 抓不住:配色与状态层(那走 `interaction` 的既有测试)、以及图上好不好看(D30)。 + +use unlucky_design::inkcat::size::FabSize; + +#[test] +fn the_four_states_are_the_spec_sizes() { + for (size, width, height, radius) in [ + (FabSize::Small, 40.0, 40.0, 12.0), + (FabSize::Default, 56.0, 56.0, 16.0), + (FabSize::Medium, 80.0, 80.0, 20.0), + (FabSize::Extended, 160.0, 56.0, 16.0), + ] { + assert_eq!(size.width_px(), width, "{size:?} 宽"); + assert_eq!(size.height_px(), height, "{size:?} 高"); + assert_eq!(size.radius_px(), radius, "{size:?} 圆角"); + } + assert_eq!(FabSize::ALL.len(), 4); +} + +#[test] +fn only_the_extended_state_carries_a_label_and_its_inset() { + for size in [FabSize::Small, FabSize::Default, FabSize::Medium] { + assert_eq!(size.label_px(), 0.0, "{size:?} 不该有标签"); + assert_eq!(size.pad_start_px(), 0.0); + } + assert_eq!(FabSize::Extended.label_px(), 15.0); + assert_eq!(FabSize::Extended.pad_start_px(), 28.0); + assert_eq!(FabSize::Extended.gap_px(), 8.0); +} + +/// 图标随档放大(20 / 26 / 34),扩展态回到 24(它与标签同行,跟着文字尺度走)。 +#[test] +fn the_glyph_grows_with_the_smaller_states() { + assert!(FabSize::Small.icon_px() < FabSize::Default.icon_px()); + assert!(FabSize::Default.icon_px() < FabSize::Medium.icon_px()); + assert_eq!(FabSize::Extended.icon_px(), 24.0); +} diff --git a/crates/app-iced/src/components/buttons/geometry.rs b/crates/app-iced/src/components/buttons/geometry.rs index c9ddecd..6c54a94 100644 --- a/crates/app-iced/src/components/buttons/geometry.rs +++ b/crates/app-iced/src/components/buttons/geometry.rs @@ -25,6 +25,7 @@ use iced::Padding; use unlucky_design::icons::IconSize; +use unlucky_design::inkcat::size::ControlSize; use unlucky_design::spacing::Space; use crate::theme::{Ui, STROKE_PX}; @@ -44,29 +45,50 @@ pub struct Geometry { pub gap: f32, /// 描边宽度;`0` = 该式无描边。 pub stroke: f32, + /// 尺寸档(决定高、圆角折算边、内距与标签字级)。 + pub size: ControlSize, } impl Geometry { - /// 按变体 + 有没有前置图标求值(带图标时左内边距收到 16,右仍 24)。 + /// 按变体 + 有没有前置图标求值(默认 **S 档 = 40 高**,产品页用的就是这一档)。 + /// + /// `#[cfg(test)]`:产品路径一律走 [`Geometry::sized`](`assemble` 里带上了尺寸档), + /// 这里只给单测一个「S 档」的便捷入口,留成产品 API 只会让 `dead_code` 响。 + #[cfg(test)] pub fn of(ui: &Ui, variant: Variant, leading_icon: bool) -> Self { + Self::sized(ui, variant, leading_icon, ControlSize::S) + } + + /// 按尺寸档求值(规格卡 `BUTTONS / XS - XL`)。 + /// + /// 内距取 [`ControlSize::pad_x`](规格卡没写内距,那是 `[估]` 的「半高向下取 4 栅格、封顶 48」 + /// 推导值);带前置图标时左侧再收 **4**(`Space::X1`)——药丸左半圆里图标要离弧远一点, + /// 与 M3 的「带图标左内距更小」是同一条观感规则。S 档因此是 **20 / 16 / 20**, + /// 而 M3 官方是 24 / 16 / 24:差 4,登记在 M229 报告里(规格卡没给内距,我们按它自己的 + /// 半高规则推,而不是回到官方值——否则五档里只有 S 一种内距口径)。 + pub fn sized(ui: &Ui, variant: Variant, leading_icon: bool, size: ControlSize) -> Self { let (_, _, border) = variant.roles(); + let pad = size.pad_x(); let start = if leading_icon { - ui.space(Space::X4) + pad - ui.space(Space::X1) } else { - ui.space(Space::X6) + pad }; Self { - height: ui.metrics().control_height, + height: size.height_px(), padding: Padding { top: 0.0, - right: ui.space(Space::X6), + right: pad, bottom: 0.0, left: start, }, + // 图标盒固定 18(`IconSize::Button`):规格卡五档按钮的图标都没单独写字号, + // 档位只放大容器与标签,不放大图标——那是按钮,不是 FAB。 icon: IconSize::Button.px(), gap: ui.space(Space::X2), stroke: if border.is_some() { STROKE_PX } else { 0.0 }, + size, } } } diff --git a/crates/app-iced/src/components/buttons/mod.rs b/crates/app-iced/src/components/buttons/mod.rs index 0686d89..5e68ff5 100644 --- a/crates/app-iced/src/components/buttons/mod.rs +++ b/crates/app-iced/src/components/buttons/mod.rs @@ -47,6 +47,7 @@ use unlucky_design::color::Role; use unlucky_design::component_map::Component; use unlucky_design::elevation::Elevation; use unlucky_design::icons::IconKind; +use unlucky_design::inkcat::size::ControlSize; use unlucky_design::shape::ShapeScale; use crate::icons; @@ -56,8 +57,10 @@ use super::interaction::Interaction; use super::layout; mod assemble; +mod fab; mod geometry; +pub use fab::{fab, Fab}; pub use geometry::{Geometry, IconGeometry}; use assemble::{assemble, style_closure, StyleSpec}; @@ -160,35 +163,47 @@ impl Variant { } } -/// IconButton 的三种容器变体(12b §2.5)。 +/// IconButton 的**四式**(规格卡 `ICON BUTTONS / FILLED - TONAL - OUTLINED - PLAIN`)。 +/// +/// 规格卡画了四颗:实心 / 色调 / 描边 / 纯图标。12b §2.5 只有前三式(Standard / Filled / Tonal), +/// M229 按规格卡把 Standard 拆成 **Plain**(纯图标)与 **Outlined**(1px 描边圆圈), +/// `Standard` 这个名字保留为 `Plain` 的别名语义,避免既有调用点全改。 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum IconVariant { - /// 标准:容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。 - Standard, + /// 纯图标(规格卡 `PLAIN`):容器透明、图标 `OnSurfaceVariant`(各态图标色不变,12b 2.4)。 + Plain, /// 实心:`Primary` + `OnPrimary`。 Filled, /// Tonal:`SecondaryContainer` + `OnSecondaryContainer`。 Tonal, + /// 描边:透明容器 + 1px `Outline`(规格卡第三颗,M229 新增)。 + Outlined, } impl IconVariant { - /// 三式全序(gallery 顺序)。 - pub const ALL: [IconVariant; 3] = [ - IconVariant::Standard, + /// 旧名字的别名:`Standard` = [`IconVariant::Plain`](既有调用点不必改语义)。 + #[allow(non_upper_case_globals)] + pub const Standard: IconVariant = IconVariant::Plain; + + /// 四式全序(gallery 顺序 = 规格卡从左到右)。 + pub const ALL: [IconVariant; 4] = [ IconVariant::Filled, IconVariant::Tonal, + IconVariant::Outlined, + IconVariant::Plain, ]; /// 稳定名字。 pub const fn name(self) -> &'static str { match self { - IconVariant::Standard => "standard", + IconVariant::Plain => "plain", IconVariant::Filled => "filled", IconVariant::Tonal => "tonal", + IconVariant::Outlined => "outlined", } } - /// **禁用态图标色**:三式都是 `OnSurface` @0.38 —— A1 + /// **禁用态图标色**:四式都是 `OnSurface` @0.38 —— A1 /// (`StandardIconButtonTokens.DisabledColor` / `FilledIconButtonTokens.DisabledColor`)。 pub(crate) const fn disabled_content(self) -> Role { Role::OnSurface @@ -197,13 +212,14 @@ impl IconVariant { /// 色彩角色三元组(同 [`Variant::roles`] 的口径)。 pub(crate) const fn roles(self) -> (Option, Role, Option) { match self { - IconVariant::Standard => (None, Role::OnSurfaceVariant, None), + IconVariant::Plain => (None, Role::OnSurfaceVariant, None), IconVariant::Filled => (Some(Role::Primary), Role::OnPrimary, None), IconVariant::Tonal => ( Some(Role::SecondaryContainer), Role::OnSecondaryContainer, None, ), + IconVariant::Outlined => (None, Role::OnSurfaceVariant, Some(Role::Outline)), } } } @@ -220,6 +236,8 @@ pub struct Button<'a, M> { leading: Option, trailing: Option, state: Option, + /// 尺寸档(规格卡 `BUTTONS / XS - XL`)。产品默认 **S(40 高)**,与 M3 `control_height` 同值。 + size: ControlSize, } impl<'a, M> Button<'a, M> { @@ -231,6 +249,7 @@ impl<'a, M> Button<'a, M> { leading: None, trailing: None, state: None, + size: ControlSize::S, } } @@ -242,9 +261,19 @@ impl<'a, M> Button<'a, M> { leading: None, trailing: None, state: None, + size: ControlSize::S, } } + /// 换尺寸档(`XS 32 / S 40 / M 56 / L 96 / XL 136`,圆角一律药丸)。 + /// + /// 产品页只用 **S**(= M3 `control_height` 40);M / L / XL 是规格卡的梯子档,给 + /// 空态主行动与展示型入口留着,`--screen=gallery` 的矩阵会把五档一起拍出来。 + pub fn size(mut self, size: ControlSize) -> Self { + self.size = size; + self + } + /// 前置图标(12b 1.4:带图标时左内边距收到 16)。 pub fn leading(mut self, kind: IconKind) -> Self { self.leading = Some(kind); diff --git a/crates/app-iced/src/components/buttons/tests.rs b/crates/app-iced/src/components/buttons/tests.rs index c21a610..c33467e 100644 --- a/crates/app-iced/src/components/buttons/tests.rs +++ b/crates/app-iced/src/components/buttons/tests.rs @@ -69,16 +69,20 @@ fn geometry_matches_the_reference_values() { 20.0, "12b 1.2 full 圆角 = 高/2" ); - assert_eq!(geometry.padding.left, 24.0, "12b 1.3 左 24"); - assert_eq!(geometry.padding.right, 24.0, "12b 1.3 右 24"); + // M229:内距取规格卡的 `ControlSize::pad_x`(S 档 = 20),不再是 12b 的 24。 + assert_eq!(geometry.padding.left, 20.0, "S 档 pad_x 20"); + assert_eq!(geometry.padding.right, 20.0, "S 档 pad_x 20"); assert_eq!(geometry.padding.top, 0.0); assert_eq!(geometry.gap, 8.0, "12b 1.6 图标↔文字 8"); assert_eq!(geometry.gap, ui.space(Space::X2)); assert_eq!(geometry.stroke, 0.0, "实心按钮无描边"); let with_icon = Geometry::of(&ui, Variant::Filled, true); - assert_eq!(with_icon.padding.left, 16.0, "12b 1.4 带图标左内边距 16"); - assert_eq!(with_icon.padding.right, 24.0, "带图标右内边距不动"); + assert_eq!( + with_icon.padding.left, 16.0, + "带图标左侧再收 4(20 − 4):与 12b 1.4 的 16 恰好同值" + ); + assert_eq!(with_icon.padding.right, 20.0, "带图标右内边距不动"); let outlined = Geometry::of(&ui, Variant::Outlined, false); assert_eq!(outlined.stroke, STROKE_PX, "rubric G7 全站 1px"); diff --git a/crates/app-iced/src/components/feedback/geometry.rs b/crates/app-iced/src/components/feedback/geometry.rs index 5ac3206..e65ccff 100644 --- a/crates/app-iced/src/components/feedback/geometry.rs +++ b/crates/app-iced/src/components/feedback/geometry.rs @@ -8,8 +8,8 @@ //! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 | //! | 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 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` | -//! | 14.3 | 单行 / 双行高 | **48 / 68** | `panel::SnackbarGeometry::height` | +//! | 14.1 | Snackbar 圆角 | 规格卡 **16**(`inkcat::metrics::SNACKBAR`;官方 `CornerExtraSmall` 是 4,M229 换肤按规格卡) | `panel::SnackbarGeometry::radius` | +//! | 14.3 | 单行 / 双行高 | 规格卡 **52** / **72**(+20;官方 48/68) | `panel::SnackbarGeometry::height_for` | //! | 14.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` | //! | 14.4b | 外边距(距窗口边) | **12**(`[规格]`:AAR 里**没有**这个常数,见 G17-c) | `panel::SnackbarGeometry::margin` | //! | 14.x | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `panel::SnackbarGeometry::max_width` | diff --git a/crates/app-iced/src/components/feedback/panel.rs b/crates/app-iced/src/components/feedback/panel.rs index 08b537d..e867e88 100644 --- a/crates/app-iced/src/components/feedback/panel.rs +++ b/crates/app-iced/src/components/feedback/panel.rs @@ -4,6 +4,7 @@ //! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。 use unlucky_design::component_map::Component; +use unlucky_design::inkcat::metrics as inkcat; use unlucky_design::spacing::Space; use crate::theme::Ui; @@ -33,18 +34,24 @@ pub struct SnackbarGeometry { impl SnackbarGeometry { /// 从令牌求值。 + /// + /// M229 换肤把**高与圆角**改成规格卡口径(`inkcat::metrics::SNACKBAR`:340×**52**、rx=**16**): + /// 官方 `SnackbarTokens` 是 48 高 + `CornerExtraSmall(4)`,规格卡画成 52/16 —— M228 已把这条 + /// 差异登记为「换肤时取 `SNACKBAR`」。双行高仍按官方「每多一行 +20」推成 **72**(52 + 20), + /// 与单行同 delta;宽上限 600 与边距 12 不动(它们不在规格卡上)。 pub fn of(ui: &Ui) -> Self { - let single_height = 48.0; + let spec = inkcat::SNACKBAR; + let single_height = spec.height; Self { min_width: 344.0, max_width: 600.0, single_height, - two_height: 68.0, + two_height: single_height + 20.0, margin: ui.space(Space::X3), pad_h: ui.space(Space::X4), pad_v: 6.0, gap: ui.space(Space::X2), - radius: Component::Snackbar.spec().shape.radius_px(single_height), + radius: spec.radius_px(), } } diff --git a/crates/app-iced/src/components/feedback/probe.rs b/crates/app-iced/src/components/feedback/probe.rs index bd88fe2..b130802 100644 --- a/crates/app-iced/src/components/feedback/probe.rs +++ b/crates/app-iced/src/components/feedback/probe.rs @@ -200,7 +200,7 @@ fn indeterminate_row(shot: &Shot, active: iced::Color, surface: iced::Color) -> /// §14:宽度上下限 + 单/双行高。 fn check_snackbar(shot: &Shot, ui: &Ui) { - // 参考值写字面量(A1):600 = SnackbarKt.ContainerMaxWidth,48/68 = SnackbarTokens。 + // 参考值写字面量:600 = SnackbarKt.ContainerMaxWidth,52/72 = 规格卡(单行 52 + 每多一行 20)。 let surface = ui.color(Role::Surface); let bar = ui.color(Role::InverseSurface); @@ -242,12 +242,12 @@ fn check_snackbar(shot: &Shot, ui: &Ui) { "宿区 324 那格必须量到 300(组件没有 min-width,G17-d),实测 {widths:?}" ); assert!( - heights.iter().any(|h| near(*h, 48)), - "14.3:单行高 48,实测高度集 {heights:?}" + heights.iter().any(|h| near(*h, 52)), + "规格卡:单行高 52,实测高度集 {heights:?}" ); assert!( - heights.iter().any(|h| near(*h, 68)), - "14.3:双行高 68,实测高度集 {heights:?}" + heights.iter().any(|h| near(*h, 72)), + "双行高 72(52 + 20),实测高度集 {heights:?}" ); // 14.6 动作色:`InversePrimary` 必须出现在提示条内(不是按钮家族的 OnSurfaceVariant) diff --git a/crates/app-iced/src/components/feedback/snackbar.rs b/crates/app-iced/src/components/feedback/snackbar.rs index 713dd49..fcf87ae 100644 --- a/crates/app-iced/src/components/feedback/snackbar.rs +++ b/crates/app-iced/src/components/feedback/snackbar.rs @@ -4,9 +4,9 @@ //! //! | # | 项 | 值 | 落点 | //! |---|---|---|---| -//! | 14.1 | 圆角 | **4**(`CornerExtraSmall`) | `Component::Snackbar.spec().shape` | +//! | 14.1 | 圆角 | **16**(规格卡 `rx=16`;官方 `CornerExtraSmall` 是 4,M229 换肤按规格卡) | `inkcat::metrics::SNACKBAR` | //! | 14.2 | 容器色 | **`InverseSurface`**(高程 L3 是设计口头径,见下「不画阴影」) | [`Role::InverseSurface`] | -//! | 14.3 | 高 | 单行 **48** / 双行 **68** | [`SnackbarGeometry::height`] | +//! | 14.3 | 高 | 单行 **52**(规格卡 340×52;官方 48)/ 双行 **72**(+20) | [`SnackbarGeometry::height_for`] | //! | 14.4 | 内距 | 水平 **16** / 垂直 **6** / 文本↔动作 **8** | `Space::X4` / 6 / `Space::X2` | //! | 14.6 | 颜色 | 文本 `InverseOnSurface`、动作 **`InversePrimary`**、图标 24 `InverseOnSurface` | 组件层取角色 | //! | — | 宽度上限 | **600**(`SnackbarKt.ContainerMaxWidth`) | `max_width` | @@ -75,7 +75,7 @@ pub struct Snackbar<'a, M> { } impl<'a, M> Snackbar<'a, M> { - /// 单行提示条(高 48)。 + /// 单行提示条(高 52,规格卡口径)。 pub fn new(text: &'a str) -> Self { Self { text, diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index 7ecc203..74e7b81 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -32,13 +32,18 @@ pub(super) fn ui(flavor: SchemeFlavor) -> Ui { // ---- §14 Snackbar ----------------------------------------------------------- -/// 14.3 / 14.1 / 宽度上限:48 / 68 / 4 / 600。 +/// 14.3 / 14.1 / 宽度上限:规格卡 52 / 72 / 16,上限 600。 #[test] fn snackbar_heights_widths_and_radius_are_the_tokens() { let geometry = SnackbarGeometry::of(&ui(SchemeFlavor::Light)); - assert_eq!(geometry.height_for(1), 48.0, "14.3 单行 48"); - assert_eq!(geometry.height_for(2), 68.0, "14.3 双行 68"); - assert_eq!(geometry.radius, 4.0, "14.1 圆角 4(CornerExtraSmall)"); + // M229 换肤:高与圆角取规格卡(52 / 16),官方是 48 / `CornerExtraSmall(4)`。 + assert_eq!(geometry.height_for(1), 52.0, "规格卡 340×52"); + assert_eq!( + geometry.height_for(2), + 72.0, + "双行 = 单行 + 20(同官方 delta)" + ); + assert_eq!(geometry.radius, 16.0, "规格卡 rx=16(官方 4,登记差异)"); assert_eq!(geometry.max_width, 600.0, "SnackbarKt.ContainerMaxWidth"); assert_eq!(geometry.min_width, 344.0, "G17-d [估] 最小宽"); assert!(geometry.min_width < geometry.max_width, "上下限必须有区间"); diff --git a/crates/app-iced/src/components/gallery/feedback.rs b/crates/app-iced/src/components/gallery/feedback.rs index 7da2d75..3edc214 100644 --- a/crates/app-iced/src/components/gallery/feedback.rs +++ b/crates/app-iced/src/components/gallery/feedback.rs @@ -73,7 +73,7 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { let mut body = column![heading( ui, "feedback · progress / snackbar / dialog / empty state", - "12b §7/§12/§14/§15:Linear 厚 4 圆角 full · Circular 40/4 · Snackbar 48/68 上限宽 600 · \ + "规格卡 + 12b §7/§12/§15:Linear 厚 4 圆角 full · Circular 40/4 · Snackbar 52/72 圆角 16 上限宽 600 · \ Dialog R28 pad24 宽 480 · EmptyState 项目自定[估](官方无此组件)" )] .spacing(ui.space(Space::X3)); @@ -270,7 +270,7 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { wide, caption( ui, - "Snackbar · 双行 68 + action InversePrimary + 关闭键(动作强制 hover 态)" + "Snackbar · 双行 72(52+20)+ action InversePrimary + 关闭键(动作强制 hover 态)" ), two_lines, caption( diff --git a/crates/app-iced/src/components/gallery/mod.rs b/crates/app-iced/src/components/gallery/mod.rs index f83897a..ea4f54b 100644 --- a/crates/app-iced/src/components/gallery/mod.rs +++ b/crates/app-iced/src/components/gallery/mod.rs @@ -48,7 +48,7 @@ //! ULL_GALLERY_SECTION=selection # 只渲染 selection 段(本 mission 的取证与 probe 用它) //! ``` //! -//! 取值:`all` / `buttons` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。 +//! 取值:`all` / `buttons` / `sizes` / `divider` / `selection` / `inputs` / `feedback` / `navigation`。 //! 语义是「**不构建**没被选中的段」(不是构建后隐藏):`column` 里根本不 push 它, //! 于是该家族的 `view` 一行都不跑。未识别的值**回落 `all`** 并在 stderr 打一行提示 //! (静默回落会让人拿错图还以为拍的是那一段)。 @@ -87,6 +87,7 @@ mod probe; #[cfg(test)] mod scan; mod selection; +mod sizes; /// 段落过滤的环境变量名(语义见模块文档「段落过滤」)。 pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION"; @@ -95,6 +96,7 @@ pub const SECTION_ENV: &str = "ULL_GALLERY_SECTION"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct Sections { buttons: bool, + sizes: bool, divider: bool, selection: bool, inputs: bool, @@ -106,6 +108,7 @@ impl Sections { /// 五段全渲染(默认)。 const ALL: Self = Self { buttons: true, + sizes: true, divider: true, selection: true, inputs: true, @@ -117,6 +120,7 @@ impl Sections { fn only(name: &str) -> Option { let mut sections = Self { buttons: false, + sizes: false, divider: false, selection: false, inputs: false, @@ -125,6 +129,7 @@ impl Sections { }; match name { "buttons" => sections.buttons = true, + "sizes" => sections.sizes = true, "divider" => sections.divider = true, "selection" => sections.selection = true, "inputs" => sections.inputs = true, @@ -150,7 +155,7 @@ impl Sections { return sections; } eprintln!( - "[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|divider|selection|inputs|feedback|navigation),已回落 all" + "[gallery] {SECTION_ENV}={raw:?} 不是已知段(all|buttons|sizes|divider|selection|inputs|feedback|navigation),已回落 all" ); Self::ALL } @@ -160,6 +165,7 @@ impl Sections { let mut names = Vec::new(); for (on, name) in [ (self.buttons, "buttons"), + (self.sizes, "sizes"), (self.divider, "divider"), (self.selection, "selection"), (self.inputs, "inputs"), @@ -215,8 +221,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { .font(ui.emphasized_font(TypeRole::TitleLarge)) .color(ui.color(Role::OnSurface)), text(format!( - "M3 Expressive component base · flavor={} · 1180×760 @100% DPI · \ + "M3 Expressive component base · board={} · flavor={} · 1180×760 @100% DPI · \ focus 与 pressed 同为 10%(令牌口径,图上一样是对的)", + ui.board().name(), flavor_slug(ui) )) .size(page.size_px) @@ -236,6 +243,9 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { if picked.buttons { sections = sections.push(buttons::section(ui)); } + if picked.sizes { + sections = sections.push(sizes::section(ui)); + } if picked.divider { sections = sections.push(divider_proof(ui)); } diff --git a/crates/app-iced/src/components/gallery/sizes.rs b/crates/app-iced/src/components/gallery/sizes.rs new file mode 100644 index 0000000..c8ff24c --- /dev/null +++ b/crates/app-iced/src/components/gallery/sizes.rs @@ -0,0 +1,123 @@ +//! 尺寸档矩阵:**按钮 XS~XL 五档** + **FAB 四态**(规格卡 `BUTTONS / XS - XL` 与 +//! `FAB / SMALL - DEFAULT - MEDIUM - EXTENDED`)。 +//! +//! ## 为什么单开一段(而不是塞进 `buttons`) +//! +//! `buttons` 段是「五式 × 五态 + 图标四式」的 40 格矩阵,一屏(1180×760)本来就贴着折叠线; +//! 五档梯子(最高 136)与四态 FAB(最宽 160)塞进去会被压到屏幕外——那样出的图**看不见**, +//! 等于没拍。所以按「一段 = 一个能一屏判完的问题」拆开: +//! +//! ```text +//! ULL_GALLERY_SECTION=sizes # 本段:尺寸梯子 + FAB 四态 +//! ``` +//! +//! ## 图上要能判什么 +//! +//! 1. 五档高度递增、圆角全部是**半高**(药丸),标签字号跟着档位升(14/14/16/24/32); +//! 2. FAB 四态:40/56/80 三个方态 + 160×56 扩展态(图标 + 标签); +//! 3. 配色:FAB 一律 `PrimaryContainer` / `OnPrimaryContainer`(规格卡上四颗都是淡绿底深绿字)。 +//! +//! 判不了:状态层(FAB 在这一段是静息态,五态矩阵在 `buttons` 段的按钮上验)、以及动效。 + +use iced::widget::{column, row}; +use iced::{Element, Length}; + +use unlucky_design::color::Role; +use unlucky_design::icons::IconKind; +use unlucky_design::inkcat::size::{ControlSize, FabSize}; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; + +use crate::theme::Ui; + +use super::super::buttons::{self, fab, Button, Fab}; +use super::super::interaction::Interaction; +use super::{heading, DemoMessage}; + +/// 整段(标题 + 五档梯子 + 四态 FAB + 图例)。 +pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { + let legend = ui.style(TypeRole::BodySmall); + + let ladder = row(ControlSize::ALL + .iter() + .map(|size| sized(ui, *size)) + .collect::>()) + .spacing(ui.space(Space::X4)) + .align_y(iced::Alignment::Center); + + let fabs = row(FabSize::ALL + .iter() + .map(|size| fab_cell(ui, *size)) + .collect::>()) + .spacing(ui.space(Space::X4)) + .align_y(iced::Alignment::Center); + + column![ + heading( + ui, + "sizes · buttons xs~xl + fab small~extended", + "规格卡:按钮 32/40/56/96/136(圆角 = 半高,标签 14/14/16/24/32 字重 500);\ + FAB 40/r12 · 56/r16 · 80/r20 · 160×56/r16(图标 20/26/34/24,扩展态内距 28、间距 8)" + ), + labelled(ui, "buttons xs - xl", ladder.into()), + labelled(ui, "fab small - extended", fabs.into()), + iced::widget::text( + "图例:产品页的按钮只用 S 档(40,= M3 control_height);M/L/XL 留给空态主行动与展示型入口。\ + FAB 四颗都是静息态,配色 PrimaryContainer / OnPrimaryContainer。", + ) + .size(legend.size_px) + .font(ui.font_of(legend)) + .color(ui.color(Role::OnSurfaceVariant)), + ] + .spacing(ui.space(Space::X2)) + .into() +} + +/// 一档 Filled 按钮(标签是档位名的大写:XS / S / M / L / XL。 +/// +/// **必须大写**:小写的 `l` 在 Inter/Segoe 里就是一根竖线,XL 会读成「x|」——第一版出图就是这么错的。 +fn sized<'a>(ui: &'a Ui, size: ControlSize) -> Element<'a, DemoMessage> { + let label: &'static str = match size { + ControlSize::Xs => "XS", + ControlSize::S => "S", + ControlSize::M => "M", + ControlSize::L => "L", + ControlSize::Xl => "XL", + }; + buttons::filled( + ui, + Button::new(label, DemoMessage::Pressed("size")).size(size), + ) +} + +/// 一颗 FAB(扩展态带标签;静息态强制,避免光标位置影响出图)。 +fn fab_cell<'a>(ui: &'a Ui, size: FabSize) -> Element<'a, DemoMessage> { + let spec = if matches!(size, FabSize::Extended) { + Fab::extended(IconKind::Plus, "New", DemoMessage::Pressed("fab")) + } else { + Fab::new(IconKind::Plus, DemoMessage::Pressed("fab")) + } + .size(size) + .state(Interaction::Enabled); + fab(ui, spec) +} + +/// 一行「小标题 + 内容」。 +fn labelled<'a>( + ui: &'a Ui, + label: &'a str, + content: Element<'a, DemoMessage>, +) -> Element<'a, DemoMessage> { + let style = ui.style(TypeRole::LabelSmall); + column![ + iced::widget::text(label) + .size(style.size_px) + .font(ui.font_of(style)) + .color(ui.color(Role::OnSurfaceVariant)), + iced::widget::container(content) + .width(Length::Fill) + .padding(iced::Padding::from([ui.space(Space::X2), 0.0])), + ] + .spacing(ui.space(Space::X1)) + .into() +} diff --git a/crates/app-iced/src/components/mod.rs b/crates/app-iced/src/components/mod.rs index 8c26d97..266cf07 100644 --- a/crates/app-iced/src/components/mod.rs +++ b/crates/app-iced/src/components/mod.rs @@ -16,6 +16,7 @@ //! inputs/ ← 同上(§9/§20/§11/§8) //! feedback/ ← 同上(§12/§14/§7/§15) //! navigation/ ← 同上(§4/§6/§21/§16) +//! surfaces/ ← M229:ELEVATED CARD(规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一格) //! gallery/ ← 视觉验收 harness(`--screen=gallery`) //! ``` //! @@ -58,6 +59,7 @@ pub mod motion; pub mod navigation; pub mod scroll; pub mod selection; +pub mod surfaces; #[cfg(test)] mod gate; diff --git a/crates/app-iced/src/components/selection/geometry.rs b/crates/app-iced/src/components/selection/geometry.rs index 3a6656e..33de7d8 100644 --- a/crates/app-iced/src/components/selection/geometry.rs +++ b/crates/app-iced/src/components/selection/geometry.rs @@ -26,6 +26,7 @@ use unlucky_design::component_map::{Component, ComponentMetrics}; use unlucky_design::icons::IconSize; +use unlucky_design::inkcat::metrics as inkcat; use unlucky_design::spacing::Space; use crate::theme::Ui; @@ -63,40 +64,39 @@ pub struct ControlGeometry { } impl ControlGeometry { - /// Checkbox:20×20 / 圆角 2 / 描边 2 / 状态层 Ø40(全部 `[源]`,见模块表)。 + /// Checkbox:**18×18** / 圆角 **4** / 描边 2 / 状态层 Ø40(M229 取规格卡口径,见模块表)。 + /// + /// 与官方 20×20 / 2dp 差 2px 与 2dp:规格卡画的是 `rect 18×18 rx4`,M228 的 `inkcat` 把这条 + /// 差异登记为「换肤时取 `CHECKBOX`」。4 是 `ShapeScale` 的刻度值,所以走容器层不违反 D32。 pub fn checkbox(ui: &Ui) -> Self { let metrics = metrics(Component::Checkbox); + let spec = inkcat::CHECKBOX; Self { - side: metrics.width, - stroke: metrics.stroke, + side: spec.width, + stroke: inkcat::CHECKBOX_STROKE, state_layer: metrics.state_layer, hit: ui.metrics().touch_target, gap: metrics.gap, - // 微控件层(D32):2dp 不在 `ShapeScale` 刻度里,只能走 `ComponentRadius`。 - // 传 20 拿不到值说明有人把它挪到容器层了——那是 D32 违规,`unwrap_or` 会悄悄 - // 变成直角,所以这里用 `expect` 直接炸(比图画错好查)。 - radius: metrics - .radius - .expect("Checkbox 的圆角必须在微控件层(ComponentSpec::micro_radius)") - .radius_px(metrics.width) - .expect("Checkbox 20dp ≤ 微控件上限 24dp"), + radius: spec.radius_px(), // Checkbox 没有内点(内点是 radio 的语言,§19.6 明令两者不许混用) - inner_dot: metrics.inner_dot, + inner_dot: 0.0, } } - /// Radio:20 / 环线 2 / 内点 10 / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。 + /// Radio:**18 Ø** / 环线 2 / 内点 **10** / 状态层 Ø40;圆角是**真圆**(`ShapeScale::Full`)。 pub fn radio(ui: &Ui) -> Self { let metrics = metrics(Component::Radio); + let spec = inkcat::RADIO; Self { - side: metrics.width, - stroke: metrics.stroke, + side: spec.width, + stroke: inkcat::RADIO_RING, state_layer: metrics.state_layer, hit: ui.metrics().touch_target, gap: metrics.gap, - radius: Component::Radio.spec().shape.radius_px(metrics.width), - // 内点 **10 不是 12**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5) - inner_dot: metrics.inner_dot, + radius: spec.radius_px(), + // 内点 **10**(§19.2:12 是含描边前的名义值,绘制半径 = 12/2 − 2/2 = 5)。 + // 规格卡实测同样是 10,所以这里取 `inkcat::RADIO_DOT`。 + inner_dot: inkcat::RADIO_DOT, } } diff --git a/crates/app-iced/src/components/selection/tests.rs b/crates/app-iced/src/components/selection/tests.rs index 3bb995b..a0fa6c1 100644 --- a/crates/app-iced/src/components/selection/tests.rs +++ b/crates/app-iced/src/components/selection/tests.rs @@ -45,15 +45,16 @@ fn hit_area_is_48_square_even_without_a_label() { fn checkbox_geometry_comes_from_the_token_layer() { let ui = ui(); let g = ControlGeometry::checkbox(&ui); - assert_eq!(g.side, 20.0, "18.1:容器 20×20(ContainerWidth=18 是死值)"); - assert_eq!(g.radius, 2.0, "18.1:圆角 2(ComponentRadius::Micro)"); + // M229:取规格卡的 18×18 / rx4(官方 20×20 / 2dp,登记在 `inkcat` 模块)。 + assert_eq!(g.side, 18.0, "规格卡 rect 18×18"); + assert_eq!(g.radius, 4.0, "规格卡 rx=4(ShapeScale::ExtraSmall)"); assert_eq!(g.stroke, 2.0, "18.2:未选中描边 2px"); assert_eq!(g.state_layer, 40.0, "18.5:状态层 Ø40"); assert_eq!(g.hit, 48.0, "18.9:命中区 48"); assert_eq!(g.gap, 8.0, "18.9:框↔标签 8([估] 团队口径)"); assert_eq!(g.inner_dot, 0.0, "Checkbox 没有内点"); - // 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 20 = 12 - assert!((g.check_span() - 12.0).abs() < 0.001, "18.4:勾横向 12dp"); + // 18.4:勾形横向端点跨度 = 0.8−0.2 = 0.6 × 18 = 10.8(比例不变,边长换成规格卡的 18) + assert!((g.check_span() - 10.8).abs() < 0.001, "18.4:勾横向 10.8dp"); // 方头外扩:每端 2/√2 ≈ 1.41 → 墨迹跨度 ≈ 14.8(参考值 12 是端点跨度,不是墨迹) let bleed = g.square_cap_bleed(); assert!( @@ -61,8 +62,8 @@ fn checkbox_geometry_comes_from_the_token_layer() { "方头外扩 1/√2×线宽" ); assert!( - (g.check_span() + bleed * 2.0 - 14.83).abs() < 0.05, - "墨迹跨度 ≈14.83" + (g.check_span() + bleed * 2.0 - 13.63).abs() < 0.05, + "墨迹跨度 ≈13.63(10.8 + 2×1.41)" ); } @@ -71,9 +72,10 @@ fn checkbox_geometry_comes_from_the_token_layer() { fn radio_inner_dot_is_ten_not_twelve() { let ui = ui(); let g = ControlGeometry::radio(&ui); - assert_eq!(g.side, 20.0, "19.1:外圈直径 20"); + // M229:外圈取规格卡的 18 Ø(官方 20 Ø,登记在 `inkcat` 模块)。 + assert_eq!(g.side, 18.0, "规格卡 r=9 ⇒ 直径 18"); assert_eq!(g.stroke, 2.0, "19.1:环线 2px"); - assert_eq!(g.radius, 10.0, "19.1:真圆(半径 = 边长/2)"); + assert_eq!(g.radius, 9.0, "19.1:真圆(半径 = 边长/2)"); assert_eq!(g.inner_dot, 10.0, "19.2:内点可视直径 10"); assert_ne!( g.inner_dot, 12.0, diff --git a/crates/app-iced/src/components/surfaces/elevated.rs b/crates/app-iced/src/components/surfaces/elevated.rs new file mode 100644 index 0000000..e1c38df --- /dev/null +++ b/crates/app-iced/src/components/surfaces/elevated.rs @@ -0,0 +1,126 @@ +//! **ELEVATED CARD**(规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一个方框)。 +//! +//! ## 规格卡原值(`DevDocs/artifacts/components-ink.svg`,`[spec]`) +//! +//! | 项 | 值 | 出处 | +//! |---|---|---| +//! | 方框 | 300×150、**rx=16**(`inkcat::metrics::CARD`) | `rect x=64 y=1312 rx=16` | +//! | 底色 | **`SurfaceContainer`**(Ink 板上比画布 `Surface` 亮一档) | 规格卡卡底那一格的 `fill` | +//! | 内边距 | **24**(文字 `x=88` − 框 `x=64`) | 同上 | +//! | 描边 | **无**(`stroke="none"`) | 同上 | +//! | 上标注 | **11 / 600**(`LabelSmall` + 半粗,字距 1) | `ELEVATED CARD` 那行 | +//! | 标题 | **16 / 400**(`BodyLarge`) | `Night bridge, bright rails.` 那行 | +//! | 副文案 | 13 / 400 → **14**(`BodyMedium`;13 落在 12/14 之间,按 `inkcat::type_scale` 取较大档) | `night / bridge / cat` 那行 | +//! +//! ## 与 `pages::frame::card`(描边卡)的分工 +//! +//! - [`card`]:**靠层级差站立**的卡(无描边、`SurfaceContainer` 底、16 圆角)。规格卡的 +//! `ELEVATED CARD` 就是这一档,实例页的元信息卡用它; +//! - `pages::frame::card`:描边卡(`OutlineVariant` 1px + 卡片层底),大面积表单容器用它。 +//! +//! 两种卡并存是**有意的**:规格卡只画了 elevated 一种,而桌面端的长表单需要「弱一点的容器」 +//! (满屏都是 16 圆角 + 容器底,分隔线会看起来像浮块)。哪种卡用在哪儿由本节决定,不是随手挑。 + +use iced::widget::text::LineHeight; +use iced::widget::{column, container, text}; +use iced::{Background, Border, Element, Length, Padding, Pixels}; + +use unlucky_design::color::Role; +use unlucky_design::spacing::Space; +use unlucky_design::typography::{TypeRole, TypeStyle, Weight}; + +use crate::theme::Ui; + +/// ELEVATED CARD 的内边距(规格卡 88 − 64 = **24**)。 +/// +/// 与 `LayoutMetrics::card_padding`(描边卡的 21)不是一回事:那个数服务长表单,这个是规格卡量出来的。 +pub const PAD: f32 = 24.0; + +/// 一个 ELEVATED CARD 方框(只管壳:底、圆角、内边距;**无描边**)。 +/// +/// 消息类型与被包的内容同型,所以它能被任何页面复用(实例页 / 外观页 / 账户页各用自己的 +/// `Message`)——这是「组件不 import 业务消息」那条契约的落地方式。 +pub fn card<'a, M: 'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, M> { + let shape = ui.card_shape(); + container(content) + .width(Length::Fill) + .padding(Padding::from([PAD, PAD])) + .style(move |_theme| container::Style { + // 规格卡的卡底是 **surface-container**(不是 `surface-container-low`): + // 层级靠与画布(`surface`)的 tone 差表达,不靠描边。 + background: Some(Background::Color(ui.color(Role::SurfaceContainer))), + border: Border { + radius: ui.radius_dp(shape), + ..Border::default() + }, + ..container::Style::default() + }) + .into() +} + +/// 一张规格卡的三段文字:上标注(可选)/ 标题 / 副文案(可选)。 +#[derive(Debug, Clone, Copy)] +pub struct Texts<'a> { + /// 上标注(`ELEVATED CARD` 那种小字标签)。 + pub overline: Option<&'a str>, + /// 标题(16/400)。 + pub title: &'a str, + /// 副文案(14/400)。 + pub support: Option<&'a str>, +} + +/// 三段式内容(排版按规格卡:11/600 + 16/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)); + + if let Some(overline) = parts.overline { + stack = stack.push(role_text( + ui, + overline_style(), + overline, + Role::OnSurfaceVariant, + )); + } + stack = stack.push(role_text( + ui, + ui.style(TypeRole::BodyLarge), + parts.title, + Role::OnSurface, + )); + if let Some(support) = parts.support { + stack = stack.push(role_text( + ui, + ui.style(TypeRole::BodyMedium), + support, + Role::OnSurfaceVariant, + )); + } + + stack.width(Length::Fill).into() +} + +/// 上标注的排版(`LabelSmall` 11/16,字重 **600**:规格卡 `font-weight=600`; +/// M3 的 `LabelSmall` 是 500,`emphasized` 是 700 —— 两个都不是 600,所以在这里显式给 600)。 +pub fn overline_style() -> TypeStyle { + TypeStyle { + weight: Weight::SemiBold, + ..TypeRole::LabelSmall.style() + } +} + +fn role_text<'a, M: 'a>( + ui: &'a Ui, + style: TypeStyle, + content: &'a str, + role: Role, +) -> Element<'a, M> { + text(content) + .size(style.size_px) + .line_height(LineHeight::Absolute(Pixels(style.line_height_px))) + .font(ui.font_of(style)) + .color(ui.color(role)) + .into() +} + +#[cfg(test)] +mod tests; diff --git a/crates/app-iced/src/components/surfaces/elevated/tests.rs b/crates/app-iced/src/components/surfaces/elevated/tests.rs new file mode 100644 index 0000000..74bff3a --- /dev/null +++ b/crates/app-iced/src/components/surfaces/elevated/tests.rs @@ -0,0 +1,68 @@ +//! ELEVATED CARD 的几何/排版断言(对着规格卡原值,不看渲染结果)。 +//! +//! 抓得住:圆角退回组件表的 12、内边距被换成 `card_padding`、上标注变成 500/700 这些「悄悄改数」。 +//! 抓不住:底色对不对(要看 `Ui::color(SurfaceContainer)` 的来源,那条由 `theme` 的色板测试守)、 +//! 以及卡在页面里好不好看(D30 视觉判定)。 + +use super::*; +use unlucky_design::shape::ShapeScale; + +const FAMILIES: crate::theme::Families = crate::theme::Families { + text: "Segoe UI Variable Text", + display: "Segoe UI Variable Display", + mono: "Cascadia Mono", +}; + +fn ui(flavor: unlucky_design::color::SchemeFlavor) -> Ui { + Ui::with_platform( + flavor, + unlucky_design::typography::Platform::Windows, + FAMILIES, + ) +} + +#[test] +fn the_card_takes_the_spec_corner_and_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); +} + +#[test] +fn the_typography_matches_the_spec_rows() { + let style = overline_style(); + 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"); + + let support = TypeRole::BodyMedium.style(); + assert_eq!(support.size_px, 14.0, "副文案 13 → 取较大档 14"); + assert_eq!(support.weight, Weight::Regular); +} + +/// 卡片是**靠层级差站立**的:底色必须是 `SurfaceContainer`(画布上一档),不是画布色本身。 +#[test] +fn the_card_surface_sits_one_step_above_the_canvas() { + for board in unlucky_design::color::BrandBoard::ALL { + let scheme = board.scheme(); + assert_ne!( + board.get(Role::SurfaceContainer), + board.get(Role::Surface), + "{}:卡底与画布同色,卡会「消失」", + board.name() + ); + // 无描边:卡的存在感只能来自这一档色差,所以必须 > 1.0(差多少属观感,D30 判)。 + let card_vs_canvas = scheme.contrast(Role::SurfaceContainer, Role::Surface); + assert!( + card_vs_canvas > 1.0, + "{}:卡底对画布 {card_vs_canvas:.3}:1,看不出层级", + board.name() + ); + } +} diff --git a/crates/app-iced/src/components/surfaces/mod.rs b/crates/app-iced/src/components/surfaces/mod.rs new file mode 100644 index 0000000..bb21590 --- /dev/null +++ b/crates/app-iced/src/components/surfaces/mod.rs @@ -0,0 +1,7 @@ +//! 面类家族(目前只有 **ELEVATED CARD**,规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一格)。 +//! +//! 为什么单开一个家族而不是塞进 `feedback`:`feedback` 是「系统对用户动作的回应」(进度 / 对话框 / +//! snackbar / 空态),卡是**静态容器**,语义上属于版式而不是反馈。家族边界写在这里,后来加 +//! 「带描边卡 / 列表行药丸」时按语义归位,不要因为「都在同一个规格卡小节里」就合进 `feedback`。 + +pub mod elevated; diff --git a/crates/app-iced/src/pages/account/widgets.rs b/crates/app-iced/src/pages/account/widgets.rs index d378036..7ef46a3 100644 --- a/crates/app-iced/src/pages/account/widgets.rs +++ b/crates/app-iced/src/pages/account/widgets.rs @@ -11,7 +11,6 @@ use iced::widget::{container, image, row, text}; use iced::{Alignment, Background, Border, ContentFit, Element, Length, Pixels}; use unlucky_design::color::Role; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use unlucky_design::icons::IconKind; use unlucky_design::shape::ShapeScale; @@ -171,7 +170,7 @@ pub fn alert<'a>( .padding(ui.metrics().card_padding) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: ui.outlined(Component::OutlinedCard.spec().shape), + border: ui.outlined(ui.card_shape()), ..container::Style::default() }) .into() diff --git a/crates/app-iced/src/pages/appearance/section/mod.rs b/crates/app-iced/src/pages/appearance/section/mod.rs index a769e6c..d4cf2b0 100644 --- a/crates/app-iced/src/pages/appearance/section/mod.rs +++ b/crates/app-iced/src/pages/appearance/section/mod.rs @@ -59,7 +59,6 @@ use iced::{ }; use unlucky_design::color::Role; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; @@ -100,7 +99,7 @@ pub fn section<'a>(ui: &'a Ui, state: &'a State, lang: Lang) -> Element<'a, Mess /// 外观卡片:底色走 `ui.layer`(面板 α 的唯一来源),与其它页的卡片同源。 fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { - let spec = Component::OutlinedCard.spec(); + let shape = ui.card_shape(); let background = ui.layer(MaterialLayer::Card); container(content) @@ -108,7 +107,7 @@ fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, .padding(ui.metrics().card_padding) .style(move |_theme| container::Style { background: Some(Background::Color(background)), - border: ui.outlined(spec.shape), + border: ui.outlined(shape), ..container::Style::default() }) .into() diff --git a/crates/app-iced/src/pages/frame/pieces.rs b/crates/app-iced/src/pages/frame/pieces.rs index 1a6b0f0..e9e1490 100644 --- a/crates/app-iced/src/pages/frame/pieces.rs +++ b/crates/app-iced/src/pages/frame/pieces.rs @@ -32,15 +32,19 @@ pub const SCROLLBAR_WIDTH: f32 = 6.0; /// 滑块离卡片内边的留白(2px,避免贴边)。 pub const SCROLLBAR_MARGIN: f32 = 2.0; -/// 描边卡片(占位:M75 的 `components` 会提供真正的 `OutlinedCard`)。 +/// 描边卡片(长表单容器)。圆角取**规格卡**的 16(`Ui::card_shape`),不是组件表的 12。 +/// +/// M229 换肤:规格卡 `CARD` 小节把卡片画成 `rx=16`,而 `component_map` 的 `OutlinedCard` 是 +/// `Medium(12)`(官方值)。M228 把这条差异登记为「换肤时取 `inkcat::metrics::CARD`」,这里就是那次取值; +/// 组件表的值不动(它还服务别的面)。描边仍走 `OutlineVariant` 1px。 pub fn card<'a>(ui: &'a Ui, content: impl Into>) -> Element<'a, Message> { - let spec = Component::OutlinedCard.spec(); + let shape = ui.card_shape(); container(content) .width(Length::Fill) .padding(ui.metrics().card_padding) .style(move |_theme| container::Style { background: Some(Background::Color(ui.layer(MaterialLayer::Card))), - border: ui.outlined(spec.shape), + border: ui.outlined(shape), ..container::Style::default() }) .into() diff --git a/crates/app-iced/src/pages/frame/tests.rs b/crates/app-iced/src/pages/frame/tests.rs index c44de73..38f7fa2 100644 --- a/crates/app-iced/src/pages/frame/tests.rs +++ b/crates/app-iced/src/pages/frame/tests.rs @@ -36,13 +36,15 @@ fn empty_state_follows_the_12b_reference() { const { assert!(EMPTY_ICON_GLYPH < EMPTY_ICON_BLOCK) }; } +/// 卡片圆角取**规格卡**的 16(M229 换肤),组件表 `OutlinedCard` 的 12 只作为对照保留。 #[test] -fn card_uses_the_outlined_card_spec() { - let spec = Component::OutlinedCard.spec(); - assert_eq!(spec.shape, ShapeScale::Medium); - assert_eq!(ui().radius_dp(spec.shape).top_left, 12.0); - assert_eq!(spec.text, TypeRole::TitleMedium); - assert_eq!(spec.support, Some(TypeRole::BodySmall)); +fn card_uses_the_spec_corner_not_the_component_table() { + let ui = ui(); + assert_eq!(ui.card_shape(), ShapeScale::Large); + assert_eq!(ui.radius_dp(ui.card_shape()).top_left, 16.0); + // 组件表的官方值仍是 12(表不动,差异登记在 `inkcat` 模块与 M229 报告里)。 + assert_eq!(Component::OutlinedCard.spec().shape, ShapeScale::Medium); + assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0); } #[test] diff --git a/crates/app-iced/src/pages/instances/detail.rs b/crates/app-iced/src/pages/instances/detail.rs index 31e99f2..6709efb 100644 --- a/crates/app-iced/src/pages/instances/detail.rs +++ b/crates/app-iced/src/pages/instances/detail.rs @@ -15,6 +15,7 @@ use unlucky_presenter::router::Route; use crate::app::components::buttons::{self, Button}; use crate::app::components::divider; use crate::app::components::interaction::Interaction; +use crate::app::components::surfaces::elevated; use crate::theme::Ui; use unlucky_design::icons::IconKind; @@ -229,26 +230,18 @@ 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 { - let card = frame::card( + // M229:元信息卡 = 规格卡的 **ELEVATED CARD**(无描边、`SurfaceContainer` 底、 + // 16 圆角、内距 24;上标注 11/600 + 值 16/400)。换肤前这里是描边卡。 + let card = elevated::card( ui, - column![ - frame::text_role( - ui, - TypeRole::LabelMedium, - false, - cell.label.as_str(), - ui.color(Role::OnSurfaceVariant), - ), - frame::text_role( - ui, - TypeRole::BodyMedium, - false, - cell.value.as_str(), - ui.color(Role::OnSurface) - ), - ] - .spacing(ui.space(Space::X1)) - .width(Length::Fill), + elevated::texts( + ui, + elevated::Texts { + overline: Some(cell.label.as_str()), + title: cell.value.as_str(), + support: None, + }, + ), ); cells_row = cells_row.push(container(card).width(Length::Fill)); } diff --git a/crates/app-iced/src/pages/logs/panel.rs b/crates/app-iced/src/pages/logs/panel.rs index 64c401d..cab5e8b 100644 --- a/crates/app-iced/src/pages/logs/panel.rs +++ b/crates/app-iced/src/pages/logs/panel.rs @@ -18,7 +18,6 @@ use iced::widget::{container, scrollable}; use iced::{Background, Element, Length}; -use unlucky_design::component_map::Component; use unlucky_design::elevation::MaterialLayer; use crate::theme::Ui; @@ -33,7 +32,7 @@ pub fn card<'a>( height: Length, content: impl Into>, ) -> Element<'a, Message> { - let shape = Component::OutlinedCard.spec().shape; + let shape = ui.card_shape(); container(content) .width(Length::Fill) diff --git a/crates/app-iced/src/pages/message.rs b/crates/app-iced/src/pages/message.rs index 63d9676..bf13adc 100644 --- a/crates/app-iced/src/pages/message.rs +++ b/crates/app-iced/src/pages/message.rs @@ -8,6 +8,7 @@ //! 同一条纪律);app 层只做一次 `.map`。 use unlucky_i18n::LangChoice; +use unlucky_presenter::prefs::ThemeMode; use unlucky_presenter::router::Route; use super::appearance; @@ -29,6 +30,11 @@ pub enum Message { Launch(String), /// 停止某实例(app 层翻成 `AppCommand::StopInstance`)。 Stop(String), + /// 切主题档(M229:设置页「通用 → 主题」的 Ink / Silver / 跟随系统三档)。 + /// + /// 载荷是 `ThemeMode` 而不是色板枚举:Ink / Silver 就是 `dark` / `light` 两侧的**产品名**, + /// 第三档 `auto` 由系统深浅落到对应色板。落盘键仍是 `appearance.theme_mode`。 + Theme(ThemeMode), /// 外观项改动(设置页「外观」分组 → `App` 的状态与落盘)。 /// /// 载荷是**用户请求值**(未钳位):钳位与 CPU 档降级在 `appearance::alpha` 里做, diff --git a/crates/app-iced/src/pages/settings/general.rs b/crates/app-iced/src/pages/settings/general.rs index 4757b56..c44b083 100644 --- a/crates/app-iced/src/pages/settings/general.rs +++ b/crates/app-iced/src/pages/settings/general.rs @@ -29,9 +29,11 @@ //! 3. **文案全部走 catalog**([`Key`]),三档选项名跟着界面语言走(见 `catalog/general.rs` //! 的头注释:为什么不写语言自称)。 //! -//! ## 其余三行仍只读 +//! ## 主题行已接真控件(M229),另两行仍只读 //! -//! 主题 / 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),所以它们是**只读行**; +//! 主题三档(Ink / Silver / 跟随系统)在 M229 接成真控件:点一下换色板 + 落盘 +//! `appearance.theme_mode`(实现在 `general/theme.rs`,消息是 `pages::Message::Theme`)。 +//! 启动页 / 关闭按钮还没有后端(`pages::Action` 里是占位动作),仍是**只读行**; //! 但标签与取值已经进 catalog,中文界面下不会再冒出英文。 //! //! 分组元数据只有一份(这里的 [`GROUP`]):`settings/mod.rs` 的 `GROUPS` 直接引用它。 @@ -41,7 +43,6 @@ use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Them use unlucky_design::color::{Role, StateLayer}; use unlucky_design::icons::IconKind; -use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::Space; use unlucky_design::typography::TypeRole; use unlucky_i18n::catalog::Key; @@ -52,16 +53,18 @@ use crate::pages::{frame, Language, Message}; use crate::theme::Ui; use super::frame::{card, divider}; -use super::Group; +use super::{Group, States}; + +mod theme; /// General 分组(左列名固定英文:左列是**导航**,与导航轨同源,不进 catalog)。 pub(super) const GROUP: Group = Group { name: "General", icon: IconKind::Settings, // 只给 harness(`pages_demo` 走 `appearance: None` 时)用的演示行:产品路径走 [`body`]。 - // 语言那一行**不在**这里——它的值是要跟着语言变的,写死在静态表里就是用户看到的那条假值。 + // 语言与主题那两行**不在**这里——它们的值跟着语言/档位变,写死在静态表里就是用户看到的假值 + // (语言 M106 移出、主题 M229 移出)。 rows: &[ - ("Theme", "Follow system"), ("Startup page", "Instances"), ("Close button", "Exit to tray"), ], @@ -69,7 +72,7 @@ pub(super) const GROUP: Group = Group { }; /// 这一组的正文(**产品路径**)。 -pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { +pub(super) fn body<'a>(ui: &'a Ui, states: States<'a>, language: Language) -> Element<'a, Message> { let catalog = language.effective.catalog(); let mut stacked = column![].spacing(0.0).width(Length::Fill); stacked = stacked.push(language_row(ui, language)); @@ -78,14 +81,22 @@ pub(super) fn body<'a>(ui: &'a Ui, language: Language) -> Element<'a, Message> { stacked = stacked.push(divider(ui)); stacked = stacked.push(font_row(ui, language)); - // M188:日志文件夹(路径 + 打开 + 复制路径)。放在「主题/启动页」之前——用户投诉 + // M229:主题行接真控件(Ink / Silver / 跟随系统)。档位住在设置组的自有状态里 + // (`language_appearance::State::theme`),与语言行同一个排版:标题 + 三枚互斥 chip + 一句说明。 + stacked = stacked.push(divider(ui)); + stacked = stacked.push(theme::view( + ui, + &catalog, + states.groups.language_appearance.theme, + )); + + // M188:日志文件夹(路径 + 打开 + 复制路径)。放在「启动页/关闭按钮」之前——用户投诉 // 「找不到崩溃日志」要的答案,不该埋在只读行后面。 stacked = stacked.push(divider(ui)); stacked = stacked.push(super::logs_row::folder_row(ui, &catalog)); - // 主题 / 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 + // 启动页 / 关闭按钮:还没有后端,只读;文案已经进 catalog。 for (label, value) in [ - (Key::SettingsTheme, Key::SettingsThemeFollowSystem), (Key::SettingsStartupPage, Key::SettingsStartupInstances), (Key::SettingsCloseButton, Key::SettingsCloseToTray), ] { @@ -290,14 +301,20 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me /// 一个互斥按钮(选中态 `SecondaryContainer`,未选中透明 + `OnSurfaceVariant`)。 /// -/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款,但文案来自 catalog(那份是英文基线)。 +/// 与 `pages/appearance/section/mod.rs` 的 `choice` 同款几何,但文案来自 catalog(那份是英文基线)。 +/// +/// **几何(M229)**:取规格卡的 chip(高 32、圆角 8、内距 16、未选中 1px `OutlineVariant`), +/// 与同屏的主题三档 chips 完全同源——两行都是「并排的互斥筛选」,长得不一样会让读者以为是 +/// 两种控件(M229 第一版就是这样:语言 40 高药丸、主题 32 高方角,出图一眼就不齐)。 fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> Element<'a, Message> { - let height = ui.metrics().control_height; + let box_ = unlucky_design::inkcat::metrics::CHIP; + let height = box_.height; let content_color = if selected { ui.color(Role::OnSecondaryContainer) } else { ui.color(Role::OnSurfaceVariant) }; + let border_color = ui.color(Role::OutlineVariant); button( container(frame::text_role( @@ -310,7 +327,10 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El .align_y(Alignment::Center), ) .height(height) - .padding(Padding::from([0.0, ui.space(Space::X3)])) + .padding(Padding::from([ + 0.0, + unlucky_design::inkcat::metrics::CHIP_PAD_X, + ])) .style(move |_theme: &IcedTheme, status| { let background = if selected { Some(Background::Color(ui.color(Role::SecondaryContainer))) @@ -328,8 +348,13 @@ fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, choice: LangChoice) -> El background, text_color: content_color, border: Border { - radius: ui.radius(ShapeScale::Full, height), - ..Border::default() + color: border_color, + width: if selected { + 0.0 + } else { + crate::theme::STROKE_PX + }, + radius: ui.radius(box_.shape, height), }, shadow: Shadow::default(), snap: true, diff --git a/crates/app-iced/src/pages/settings/general/theme.rs b/crates/app-iced/src/pages/settings/general/theme.rs new file mode 100644 index 0000000..88d14ef --- /dev/null +++ b/crates/app-iced/src/pages/settings/general/theme.rs @@ -0,0 +1,161 @@ +//! 设置页「General → 主题」行:Ink / Silver / 跟随系统三档(M229)。 +//! +//! 从 `general.rs` 拆出来的理由只有一条:加这三档之后那个文件 474 行(400 行硬线)。 +//! 拆法是「整段搬走 + 一个 `pub(super) fn row` 入口」,排版与 handler 一字未改。 +//! +//! 三档的语义:**Ink = 深色主方案、Silver = 浅色主方案**,落盘取值仍是 `appearance.theme_mode` +//! 的 `dark` / `light`;第三档「跟随系统」= `auto`,由系统深浅落到对应色板 +//! (`BrandBoard::for_flavor`)。所以这里**不新增配置键**,旧配置读进来语义也一致。 + +use iced::widget::{button, column, container, row}; +use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme as IcedTheme}; + +use unlucky_design::color::{Role, StateLayer}; +use unlucky_design::inkcat::metrics as chipbox; +use unlucky_design::spacing::Space; +use unlucky_design::typography::TypeRole; +use unlucky_i18n::catalog::{Catalog, Key}; +use unlucky_presenter::prefs::ThemeMode; + +use crate::pages::{frame, Message}; +use crate::theme::Ui; + +/// 三档全序(Ink / Silver / 跟随系统):`ThemeMode` 在 presenter,声明序是 `Auto/Light/Dark`, +/// 画出来的顺序按「深 → 浅 → 跟随」,所以这里另给一张表,不靠枚举声明序。 +pub(super) const THEME_MODES: [ThemeMode; 3] = [ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto]; + +/// 主题行:标题 + 三档互斥 chip + 一句说明(排版与语言行同族)。 +pub(super) fn view<'a>(ui: &'a Ui, catalog: &Catalog, mode: ThemeMode) -> Element<'a, Message> { + let head = row![ + container(frame::text_role( + ui, + TypeRole::BodyMedium, + false, + catalog.text(Key::SettingsTheme), + ui.color(Role::OnSurface), + )) + .width(Length::Fill) + .align_y(Alignment::Center), + frame::text_role( + ui, + TypeRole::LabelLarge, + true, + catalog.text(mode_label(mode)), + ui.color(Role::OnPrimaryFixedVariant), + ), + ] + .width(Length::Fill) + .align_y(Alignment::Center); + + let chips = row(THEME_MODES + .iter() + .map(|choice| { + chip( + ui, + catalog.text(mode_label(*choice)), + *choice == mode, + *choice, + ) + }) + .collect::>()) + .spacing(ui.space(Space::X1)) + .align_y(Alignment::Center); + + column![ + head, + chips, + frame::text_role( + ui, + TypeRole::BodySmall, + false, + catalog.text(Key::SettingsThemeHint), + ui.color(Role::OnSurfaceVariant), + ), + ] + .spacing(ui.space(Space::X1)) + .width(Length::Fill) + .padding(Padding::from([ui.space(Space::X2), 0.0])) + .into() +} + +/// 一档主题的选项名(Ink / Silver / 跟随系统)。 +fn mode_label(mode: ThemeMode) -> Key { + match mode { + ThemeMode::Dark => Key::SettingsThemeInk, + ThemeMode::Light => Key::SettingsThemeSilver, + ThemeMode::Auto => Key::SettingsThemeFollowSystem, + } +} + +/// 一枚主题档位按钮(选中态 `SecondaryContainer`,未选中透明 + 1px `OutlineVariant`)。 +/// +/// 几何取规格卡的 chip(高 32、圆角 8,`inkcat::metrics::CHIP`):主题三档是**可并排的筛选**, +/// 不是主行动按钮,用 chip 档比语言行那枚 40 高药丸更贴规格卡。 +fn chip<'a>(ui: &'a Ui, text: &'a str, selected: bool, mode: ThemeMode) -> Element<'a, Message> { + let box_ = chipbox::CHIP; + let height = box_.height; + let content_color = if selected { + ui.color(Role::OnSecondaryContainer) + } else { + ui.color(Role::OnSurfaceVariant) + }; + let border_color = ui.color(Role::OutlineVariant); + let stroke = crate::theme::STROKE_PX; + + button( + container(frame::text_role( + ui, + TypeRole::LabelLarge, + selected, + text, + content_color, + )) + .align_y(Alignment::Center), + ) + .height(height) + .padding(Padding::from([0.0, chipbox::CHIP_PAD_X])) + .style(move |_theme: &IcedTheme, status| { + let background = if selected { + Some(Background::Color(ui.color(Role::SecondaryContainer))) + } else if matches!(status, button::Status::Hovered | button::Status::Pressed) { + Some(Background::Color(ui.state_over( + StateLayer::Hover, + Role::OnSurface, + Role::Surface, + ))) + } else { + None + }; + + button::Style { + background, + text_color: content_color, + border: Border { + color: border_color, + width: if selected { 0.0 } else { stroke }, + radius: ui.radius(box_.shape, height), + }, + shadow: Shadow::default(), + snap: true, + } + }) + .on_press(Message::Theme(mode)) + .into() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 三档 = 两块板 + 一种跟随,顺序是深 → 浅 → 跟随(换序会让「Ink 在最左」这条观感失守)。 + #[test] + fn the_three_modes_are_ink_silver_and_follow_system() { + assert_eq!( + THEME_MODES, + [ThemeMode::Dark, ThemeMode::Light, ThemeMode::Auto] + ); + assert_eq!(mode_label(ThemeMode::Dark), Key::SettingsThemeInk); + assert_eq!(mode_label(ThemeMode::Light), Key::SettingsThemeSilver); + assert_eq!(mode_label(ThemeMode::Auto), Key::SettingsThemeFollowSystem); + } +} diff --git a/crates/app-iced/src/pages/settings/language_appearance.rs b/crates/app-iced/src/pages/settings/language_appearance.rs index 2af25b2..f94b74b 100644 --- a/crates/app-iced/src/pages/settings/language_appearance.rs +++ b/crates/app-iced/src/pages/settings/language_appearance.rs @@ -21,10 +21,16 @@ /// 语言 + 外观线的状态(**只归本线**)。 /// -/// 首版是空结构:这一条线还没有自己的运行期状态(外观那份状态是 `pages::appearance::State`, -/// 归 M93,走 `States::appearance` 那条既有通道,不在这里)。加字段只改本文件。 +/// 外观那份运行期状态是 `pages::appearance::State`(M93),走 `States::appearance` 那条既有通道, +/// 不在这里。本结构只装**分组自己的**档位:M229 起主题三档(Ink / Silver / 跟随系统)住这儿, +/// 由 `app` 在启动与切换时同步(唯一写者见 `app/state/theme.rs::set_theme_mode`)。 +/// +/// 初值 `Auto`:harness / 演示路径没有配置可读,跟随系统正是出厂值。 #[derive(Debug, Default)] -pub struct State {} +pub struct State { + /// 当前主题档(Ink = `Dark` / Silver = `Light` / 跟随系统 = `Auto`)。 + pub theme: unlucky_presenter::prefs::ThemeMode, +} /// 语言 + 外观线的消息(**只归本线**)。 /// diff --git a/crates/app-iced/src/pages/settings/mod.rs b/crates/app-iced/src/pages/settings/mod.rs index 97680bf..ff25f01 100644 --- a/crates/app-iced/src/pages/settings/mod.rs +++ b/crates/app-iced/src/pages/settings/mod.rs @@ -358,7 +358,7 @@ fn section_columns<'a>( // 档位与生效语言不在 [`States`] 里,只能从 `pages::Language` 进来;它与 `appearance` 在不在场 // 无关(harness 那条路径也画得出真控件)。 let card = if index == GENERAL_GROUP { - general::body(ui, language) + general::body(ui, states, language) } else if index == UPDATE_GROUP { // M159:更新组读 presenter 快照(`view` 的 `update` 形参),与 General 同一条按下标分发。 update::body(ui, update, language.effective) diff --git a/crates/app-iced/src/pages/settings/tests/general_section.rs b/crates/app-iced/src/pages/settings/tests/general_section.rs index 289eb15..ca38f34 100644 --- a/crates/app-iced/src/pages/settings/tests/general_section.rs +++ b/crates/app-iced/src/pages/settings/tests/general_section.rs @@ -17,20 +17,19 @@ use unlucky_design::icons::IconKind; use unlucky_i18n::LangChoice; use unlucky_i18n::{Key, Lang}; -/// 这一组的**静态演示行**:语言那一行已经换真控件,所以只剩三行。 +/// 这一组的**静态演示行**:语言(M106)与主题(M229)都已经换成真控件,所以只剩两行。 /// -/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言行被「顺手」加回静态表—— -/// 那等于把用户看到的假值又写回去(它的值必须跟着语言变,静态表做不到)。 -/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m106/`)。 +/// 抓得住:①四条线并行合并时把别人的行粘到本文件;②语言/主题被「顺手」加回静态表—— +/// 那等于把用户看到的假值又写回去(它们的值必须跟着语言/档位变,静态表做不到)。 +/// 抓不住:真控件画出来长什么样(那是截图验收的事,见 `DevDocs/artifacts/m229/`)。 #[test] -fn the_general_group_keeps_theme_startup_and_close_as_static_rows() { +fn the_general_group_keeps_only_the_two_rows_without_a_backend() { let rows: Vec<(&str, &str)> = general::GROUP.rows.to_vec(); assert_eq!( rows, vec![ - ("Theme", "Follow system"), ("Startup page", "Instances"), - ("Close button", "Exit to tray"), + ("Close button", "Exit to tray") ] ); } diff --git a/crates/app-iced/src/shell/mod.rs b/crates/app-iced/src/shell/mod.rs index e726fc0..6d1e47c 100644 --- a/crates/app-iced/src/shell/mod.rs +++ b/crates/app-iced/src/shell/mod.rs @@ -7,7 +7,7 @@ //! | 标题栏高度 | 16.2 桌面标题栏 **40**(登记偏离) | `LM.titlebar_height = 48`(M63 §1.4/R13 桌面档,令牌优先) | //! | 标题栏按钮 | 16.5 官方图标 **24** → 本项目 **20**(M86 项目偏离,用户点名「图标稍大」) | `icons::CAPTION_PX`(令牌 `TITLEBAR_CAPTION_ICON_PX`)+ `LM.titlebar_button_width = 46` | //! | 页头 | 16.1 官方 AppBar **64**;本项目 **56** | `LM.page_header_height = 56`(已有偏离登记) | -//! | 卡片 | 3.x Outlined:**Medium(12)**、L0、`OutlineVariant` 1px、`SurfaceContainerLow` | `Component::OutlinedCard.spec()` + `STROKE_PX` | +//! | 卡片 | 3.x Outlined,圆角取**规格卡 16**(`Large`;组件表 `OutlinedCard` 的官方值是 12)、`OutlineVariant` 1px、卡片层底 | `Ui::card_shape()` + `STROKE_PX` | //! | 字级 | 16.4 标题 `TitleLarge`;卡片 `TitleMedium` / `BodySmall` | `Ui::style` / `Ui::emphasized` | //! //! 导航轨在 [`nav`] 子模块里(12b §5 的判据表也在那边)。 diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 4d512db..35edc95 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -1,8 +1,20 @@ -//! 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 +//! # 令牌 → iced 类型(`Color` / `Font` / `Border` / `Radius` / `Theme`)。 //! //! 本模块是**唯一**允许把 `unlucky-design` 的值翻成 iced 类型的地方(M61 §0):组件层只调 //! [`Ui`],绝不出现色值/字号/半径/间距字面量(`tests/no_hardcoded_tokens.rs` 扫 `src/**`)。 //! +//! ## 两套来源:品牌色板(出厂)与取色方案(D38) +//! +//! M229 起 [`Ui::new`] 走 **品牌色板**([`BrandBoard`]:Ink = 深色主方案 / Silver = 浅色主方案), +//! `Scheme::baseline`(tone 派生)退回它本来的角色——D38 取色链的中性锚点。两条路径并列: +//! +//! - [`Ui::brand`]:出厂外观,色板自己决定深浅([`BrandBoard::flavor`]); +//! - [`Ui::from_scheme`]:背景图 / 壁纸取色成功时接管 49 个角色(取色失败回落色板); +//! - [`Ui::baseline`]:老的 tone 派生口径,只给对照与测试用(产品路径不再默认走它)。 +//! +//! 「跟随系统」是**设置层的第三种态**,不是第三套板:系统浅色 → Silver、系统深色 → Ink +//! ([`BrandBoard::for_flavor`])。色板始终只有两套。 +//! //! ## 为什么不用 `iced::theme::Base` 承载 49 个角色 //! //! iced 的 `Palette` 只有 6 色,M3 有 49 个角色(M60 Q5):真源是本模块的 [`Scheme`],`Theme` @@ -13,8 +25,9 @@ use iced::border::Radius; use iced::theme::Palette; use iced::{Border, Color, Font, Theme}; -use unlucky_design::color::{Role, Scheme, SchemeFlavor, StateLayer}; +use unlucky_design::color::{BrandBoard, Role, Scheme, SchemeFlavor, StateLayer}; use unlucky_design::elevation::MaterialLayer; +use unlucky_design::inkcat::metrics::{self as inkcat, SpecBox}; use unlucky_design::shape::ShapeScale; use unlucky_design::spacing::{LayoutMetrics, Space}; use unlucky_design::typography::{FontFamily, Platform, TypeRole, TypeStyle, Weight}; @@ -40,6 +53,8 @@ pub struct Families { #[derive(Debug, Clone)] pub struct Ui { scheme: Scheme, + /// 当前色板(Ink / Silver)。`from_scheme` 时取方案深浅对应的那块,`name()` 与图例用它。 + board: BrandBoard, platform: Platform, metrics: LayoutMetrics, families: Families, @@ -48,15 +63,28 @@ pub struct Ui { } impl Ui { - /// 按平台(由 `cfg!` 决定)与令牌基线方案求值。 + /// 按平台(由 `cfg!` 决定)与**品牌色板**求值:深浅决定色板(浅 → Silver / 深 → Ink)。 + /// + /// 这是产品出厂外观的唯一入口(M229):`DesignTokens::brand(BrandBoard::for_flavor(flavor))`。 pub fn new(flavor: SchemeFlavor, families: Families) -> Self { Self::with_platform(flavor, Platform::current(), families) } /// 显式指定平台(跨平台核对与测试用)。 pub fn with_platform(flavor: SchemeFlavor, platform: Platform, families: Families) -> Self { + Self::with_board(BrandBoard::for_flavor(flavor), platform, families) + } + + /// 按**指定色板**求值(色板自己决定深浅,见 [`BrandBoard::flavor`])。 + pub fn brand(board: BrandBoard, families: Families) -> Self { + Self::with_board(board, Platform::current(), families) + } + + /// 指定色板 + 平台(截图与测试用)。 + pub fn with_board(board: BrandBoard, platform: Platform, families: Families) -> Self { Self { - scheme: Scheme::baseline(flavor), + scheme: board.scheme(), + board, platform, metrics: LayoutMetrics::DESKTOP, families, @@ -64,17 +92,30 @@ impl Ui { } } + /// tone 派生的 `Scheme::baseline` 口径(D38 取色链的中性锚点;产品路径不再默认走它)。 + /// + /// 留成公开入口是为了对照与测试:`Ui::new(flavor)` 与它在**颜色上不同**,那是 M229 的换肤。 + pub fn baseline(flavor: SchemeFlavor, families: Families) -> Self { + Self::from_scheme(Scheme::baseline(flavor), families) + } + /// 当前深浅。 pub fn flavor(&self) -> SchemeFlavor { self.scheme.flavor() } - /// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉 baseline)。 + /// 当前色板(Ink / Silver)。 + pub fn board(&self) -> BrandBoard { + self.board + } + + /// 用**已解析的方案**构造求值器(D38:背景图 / 壁纸取色 → 49 个角色换掉色板)。 /// - /// 追加式入口:`new` / `with_platform` 的行为一字未动(默认仍是 [`Scheme::baseline`]); - /// 平台与骨架尺寸仍按当前环境求值——方案只带颜色。 + /// 色板字段取方案深浅对应的那块(`Light → Silver` / `Dark → Ink`):取色方案只换颜色, + /// 不改变「这一屏是深色还是浅色」,所以 `name()` 仍然说实话。 pub fn from_scheme(scheme: Scheme, families: Families) -> Self { Self { + board: BrandBoard::for_flavor(scheme.flavor()), scheme, platform: Platform::current(), metrics: LayoutMetrics::DESKTOP, @@ -150,6 +191,20 @@ impl Ui { Radius::new(shape.radius_px(side)) } + /// **规格卡口径的卡片方框**(`ELEVATED CARD` / `CARD`,`inkcat::metrics::CARD`)。 + /// + /// 与 `component_map` 的 `OutlinedCard = Medium(12)` 差 4:规格卡画的是 `rx=16`(Large), + /// M228 的 `inkcat` 模块把这条差异登记为「换肤时取 `CARD`」,M229 就是那次换肤。表值不动 + /// (它还服务别的面),页面卡片统一从这里取。 + pub fn card_box(&self) -> SpecBox { + inkcat::CARD + } + + /// 卡片圆角档(= [`Self::card_box`] 的 `shape`,即 `ShapeScale::Large` = 16)。 + pub fn card_shape(&self) -> ShapeScale { + self.card_box().shape + } + /// 固定档圆角。`ShapeScale::Full` 必须走 [`Ui::radius`](它没有绝对值)。 pub fn radius_dp(&self, shape: ShapeScale) -> Radius { debug_assert!( @@ -225,10 +280,14 @@ impl Ui { } /// `Theme::name()` 的取值(稳定字符串,改名要先在协作文档登记)。 + /// + /// M229 起按**色板**取名(`ull-ink` / `ull-silver`):换肤之后「深色」这个词不再等于 + /// 「M3 深色 tone 派生」,名字必须能区分「是哪块板」。D38 取色方案仍叫它所属深浅的那块 + /// (取色只换颜色,不换深浅)。 pub fn name(&self) -> &'static str { - match self.flavor() { - SchemeFlavor::Light => "ull-m3-light", - SchemeFlavor::Dark => "ull-m3-dark", + match self.board { + BrandBoard::Ink => "ull-ink", + BrandBoard::Silver => "ull-silver", } } } @@ -254,147 +313,4 @@ pub fn weight_of(weight: Weight) -> iced::font::Weight { } #[cfg(test)] -mod tests { - use super::*; - - const FAMILIES: Families = Families { - text: "Segoe UI Variable Text", - display: "Segoe UI Variable Display", - mono: "Cascadia Mono", - }; - - fn ui(flavor: SchemeFlavor) -> Ui { - Ui::with_platform(flavor, Platform::Windows, FAMILIES) - } - - #[test] - fn theme_name_tracks_the_flavor() { - assert_eq!(ui(SchemeFlavor::Light).name(), "ull-m3-light"); - assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-m3-dark"); - assert_ne!( - ui(SchemeFlavor::Light).theme().to_string(), - ui(SchemeFlavor::Dark).theme().to_string() - ); - } - - #[test] - fn canvas_card_overlay_follow_surface_containers() { - let ui = ui(SchemeFlavor::Light); - assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface)); - assert_eq!( - ui.layer(MaterialLayer::Card), - ui.color(Role::SurfaceContainerLow) - ); - assert_eq!( - ui.layer(MaterialLayer::Overlay), - ui.color(Role::SurfaceContainerHigh) - ); - } - - #[test] - fn radii_only_come_from_the_scale() { - let ui = ui(SchemeFlavor::Light); - // 卡片 Medium(12):边长够大时就是 12 - assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0); - assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0); - // 药丸 Full:按边长一半折算 - assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0); - // 不许超过边长一半 - assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0); - } - - #[test] - fn outlines_are_one_pixel_and_use_outline_variant() { - let ui = ui(SchemeFlavor::Light); - let border = ui.outlined(ShapeScale::Medium); - assert_eq!(border.width, STROKE_PX); - assert_eq!(border.width, 1.0, "rubric G7:全站 1px"); - assert_eq!(border.color, ui.color(Role::OutlineVariant)); - assert_eq!(border.radius.top_left, 12.0); - } - - #[test] - fn emphasized_weights_are_the_official_ones() { - let ui = ui(SchemeFlavor::Light); - // title-small 的强调档是 700(官方 Emphasized) - assert_eq!( - ui.emphasized_font(TypeRole::TitleSmall).weight, - iced::font::Weight::Bold - ); - // baseline 正文是 400 - assert_eq!( - ui.font(TypeRole::BodyMedium).weight, - iced::font::Weight::Normal - ); - } - - #[test] - fn body_text_keeps_wcag_contrast_in_both_flavors() { - for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { - let scheme = Scheme::baseline(flavor); - assert!( - scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5, - "{flavor:?} 的正文对比度不够" - ); - assert!( - scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5, - "{flavor:?} 的选中态药丸对比度不够" - ); - } - } - - #[test] - fn every_role_maps_to_an_iced_color() { - for role in Role::ALL { - let color = ui(SchemeFlavor::Dark).color(role); - assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界"); - } - } - - #[test] - fn state_layers_are_opaque_so_cpu_and_gpu_match() { - let ui = ui(SchemeFlavor::Light); - let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface); - assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果"); - assert_ne!(hovered, ui.color(Role::Surface)); - } - - /// D38:配色方案必须真的换色;默认构造逐字段等于 baseline(防回流的那一半)。 - /// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。 - #[test] - fn a_resolved_scheme_replaces_the_baseline_colours() { - use unlucky_design::palette::{resolve_source, CardKey, ImageInput}; - - // 4×4 的纯红图:种子彩度远高于门槛,必然走到「背景图取色」这一级。 - let pixels = [0xC0_u8, 0x20, 0x20, 0xFF].repeat(16); - let input = ImageInput::pixels(&pixels, 4, 4); - let (scheme, provenance) = - resolve_source(Some(input), None, Some(CardKey::Sky), SchemeFlavor::Light); - assert_eq!( - provenance.level, - unlucky_design::palette::Level::BackgroundImage, - "纯红图必须取色成功(否则本测试的输入不合格)" - ); - - let tinted = Ui::from_scheme(scheme, FAMILIES); - let baseline = ui(SchemeFlavor::Light); - assert_ne!( - tinted.color(Role::Primary), - baseline.color(Role::Primary), - "取色方案必须换掉 primary" - ); - // 深浅仍跟着方案走(方案自带 flavor),不会把 Light 变成 Dark。 - assert_eq!(tinted.flavor(), SchemeFlavor::Light); - assert_eq!(tinted.name(), baseline.name()); - - // 默认构造不变:`with_platform` 与 baseline 逐块相等。 - let explicit = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES); - for role in Role::ALL { - assert_eq!( - explicit.color(role), - baseline.color(role), - "{role:?}:不传 scheme 时默认行为必须与改动前一致" - ); - } - } -} +mod tests; diff --git a/crates/app-iced/src/theme/tests.rs b/crates/app-iced/src/theme/tests.rs new file mode 100644 index 0000000..d3de696 --- /dev/null +++ b/crates/app-iced/src/theme/tests.rs @@ -0,0 +1,207 @@ +//! `theme` 的单元测试(M229 从 `theme.rs` 纯搬移,守 400 行硬线:加双色板字段前该文件正好 400 行)。 +//! +//! 内容一行未改,只把 `mod tests { … }` 的包裹层换成独立文件。 + +use super::*; + +const FAMILIES: Families = Families { + text: "Segoe UI Variable Text", + display: "Segoe UI Variable Display", + mono: "Cascadia Mono", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +#[test] +fn theme_name_tracks_the_flavor() { + assert_eq!(ui(SchemeFlavor::Light).name(), "ull-silver"); + assert_eq!(ui(SchemeFlavor::Dark).name(), "ull-ink"); + assert_ne!( + ui(SchemeFlavor::Light).theme().to_string(), + ui(SchemeFlavor::Dark).theme().to_string() + ); +} + +#[test] +fn canvas_card_overlay_follow_surface_containers() { + let ui = ui(SchemeFlavor::Light); + assert_eq!(ui.layer(MaterialLayer::Canvas), ui.color(Role::Surface)); + assert_eq!( + ui.layer(MaterialLayer::Card), + ui.color(Role::SurfaceContainerLow) + ); + assert_eq!( + ui.layer(MaterialLayer::Overlay), + ui.color(Role::SurfaceContainerHigh) + ); +} + +#[test] +fn radii_only_come_from_the_scale() { + let ui = ui(SchemeFlavor::Light); + // 卡片 Medium(12):边长够大时就是 12 + assert_eq!(ui.radius_dp(ShapeScale::Medium).top_left, 12.0); + assert_eq!(ui.radius_dp(ShapeScale::ExtraSmall).top_left, 4.0); + // 药丸 Full:按边长一半折算 + assert_eq!(ui.radius(ShapeScale::Full, 32.0).top_left, 16.0); + // 不许超过边长一半 + assert_eq!(ui.radius(ShapeScale::ExtraLarge, 20.0).top_left, 10.0); +} + +#[test] +fn outlines_are_one_pixel_and_use_outline_variant() { + let ui = ui(SchemeFlavor::Light); + let border = ui.outlined(ShapeScale::Medium); + assert_eq!(border.width, STROKE_PX); + assert_eq!(border.width, 1.0, "rubric G7:全站 1px"); + assert_eq!(border.color, ui.color(Role::OutlineVariant)); + assert_eq!(border.radius.top_left, 12.0); +} + +#[test] +fn emphasized_weights_are_the_official_ones() { + let ui = ui(SchemeFlavor::Light); + // title-small 的强调档是 700(官方 Emphasized) + assert_eq!( + ui.emphasized_font(TypeRole::TitleSmall).weight, + iced::font::Weight::Bold + ); + // baseline 正文是 400 + assert_eq!( + ui.font(TypeRole::BodyMedium).weight, + iced::font::Weight::Normal + ); +} + +#[test] +fn body_text_keeps_wcag_contrast_in_both_flavors() { + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let scheme = Scheme::baseline(flavor); + assert!( + scheme.contrast(Role::OnSurface, Role::Surface) >= 4.5, + "{flavor:?} 的正文对比度不够" + ); + assert!( + scheme.contrast(Role::OnSecondaryContainer, Role::SecondaryContainer) >= 4.5, + "{flavor:?} 的选中态药丸对比度不够" + ); + } +} + +#[test] +fn every_role_maps_to_an_iced_color() { + for role in Role::ALL { + let color = ui(SchemeFlavor::Dark).color(role); + assert!((0.0..=1.0).contains(&color.a), "{role:?} 的 alpha 越界"); + } +} + +#[test] +fn state_layers_are_opaque_so_cpu_and_gpu_match() { + let ui = ui(SchemeFlavor::Light); + let hovered = ui.state_over(StateLayer::Hover, Role::OnSurface, Role::Surface); + assert_eq!(hovered.a, 1.0, "状态层必须给出不透明结果"); + assert_ne!(hovered, ui.color(Role::Surface)); +} + +/// D38:配色方案必须真的换色;显式传 baseline 时逐字段等于 baseline。 +/// 抓不住:取色好不好看、设置页是否真切过去(后者由 app 侧日志举证)。 +#[test] +fn a_resolved_scheme_replaces_the_baseline_colours() { + use unlucky_design::palette::{resolve_source, CardKey, ImageInput}; + + // 4×4 的纯红图:种子彩度远高于门槛,必然走到「背景图取色」这一级。 + let pixels = [0xC0_u8, 0x20, 0x20, 0xFF].repeat(16); + let input = ImageInput::pixels(&pixels, 4, 4); + let (scheme, provenance) = + resolve_source(Some(input), None, Some(CardKey::Sky), SchemeFlavor::Light); + assert_eq!( + provenance.level, + unlucky_design::palette::Level::BackgroundImage, + "纯红图必须取色成功(否则本测试的输入不合格)" + ); + + let tinted = Ui::from_scheme(scheme, FAMILIES); + let board = ui(SchemeFlavor::Light); + assert_ne!( + tinted.color(Role::Primary), + board.color(Role::Primary), + "取色方案必须换掉 primary" + ); + // 深浅仍跟着方案走(方案自带 flavor),不会把 Light 变成 Dark;色板名也仍说实话。 + assert_eq!(tinted.flavor(), SchemeFlavor::Light); + assert_eq!(tinted.name(), board.name()); + + // 显式传 baseline 时逐块等于 baseline 口径(M229 之后产品默认不再是它,但这条通路必须诚实)。 + let explicit = Ui::from_scheme(Scheme::baseline(SchemeFlavor::Light), FAMILIES); + let baseline = Ui::baseline(SchemeFlavor::Light, FAMILIES); + for role in Role::ALL { + assert_eq!( + explicit.color(role), + baseline.color(role), + "{role:?}:显式传 baseline 时颜色必须与 baseline 口径一致" + ); + } +} + +/// M229:`Ui::new` 走**品牌色板**(Ink 深 / Silver 浅),不再走 tone 派生的 `baseline`。 +/// +/// 抓得住:换肤没接上(色值仍是 baseline)、深浅映射反了(Light 拿到 Ink)。 +/// 抓不住:色板好不好看、页面有没有绕过 `Ui::color` 自己写色值(后者由 `no_hardcoded_tokens` 扫)。 +#[test] +fn every_ui_is_built_from_a_brand_board() { + for (flavor, board) in [ + (SchemeFlavor::Light, BrandBoard::Silver), + (SchemeFlavor::Dark, BrandBoard::Ink), + ] { + let evaluator = ui(flavor); + assert_eq!(evaluator.board(), board, "{flavor:?} 该用 {:?}", board); + assert_eq!(evaluator.name(), board_name(board)); + for role in Role::ALL { + assert_eq!( + evaluator.color(role), + to_iced(board.get(role)), + "{flavor:?} 的 {role:?} 不是色板值" + ); + } + // 与 tone 派生的 baseline 确实不同(否则「换肤」这件事没发生)。 + let baseline = Ui::baseline(flavor, FAMILIES); + assert_ne!( + evaluator.color(Role::Primary), + baseline.color(Role::Primary), + "{flavor:?}:色板与 baseline 的 primary 撞了,换肤等于没接" + ); + assert_eq!(evaluator.flavor(), flavor); + } +} + +/// 显式选板时深浅跟着板走(`Ink = Dark` / `Silver = Light`),「跟随系统」不必另造一种板。 +#[test] +fn picking_a_board_sets_the_flavor_from_the_board() { + let ink = Ui::with_board(BrandBoard::Ink, Platform::Windows, FAMILIES); + let silver = Ui::with_board(BrandBoard::Silver, Platform::Windows, FAMILIES); + assert_eq!(ink.flavor(), SchemeFlavor::Dark); + assert_eq!(silver.flavor(), SchemeFlavor::Light); + assert_ne!(ink.theme().to_string(), silver.theme().to_string()); +} + +/// 卡片方框走规格卡(`rx=16`),不是组件表的 `Medium(12)`。 +#[test] +fn cards_take_the_spec_corner() { + let evaluator = ui(SchemeFlavor::Light); + assert_eq!(evaluator.card_shape(), ShapeScale::Large); + assert_eq!(evaluator.card_box().radius_px(), 16.0); + assert_eq!( + evaluator.outlined(evaluator.card_shape()).radius.top_left, + 16.0 + ); +} + +fn board_name(board: BrandBoard) -> &'static str { + match board { + BrandBoard::Ink => "ull-ink", + BrandBoard::Silver => "ull-silver", + } +} diff --git a/crates/i18n/src/catalog/general.rs b/crates/i18n/src/catalog/general.rs index 54ccfd8..78f4c41 100644 --- a/crates/i18n/src/catalog/general.rs +++ b/crates/i18n/src/catalog/general.rs @@ -31,6 +31,15 @@ macro_rules! general_entries { SettingsLanguageEffective => ("Interface right now: {language}", "界面当前使用:{language}"), SettingsTheme => ("Theme", "主题"), SettingsThemeFollowSystem => ("Follow system", "跟随系统"), + // M229:主题三态改成「Ink / Silver / 跟随系统」——前两档就是两块品牌色板 + // (Ink = 深色主方案 / Silver = 浅色主方案),落盘仍是 `appearance.theme_mode` 的 + // `dark` / `light` / `auto` 三个值,不新增配置键。 + SettingsThemeInk => ("Ink", "Ink(深色)"), + SettingsThemeSilver => ("Silver", "Silver(浅色)"), + SettingsThemeHint => ( + "Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.", + "Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。" + ), SettingsStartupPage => ("Startup page", "启动页"), SettingsStartupInstances => ("Instances", "实例"), SettingsCloseButton => ("Close button", "关闭按钮"), diff --git a/crates/i18n/src/catalog/golden/tail_ai.rs b/crates/i18n/src/catalog/golden/tail_ai.rs index 9cd5dcc..93ad7f8 100644 --- a/crates/i18n/src/catalog/golden/tail_ai.rs +++ b/crates/i18n/src/catalog/golden/tail_ai.rs @@ -186,6 +186,13 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[ ("SettingsLanguageEffective", "Interface right now: {language}", "界面当前使用:{language}"), ("SettingsTheme", "Theme", "主题"), ("SettingsThemeFollowSystem", "Follow system", "跟随系统"), + ("SettingsThemeInk", "Ink", "Ink(深色)"), + ("SettingsThemeSilver", "Silver", "Silver(浅色)"), + ( + "SettingsThemeHint", + "Ink is the dark scheme, Silver the light one. Follow system picks between them as Windows switches.", + "Ink 是深色主方案,Silver 是浅色主方案;跟随系统会随 Windows 的深浅在这两块之间切换。", + ), ("SettingsStartupPage", "Startup page", "启动页"), ("SettingsStartupInstances", "Instances", "实例"), ("SettingsCloseButton", "Close button", "关闭按钮"), From f9877a89d8cf61576f037f323fae88b6907ef64b Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 23:30:34 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix(app-iced):=20=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E9=A1=B5=E9=A1=B5=E5=A4=B4=E3=80=8C=E5=86=85=E5=AE=B9=E5=BA=93?= =?UTF-8?q?=E3=80=8D=E9=80=89=E4=B8=AD=E6=80=81=E5=8F=8D=E8=BD=AC=EF=BC=88?= =?UTF-8?q?M229=20=E5=A4=8D=E5=AE=A1=20F3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pages/content.rs::section_switcher` 把 `on_content` 写死成 `true`,而下载页也调它, 于是版本管理页的页头把「内容库」画成选中、「版本管理」画成未选中。 改成由调用方给位置:下载页传 false、内容库页自己传 true;并加一条源码层面的调用点断言 (iced 的 Element 不可内省,判据只有一行,写错必红)。 不是 M229 引入的缺陷(M109 就有),按复审要求单独提交,不与收官换肤混在一起。 --- crates/app-iced/src/pages/content.rs | 6 +++++- crates/app-iced/src/pages/content/tests.rs | 19 +++++++++++++++++++ crates/app-iced/src/pages/download.rs | 4 +++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/app-iced/src/pages/content.rs b/crates/app-iced/src/pages/content.rs index 6d58ce8..2a9b1bc 100644 --- a/crates/app-iced/src/pages/content.rs +++ b/crates/app-iced/src/pages/content.rs @@ -105,9 +105,13 @@ pub fn demo(kind: ContentKind, lang: Lang) -> Content { } /// 页头那条一级切换(版本管理页也要用同一个函数:两页共用一个入口才不会各画一套)。 +/// +/// `on_content` 是**调用方自己**的位置:内容库页传 `true`、版本管理页传 `false`。 +/// M229 复审 F3 之前这里写死 `true`,于是下载页页头的「内容库」也画成选中态(选中态反了)。 pub(crate) fn section_switcher<'a>( ui: &'a Ui, catalog: unlucky_i18n::Catalog, + on_content: bool, ) -> Element<'a, PageMessage> { - parts::section_switcher(ui, catalog, true) + parts::section_switcher(ui, catalog, on_content) } diff --git a/crates/app-iced/src/pages/content/tests.rs b/crates/app-iced/src/pages/content/tests.rs index 5b1453f..1555846 100644 --- a/crates/app-iced/src/pages/content/tests.rs +++ b/crates/app-iced/src/pages/content/tests.rs @@ -98,3 +98,22 @@ fn the_default_demo_screen_has_no_retry_surface() { ); } } + +/// M229 复审 F3 的回归门:页头一级切换的选中态由**调用方所在页**决定,不是写死「内容库」。 +/// +/// 抓得住:`download.rs` 又传回 `true`(下载页页头的「内容库」被画成选中,正是 F3 的现场)。 +/// 抓不住:像素长什么样(那要截图);iced 的 `Element` 不可内省,所以这条从源码层面钉住调用点—— +/// 判据只有一行,读的人也不会误解。 +#[test] +fn the_download_header_marks_versions_selected_not_content() { + let download = include_str!("../download.rs"); + assert!( + download.contains("section_switcher(ui, catalog, false)"), + "下载页是「版本管理」那一侧,必须传 false(传 true = F3 复发)" + ); + let content_view = include_str!("view.rs"); + assert!( + content_view.contains("section_switcher(ui, catalog, true)"), + "内容库页自己那一份必须传 true" + ); +} diff --git a/crates/app-iced/src/pages/download.rs b/crates/app-iced/src/pages/download.rs index 107c456..37fb11d 100644 --- a/crates/app-iced/src/pages/download.rs +++ b/crates/app-iced/src/pages/download.rs @@ -187,7 +187,9 @@ pub fn view_sized<'a>( .subtitle(catalog.text(Key::DownloadSubtitle)) // M109:一级切换「版本管理 | 内容库」。两页共用同一条导航轨项(`NavSection::Download`), // 所以这个切换是进内容库的唯一产品入口——少了它,内容库只能靠 `ULL_START_ROUTE` 到达。 - .action(content::section_switcher(ui, catalog)) + // 本页是「版本管理」那一侧:`on_content = false`(传 true 会让「内容库」画成选中, + // 这正是 M229 复审 F3 的现场)。 + .action(content::section_switcher(ui, catalog, false)) .action(buttons::text( ui, Button::new( From 4ce24d6fbd430106c2293a35f0f02968a7a8d9a6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 23:42:40 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix(app-iced):=20=E7=A6=81=E7=94=A8?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E9=81=AE=E7=BD=A9=E6=94=B9=E4=BB=8E=E5=93=81?= =?UTF-8?q?=E7=89=8C=E6=9D=BF=E5=90=88=E6=88=90=EF=BC=88M229=20=E5=A4=8D?= =?UTF-8?q?=E5=AE=A1=20F1=EF=BC=89+=20=E9=80=89=E6=8B=A9=E7=B1=BB=E5=9B=BE?= =?UTF-8?q?=E6=B3=A8=E5=9B=9E=E5=A1=AB=EF=BC=88F2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1(核心):三处 `Scheme::baseline(ui.flavor())` 自己重算了一份方案,绕开 `Ui` 里注入的品牌板, 于是 Ink 板上的禁用控件与对话框遮罩仍按 tone 派生的天蓝中性合成(复审量到滑块轨道 #58646C、 填充框 #222E36、遮罩 #060E13,全是蓝系)。修法:`Ui::scheme()` 公开已注入的那份, `interaction/disabled.rs`(两族合成 + 内容色)、`feedback/dialog.rs`(scrim)、 `feedback/action.rs`(禁用动作文字)改为从它取。 修完 Ink 下实测:禁用容器(12%)#292F28、按钮容器(10%)#252B24、遮罩 #0A0E0A(绿系中性, R = B);Silver 档遮罩 #A9A9AA。旧三个蓝系值在 7 张深色图里出现次数均为 0。 回归门:`disabled_colors_follow_the_brand_board_not_the_tone_baseline`(按板合成 + 与 baseline 不等 + 深色档 G ≥ B)、`scrim_is_scrimalpha_precomposited_onto_the_canvas` 增加「不许退回 baseline」。 F2:`selection/{matrix,mod,checkbox,radio,geometry}.rs` 里「20×20 / rx2 / 外圈 20」回填成实际渲染的 「18×18 / rx4 / 外圈 18」(勾形比例不变 ⇒ 10.8dp),并按塔裁决把搜索药丸容器从 `SurfaceContainerHigh` 抬到 **`SurfaceContainerHighest`**(贴规格卡,与 filled 字段同底); `search.rs` / `probe/search.rs` / gallery `inputs` 图注同步。展开的搜索面板与菜单是浮层, 仍留 High 档(已在文档里写明不是漏改)。 `interaction/disabled.rs` 加测试后 405 行 ⇒ 单测纯搬移到 `interaction/disabled/tests.rs`。 --- .../src/components/feedback/action.rs | 5 +- .../src/components/feedback/dialog.rs | 10 +- .../app-iced/src/components/feedback/tests.rs | 17 +- .../src/components/gallery/buttons.rs | 2 +- .../app-iced/src/components/gallery/inputs.rs | 2 +- .../src/components/inputs/probe/search.rs | 2 +- .../app-iced/src/components/inputs/search.rs | 10 +- .../src/components/interaction/disabled.rs | 185 +----------- .../components/interaction/disabled/tests.rs | 276 ++++++++++++++++++ .../src/components/selection/checkbox.rs | 17 +- .../src/components/selection/geometry.rs | 8 +- .../src/components/selection/matrix.rs | 4 +- .../app-iced/src/components/selection/mod.rs | 4 +- .../src/components/selection/pixel.rs | 2 +- .../src/components/selection/probe.rs | 4 +- .../src/components/selection/radio.rs | 11 +- .../src/components/selection/tests.rs | 2 +- crates/app-iced/src/theme.rs | 13 + 18 files changed, 361 insertions(+), 213 deletions(-) create mode 100644 crates/app-iced/src/components/interaction/disabled/tests.rs diff --git a/crates/app-iced/src/components/feedback/action.rs b/crates/app-iced/src/components/feedback/action.rs index 843ce2d..4b6d2d6 100644 --- a/crates/app-iced/src/components/feedback/action.rs +++ b/crates/app-iced/src/components/feedback/action.rs @@ -21,7 +21,7 @@ use iced::widget::{button, text}; use iced::{Background, Border, Element, Length, Padding, Shadow}; use unlucky_design::color::state::DISABLED_CONTENT_ALPHA; -use unlucky_design::color::{Role, Scheme}; +use unlucky_design::color::Role; use unlucky_design::component_map::Component; use unlucky_design::icons::IconKind; use unlucky_design::spacing::Space; @@ -43,7 +43,8 @@ fn content_color(ui: &Ui, role: Role, container_role: Role, disabled: bool) -> i if !disabled { return ui.color(role); } - let scheme = Scheme::baseline(ui.flavor()); + // 取 `Ui` 的当前方案(品牌板 / 取色方案),不重算 baseline(复审 F1)。 + let scheme = ui.scheme(); to_iced( scheme .get(Role::OnSurface) diff --git a/crates/app-iced/src/components/feedback/dialog.rs b/crates/app-iced/src/components/feedback/dialog.rs index 78cae57..1b07a13 100644 --- a/crates/app-iced/src/components/feedback/dialog.rs +++ b/crates/app-iced/src/components/feedback/dialog.rs @@ -38,7 +38,7 @@ use iced::widget::{column, container, row, text, Space}; use iced::{Alignment, Background, Border, Element, Length, Padding}; -use unlucky_design::color::{Role, Scheme}; +use unlucky_design::color::Role; use unlucky_design::elevation::MaterialLayer; use unlucky_design::typography::TypeRole; @@ -128,9 +128,13 @@ impl<'a, M> Dialog<'a, M> { } } -/// 遮罩色:`黑 @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。 +/// 遮罩色:`scrim @ SCRIM_ALPHA` 叠在 `MaterialLayer::Canvas` 上之后的**实色**(D27)。 +/// +/// 叠的底是 [`Ui::scheme`](品牌色板 / 取色方案):M229 之前这里按 `Scheme::baseline(flavor)` +/// 重算,Ink 板上量到的是旧蓝中性底压出来的**偏蓝**遮罩;按品牌板合成才是中性绿黑 +/// (`scrim` 是 M3 定值纯黑,两板同值,差的是它下面的画布色)。 pub fn scrim(ui: &Ui) -> iced::Color { - let scheme = Scheme::baseline(ui.flavor()); + let scheme = ui.scheme(); let canvas = scheme.get(MaterialLayer::Canvas.surface_role()); to_iced(scheme.scrim().over(canvas)) } diff --git a/crates/app-iced/src/components/feedback/tests.rs b/crates/app-iced/src/components/feedback/tests.rs index 74e7b81..ae1f84a 100644 --- a/crates/app-iced/src/components/feedback/tests.rs +++ b/crates/app-iced/src/components/feedback/tests.rs @@ -104,13 +104,26 @@ fn dialog_padding_and_action_gaps_are_the_tokens() { fn scrim_is_scrimalpha_precomposited_onto_the_canvas() { for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { let ui = ui(flavor); - let scheme = Scheme::baseline(flavor); + // 期望值按**品牌色板**(= `Ui` 注入的那一份):M229 复审 F1 之前这里用的是 + // `Scheme::baseline`,Ink 板上会得到偏蓝的遮罩(数值见 `DevDocs/artifacts/m229/README.md` §6)。 + let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme(); let canvas = scheme.get(MaterialLayer::Canvas.surface_role()); let expected = to_iced(scheme.scrim().over(canvas)); assert_eq!( scrim(&ui), expected, - "{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas」" + "{flavor:?}:scrim 必须逐位等于「黑@0.32 叠 canvas(品牌板)」" + ); + // F1 的回归门:不许回到 tone 派生的 baseline 上算。 + let baseline = Scheme::baseline(flavor); + assert_ne!( + expected, + to_iced( + baseline + .scrim() + .over(baseline.get(MaterialLayer::Canvas.surface_role())) + ), + "{flavor:?}:遮罩退回了 baseline(Ink 下会偏蓝)" ); assert_eq!( scrim(&ui).a, diff --git a/crates/app-iced/src/components/gallery/buttons.rs b/crates/app-iced/src/components/gallery/buttons.rs index b6b1451..19ec5bb 100644 --- a/crates/app-iced/src/components/gallery/buttons.rs +++ b/crates/app-iced/src/components/gallery/buttons.rs @@ -77,7 +77,7 @@ pub(super) fn section<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { heading( ui, "buttons · filled / tonal / elevated / outlined / text + icon", - "12b §1/§2:高 40 · 圆角 full(20) · 内边距 24(带图标 16/24)· 图标 18 间距 8 · \ + "规格卡 + 12b §1/§2:S 档高 40 · 圆角 full(20) · 内边距 20(带图标 16/20;M229 起按 ControlSize::pad_x)· 图标 18 间距 8 · \ hover 8% / focus 10% / pressed 10% / disabled 容器 10% 内容 38%" ), rows, diff --git a/crates/app-iced/src/components/gallery/inputs.rs b/crates/app-iced/src/components/gallery/inputs.rs index 04f8e20..e73baec 100644 --- a/crates/app-iced/src/components/gallery/inputs.rs +++ b/crates/app-iced/src/components/gallery/inputs.rs @@ -222,7 +222,7 @@ fn search_part<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> { column![ note( ui, - "bar:高 56 · 圆角 full(28) · SurfaceContainerHigh · 图标可视边 16 = pad 12 + offset 4(M69 不变量)· \ + "bar:高 56 · 圆角 full(28) · SurfaceContainerHighest(M229 r2 抬档:与 filled 字段同底)· 图标可视边 16 = pad 12 + offset 4(M69 不变量)· \ 聚焦 1px Secondary;view:docked,头部 56 + 1px Outline 分隔 + 结果区最小高 240" ), row![bars, view].spacing(ui.space(Space::X6)), diff --git a/crates/app-iced/src/components/inputs/probe/search.rs b/crates/app-iced/src/components/inputs/probe/search.rs index 40dc2c3..d774c71 100644 --- a/crates/app-iced/src/components/inputs/probe/search.rs +++ b/crates/app-iced/src/components/inputs/probe/search.rs @@ -10,7 +10,7 @@ fn search_bar_and_docked_panel_geometry() { for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { let shot = shot(&dir, "search", flavor); let ui = ui(flavor); - let bar_color = rgb(ui.color(Role::SurfaceContainerHigh)); + let bar_color = rgb(ui.color(Role::SurfaceContainerHighest)); let ink = rgb(ui.color(Role::OnSurface)); let bars = shot.bbox_of(bar_color, 2000); diff --git a/crates/app-iced/src/components/inputs/search.rs b/crates/app-iced/src/components/inputs/search.rs index 3d99951..6f79170 100644 --- a/crates/app-iced/src/components/inputs/search.rs +++ b/crates/app-iced/src/components/inputs/search.rs @@ -6,7 +6,7 @@ //! |---|---|---|---| //! | 20.1 | 高 + 圆角 | 高 **56**、圆角 **full = 28**(药丸,不是圆角矩形) | `LM.search_bar_height` + `Component::Search.spec().shape` | //! | 20.2 | 宽 | min **360** / max **720**(G20:iced 无 min-width,只施加 max) | [`SEARCH_MAX_WIDTH`](super::geometry::SEARCH_MAX_WIDTH) | -//! | 20.3 | 容器色 / 高程 | `SurfaceContainerHigh` + **L0**(token 写 L3,默认值是 L0,按观感取 L0) | `Role::SurfaceContainerHigh` | +//! | 20.3 | 容器色 / 高程 | **`SurfaceContainerHighest`** + **L0**(token 写 L3,默认值是 L0,按观感取 L0) | `Role::SurfaceContainerHighest` | //! | 20.4 | 图标位 | leading 24/`OnSurface`、trailing 24/`OnSurfaceVariant`;**可视边 16 = pad 12 + offset 4** | `ComponentMetrics::SEARCH_BAR` | //! | 20.5 | 文本 / 占位 / 聚焦 | 输入 `BodyLarge`+`OnSurface`;占位 `OnSurfaceVariant`;聚焦 `Secondary` 1px 指示 | `TypeRole` / `Role::Secondary` | //! | 20.6 | docked 面板 | 头部 **56**、形状 `CornerExtraLarge` = **28** | `ShapeScale::ExtraLarge` | @@ -179,7 +179,7 @@ fn bar_content<'a, M: Clone + 'a>( .into() } -/// 收起态搜索条(56 高、药丸、`SurfaceContainerHigh`、无可见阴影)。 +/// 收起态搜索条(56 高、药丸、`SurfaceContainerHighest`、无可见阴影)。 pub fn search_bar<'a, M: Clone + 'a>( ui: &'a Ui, bar: SearchBar<'a>, @@ -192,7 +192,9 @@ pub fn search_bar<'a, M: Clone + 'a>( let background = if state.is_disabled() { interaction::disabled_colors(ui, Role::Surface).0 } else { - let role = Role::SurfaceContainerHigh; + // 规格卡里搜索药丸与 filled 字段是**同一个底**(`SurfaceContainerHighest`): + // M229 r1 用的是 `High`,比 filled 字段低一档,两块并排时能看出色差(复审 F2 裁决抬档)。 + let role = Role::SurfaceContainerHighest; interaction::overlay_color(ui, state, Role::OnSurface, role).unwrap_or(ui.color(role)) }; @@ -265,6 +267,8 @@ pub fn search_view<'a, M: Clone + 'a>( view: SearchView<'a>, on_input: impl Fn(String) -> M + 'a, ) -> Element<'a, M> { + // 展开面板**不跟着药丸抬档**:它是浮层(菜单语言,`SurfaceContainerHigh`), + // 规格卡只画了收起的药丸。两块不同档是层级差,不是漏改(复审 F2 只裁药丸)。 let panel = ui.color(Role::SurfaceContainerHigh); let header_height = ui.metrics().search_bar_height; let body_style = ui.style(TypeRole::BodyMedium); diff --git a/crates/app-iced/src/components/interaction/disabled.rs b/crates/app-iced/src/components/interaction/disabled.rs index 4e033c5..1b05190 100644 --- a/crates/app-iced/src/components/interaction/disabled.rs +++ b/crates/app-iced/src/components/interaction/disabled.rs @@ -47,8 +47,9 @@ use crate::theme::{to_iced, Ui}; /// 之前这里写死 `OnSurfaceVariant` 且只有一个「底」,导致 FilledTonal 与 IconButton 两族偏浅 /// 15/255(视觉 r3 用像素量的)。 pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Color { - let scheme = Scheme::baseline(ui.flavor()); - disabled_content_with_alpha(&scheme, role, on_container, DISABLED_SURFACE_ALPHA) + // **取 `Ui` 里已注入的方案**,不重算 baseline:M229 之后产品方案是品牌色板 + // (Ink / Silver),照 baseline 重算会画出旧的天蓝中性色(复审 F1)。 + disabled_content_with_alpha(ui.scheme(), role, on_container, DISABLED_SURFACE_ALPHA) } /// 禁用态内容色——**按钮家族**(`on_container`:是否叠在本族 10% 容器上)。 @@ -56,8 +57,7 @@ pub fn disabled_content_over_role(ui: &Ui, role: Role, on_container: bool) -> Co /// 与 [`disabled_content_over_role`] 的差别只有「底」的 α:按钮 10%、面状控件 12%。 /// 两个入口分开是 M86 的结论:合成一个数会让一族永远差 1/255,肉眼看不见、只有 probe 会红。 pub fn disabled_button_content(ui: &Ui, role: Role, on_container: bool) -> Color { - let scheme = Scheme::baseline(ui.flavor()); - disabled_content_with_alpha(&scheme, role, on_container, DISABLED_CONTAINER_ALPHA) + disabled_content_with_alpha(ui.scheme(), role, on_container, DISABLED_CONTAINER_ALPHA) } /// 两个族共用的内容色求值(「底」的 α 由调用方给)。 @@ -94,8 +94,8 @@ fn disabled_content_with_alpha( /// /// **按钮家族用 [`disabled_button_colors`]**(0.10,`*ButtonTokens.DisabledContainerOpacity = 0.1f`)。 /// -/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是 -/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。 +/// 合成的输入是 [`Ui::scheme`](品牌色板 / 取色方案),**不是** `Scheme::baseline(flavor)`: +/// 后者是 tone 派生的旧口径,在 Ink 板上会让禁用容器偏蓝(复审 F1 的根因)。 /// /// 顺序必须「先容器后内容」:Compose 也是这么画的,先叠容器再叠 label, /// 所以内容叠的角色是容器实色而不是底下的 surface。 @@ -116,7 +116,7 @@ pub fn disabled_button_colors(ui: &Ui, surface: Role) -> (Color, Color) { /// 两个族共用的求值:容器 = `OnSurface` @ `container_alpha` 叠在 `surface` 上,内容 = 38% 叠在容器上。 fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> (Color, Color) { - let scheme = Scheme::baseline(ui.flavor()); + let scheme = ui.scheme(); let base = scheme.get(surface); let container = scheme .get(Role::OnSurface) @@ -133,173 +133,4 @@ fn disabled_colors_with_alpha(ui: &Ui, surface: Role, container_alpha: f32) -> ( } #[cfg(test)] -mod tests { - use super::*; - use unlucky_design::color::SchemeFlavor; - use unlucky_design::typography::Platform; - - use crate::theme::Families; - - const FAMILIES: Families = Families { - text: "Segoe UI", - display: "Segoe UI", - mono: "Consolas", - }; - - fn ui(flavor: SchemeFlavor) -> Ui { - Ui::with_platform(flavor, Platform::Windows, FAMILIES) - } - - /// 两族的 α 必须都钉住(只钉一条,另一族被合并时会静默错 1/255)。 - #[test] - fn both_disabled_alpha_classes_are_pinned_to_the_tokens() { - // 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」。 - let button_alpha = DISABLED_CONTAINER_ALPHA; - let surface_alpha = DISABLED_SURFACE_ALPHA; - let content_alpha = DISABLED_CONTENT_ALPHA; - // 按钮容器:官方 *ButtonTokens.DisabledContainerOpacity = 0.1f - assert!( - (button_alpha - 0.10).abs() < f32::EPSILON, - "按钮禁用容器 α 必须是 0.10(官方 DisabledContainerOpacity);\ - 0.12 是面状控件那一类,别混用" - ); - // 面状控件:chip 描边 / slider 未激活轨 / switch 轨道 = 0.12f - assert!( - (surface_alpha - 0.12).abs() < f32::EPSILON, - "面状控件禁用 α 必须是 0.12(官方 FlatDisabledOutlineOpacity / \ - DisabledInactiveTrackOpacity / DisabledTrackOpacity)" - ); - assert!((content_alpha - 0.38).abs() < f32::EPSILON); - assert!( - surface_alpha > button_alpha, - "两族必须可区分:面状控件比按钮容器更实({surface_alpha} > {button_alpha})" - ); - } - - /// **面状控件族**的那一对颜色 = 0.12 预混合(chip / switch / slider / TextField 描边)。 - /// - /// ⚠️ 期望值**写死 0.12 / 0.38**,不从 `DISABLED_SURFACE_ALPHA` 取: - /// 从被测常量求期望是**自指断言**——常量一改,expected 跟着改,永远相等(复审 r1 P2-1 实测: - /// 把 `DISABLED_SURFACE_ALPHA` 改成 0.10,旧版这条照样绿)。现在常量改坏必红。 - #[test] - fn surface_family_disabled_colors_are_the_12_percent_class() { - const EXPECTED_SURFACE_ALPHA: f32 = 0.12; - const EXPECTED_CONTENT_ALPHA: f32 = 0.38; - let actual_alpha = DISABLED_SURFACE_ALPHA; - assert_eq!( - actual_alpha, EXPECTED_SURFACE_ALPHA, - "面状控件族 α 必须是官方 0.12f;改常量会让下面每一条合成色断言同时红" - ); - - for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { - let ui = ui(flavor); - let scheme = Scheme::baseline(flavor); - let base = scheme.get(Role::Surface); - let expected_container = scheme - .get(Role::OnSurface) - .with_alpha(EXPECTED_SURFACE_ALPHA) - .over(base); - - let (surface, surface_content) = disabled_colors(&ui, Role::Surface); - assert_eq!( - surface, - to_iced(expected_container), - "{flavor:?}:面状控件容器 = on-surface@12% 叠表面(期望值硬编码,不取自常量)" - ); - assert_eq!( - surface_content, - to_iced( - scheme - .get(Role::OnSurfaceVariant) - .with_alpha(EXPECTED_CONTENT_ALPHA) - .over(expected_container) - ), - "{flavor:?}:面状控件内容仍是 38%,只是叠的底不同" - ); - assert_ne!(surface, ui.color(Role::Surface), "12% 叠上去必须能看出来"); - assert_eq!(surface.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)"); - assert_eq!(surface_content.a, 1.0, "禁用内容也是实色"); - // 内容叠在容器上 vs 叠在表面上,是两回事(probe 量的是前者) - assert_ne!( - surface_content, - to_iced( - scheme - .get(Role::OnSurfaceVariant) - .with_alpha(EXPECTED_CONTENT_ALPHA) - .over(base) - ), - "{flavor:?}:内容必须叠在容器上" - ); - // 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红) - let (button, _) = disabled_button_colors(&ui, Role::Surface); - assert_ne!( - surface, button, - "{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上" - ); - } - } - - /// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。 - /// - /// 期望值同样写死(0.10 / 0.38),理由见上一条:自指断言抓不住常量被改坏。 - #[test] - fn button_family_disabled_colors_are_the_10_percent_class() { - const EXPECTED_BUTTON_ALPHA: f32 = 0.10; - const EXPECTED_CONTENT_ALPHA: f32 = 0.38; - let actual_alpha = DISABLED_CONTAINER_ALPHA; - assert_eq!( - actual_alpha, EXPECTED_BUTTON_ALPHA, - "按钮族 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)" - ); - - for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { - let ui = ui(flavor); - let scheme = Scheme::baseline(flavor); - let base = scheme.get(Role::Surface); - let expected_container = scheme - .get(Role::OnSurface) - .with_alpha(EXPECTED_BUTTON_ALPHA) - .over(base); - - let (button, button_content) = disabled_button_colors(&ui, Role::Surface); - assert_eq!( - button, - to_iced(expected_container), - "{flavor:?}:按钮容器 = on-surface@10% 叠表面(官方 DisabledContainerOpacity = 0.1f)" - ); - assert_eq!( - button_content, - to_iced( - scheme - .get(Role::OnSurfaceVariant) - .with_alpha(EXPECTED_CONTENT_ALPHA) - .over(expected_container) - ), - ); - - let (surface, _) = disabled_colors(&ui, Role::Surface); - assert_ne!( - button, surface, - "{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)" - ); - - // 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底 - assert_ne!( - button_content, - disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), - "{flavor:?}:两族的「有容器」内容色不能同源" - ); - assert_eq!( - disabled_button_content(&ui, Role::OnSurfaceVariant, true), - button_content, - "{flavor:?}:按钮内容入口必须与按钮颜色入口同源" - ); - // 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支 - assert_eq!( - disabled_button_content(&ui, Role::OnSurfaceVariant, false), - disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), - "{flavor:?}:没有容器时不该有族差异" - ); - } - } -} +mod tests; diff --git a/crates/app-iced/src/components/interaction/disabled/tests.rs b/crates/app-iced/src/components/interaction/disabled/tests.rs new file mode 100644 index 0000000..193d7f7 --- /dev/null +++ b/crates/app-iced/src/components/interaction/disabled/tests.rs @@ -0,0 +1,276 @@ +//! 禁用态两族 α 与合成色的单测(M229 复审 F1 后从 `disabled.rs` 纯搬移,守 400 行硬线)。 +//! +//! 内容一行未改;新增的 `disabled_colors_follow_the_brand_board_not_the_tone_baseline` 是 F1 的回归门, +//! 它与既有两条「α 族」测试放在一起,读的人一眼能看到「族」与「色源」两件事。 + +use super::*; +use unlucky_design::color::SchemeFlavor; +use unlucky_design::typography::Platform; + +use crate::theme::Families; + +const FAMILIES: Families = Families { + text: "Segoe UI", + display: "Segoe UI", + mono: "Consolas", +}; + +fn ui(flavor: SchemeFlavor) -> Ui { + Ui::with_platform(flavor, Platform::Windows, FAMILIES) +} + +/// 两族的 α 必须都钉住(只钉一条,另一族被合并时会静默错 1/255)。 +#[test] +fn both_disabled_alpha_classes_are_pinned_to_the_tokens() { + // 绑定成局部变量再过断言:直接比两个常量会被 clippy 判成「常量断言」。 + let button_alpha = DISABLED_CONTAINER_ALPHA; + let surface_alpha = DISABLED_SURFACE_ALPHA; + let content_alpha = DISABLED_CONTENT_ALPHA; + // 按钮容器:官方 *ButtonTokens.DisabledContainerOpacity = 0.1f + assert!( + (button_alpha - 0.10).abs() < f32::EPSILON, + "按钮禁用容器 α 必须是 0.10(官方 DisabledContainerOpacity);\ + 0.12 是面状控件那一类,别混用" + ); + // 面状控件:chip 描边 / slider 未激活轨 / switch 轨道 = 0.12f + assert!( + (surface_alpha - 0.12).abs() < f32::EPSILON, + "面状控件禁用 α 必须是 0.12(官方 FlatDisabledOutlineOpacity / \ + DisabledInactiveTrackOpacity / DisabledTrackOpacity)" + ); + assert!((content_alpha - 0.38).abs() < f32::EPSILON); + assert!( + surface_alpha > button_alpha, + "两族必须可区分:面状控件比按钮容器更实({surface_alpha} > {button_alpha})" + ); +} + +/// **面状控件族**的那一对颜色 = 0.12 预混合(chip / switch / slider / TextField 描边)。 +/// +/// ⚠️ 期望值**写死 0.12 / 0.38**,不从 `DISABLED_SURFACE_ALPHA` 取: +/// 从被测常量求期望是**自指断言**——常量一改,expected 跟着改,永远相等(复审 r1 P2-1 实测: +/// 把 `DISABLED_SURFACE_ALPHA` 改成 0.10,旧版这条照样绿)。现在常量改坏必红。 +#[test] +fn surface_family_disabled_colors_are_the_12_percent_class() { + const EXPECTED_SURFACE_ALPHA: f32 = 0.12; + const EXPECTED_CONTENT_ALPHA: f32 = 0.38; + let actual_alpha = DISABLED_SURFACE_ALPHA; + assert_eq!( + actual_alpha, EXPECTED_SURFACE_ALPHA, + "面状控件族 α 必须是官方 0.12f;改常量会让下面每一条合成色断言同时红" + ); + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + // 期望值按**品牌色板**(= `Ui` 注入的那一份),不是 tone 派生的 baseline。 + let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme(); + let base = scheme.get(Role::Surface); + let expected_container = scheme + .get(Role::OnSurface) + .with_alpha(EXPECTED_SURFACE_ALPHA) + .over(base); + + let (surface, surface_content) = disabled_colors(&ui, Role::Surface); + assert_eq!( + surface, + to_iced(expected_container), + "{flavor:?}:面状控件容器 = on-surface@12% 叠表面(期望值硬编码,不取自常量)" + ); + assert_eq!( + surface_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(expected_container) + ), + "{flavor:?}:面状控件内容仍是 38%,只是叠的底不同" + ); + assert_ne!(surface, ui.color(Role::Surface), "12% 叠上去必须能看出来"); + assert_eq!(surface.a, 1.0, "禁用容器也是实色(CPU/GPU 一致)"); + assert_eq!(surface_content.a, 1.0, "禁用内容也是实色"); + // 内容叠在容器上 vs 叠在表面上,是两回事(probe 量的是前者) + assert_ne!( + surface_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(base) + ), + "{flavor:?}:内容必须叠在容器上" + ); + // 跨族对照:面状族的合成色必须与按钮族不同(两族一旦合流,这条红) + let (button, _) = disabled_button_colors(&ui, Role::Surface); + assert_ne!( + surface, button, + "{flavor:?}:面状族 12% 与按钮族 10% 必须落在不同像素值上" + ); + } +} + +/// **按钮家族**的那一对颜色 = 0.10 预混合,且与面状控件族**必须落在不同像素值上**。 +/// +/// 期望值同样写死(0.10 / 0.38),理由见上一条:自指断言抓不住常量被改坏。 +#[test] +fn button_family_disabled_colors_are_the_10_percent_class() { + const EXPECTED_BUTTON_ALPHA: f32 = 0.10; + const EXPECTED_CONTENT_ALPHA: f32 = 0.38; + let actual_alpha = DISABLED_CONTAINER_ALPHA; + assert_eq!( + actual_alpha, EXPECTED_BUTTON_ALPHA, + "按钮族 α 必须是官方 0.1f(*ButtonTokens.DisabledContainerOpacity)" + ); + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + // 期望值按**品牌色板**(= `Ui` 注入的那一份),不是 tone 派生的 baseline。 + let scheme = unlucky_design::color::BrandBoard::for_flavor(flavor).scheme(); + let base = scheme.get(Role::Surface); + let expected_container = scheme + .get(Role::OnSurface) + .with_alpha(EXPECTED_BUTTON_ALPHA) + .over(base); + + let (button, button_content) = disabled_button_colors(&ui, Role::Surface); + assert_eq!( + button, + to_iced(expected_container), + "{flavor:?}:按钮容器 = on-surface@10% 叠表面(官方 DisabledContainerOpacity = 0.1f)" + ); + assert_eq!( + button_content, + to_iced( + scheme + .get(Role::OnSurfaceVariant) + .with_alpha(EXPECTED_CONTENT_ALPHA) + .over(expected_container) + ), + ); + + let (surface, _) = disabled_colors(&ui, Role::Surface); + assert_ne!( + button, surface, + "{flavor:?}:10% 与 12% 必须落在不同像素值上(合成一个数就是这次要防的回归)" + ); + + // 内容色同理:按钮 38% 叠 10% 底 ≠ 面状 38% 叠 12% 底 + assert_ne!( + button_content, + disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), + "{flavor:?}:两族的「有容器」内容色不能同源" + ); + assert_eq!( + disabled_button_content(&ui, Role::OnSurfaceVariant, true), + button_content, + "{flavor:?}:按钮内容入口必须与按钮颜色入口同源" + ); + // 无容器时两族完全一致(都叠 surface)——差异只在「有容器」那一支 + assert_eq!( + disabled_button_content(&ui, Role::OnSurfaceVariant, false), + disabled_content_over_role(&ui, Role::OnSurfaceVariant, false), + "{flavor:?}:没有容器时不该有族差异" + ); + } +} + +/// **M229 复审 F1 的回归门**:禁用态的合成必须跟着**品牌色板**走,不许偷偷回到 tone 派生的 baseline。 +/// +/// Ink 板的中性色是绿系(`on-surface` 与 `surface` 的 G 通道最高),baseline 是蓝系;所以只要 +/// 「禁用容器」在 Ink 下是绿系(`g >= b`)且与 baseline 算出来的值**不相等**,就说明它走的是板。 +/// +/// 抓得住:有人把三处 `Scheme::baseline(ui.flavor())` 写回去(那时本测试立刻红,且报出偏蓝的像素)。 +/// 抓不住:色板本身改了色相(那是 `design` 的 `anchors_pin_the_spec` 的事)。 +#[test] +fn disabled_colors_follow_the_brand_board_not_the_tone_baseline() { + use unlucky_design::color::BrandBoard; + + for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] { + let ui = ui(flavor); + let board = BrandBoard::for_flavor(flavor).scheme(); + let baseline = Scheme::baseline(flavor); + + // ① 面状控件族的容器(chip 描边 / slider 未激活轨 / switch 轨道) + let (surface, _surface_content) = disabled_colors(&ui, Role::Surface); + let expect_surface = to_iced( + board + .get(Role::OnSurface) + .with_alpha(DISABLED_SURFACE_ALPHA) + .over(board.get(Role::Surface)), + ); + assert_eq!( + surface, expect_surface, + "{flavor:?}:禁用容器必须按色板合成" + ); + + let baseline_surface = to_iced( + baseline + .get(Role::OnSurface) + .with_alpha(DISABLED_SURFACE_ALPHA) + .over(baseline.get(Role::Surface)), + ); + assert_ne!( + surface, baseline_surface, + "{flavor:?}:禁用容器退回了 baseline(这就是复审 F1 的蓝系像素)" + ); + + // ② 按钮族的容器 + let (button, _) = disabled_button_colors(&ui, Role::Surface); + assert_ne!(button, surface, "{flavor:?}:两族 α 仍要可区分"); + + // ③ 内容色(两个入口) + assert_eq!( + disabled_content_over_role(&ui, Role::OnSurfaceVariant, true), + to_iced( + board + .get(Role::OnSurfaceVariant) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over( + board + .get(Role::OnSurface) + .with_alpha(DISABLED_SURFACE_ALPHA) + .over(board.get(Role::Surface)) + ) + ), + "{flavor:?}:面状控件族内容色要按色板合成" + ); + assert_eq!( + disabled_button_content(&ui, Role::OnSurfaceVariant, true), + to_iced( + board + .get(Role::OnSurfaceVariant) + .with_alpha(DISABLED_CONTENT_ALPHA) + .over( + board + .get(Role::OnSurface) + .with_alpha(DISABLED_CONTAINER_ALPHA) + .over(board.get(Role::Surface)) + ) + ), + "{flavor:?}:按钮族内容色要按色板合成" + ); + + // ④ **色相判据**(像素级的自证):Ink 板的禁用容器是绿系(G 最高),baseline 是蓝系(B 最高)。 + // Ink 板:`on-surface` 与 `surface` 的 G 通道都高于 B 通道(两个中性角色都是绿黑), + // 合成后仍是 G ≥ B;baseline 深色则是 B > G(天蓝中性)。浅色板上两者都是近中性, + // 所以只在深色档下判色相。 + if matches!(flavor, SchemeFlavor::Dark) { + let (r, g, b) = channels(surface); + assert!( + g >= b, + "{flavor:?}:禁用容器 {r}/{g}/{b} 仍是蓝系(G < B),说明没走 Ink 板" + ); + let (_, bg, bb) = channels(baseline_surface); + assert!(bb > bg, "对照组不合格:baseline 深色的禁用容器应当是蓝系"); + } + } +} + +/// iced 颜色 → 8bit 通道(像素值,报告里的证据就用它)。 +fn channels(color: Color) -> (u8, u8, u8) { + ( + (color.r * 255.0).round() as u8, + (color.g * 255.0).round() as u8, + (color.b * 255.0).round() as u8, + ) +} diff --git a/crates/app-iced/src/components/selection/checkbox.rs b/crates/app-iced/src/components/selection/checkbox.rs index 41a7d69..0bea349 100644 --- a/crates/app-iced/src/components/selection/checkbox.rs +++ b/crates/app-iced/src/components/selection/checkbox.rs @@ -1,14 +1,19 @@ -//! Checkbox(`research/15` §18):20×20 / 圆角 2 / 描边向内收的 2px 框 / 勾形 12dp 方头 / -//! indeterminate 形变为居中短横 / Ø40 状态层 / 48 命中区。 +//! Checkbox(`research/15` §18 + 规格卡):**18×18 / 圆角 4** / 描边向内收的 2px 框 / +//! 勾形 10.8dp 方头(路径比例 0.6 × 边长)/ indeterminate 形变为居中短横 / Ø40 状态层 / 48 命中区。 +//! +//! 尺寸口径(M229):官方 `Checkbox.kt` 画 20×20 / rx2,规格卡画 `rect 18×18 rx4`,本实现取规格卡 +//! (`inkcat::metrics::CHECKBOX`)。勾形**比例不变**(0.2→0.8 的横向跨度),所以墨水跨度随边长从 +//! 12dp 变成 10.8dp。 //! //! ## 三条容易画错的(每条都有对应的像素断言或单测) //! -//! 1. **描边向内**:外缘恒 20(§18.3)。iced 的 quad 渲染器把描边路径**向内缩半个线宽** -//! (`iced_tiny_skia::engine::draw_quad`:`border_bounds + border_width / 2`),与 Compose -//! `drawBox` 的 `topLeft = stroke/2, size = 20 − stroke` 同一口径,所以选中/未选中外缘同尺寸。 +//! 1. **描边向内**:外缘恒 **18**(§18.3 的尺寸换成本项目的 18)。iced 的 quad 渲染器把描边路径 +//! **向内缩半个线宽**(`iced_tiny_skia::engine::draw_quad`:`border_bounds + border_width / 2`), +//! 与 Compose `drawBox` 的 `topLeft = stroke/2, size = side − stroke` 同一口径,所以 +//! 选中/未选中外缘同尺寸。 //! 2. **勾不是图标**:勾形按路径比例画(`shell::glyph`),代码里**没有第二个 `IconKind`**(§18.6)。 //! 3. **状态层是 Ø40 的圆**、命中区是 48:两者不同尺寸,所以外壳是透明的 48 按钮, -//! 里面套一个 40 的圆容器,再套 20 的方框(见 `shell` 的模块文档)。 +//! 里面套一个 40 的圆容器,再套 18 的方框(见 `shell` 的模块文档)。 use iced::widget::{container, row, text}; use iced::{Alignment, Element, Length, Padding}; diff --git a/crates/app-iced/src/components/selection/geometry.rs b/crates/app-iced/src/components/selection/geometry.rs index 33de7d8..26f0581 100644 --- a/crates/app-iced/src/components/selection/geometry.rs +++ b/crates/app-iced/src/components/selection/geometry.rs @@ -4,12 +4,12 @@ //! //! | # | 量 | 参考值 | 来源 | 落点 | //! |---|---|---|---|---| -//! | 18.1 | Checkbox 外框 / 圆角 | **20×20** / **2** | `[源]` `Checkbox.kt:737-738`(`COMPONENT_WIDTH/HEIGHT=18` 是死值,见 §26.2) | `Component::Checkbox.metrics()` + `ComponentRadius::Micro` | -//! | 18.2 | Checkbox 描边 | **2**(选中时 0) | `[源]` `UnselectedOutlineWidth` | `ComponentMetrics::CHECKBOX.stroke` | +//! | 18.1 | Checkbox 外框 / 圆角 | 官方 **20×20** / **2**;**本项目取规格卡 18×18 / rx4** | `[源]` `Checkbox.kt:737-738` + 规格卡 `rect 18×18 rx4` | `inkcat::metrics::CHECKBOX` | +//! | 18.2 | Checkbox 描边 | **2**(选中时 0) | `[源]` `UnselectedOutlineWidth` / 规格卡 `stroke-width=2` | `inkcat::metrics::CHECKBOX_STROKE` | //! | 18.5 | Checkbox 状态层 | **Ø40** | `[源]` `StateLayerSize` | `ComponentMetrics::CHECKBOX.state_layer` | //! | 18.9 | 命中区 / 标签间距 | **48** / **8** | `[源]` `minimumInteractiveComponentSize` / 间距 `[估]` | `LayoutMetrics::touch_target` / `metrics.gap` | -//! | 19.1 | Radio 外圈 / 环线 | **20** / **2** | `[源]` `IconSize` / `RadioStrokeWidth` | `Component::Radio.metrics()` | -//! | 19.2 | Radio 内点 | **10**(不是 12) | `[源]` `DotSize=12`,绘制半径 12/2−2/2=5 | `ComponentMetrics::RADIO.inner_dot` | +//! | 19.1 | Radio 外圈 / 环线 | 官方 **20** / **2**;**本项目取规格卡 18 Ø** / 2 | `[源]` `IconSize` / `RadioStrokeWidth` + 规格卡 `r=9` | `inkcat::metrics::RADIO` | +//! | 19.2 | Radio 内点 | **10**(不是 12) | `[源]` `DotSize=12`,绘制半径 12/2−2/2=5;规格卡 `r=5` 同为 10 Ø | `inkcat::metrics::RADIO_DOT` | //! | 10.1 | Switch 轨道 | **52×32**,圆角 full(16) | `[源]` `TrackWidth/TrackHeight/TrackShape` | [`SwitchGeometry`](**G16**) | //! | 10.2 | Switch 滑块 | 选中 **24** / 未选 **16** / 按下 **28** | `[源]` `Selected/Unselected/PressedHandleWidth` | [`SwitchGeometry`](**G16**) | //! | 13.1 | Chip 高 / 圆角 | **32** / **8** | `[源]` `ContainerHeight` / `ContainerShape=CornerSmall` | `Space::X8` + `Component::Chip.spec().shape` | diff --git a/crates/app-iced/src/components/selection/matrix.rs b/crates/app-iced/src/components/selection/matrix.rs index 5f44807..11273ed 100644 --- a/crates/app-iced/src/components/selection/matrix.rs +++ b/crates/app-iced/src/components/selection/matrix.rs @@ -53,8 +53,8 @@ pub fn heading_and_note<'a>(ui: &'a Ui) -> Element<'a, ()> { heading( ui, "selection · checkbox / radio / switch / chip", - "15 §18/§19 + 12b §10/§13:checkbox 20×20 圆角 2 描边 2(向内收)勾 12 方头 · \ - radio 外圈 20 环线 2 内点 10 · switch 轨道 52×32 滑块 24/16 · chip 高 32 圆角 8 · \ + "规格卡 + 15 §18/§19 + 12b §10/§13:checkbox **18×18 圆角 4** 描边 2(向内收)勾 10.8 方头 · \ + radio 外圈 **18** 环线 2 内点 10 · switch 轨道 52×32 滑块 24/16 · chip 高 32 圆角 8 · \ Ø40 状态层 · 命中 48", ) .map(|_| ()) diff --git a/crates/app-iced/src/components/selection/mod.rs b/crates/app-iced/src/components/selection/mod.rs index c6c15cc..82ed456 100644 --- a/crates/app-iced/src/components/selection/mod.rs +++ b/crates/app-iced/src/components/selection/mod.rs @@ -2,8 +2,8 @@ //! //! | 组件 | 参考章节 | 尺寸一句话 | //! |---|---|---| -//! | [`Checkbox`] | `research/15` §18 | 20×20 / 圆角 2 / 描边 2(向内收)/ 勾 12dp 方头 / Ø40 状态层 / 命中 48 | -//! | [`Radio`] | `research/15` §19 | 外圈 20 / 环线 2 / 内点 **10**(不是 12)/ Ø40 状态层 | +//! | [`Checkbox`] | `research/15` §18 + 规格卡 | **18×18** / 圆角 **4** / 描边 2(向内收)/ 勾 10.8dp 方头 / Ø40 状态层 / 命中 48 | +//! | [`Radio`] | `research/15` §19 + 规格卡 | 外圈 **18** / 环线 2 / 内点 **10**(不是 12)/ Ø40 状态层 | //! | [`Switch`] | `research/12b` §10 | 轨道 52×32 / 滑块 24·16 / Ø40 状态层 | //! | [`Chip`] | `research/12b` §13 | 高 32 / 圆角 8 / 内边距 8 / Assist·Filter·Input | //! diff --git a/crates/app-iced/src/components/selection/pixel.rs b/crates/app-iced/src/components/selection/pixel.rs index d5528ab..b22f7dd 100644 --- a/crates/app-iced/src/components/selection/pixel.rs +++ b/crates/app-iced/src/components/selection/pixel.rs @@ -134,7 +134,7 @@ fn on_ramp(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> bool { /// /// 踩过的两个坑:① 按「精确色」连通会把圆形的环切成碎片(每片面积很小,量不到内点); /// ② 按「更接近 fill 还是底色」判会让**另一种深色墨**混进来(`OnSurfaceVariant` 比 `Primary` -/// 更接近深色,于是未选中的框被当成 20×20 的 Primary 块)。所以判据是「落在混色线上」。 +/// 更接近深色,于是未选中的框被当成 18×18 的 Primary 块)。所以判据是「落在混色线上」。 pub(super) fn ink_boxes( shot: &Shot, color: Color, diff --git a/crates/app-iced/src/components/selection/probe.rs b/crates/app-iced/src/components/selection/probe.rs index 7a7e697..a10ac73 100644 --- a/crates/app-iced/src/components/selection/probe.rs +++ b/crates/app-iced/src/components/selection/probe.rs @@ -72,7 +72,7 @@ fn selection_geometry_matches_the_reference() { // ---- §18.1 / §18.3 / §19.1:20 的外缘,选中与未选中**同尺寸** ----------------- // 逐块断言(复审 P2-4):**每一块** Primary 都必须是 20(±1)——任何一块到 22 就说明描边 - // 画到了框外。只数「20×20 有几块」的时候,「把其中一格放大成 22」照样绿。 + // 画到了框外。只数「18×18 有几块」的时候,「把其中一格放大成 22」照样绿。 let filled = ink_boxes(&shot, primary, surface, 15, 24); for bbox in &filled { assert!( @@ -310,7 +310,7 @@ fn selection_geometry_matches_the_reference() { .collect(); assert!( container_20.is_empty(), - "P2-3:20×20 的禁用块不许是 12% 容器色(那是普通禁用容器,选中禁用是 38%),实测 {}", + "P2-3:18×18 的禁用块不许是 12% 容器色(那是普通禁用容器,选中禁用是 38%),实测 {}", dims(&container_20) ); diff --git a/crates/app-iced/src/components/selection/radio.rs b/crates/app-iced/src/components/selection/radio.rs index 64680cd..aa026ba 100644 --- a/crates/app-iced/src/components/selection/radio.rs +++ b/crates/app-iced/src/components/selection/radio.rs @@ -1,17 +1,18 @@ -//! Radio button(`research/15` §19):外圈 **20** / 环线 **2** / 内点 **10**(不是 12)/ +//! Radio button(`research/15` §19 + 规格卡):外圈 **18** / 环线 **2** / 内点 **10**(不是 12)/ //! 选中环+点 `Primary` / 未选中环 `OnSurfaceVariant`(hover 提到 `OnSurface`)/ Ø40 状态层。 //! //! ## 为什么它不是「基于 Checkbox 画的」 //! //! §19.6 明令:Radio **只能**是「2px 环 + 实心内点」,Checkbox **只能**是「2px 方框 + 勾/横」; -//! 两者尺寸相同(20)但形状语言不同,禁止混用。所以本文件是独立实现,**没有** import +//! 两者尺寸相同(**18**)但形状语言不同,禁止混用。所以本文件是独立实现,**没有** import //! `super::checkbox`(gallery 的 probe 另有一条断言:radio 的墨迹里不得出现方角像素)。 //! //! ## 几何怎么落到像素 //! -//! 环:20×20 容器 + `Border { width: 2, radius: 10 }`。iced 的 quad 渲染器把描边路径向内缩 -//! 半个线宽(半径 10−1=9、路径外接 18),再以 2 描边 → 外缘正好 20、内缘 16。这与 Compose -//! `RadioButton.kt` 的「环半径 = 10 − 1 = 9 且描边居中 → 外缘正好 20」逐像素同源。 +//! 环:**18×18** 容器 + `Border { width: 2, radius: 9 }`。iced 的 quad 渲染器把描边路径向内缩 +//! 半个线宽(半径 9−1=8、路径外接 16),再以 2 描边 → 外缘正好 18、内缘 14。这与 Compose +//! `RadioButton.kt` 的「环半径 = side/2 − 1 且描边居中 → 外缘正好 side」逐像素同源, +//! 只看 side 取 18(规格卡 `r=9`)而不是官方的 20。 //! 内点:独立 10×10 圆容器居中在环里(绘制半径 12/2 − 2/2 = 5,即**直径 10**,§19.2)。 use iced::widget::{container, row, text}; diff --git a/crates/app-iced/src/components/selection/tests.rs b/crates/app-iced/src/components/selection/tests.rs index a0fa6c1..6eed39b 100644 --- a/crates/app-iced/src/components/selection/tests.rs +++ b/crates/app-iced/src/components/selection/tests.rs @@ -67,7 +67,7 @@ fn checkbox_geometry_comes_from_the_token_layer() { ); } -/// §19.1 / §19.2:Radio 外圈 20、环线 2、**内点 10 不是 12**、真圆。 +/// §19.1 / §19.2:Radio 外圈 **18**(规格卡)、环线 2、**内点 10 不是 12**、真圆。 #[test] fn radio_inner_dot_is_ten_not_twelve() { let ui = ui(); diff --git a/crates/app-iced/src/theme.rs b/crates/app-iced/src/theme.rs index 35edc95..ab4f3ae 100644 --- a/crates/app-iced/src/theme.rs +++ b/crates/app-iced/src/theme.rs @@ -149,6 +149,19 @@ impl Ui { to_iced(self.scheme.get(role)) } + /// 当前生效的 **49 角色方案**(品牌色板 / D38 取色方案,二选一的那一份)。 + /// + /// 凡是要**在令牌侧合成颜色**的地方(禁用态 38% 叠底、对话框遮罩 scrim、状态层叠色)都必须 + /// 从这里取,**不许**再 `Scheme::baseline(self.flavor())` 重算: + /// + /// - 重算出来的永远是 tone 派生的旧色(天蓝中性),品牌板上的禁用滑块轨道、填充框、遮罩会 + /// 各画各的(M229 复审 F1 实测:Ink 下三个蓝系像素值,应为绿系去饱和、遮罩为中性 + /// 绿黑;具体数值与出处见 `DevDocs/artifacts/m229/README.md` §6)。 + /// - 取色方案(D38)成功时也必须同源,否则「取色接管 49 个角色」这件事在禁用态上破一个洞。 + pub fn scheme(&self) -> &Scheme { + &self.scheme + } + /// 材质层(Canvas/Card/Overlay)→ 容器色;**只有令牌允许半透明的层才乘面板 α**(M93 唯一来源)。 /// /// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片), From 6ed26626dbc6b8dfeb03bed51ae492dcbf161e5e Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 23:59:07 +0800 Subject: [PATCH 4/7] =?UTF-8?q?docs(app-iced):=20=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=E7=A6=81=E7=94=A8=E6=BB=91=E5=9D=97=E6=BF=80=E6=B4=BB=E8=BD=A8?= =?UTF-8?q?=2038%=20=E4=B8=BA=20Pixel=20=E7=BA=A7=E5=81=8F=E7=A6=BB=20#9?= =?UTF-8?q?=EF=BC=88M229=20=E5=A4=8D=E5=AE=A1=20r3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复审核对出 artifacts README 的归因错误:`page-settings-g1-dark.png` 里禁用滑块轨是 `on-surface@38% over surface`(轨带 6030 px),不是面状控件族的 12%;上一版引用的 7 px 12% 小簇在 Image path 字段标签附近,与滑轨无关。 按塔裁决**不改实现**:规格卡只定义了面状控件族(chip 描边 / switch 轨 / 字段底 / 滑轨未激活段)的 12%,而禁用**激活轨**沿用 M3 `SliderTokens.DisabledActiveTrack` 的 38% —— 两者是两个族。 登记写在本文件参考值表下方(`DevDocs/` 被 gitignore,README 里的同一段无法入库,故以代码为准)。 --- crates/app-iced/src/components/inputs/slider.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/app-iced/src/components/inputs/slider.rs b/crates/app-iced/src/components/inputs/slider.rs index 91449a2..dccf756 100644 --- a/crates/app-iced/src/components/inputs/slider.rs +++ b/crates/app-iced/src/components/inputs/slider.rs @@ -12,6 +12,9 @@ //! | 11.6 | 禁用 | 激活轨 `OnSurface`@**38%**、未激活 @**12%**、handle @**38%** | `interaction::*` | //! | — | 离散停靠点 | **4** 圆点 `SecondaryContainer`(`StopIndicatorSize`);尾随间距 6 | [`Slider::steps`] | //! +//! **Pixel 级偏离 #9(M229 r3,塔裁决不改实现)**:禁用**激活轨**沿用 M3 的 `DisabledActiveTrack` **38%** +//! (规格卡只定义了面状控件族的 12%;两者是两个族,不是同一档的两个名字,取证见 artifacts README §6)。 +//! //! ## 为什么必须自绘 //! //! iced 内置 `slider` 只能画「轨道 + 圆形/矩形 handle(高度 = 控件高)」, From 8958b7f67d584e966fe3693a80dd6b124a718583 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 00:48:23 +0800 Subject: [PATCH 5/7] =?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::*; From f5a2610e4d1d6e0ce0b06229cf10ffc5dea84aa5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 01:21:33 +0800 Subject: [PATCH 6/7] =?UTF-8?q?docs(app-iced):=20=E5=80=BC=E5=AD=97?= =?UTF-8?q?=E7=BA=A7=E6=B3=A8=E9=87=8A=E6=94=B9=E7=9C=9F=20+=20probe=20?= =?UTF-8?q?=E5=8F=AA=E8=AE=A4=20buttons/divider=20+=20=E6=88=AA=E5=9B=BE?= =?UTF-8?q?=E4=B8=BB=E9=A2=98=E9=92=89=E6=AD=BB=E5=9B=9E=E5=BD=92=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=EF=BC=88M229=20=E5=A4=8D=E5=AE=A1=20r2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) P1-2 的注释值与量测对齐(四处):`elevated.rs` 的 `PAD`/`Texts::title`/`texts()` 与 `pages/instances/detail.rs` 还写着「值 14/400」,而实现是 12/400;`PAD` 文档更把 12px 的 118px 量测挂在「14px 下」。全部改成真值,并把 14px=138px 与 12px=118px 两个量测并列写清。 2) probe 门禁只解决一半:`SECTION=buttons` 绿、`SECTION=all` 仍红(sizes 段的实心块撞「按钮 40 高」判据),断言文案却写「all 或 buttons」。现在: - 只支持 `buttons`(按钮矩阵,实心块判据**只量矩阵区**,后续段不再参与)与 `divider` (分隔线两条判据,独立出口); - `all` / 其它 / 未给 → 明确失败并给理由(整页比 1180×760 高,分隔线段与按钮矩阵不可能同屏; 整页图的 chip/卡片会被当按钮量); - 模块文档的复现命令补上 `ULL_GALLERY_SECTION`(以前照着跑会 panic); - `gallery/mod.rs` 的「五段」→ 七段;`gallery/buttons.rs` 的「8 行 + icon·standard」→ 9 行 + 四式。 3) 截图通道的主题档钉死此前没有回归测试(复审 r2 P2-a)。新增 `the_screenshot_channel_pins_the_theme_mode_instead_of_reading_the_file`:两份不同的机器配置 + 同一组截图参数 ⇒ 同一个档位且等于参数;反证非截图通道下两份配置确实分出两档。 变异自证:把分支改回 `bridge.load().1` → 该测试红(left: Dark, right: Light)。 门禁:fmt --check 0 / check -p unlucky-app-iced --all-targets 0 / test -p unlucky-app-iced 0 (887 passed + 6 integration)/ clippy -p unlucky-app-iced --all-targets -D warnings 0 / 400 行扫描干净。 probe 亲跑:buttons 浅+深两张 sheet OK,divider 一张 OK,`all` 按预期被拒。 --- crates/app-iced/src/app/tests/theme.rs | 101 +++++++++++++ .../src/components/gallery/buttons.rs | 11 +- crates/app-iced/src/components/gallery/mod.rs | 11 +- .../app-iced/src/components/gallery/probe.rs | 139 +++++++++++------- .../src/components/gallery/probe/rows.rs | 2 +- .../src/components/surfaces/elevated.rs | 7 +- crates/app-iced/src/pages/instances/detail.rs | 3 +- 7 files changed, 205 insertions(+), 69 deletions(-) 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, From 36bac74822329440411a65b11284813c934e5c77 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 11 Nov 2026 01:39:32 +0800 Subject: [PATCH 7/7] =?UTF-8?q?docs(app-iced):=20probe=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E6=96=87=E6=A1=A3=E7=9A=84=E5=A4=8D=E7=8E=B0=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=A5=20SECTION=EF=BC=88M229=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20r3=20P2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r5 报告说「模块文档复现命令已补 `ULL_GALLERY_SECTION`」,但 diff 只碰了 `shot_path()` 与 测试体——模块文档 9-15 行一个字没改,照抄那条命令仍 exit 101(panic 报只支持 buttons/divider)。 这轮补齐: - 模块文档复现命令改成两组(`ULL_GALLERY_SECTION=buttons` / `=divider`),产物路径用 `${CARGO_TARGET_DIR:-target}`(本仓一树一 target,旧命令的 `./target/debug` 在 worktree 里根本 没有 exe),并写明 `all` 不支持及其理由; - `shot_path()` 的文档改真:它只读 `ULL_GALLERY_SHOT`,「这张图属于哪一段」由测试体开头那条 assert 管(assert 早已搬走,且现在也认 divider)——原文还写着「此处不满足就失败」; - 收掉 assert 文案里的九连空格接缝; - 矩阵带过滤的注释从「`all` 默认会画全段」改成「buttons 段没有后续段,过滤是防御性的」。 自证:按文档两组命令实跑,四条命令全部 exit 0;`grep -n ULL_GALLERY_SECTION probe.rs` 命中 8/15/17/21/23(模块文档)与 144/150/357(实现)。 --- .../app-iced/src/components/gallery/probe.rs | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/crates/app-iced/src/components/gallery/probe.rs b/crates/app-iced/src/components/gallery/probe.rs index f948b02..2ade179 100644 --- a/crates/app-iced/src/components/gallery/probe.rs +++ b/crates/app-iced/src/components/gallery/probe.rs @@ -5,15 +5,28 @@ //! CI 没有桌面,所以这里不自己开窗:先用 `--screen=gallery --screenshot` 出真窗截图,本测试读 //! 那张 PNG 量几何、与令牌逐项比对。**没给图就跳过并大声说出来**(不静默通过)。 //! -//! 复现命令(在 worktree 根目录): +//! 复现命令(在 worktree 根目录;**必须同时给 `ULL_GALLERY_SECTION`**,只支持 `buttons` / `divider`): //! //! ```text -//! cargo build -p unlucky-app-iced --bin unlucky-launcher-iced -//! ./target/debug/unlucky-launcher-iced --software-renderer --screen=gallery \ -//! --screenshot target/m75/gallery-light.png --screenshot-theme=light -//! ULL_GALLERY_SHOT=target/m75/gallery-light.png cargo test -p unlucky-app-iced gallery::probe +//! EXE=${CARGO_TARGET_DIR:-target}/debug/unlucky-launcher-iced # 本仓纪律:一树一 target +//! OUT=${CARGO_TARGET_DIR:-target}/m229-probe && mkdir -p "$OUT" +//! +//! # ① 按钮矩阵(实心块 / 药丸 / 字形 / 逐行禁用色 / Outlined 环) +//! ULL_GALLERY_SECTION=buttons $EXE --software-renderer --screen=gallery \ +//! --screenshot "$OUT/buttons-light.png" --screenshot-theme=light +//! ULL_GALLERY_SECTION=buttons ULL_GALLERY_SHOT="$OUT/buttons-light.png" \ +//! cargo test -p unlucky-app-iced gallery::probe +//! +//! # ② 分隔线(`buttons` 段里没画线,必须单独出一张) +//! ULL_GALLERY_SECTION=divider $EXE --software-renderer --screen=gallery \ +//! --screenshot "$OUT/divider-light.png" --screenshot-theme=light +//! ULL_GALLERY_SECTION=divider ULL_GALLERY_SHOT="$OUT/divider-light.png" \ +//! cargo test -p unlucky-app-iced gallery::probe //! ``` //! +//! `all` **不支持**:gallery 整页比 1180×760 的视口高,分隔线段与按钮矩阵不可能同屏,而 sizes 段的 +//! 实心块又会被「按钮 40 高」的判据量到 —— 所以按段出图、按段断言,`all` 会被明确拒绝(见测试体)。 +//! //! 量什么(每条对一个已发生过的错误):实心块高 **40**(12b 1.1)· 药丸顶行**左内缩 ≈14** ⇒ 圆角 full=20 //! (12b 1.2,8 圆角会接近通栏)· IconButton **40×40**(12b 2.1)· 字形墨迹 **16~26** //! (Q9:24 盒被填满会到 40)· 分隔线 **1px**(22.1)· 禁用容器 **on-surface@10%**(按钮族)存在(12b 1.8)。 @@ -86,10 +99,9 @@ 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`,不满足就明确失败——「跳过」会把「指错了图」变成静默通过。 +/// 只把 `ULL_GALLERY_SHOT` 读成路径;**「这张图属于哪一段」由测试体开头那条 assert 管** +/// (`buttons` / `divider` 之外一律失败),这里不做校验——图与判据的配对是测试的前提条件, +/// 放在测试里读起来才连贯(复审 r3 P2-1:这段文档曾写「此处不满足就失败」,而 assert 早已搬走)。 fn shot_path() -> Option { let raw = std::env::var_os("ULL_GALLERY_SHOT")?; let path = std::path::PathBuf::from(raw); @@ -127,24 +139,15 @@ 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 与卡片会被当成按钮量, - // 那是误导性的红,不是缺陷。 + // 只认两种 sheet(`all` 与整页图都拒绝,理由见 [`shot_path`] 与模块文档): + // `buttons` = 按钮矩阵的主判据;`divider` = 分隔线的两条判据(1px / 墨量覆盖率)。 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` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。" + "这份 probe 只支持 `ULL_GALLERY_SECTION=buttons`(按钮矩阵)或 `divider`(分隔线);当前 {section:?}。`all` 不行:整页比视口高,分隔线段与按钮矩阵不可能同屏。" ); // 分隔线那段是**独立出口**:`divider` 段的 sheet 里没有按钮矩阵,判据也不同。 if section == "divider" { @@ -160,18 +163,13 @@ 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 余量。 + // **只判「按钮矩阵那一段」**:矩阵区由「第一块文字按钮(宽 > 60、高 40)」的顶边起、往下 9 行 + // + 8px。`buttons` 段的 sheet 里没有后续段,所以这条过滤是防御性的(`all` 现在被拒;将来若允许 + // 拼接多段,它保证判据只落在矩阵上,不会被别段的 chip/卡片撞到)。 let matrix_top = primary .iter() - .filter(|bbox| { - bbox.area() >= 800 && bbox.width() > 60 && (39..=41).contains(&bbox.height()) - }) - .map(|bbox| bbox.top) + .filter(|b| b.area() >= 800 && b.width() > 60 && (39..=41).contains(&b.height())) + .map(|b| b.top) .min() .expect("没找到文字按钮的实心块(矩阵不在画面里?)"); let matrix_bottom = matrix_top + rows::ROW_COUNT * rows::ROW_STEP + 8;