From 4ce24d6fbd430106c2293a35f0f02968a7a8d9a6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 10 Nov 2026 23:42:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(app-iced):=20=E7=A6=81=E7=94=A8=E6=80=81?= =?UTF-8?q?=E4=B8=8E=E9=81=AE=E7=BD=A9=E6=94=B9=E4=BB=8E=E5=93=81=E7=89=8C?= =?UTF-8?q?=E6=9D=BF=E5=90=88=E6=88=90=EF=BC=88M229=20=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20F1=EF=BC=89+=20=E9=80=89=E6=8B=A9=E7=B1=BB=E5=9B=BE=E6=B3=A8?= =?UTF-8?q?=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 唯一来源)。 /// /// α 乘在这里而不是调用点:所有表面色都从这里出去(根容器/标题栏/导航轨/页头带/卡片),