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:
1 parent
a06b7ff4ec
commit
db541c820e
44 files changed
+1439
-300
No files matched your search
+70
-154
@@ -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;
|
||||
Reference in new issue
Block a user