fix(app-iced): M75 r3 —— 禁用内容角色按 A1 改 OnSurfaceVariant + 两项语汇 + probe 双色护栏

A1 定案(本机 gradle 缓存 material3.aar + javap):
  FilledButtonTokens.<clinit>: DisabledContainerColor=OnSurface, DisabledContainerOpacity=0.1f
    DisabledIconColor/DisabledLabelTextColor=OnSurfaceVariant, 两者 Opacity=0.38f
  OutlinedButtonTokens/TextButtonTokens: LabelTextColor=OnSurfaceVariant(12b §1.9 原本就对)

据此:
- interaction::disabled_content_over / disabled_colors 的内容角色从 OnSurface 改成
  OnSurfaceVariant(α 仍取令牌 DISABLED_CONTENT_ALPHA=0.38);设计层 color::state::disabled_content()
  用的是 OnSurface,与 A1 不符 —— 令牌线修好前这里按 A1 直接求值,已登记。
- content_color 的「底」按有无容器分开:有容器叠 12% 容器、无容器叠 surface(视觉 r2 P2-1)。
- probe 加第二条护栏:无容器三式的 38% 墨迹单独计数,避免只护住有容器那四式(code r2 p2-1)。
- gallery 样例图标改描边(Download + ChevronRight),并加单测钉「同控件不混用 Fill/Stroke」(P2-3)。
- 滚动条:透明轨 + OutlineVariant thumb(P2-4;宽度 10px 是 iced 内部常量,只能改颜色)。
- probe 文档修好被 //! 打断的命令、shot_path 注释归位、去掉空转的重复断言(P2-5)。
This commit is contained in:
AskaEth committed 2026-11-02 04:50:54 +08:00
1 parent d075feb77d
commit 5fd1f3ca26
7 files changed
+118 -22

No files matched your search

@@ -68,7 +68,7 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
// 早先这里恒取 `ui.color(content)`,再被 `text(...).color(..)` 钉死,`Style.text_color`
// 里的 38% 永远下发不出去 —— 表现为「text / standard icon 的 enabled 格与 disabled 格
// 逐像素相同」(视觉 reviewer 实测 changed_px = 0;filled 禁用标签 1.23:1)。
let ink = content_color(ui, forced, content);
let ink = content_color(ui, forced, content, fill);
let label = text(spec.label)
.size(ui.style(TypeRole::LabelLarge).size_px)
@@ -132,9 +132,19 @@ pub(super) fn assemble<'a, M: Clone + 'a>(
///
/// 单独成函数是为了让「内容色跟态走」这件事只有一处实现——`assemble`(文字按钮)与 `icon`
/// (图标按钮)都调它,两条路径不会再各自钉死满档色。
pub(super) fn content_color(ui: &Ui, forced: Option<Interaction>, content: Role) -> iced::Color {
pub(super) fn content_color(
ui: &Ui,
forced: Option<Interaction>,
content: Role,
fill: Option<Role>,
) -> iced::Color {
match forced {
Some(Interaction::Disabled) => interaction::disabled_colors(ui, Role::Surface).1,
// 有容器:38% 叠在**12% 容器**上(Filled / Tonal / Elevated)
Some(Interaction::Disabled) if fill.is_some() => {
interaction::disabled_colors(ui, Role::Surface).1
}
// 无容器:38% 直接叠在 **surface** 上(Text / Outlined / standard IconButton)
Some(Interaction::Disabled) => interaction::disabled_content_over(ui, Role::Surface),
_ => ui.color(content),
}
}
@@ -269,6 +269,11 @@ pub fn text<'a, M: Clone + 'a>(ui: &'a Ui, button: Button<'a, M>) -> Element<'a,
/// IconButton(standard / filled / tonal):40×40、图标 24、命中区 48。
///
/// **图标语汇纪律(视觉 reviewer P2-3)**:同一个控件里**不混用**填充字形(`IconPaint::Fill`,
/// 如 `Play`/`Stop`/`More`)与描边字形(`Stroke`)——混着放会让线宽看起来忽粗忽细。
/// 允许实心字形,但只能出现在「主操作」这一处(例如播放按钮),其余位置一律描边;
/// gallery 的样例因此统一用描边图标(`Download` + `ChevronRight`),并有单测钉住。
///
/// `on_press: Option<M>`(`None` = 禁用)与 `state` 分开给:前者是语义,后者是取证开关。
pub fn icon<'a, M: Clone + 'a>(
ui: &'a Ui,
@@ -290,7 +295,7 @@ pub fn icon<'a, M: Clone + 'a>(
// 这里把盒子做成整个 40×40(而不是 24×24):`button::layout` 把内容放左上角、不居中,
// 所以「居中」这件事必须由容器自己写死,别指望 button。
// 内容色跟态走(禁用 = on-surface@38%),见 `assemble::content_color` 的说明
let glyph = assemble::content_color(ui, forced, content);
let glyph = assemble::content_color(ui, forced, content, fill);
let icon_box =
layout::centered_fixed(geometry.side, geometry.side, icons::view::<M>(kind, glyph));
@@ -109,6 +109,27 @@ fn icon_button_geometry_matches_the_reference() {
);
}
/// 视觉 reviewer P2-3:同一控件里不许混用填充字形与描边字形(线宽语汇会打架)。
///
/// gallery 的样例图标(前置/后置)必须同为 `IconPaint::Stroke`;允许实心字形,但只能作为
/// 「主操作」单独出场的图标(例如播放键),不能与描边图标同处一颗按钮。
#[test]
fn sample_icons_never_mix_fill_and_stroke() {
use unlucky_design::icons::IconPaint;
let sample = [IconKind::Download, IconKind::ChevronRight];
for kind in sample {
assert_eq!(
kind.icon().paint,
IconPaint::Stroke,
"{} 是填充字形,不能和描边图标混在同一颗按钮里",
kind.name()
);
}
// 已知的实心字形(留档,下次有人拿它们当按钮图标时这条会提醒)
assert_eq!(IconKind::Play.icon().paint, IconPaint::Fill);
}
#[test]
fn five_variants_have_distinct_colour_roles() {
let mut seen = Vec::new();
@@ -141,8 +141,10 @@ fn text_button<'a>(ui: &'a Ui, variant: Variant, state: Interaction) -> Element<
}
.state(state);
if variant != Variant::Text {
// 两个样例都必须是**描边**字形:`Play` 是填充字形,和 `ChevronRight` 混在一颗按钮里
// 会显得线宽不一致(视觉 reviewer P2-3)。主操作允许实心,但只能单独出现。
spec = spec
.leading(IconKind::Play)
.leading(IconKind::Download)
.trailing(IconKind::ChevronRight);
}
+23 -1
View File
@@ -41,6 +41,7 @@ use iced::{Alignment, Background, Element, Length};
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use unlucky_design::shape::ShapeScale;
use unlucky_design::spacing::Space;
use unlucky_design::typography::TypeRole;
@@ -110,9 +111,30 @@ pub fn view<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
.spacing(ui.space(Space::X8))
.width(Length::Fill);
// 滚动条:默认是主题给的 10px 深灰 thumb(`#6A7985`),压过组件本体(视觉 reviewer P2-4)。
// 这里把它改成 **透明轨 + OutlineVariant thumb**;宽度 10px 是 `scrollable` 的内部常量
// (该控件不暴露 Scrollbar 配置),所以只做了「低对比」这一半,已登记为偏离 D7。
let body = scrollable(sections)
.height(Length::Fill)
.width(Length::Fill);
.width(Length::Fill)
.style(move |theme, status| {
// `scrollable::Style` 没有 `Default`:底色/间隙从主题默认值出发,只覆盖竖轨
let base = scrollable::default(theme, status);
scrollable::Style {
vertical_rail: scrollable::Rail {
background: None,
border: iced::Border::default(),
scroller: scrollable::Scroller {
background: Background::Color(ui.color(Role::OutlineVariant)),
border: iced::Border {
radius: ui.radius(ShapeScale::Full, ui.space(Space::X2)),
..iced::Border::default()
},
},
},
..base
}
});
// 页面留白走 `layout::inset`(左右/上下各 24):这是 Q8/Q9 那两个 helper 的第三个兄弟,
// 存在意义是「只改内边距、不改尺寸」,也顺手避免把 Padding 的上右下左写反。
+21 -11
View File
@@ -9,7 +9,8 @@
//!
//! ```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
//! ./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
//! ```
//!
@@ -34,7 +35,6 @@ fn ui(flavor: SchemeFlavor) -> Ui {
Ui::with_platform(flavor, Platform::Windows, FAMILIES)
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
/// 一条 1dp 细线在竖直方向的「墨量覆盖率」:各行相对底色的不透明度之和(1dp ⇒ ≈1.0)。
///
/// **只统计「长横线行」**(连续非底色像素 > 900):标题/正文那些行也有几百个零散墨迹像素,
@@ -91,6 +91,7 @@ fn count_pixels(shot: &Shot, color: [u8; 3]) -> usize {
.sum()
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
fn shot_path() -> Option<std::path::PathBuf> {
let raw = std::env::var_os("ULL_GALLERY_SHOT")?;
let path = std::path::PathBuf::from(raw);
@@ -151,13 +152,12 @@ fn gallery_geometry_matches_the_tokens() {
if bbox.width() > 60 {
label_button = Some(*bbox);
}
if (39..=41).contains(&bbox.width()) {
// 12b 2.1:IconButton 容器 40×40
assert!(
(39..=41).contains(&bbox.width()),
"IconButton 应为 40×40,实测 {}×{}",
bbox.width(),
bbox.height()
// 12b 2.1:IconButton 容器精确 40×40(别再用区间——reviewer 已证 +2px 抓不住)
if bbox.width() < 60 {
assert_eq!(
(bbox.width(), bbox.height()),
(40, 40),
"IconButton 容器必须精确 40×40(12b 2.1)"
);
}
}
@@ -325,7 +325,16 @@ fn gallery_geometry_matches_the_tokens() {
let ink_px = count_pixels(&shot, rgb(disabled_ink));
assert!(
ink_px > 200,
"禁用内容色(on-surface@38%)在图上只出现 {ink_px} 像素: 说明 text / icon 的字形没走 38%(旧实现 enabled 与 disabled 逐像素相同)"
"**有容器**四式的禁用内容(OnSurfaceVariant@38% 叠 12% 容器)只出现 {ink_px} 像素: 说明 Filled/Tonal/Elevated 的字形没走 38%"
);
// **无容器**三式(Text / Outlined / standard IconButton)的禁用内容叠在 surface 上,
// 是另一个色值;单查上面那个只会覆盖到有容器的四式(reviewer code-r2 p2-1:护栏漏一半)。
let bare_ink = crate::app::components::interaction::disabled_content_over(&ui, Role::Surface);
let bare_px = count_pixels(&shot, rgb(bare_ink));
assert!(
bare_px > 200,
"**无容器**三式的禁用内容(OnSurfaceVariant@38% 叠 surface)只出现 {bare_px} 像素: 说明 Text/Outlined/standard 的字形没走 38%"
);
// filled 禁用容器里不许出现满档 OnPrimary(旧实现是 #FFFFFF 压在 12% 容器上 = 1.23:1)
@@ -352,7 +361,7 @@ fn gallery_geometry_matches_the_tokens() {
顶行左内缩 {}(r=20 应 ≈14;8 圆角 ≈4;直角 0)
实心块 {} 个(含 40×40 IconButton)
分隔线长行 {} 行(每条 1px)
禁用容器 (on-surface@12%) 命中 · 禁用内容 38% 墨迹 {} px
禁用容器 (on-surface@12%) 命中 · 禁用内容 38% 墨迹 {} px(有容器)/ {} px(无容器)
分隔线墨量覆盖率 {:.2}(1dp 应 ≈1.0)
字形墨迹 {}px(24 盒应 16~26)",
flavor,
@@ -362,6 +371,7 @@ fn gallery_geometry_matches_the_tokens() {
primary.len(),
runs,
ink_px,
bare_px,
coverage,
glyph_width
);
+31 -5
View File
@@ -33,7 +33,7 @@
use iced::widget::button;
use iced::{Color, Event};
use unlucky_design::color::state::{disabled_content, DISABLED_CONTAINER_ALPHA};
use unlucky_design::color::state::{DISABLED_CONTAINER_ALPHA, DISABLED_CONTENT_ALPHA};
use unlucky_design::color::{Role, Scheme, StateLayer};
use crate::theme::{to_iced, Ui};
@@ -114,10 +114,28 @@ pub fn overlay_color(ui: &Ui, state: Interaction, content: Role, container: Role
.map(|layer| ui.state_over(layer, content, container))
}
/// 禁用态内容色:`OnSurface` @ 38% 叠在 **`base` 角色**上(不经过 12% 容器)。
///
/// 给**不画容器**的三式(Text / Outlined / standard IconButton)用:它们的禁用内容直接落在
/// surface 上,若错用 [`disabled_colors`] 的内容值(叠在 12% 容器上),会比令牌口径深 21/255。
pub fn disabled_content_over(ui: &Ui, base: Role) -> Color {
let scheme = Scheme::baseline(ui.flavor());
// **A1 定案(AAR `FilledButtonTokens` javap)**:`DisabledLabelTextColor`/`DisabledIconColor`
// 都是 `ColorSchemeKeyTokens.OnSurfaceVariant`、`DisabledLabelTextOpacity = 0.38f`。
// 设计层 `color::state::disabled_content()` 用的是 `OnSurface`(与 A1 不符),
// 在令牌线修好之前,这里按 A1 直接用角色 + 令牌 α 求值(不引入字面量)。
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(scheme.get(base)),
)
}
/// 禁用态的一对颜色 `(容器, 内容)`,内容按令牌口径叠在**算好的容器**之上。
///
/// - 容器 = `OnSurface` @ 12%([`DISABLED_CONTAINER_ALPHA`])叠在 `surface` 上;
/// - 内容 = 令牌的 `disabled_content(&scheme, 容器)`(`OnSurface` @ 38%)。
/// - 内容 = `OnSurfaceVariant` @ 38%(A1:`FilledButtonTokens.DisabledLabelTextColor`)叠在容器上。
///
/// `ui` 不暴露 [`Scheme`],这里按同一个 flavor 求一次基线方案——调的是
/// `unlucky_design` 的**同一个**函数与同一套角色,不是第二份令牌。
@@ -131,7 +149,11 @@ pub fn disabled_colors(ui: &Ui, surface: Role) -> (Color, Color) {
.get(Role::OnSurface)
.with_alpha(DISABLED_CONTAINER_ALPHA)
.over(base);
let content = disabled_content(&scheme, container);
// 内容角色按 A1 用 `OnSurfaceVariant`(见 `disabled_content_over` 的说明)
let content = scheme
.get(Role::OnSurfaceVariant)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(container);
(to_iced(container), to_iced(content))
}
@@ -233,7 +255,6 @@ impl Tracker {
#[cfg(test)]
mod tests {
use super::*;
use unlucky_design::color::state::DISABLED_CONTENT_ALPHA;
use unlucky_design::color::SchemeFlavor;
use unlucky_design::typography::Platform;
@@ -335,7 +356,12 @@ mod tests {
);
assert_eq!(
content,
to_iced(disabled_content(&scheme, expected_container))
to_iced(
scheme
.get(Role::OnSurfaceVariant)
.with_alpha(DISABLED_CONTENT_ALPHA)
.over(expected_container)
),
);
assert_ne!(container, ui.color(Role::Surface), "12% 叠上去必须能看出来");
assert_ne!(content, ui.color(Role::OnSurface), "内容要降到 38%");