feat(app-iced): Ink Cat / Silver Cat 换肤 —— 双色板默认 + 规格卡组件几何(M229)

产品出厂外观从 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,不进提交)。
This commit is contained in:
AskaEth committed 2026-11-11 01:49:13 +08:00
1 parent a06b7ff4ec
commit db541c820e
44 files changed
+1439 -300

No files matched your search

@@ -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);
@@ -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<M>,
state: Option<Interaction>,
}
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::<M>(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;
@@ -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);
}
@@ -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,
}
}
}
+38 -9
View File
@@ -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>, Role, Option<Role>) {
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<IconKind>,
trailing: Option<IconKind>,
state: Option<Interaction>,
/// 尺寸档(规格卡 `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);
@@ -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");
@@ -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` |
@@ -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(),
}
}
@@ -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)
@@ -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,
@@ -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, "上下限必须有区间");
@@ -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(
+13 -3
View File
@@ -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<Self> {
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));
}
@@ -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::<Vec<_>>())
.spacing(ui.space(Space::X4))
.align_y(iced::Alignment::Center);
let fabs = row(FabSize::ALL
.iter()
.map(|size| fab_cell(ui, *size))
.collect::<Vec<_>>())
.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()
}
+2
View File
@@ -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;
@@ -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,
}
}
@@ -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,
@@ -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>>) -> 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;
@@ -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()
);
}
}
@@ -0,0 +1,7 @@
//! 面类家族(目前只有 **ELEVATED CARD**,规格卡 `CARD / DIALOG / SNACKBAR / LIST` 的第一格)。
//!
//! 为什么单开一个家族而不是塞进 `feedback`:`feedback` 是「系统对用户动作的回应」(进度 / 对话框 /
//! snackbar / 空态),卡是**静态容器**,语义上属于版式而不是反馈。家族边界写在这里,后来加
//! 「带描边卡 / 列表行药丸」时按语义归位,不要因为「都在同一个规格卡小节里」就合进 `feedback`。
pub mod elevated;