fix(iced/feedback): 复审 r3 的 A/B/D/E/F 项 + C 项待 rebase 后改 view_at

(先落一个中间提交,rebase main 之后再按 tower 的修正把空态图标改成 32/view_at)
This commit is contained in:
AskaEth committed 2026-11-02 15:56:46 +08:00
1 parent 5d9e75efd8
commit a4cdd61fe5
12 files changed
+325 -108

No files matched your search

@@ -188,8 +188,11 @@ pub fn dialog<'a, M: Clone + 'a>(ui: &'a Ui, spec: Dialog<'a, M>) -> Element<'a,
// 两列并排时每张卡渲染成 **548**(探针实测,见报告)。iced 没有 min-width,也没有在
// 「父级精确分配」下可用的 max-width ⇒ 与 Snackbar 的最小宽同一堵墙(G17-d 之二)。
// 所以:默认 480,窄宿主由页面传 `Dialog::width(可用宽)`。
// 可用宽 → 实际宽:`min(480, 可用宽)` 再 `.max(280)`(官方 `DialogMinWidth`,复审 E)。
// 可用宽 < 280 时仍画 280(溢出宿主)——iced 无 min-width,这条写进 G17-d 之二。
let effective = geometry.effective_width(spec.max_width);
container(content)
.width(Length::Fixed(spec.max_width.min(geometry.width)))
.width(Length::Fixed(effective))
.padding(Padding::from(geometry.padding))
.style(move |_theme| container::Style {
background: Some(Background::Color(surface)),
@@ -7,7 +7,7 @@
//! | 12.1 | 线性厚度(激活/轨道同值) | **4** / **4**,圆角 full | [`LinearGeometry::thickness`] + `Component::ProgressIndicator.spec().shape` |
//! | 12.2 | 线性颜色 | 激活 `Primary`、轨道 `SecondaryContainer` | 组件层取角色 |
//! | 12.3 | 环形直径 / 厚度 / 环↔轨间隙 | **40 / 4 / 4** | [`CircularGeometry`] |
//! | 12.4 | 不定态节奏 | 线性 **1750ms** 双段;环形 **6000ms**(弧长 0.1→0.87 + 旋转 0→360) | [`LinearGeometry::phase_segments`] / [`CircularGeometry::indeterminate`] |
//! | 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.4 | 文本水平内距 / 垂直内距 | **16** / **6** | `Space::X4` / `panel::SnackbarGeometry::pad_v` |
@@ -110,6 +110,16 @@ line!(SEG_TAIL_1, 250, 1250);
line!(SEG_HEAD_2, 650, 1500);
line!(SEG_TAIL_2, 900, 1750);
/// phase 0.55 的两段(占轨道比例)——**A1 keyframes 的手算值,单测与渲染层探针共用**。
///
/// 为什么写成常量:早先探针里手写 `0.871`、单测里手写 `0.860`,两边差 0.011(≈3.5px),
/// 实测 963 距 `want_end 966.7` 只剩 0.28px,靠容差活着(复审 B)。现在只有一份:
/// [`phase_segments`] 的回归测试对着它断言,探针也用它算期望像素位置。
///
/// 值来自:第二线 `[0.003, 0.076]`(tail 0.003 / head 0.079 → 合并到 0.076 区间的实测),
/// 第一线 `[0.355, 0.860]`。
pub const PHASE_055: [(f32, f32); 2] = [(0.003, 0.076), (0.355, 0.860)];
/// 线性进度的几何(厚度与圆角;轨道宽由宿主给,所以没有 width)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LinearGeometry {
@@ -18,13 +18,16 @@
//! 所以 [`KeyPoint::easing`] 的语义就是段起点的缓动;最后一点之后一律**保持末值**
//! (Compose 对未覆盖的区间就是保持,这也是「段跑到头以后停在满格」的来源)。
//!
//! ## 首点必须在 `ms = 0`(复审 P1-B 的教训)
//! ## 首点的两种合法情况(复审 P1-B 的教训)
//!
//! Compose 在 keyframes **缺 0ms 关键帧**时会自己补一个 `t = 0`、值 = 动画**初值**的点
//! (`VectorizedKeyframesSpec` 构造里 `Map.containsKey(0) == false` 就 `timestamps.add(0, 0)`,
//! 且 `playTime <= 0 → startValue`)。本求值器不猜:首点不在 0 就 `debug_assert` 炸,
//! 逼调用方把那个点显式写出来(环形旋转就漏过这一点:`ROTATION` 首点是 300ms/90°,
//! 于是 `[0, 0.05)` 被恒定为 90°,周期末尾 360° → 下周期 90° 还会瞬时倒跳 270°)。
//! 且 `playTime <= 0 → startValue`)。本求值器对这种情况按「保持首值」处理,而它**只等价于**
//! Compose 的「初值」—— 所以守卫是「首点不在 0ms **且** 首值 ≠ 0」才 `debug_assert` 炸:
//!
//! - `SEG_TAIL_1/2`(首点 250/900ms、首值 **0**)与 Compose 的「初值 0」等价,**合法**;
//! - `ROTATION` 曾经首点 300ms/**0.25**:`[0, 0.05)` 被恒定成 90°、周期末尾 360° → 下周期
//! 90° 瞬时倒跳 270°。现在首点显式补成 0ms/0°。
use unlucky_design::motion::Easing;
@@ -16,8 +16,11 @@
//!
//! 1. **数值来自哪里**:颜色/字级/圆角/内距走令牌(`Role` / `TypeRole` / `ShapeScale` / `Space`),
//! 进度厚度、Snackbar 宽高、Dialog 宽、空态块这四组令牌层没有 → 登记 **G17**([`geometry`])。
//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`],
//! 禁用内容一律经 [`interaction::disabled_content_over_role`];
//! 2. **状态层只有一处**:hover/pressed 的叠色一律经 [`interaction::overlay_color`];
//! 禁用内容默认经 [`interaction::disabled_content_over_role`],但**动作件是个例外**——
//! `action.rs` 的 `content_color()` 把 38% 内容色叠在它**自己的容器角色**上
//! (`InverseSurface` / `SurfaceContainerHigh`),因为 `interaction` 那两档只认
//! 「叠在 Surface / 12% 容器上」;
//! 禁用是**状态**(`on_press: Option<M>` 为 `None`),不是另一个构造器(M77 之前 Chip 踩过)。
//! 3. **精确尺寸经 `layout`**:图标/几何图元一律 `layout::fixed` / `layout::centered_fixed`(Q9),
//! 居中一律父级 `align_x/align_y`(Q8,不许 `center_*`)。
@@ -48,7 +51,7 @@
//! progress.rs linear / circular(含环形 SVG 图元)
//! snackbar.rs 提示条 + 贴顶/贴底
//! dialog.rs 对话框 + scrim
//! empty_state.rs 空态(项目自定,全部 [估])
//! empty_state.rs 空态(项目自定:`[估]` 几何 + `[规格]` 排版 + 一条 `[源]` CTA)
//! action.rs 文字动作 / 图标动作(Dialog 与 Snackbar 共用)
//! gallery_parts.rs 家族自己的 gallery 段落开关(`ULL_FEEDBACK_PART`)
//! probe.rs 渲染层像素断言(对着 gallery 截图量,见其模块文档)
@@ -4,7 +4,6 @@
//! (宽高/内距/圆角)。合在一个文件里会顶到 400 行红线(AGENTS.md §3-1)。
use unlucky_design::component_map::Component;
use unlucky_design::icons::IconSize;
use unlucky_design::spacing::Space;
use crate::theme::Ui;
@@ -83,6 +82,14 @@ pub struct DialogGeometry {
}
impl DialogGeometry {
/// 可用宽 → 实际渲染宽:`min(本项目 480, 可用宽)`,再**尊重官方下限 280**。
///
/// 复审 E:早先 `Dialog::width(200)` 会真的画 200(官方 `DialogMinWidth = 280` 只在测试里)。
/// 可用宽 < 280 时仍画 280(**溢出宿主**)—— iced 没有 min-width,这条是 G17-d 之二。
pub fn effective_width(&self, available: f32) -> f32 {
available.min(self.width).max(self.min_width)
}
/// 从令牌求值(内边距与三个间距全部落在 4px 栅格上 ⇒ 走 `Space`)。
pub fn of(ui: &Ui) -> Self {
let width = 480.0;
@@ -100,14 +107,23 @@ impl DialogGeometry {
}
}
/// 空态的几何(**全部 `[估]`**:官方没有这个组件)。
/// 空态图标边长 = `icons::view` 的实际渲染尺寸(24),见 [`EmptyStateGeometry::icon`] 的 G18-h。
pub const EMPTY_ICON: f32 = 24.0;
/// 空态的几何(**项目自定**:官方没有这个组件;`[估]` 几何 + `[规格]` 排版 + `[源]` CTA)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EmptyStateGeometry {
/// 图标块边长(96)。
pub block: f32,
/// 图标块圆角(16,`ShapeScale::Large`)。
pub block_radius: f32,
/// 块内图标(32,`IconSize::Empty`)。
/// 块内图标(**24**,实渲染值)。
///
/// 欠账 **G18-h**:早先登记的是 `IconSize::Empty`(32),但 `icons::view` 内部把 svg 钉成
/// `Length::Fixed(24)`(Q9 的同一处),外面套 32 的盒子只是**居中**、不拉伸子件 ——
/// 实测墨迹 20×20、字形 24。所以登记值改成 24,与 `view` 一致。
/// 真要 32:需要 `view_at(px)`(把 `icons::RENDER_PX` 也传进去,按 32 重算
/// `stroke_width(32)`,否则 1.5px 视觉线宽会变成 2.0px)——那是 **M86** 的 scope,本线不自造。
pub icon: f32,
/// 块 ↔ 标题(16)。
pub gap_title: f32,
@@ -124,7 +140,7 @@ impl EmptyStateGeometry {
Self {
block,
block_radius: Component::EmptyState.spec().shape.radius_px(block),
icon: IconSize::Empty.px(),
icon: EMPTY_ICON,
gap_title: ui.space(Space::X4),
gap_body: ui.space(Space::X2),
gap_action: ui.space(Space::X6),
@@ -35,6 +35,7 @@ mod panels;
use unlucky_design::color::{Role, SchemeFlavor};
use unlucky_design::typography::Platform;
use super::geometry::PHASE_055;
use super::pixel::{band_width, bands, matches, near, rgb, runs, shot_path, Shot};
use crate::theme::{Families, Ui};
@@ -154,8 +155,9 @@ fn check_linear(shot: &Shot, ui: &Ui) {
"12.4:phase 0.55 必须是**两段**(P1-A 修完才成立),实测 {found:?} @ 行 {row}"
);
// A1 keyframes 手算:第二线 0.003..0.079、第一线 0.355..0.871(EmphasizedAccelerate)
let expected = [(0.003_f32, 0.079_f32), (0.355, 0.871)];
// 期望位置与单测**共用同一个常量**(`geometry::PHASE_055`):早先这里写 0.871、单测写
// 0.860,实测 963 距 want_end 966.7 只剩 0.28px,靠容差活着(复审 B)。
let expected = PHASE_055;
for (index, (start, len)) in found.iter().enumerate() {
let want_start = TRACK3_X0 as f32 + expected[index].0 * TRACK3_W as f32;
let want_end = TRACK3_X0 as f32 + expected[index].1 * TRACK3_W as f32;
@@ -262,7 +264,41 @@ fn check_snackbar(shot: &Shot, ui: &Ui) {
"14.6:动作标签应是 InversePrimary(实测该色只有 {action_pixels} px)"
);
// ---- 位置语义(复审 D):两条 bottom 条的**条底距取景框底 = 12** ---------------
//
// 取景框是 gallery 给窄宿主画的 1px `OutlineVariant` 边(`framed_host`):透明宿主没有
// 可见底边时「贴底」在像素上不可证(旧宿主 72 = 48 + 2×12,上下留白相等)。
let frame = rgb(ui.color(Role::OutlineVariant));
let mut margins: Vec<usize> = Vec::new();
for (top, bottom) in &found {
let width = band_width(shot, (*top, *bottom), bar, surface);
if !(near(width, 300) || near(width, 344)) {
continue;
}
let frame_row = ((bottom + 1)..shot.height)
.find(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, *y), frame))
.count()
>= 200
})
.unwrap_or_else(|| {
panic!("D:窄条下方没找到宿主取景框(条 y {top}..{bottom},宽 {width})")
});
margins.push(frame_row - bottom);
}
assert!(
!margins.is_empty(),
"D:应至少量到一条 bottom 条的宿主边距,实测 {margins:?}"
);
for margin in &margins {
assert!(
near(*margin, 12),
"D:贴底 ⇒ 条底距宿主底 12(官方外边距),实测 {margin}:{margins:?}"
);
}
eprintln!(
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px"
"[feedback-probe] snackbar:宽度集 {widths:?} · 高度集 {heights:?} · InversePrimary {action_pixels}px · 底边距 {margins:?}"
);
}
@@ -6,7 +6,15 @@
//! - 环的外接框**四角无墨** —— 真圆,不是圆角方。
//!
//! 不定态的**节奏**(4 圈/周期、跨周期不跳变)不在这里量:那是时间的函数,截图只有一帧,
//! 判据在 `tests.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。
//! 判据在 `tests/progress.rs::circular_indeterminate_rotation_is_four_turns_and_continuous`。
//!
//! ## 欠账 G18-i:没有做「逐环断言」
//!
//! 复审 G 建议把环数下限收到 6 个里的 ≥5。实测做不到稳:一屏 6 个环里只有 3~4 个能被
//! 「精确色」连通块干净命中 —— 25% 环是四分之一弧(外接框 21×40)、100% 环首尾相接处被
//! 抗锯齿切开,簇数随相位在 4~6 之间跳(实测 phase 0.30 为 4 簇)。按簇数断言会把「相位换了」
//! 误报成「环丢了」。所以只保留「至少一个 40×40 真圆环 + 四角无墨」,**逐环登记为 G18-i**
//! (要稳:gallery 把 6 个环摆成固定 x 栅格,或改自绘 Widget 后按几何断言)。
use unlucky_design::color::Role;
@@ -42,8 +50,9 @@ pub(super) fn check(shot: &Shot, ui: &Ui) {
}
}
let clusters = blobs(shot, active, 10).len();
eprintln!(
"[feedback-probe] circular:环 {} 个 {}",
"[feedback-probe] circular:40(±1) 环 {} 个 {}(主色连通簇共 {clusters} 个,见 G18-i)",
rings.len(),
dims(&rings)
);
@@ -1,66 +1,110 @@
//! 两个「面」的渲染层判据:Dialog(§7)与空态(§15)。`probe` 的子模块。
//!
//! 量什么:
//! ## Dialog 量什么
//!
//! - Dialog:最宽行 **480**(且落在官方 280..560 内)· 标题墨迹左缩 **≈24**、顶缩 **≈26**
//! (A1 `DialogPadding = 24`)· 动作色是 `Primary` · scrim 带 ≥700 宽且色值 = 预合成实色;
//! - 空态:图标块 **96×96** · 块↔标题的**实际**间距 ≈16 + 字形 bearing · 条目总高 **≈232**
//! (96 + 16 + 32 + 8 + 16 + 24 + 40)· CTA **40**。
//! - **逐卡宽度**:按列剖面把每张卡单独量出来(早先只量「最宽那张」,一宽一窄并排时会漏);
//! 本项目 480,窄宿主那格 360(`Dialog::width(360)`),且都必须落在官方 280..560 内;
//! - 内距:标题墨迹左缩 ≈24、顶缩 24+bearing(24..=30);
//! - **动作色**:`Role::Primary` 的**精确色**命中数(复审 A:早先用「离谁更近」把标题/正文字形
//! 也算成了 Primary —— 一张没有动作的卡也能"通过");并配两条反向证据:
//! ①无动作的那张卡里精确 Primary 必须是 **0**;②有动作的那张卡下半区必须有字形行。
//! - scrim:一块 ≥700 宽的遮罩带,色值 = 预合成实色。
//!
//! 空态那两条间距断言是复审 P2-1 的回归:早先 `Column::spacing(8)` 与显式 `gap(16/24)` 双算,
//! 渲染成 32/40、总高 264,而登记的是 16/24、232。
//! ## 空态量什么
//!
//! 图标块 96±2(1px 描边各吃 1 行)· 块↔标题 ≈16+bearing · 条目总高 ≈232 · CTA 40。
//!
//! 常量与 [`super::super::panel`] 里登记值对齐(480 / 280 / 96 / 232),动作色用**精确色**
//! 而不是斜坡判据。
use unlucky_design::color::Role;
use unlucky_design::elevation::MaterialLayer;
use super::super::dialog::scrim;
use super::super::empty_state::BLOCK_LAYER;
use super::super::pixel::{band_width, bands, ink_inside, near, rgb, row_ink, runs, BBox, Shot};
use super::super::pixel::{
band_width, bands, ink_inside, matches, near, rgb, row_ink, runs, BBox, Shot,
};
use crate::theme::Ui;
/// Dialog 宽(本项目取值,官方区间 280..560 内)。
const DIALOG_W: usize = 480;
/// 窄宿主那格的宽(`Dialog::width(360)`)。
const DIALOG_NARROW: usize = 360;
/// 空态图标块边长。
const BLOCK: usize = 96;
/// 空态条目总高:96 块 + 16 + 32 标题 + 8 + 16 副文案 + 24 + 40 CTA。
const EMPTY_TOTAL: usize = 232;
/// 动作字形的精确色命中下限(实测 3 张有动作的卡合计 325 px)。
const ACTION_INK_MIN: usize = 120;
/// §7:卡片宽 / 内距 / 动作色 / scrim。
/// §7:逐卡宽度 / 内距 / 动作色 / scrim。
pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
// 参考值写字面量(A1 AlertDialogKt):280 / 560 / 480 / pad 24。
let surface = ui.color(Role::Surface);
let card = ui.layer(MaterialLayer::Overlay);
let primary = rgb(ui.color(Role::Primary));
// ---- 7.4 宽:所有行里最宽的一段(卡片上下留白那几行没有文字,量得到全宽)------
let mut widest = (0usize, 0usize, 0usize); // (len, x, y)
for y in 0..shot.height {
for (x, len) in runs(shot, y, card, surface) {
if len > widest.0 {
widest = (len, x, y);
// ---- 7.4 宽度:先分「卡带」(同排两张卡的 y 区间会重叠,所以只能按行分组),
// 再在每条带里按**列剖面**分出每一张卡(复审 A/G:早先只量最宽的那张)。
let card_bands = bands(shot, card, surface, 200);
assert!(
card_bands.len() >= 3,
"至少要找到 3 条卡带(第一排两张 + 双动作 + scrim 里那张),实测 {card_bands:?}"
);
let mut widths: Vec<usize> = Vec::new();
for band in &card_bands {
let height = band.1 - band.0 + 1;
let profile: Vec<usize> = (0..shot.width)
.map(|x| {
(band.0..=band.1)
.filter(|y| matches(shot.at(x, *y), rgb(card)))
.count()
})
.collect();
let mut start: Option<usize> = None;
for x in 0..=shot.width {
let column = x < shot.width && profile[x] >= height / 3;
match (column, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
if x - from >= 280 {
widths.push(x - from);
}
start = None;
}
_ => {}
}
}
}
let (width, card_left, card_row) = widest;
widths.sort_unstable();
assert!(
near(width, DIALOG_W),
"7.4:对话框宽必须是 {DIALOG_W}(G17-e 项目取值),实测 {width}"
widths.iter().any(|w| near(*w, DIALOG_W)),
"7.4:应有 {DIALOG_W} 宽的卡(G17-e 项目取值),实测逐卡宽度 {widths:?}"
);
assert!(
(280..=560).contains(&width),
"7.4:宽必须落在官方区间 280..560 内(A1 DialogMinWidth/DialogMaxWidth),实测 {width}"
widths.iter().any(|w| near(*w, DIALOG_NARROW)),
"7.4:窄宿主那格应量到 {DIALOG_NARROW}(`Dialog::width(360)`,复审 E),实测 {widths:?}"
);
// 该行上下扩成卡片带(同一张卡片的连续行;两张卡并排时各自成带)
let mut top = card_row;
while top > 0 && row_ink(shot, top - 1, card, surface) >= width / 2 {
top -= 1;
}
let mut bottom = card_row;
while bottom + 1 < shot.height && row_ink(shot, bottom + 1, card, surface) >= width / 2 {
bottom += 1;
for width in &widths {
assert!(
(280..=560).contains(width),
"7.4:每张卡都必须落在官方 280..560 内,实测 {width}:{widths:?}"
);
}
// ---- 7.3 内距:标题墨迹距卡片左缘 ≈24、顶 ≈24 + 行内 bearing -----------------
// 内距与 scrim 只在一张卡上量(第一张,最靠左上)。
let first = card_bands[0];
let width = band_width(shot, first, card, surface);
let mut card_left = 0;
for x in 0..shot.width {
if matches(shot.at(x, first.0 + 4), rgb(card)) {
card_left = x;
break;
}
}
let top = first.0;
let bottom = first.1;
let bbox = BBox {
left: card_left,
right: card_left + width - 1,
@@ -74,15 +118,68 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
(22..=28).contains(&inset),
"7.3:四边内距 24 ⇒ 标题墨迹左缩 ≈24(含 bearing 22..28),实测 {inset}"
);
// 顶缩收到 (24..=30):写错成 32 也过的话这条等于没写(复审 P2-5)。
// HeadlineSmall 24/32 的行盒顶到字形顶 ≈3~5px,所以 24+3..24+6。
let top_inset = title.top.saturating_sub(top);
assert!(
(24..=30).contains(&top_inset),
"7.3:上内距 24 ⇒ 标题墨迹顶缩 24+字形 bearing(24..30),实测 {top_inset}"
);
// ---- 7.7 scrim:一块 ≥700 宽的遮罩带,颜色 = 预合成实色 ----------------------
// ---- 7.6 动作色(复审 A:换掉「离谁更近」那条会被标题字形顶账的判据)--------
let exact_primary = |band: (usize, usize)| -> usize {
(band.0..=band.1)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), primary))
.count()
})
.sum()
};
let action_pixels: usize = (0..shot.height)
.map(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, y), primary))
.count()
})
.sum();
assert!(
action_pixels >= ACTION_INK_MIN,
"7.6:Dialog 动作标签应是 `Primary` 的**精确色**(实测只命中 {action_pixels} px;\
改成 OnSurfaceVariant 就会掉到 0)"
);
// 反向证据 ①:没有动作的那张卡(scrim 里那张)里精确 Primary 必须是 0 ——
// 这条挡住「把标题/正文的深色字形算成 Primary」这条假绿路径。
let quiet: Vec<(usize, usize)> = card_bands
.iter()
.copied()
.filter(|band| exact_primary(*band) == 0)
.collect();
assert!(
!quiet.is_empty(),
"7.6:本屏应有一张**无动作**的卡(scrim 里那张)其精确 Primary 为 0,实测每带都有 Primary 墨迹"
);
// 反向证据 ②:动作行确实存在 —— 命中最多的那条带下半区必须有字形行。
let loud = card_bands
.iter()
.copied()
.max_by_key(|band| exact_primary(*band))
.expect("卡带非空");
let half = loud.0 + (loud.1 - loud.0) / 2;
let action_rows = (half..=loud.1)
.filter(|y| {
(0..shot.width)
.filter(|x| matches(shot.at(*x, *y), primary))
.count()
>= 3
})
.count();
assert!(
action_rows >= 1,
"7.6:动作字形必须在卡片下半区(实测下半区精确 Primary 行数 {action_rows})"
);
// ---- 7.7 scrim -------------------------------------------------------------
let backdrop = scrim(ui);
let scrim_bands = bands(shot, backdrop, surface, 700);
assert!(
@@ -90,38 +187,10 @@ pub(super) fn check_dialog(shot: &Shot, ui: &Ui) {
"7.7:没找到 ≥700 宽的遮罩带(宿主 800 宽那格)"
);
// ---- 7.6 动作色:Dialog 动作是 `Primary`(不是 OnSurfaceVariant)-------------
// 按「更靠近 Primary 而不是卡片」判:14px 中文字形只有边缘几个像素能命中精确色。
let action = rgb(ui.color(Role::Primary));
let card_rgb = rgb(card);
let action_pixels = (top..=bottom)
.map(|y| {
(card_left..(card_left + width).min(shot.width))
.filter(|x| {
let pixel = shot.at(*x, y);
let to_action: u32 = pixel
.iter()
.zip(action)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
let to_card: u32 = pixel
.iter()
.zip(card_rgb)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum();
to_action < to_card
})
.count()
})
.sum::<usize>();
assert!(
action_pixels > 40,
"7.6:动作文本应是 Primary(实测该色只有 {action_pixels} px)"
);
eprintln!(
"[feedback-probe] dialog:宽 {width} · 标题左缩 {inset} · 顶缩 {top_inset} · \
卡片 y {top}..{bottom} · scrim 带 {} 条 · Primary {action_pixels}px",
"[feedback-probe] dialog:逐卡宽度 {widths:?} · 标题左缩 {inset} · 顶缩 {top_inset} · \
精确 Primary {action_pixels}px(无动作卡 {} 张)· 动作行 {action_rows} 行 · scrim 带 {} 条",
quiet.len(),
scrim_bands.len()
);
}
@@ -131,9 +200,7 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
let surface = ui.color(Role::Surface);
let block = ui.layer(BLOCK_LAYER);
// 下限 40 而不是 88:块只有 96 宽,中间的图标(32)会把那几行的块色墨量压到 ≈64。
let found = bands(shot, block, surface, 40);
// 容差 ±2:块上补了 1px `OutlineVariant` 描边,块色行/列各少 1(浅色实测 94,深色 97)。
let (top, bottom) = found
.iter()
.copied()
@@ -151,7 +218,28 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
"15.1:图标块必须 {BLOCK}(±2,1px 描边)宽,实测 {width}"
);
// ---- P2-1 回归:块↔标题的**实际**间距(16 而不是 32)-------------------------
// 图标本身:`icons::view` 钉成 24(G18-h 的登记值),墨迹 ≈20×20。
let glyph = ink_inside(
shot,
&BBox {
left: 0,
right: shot.width - 1,
top,
bottom,
},
ui.color(Role::OnSurfaceVariant),
block,
2,
)
.expect("块里必须有图标墨迹");
assert!(
(16..=26).contains(&glyph.width()) && (16..=26).contains(&glyph.height()),
"15.1:图标墨迹应在 16..26(24 盒;G18-h 登记 24,不是 32),实测 {}×{}",
glyph.width(),
glyph.height()
);
// ---- P2-1 回归:块↔标题的实际间距(16 而不是 32)---------------------------
let bbox = BBox {
left: 0,
right: shot.width - 1,
@@ -166,7 +254,6 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
"15.3:块↔标题应为 16(+ 字形 bearing),实测 {gap}(32 = spacing 与 gap 双算回来了)"
);
// ---- 条目总高 ≈232(96+16+32+8+16+24+40)-------------------------------------
let cta = bands(shot, ui.color(Role::Primary), surface, 20);
let (cta_top, cta_bottom) = cta
.iter()
@@ -180,10 +267,10 @@ pub(super) fn check_empty(shot: &Shot, ui: &Ui) {
);
eprintln!(
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 块↔标题 {gap} · \
总高 {total} · CTA {}..{}",
"[feedback-probe] empty:块 y {top}..{bottom}(高 {})· 宽 {width} · 图标 {}×{} · \
块↔标题 {gap} · 总高 {total} · CTA {cta_top}..{cta_bottom}",
bottom - top + 1,
cta_top,
cta_bottom
glyph.width(),
glyph.height()
);
}
@@ -5,7 +5,7 @@
//! | 形态 | 确定态 | 不定态 |
//! |---|---|---|
//! | [`linear`] | 轨道 `SecondaryContainer` + 指示条 `Primary`,厚 **4**、圆角 full | 官方 1750ms **双段**追逐([`phase_segments`]) |
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 0↔360,[`CircularGeometry::indeterminate`]) |
//! | [`circular`] | 40 环 + 4 线宽,弧长 = 数值 × 360° | 官方 6000ms(弧长 0.1↔0.87 + 旋转 **0→1440°**:全局 1080° 线性 + 关键帧 360°,[`CircularGeometry::indeterminate`]) |
//!
//! ## 为什么不定态要调用方给 `phase`
//!
@@ -109,7 +109,8 @@ fn empty_state_geometry_is_the_project_defined_set() {
let geometry = EmptyStateGeometry::of(&ui(SchemeFlavor::Light));
assert_eq!(geometry.block, 96.0);
assert_eq!(geometry.block_radius, 16.0, "ShapeScale::Large");
assert_eq!(geometry.icon, 32.0, "IconSize::Empty");
// 复审 C:登记值是 **24**(`icons::view` 的实渲染尺寸),不是 32;见欠账 G18-h。
assert_eq!(geometry.icon, 24.0, "图标实渲染 24(G18-h)");
// **复审 P2-1 的回归断言**:三个间距必须精确等于令牌档位。
// 早先这里只写 `> 0.0`,所以「Column::spacing(8) 与显式 gap(16/24) 双算成 32/40」
// 完全藏得住(渲染实测 264 vs 登记 232)。
@@ -10,7 +10,7 @@
use unlucky_design::color::SchemeFlavor;
use unlucky_design::shape::ShapeScale;
use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry};
use super::super::geometry::{phase_segments, CircularGeometry, LinearGeometry, PHASE_055};
use super::super::keyframes::{KeyPoint, Keyframes};
use super::super::progress::{layout_spans, portion_shares, Amount, Progress};
use super::ui;
@@ -100,13 +100,25 @@ fn raw_segments_are_unordered_and_layout_sorts_them() {
let right = laid[3].1;
let left_start: f32 = laid[0].1;
let right_start: f32 = laid[0].1 + left + laid[2].1;
// A1 keyframes 的手算值:第二线 0.003..0.079、第一线 0.355..0.871(容差 0.01)
assert!((left_start - 0.003).abs() < 0.01, "左段起点 {left_start}");
assert!((left - 0.076).abs() < 0.01, "左段宽 {left}");
assert!((right_start - 0.355).abs() < 0.01, "右段起点 {right_start}");
// 期望值来自 `geometry::PHASE_055`——与渲染层探针**共用**(复审 B:早先两边差 0.011)
let [(a0, a1), (b0, b1)] = PHASE_055;
assert!(
(right - 0.505).abs() < 0.01,
"右段宽 {right}(0.355..0.860)"
(left_start - a0).abs() < 0.01,
"左段起点 {left_start} vs {a0}"
);
assert!(
(left - (a1 - a0)).abs() < 0.01,
"左段宽 {left} vs {}",
a1 - a0
);
assert!(
(right_start - b0).abs() < 0.01,
"右段起点 {right_start} vs {b0}"
);
assert!(
(right - (b1 - b0)).abs() < 0.01,
"右段宽 {right} vs {}",
b1 - b0
);
let total: f32 = laid.iter().map(|(_, fraction)| fraction).sum();
@@ -54,8 +54,14 @@ const HOST_MIN: f32 = 368.0;
const HOST_NARROW: f32 = 324.0;
/// `Snackbar` 宿主高(比 68 + 2×12 略高,留出贴顶/贴底的判定余量)。
const HOST_TALL: f32 = 96.0;
/// 单行提示条的宿主高(48 + 2×12 外边距 = 72,正好装下)。
const HOST_SHORT: f32 = 72.0;
/// 窄宿主:给 `Dialog::width` 递 360,量「窄宿主不再被裁」的那一格(复审 E)。
const NARROW_HOST: f32 = 360.0;
/// 单行提示条的宿主高。
///
/// **96 而不是 72**(复审 D):72 = 48 + 2×12 时上下外边距相等,像素上分不出「贴底」还是
/// 「居中」;96 让贴底那格的上留白 = 36、下留白 = 12,探针能量到「条底距宿主底 = 12」。
const HOST_SHORT: f32 = 96.0;
/// 空态宿主高:内容本征高 ≈96+16+32+8+16+24+40 ≈ 232,留出上下各 40 的余量。
const HOST_EMPTY: f32 = 320.0;
@@ -100,6 +106,29 @@ fn caption<'a>(ui: &'a Ui, value: &'a str) -> Element<'a, DemoMessage> {
.into()
}
/// 带 1px 取景框的宿主:`Snackbar` 的贴底/贴顶需要一条**可见的宿主边界**才量得出来
/// (透明宿主的底边在图上不存在,`Placement::Bottom` 就不可证)。
fn framed_host<'a>(
ui: &'a Ui,
width: f32,
height: f32,
content: impl Into<Element<'a, DemoMessage>>,
) -> Element<'a, DemoMessage> {
let line = ui.color(Role::OutlineVariant);
container(content)
.width(Length::Fixed(width))
.height(Length::Fixed(height))
.style(move |_theme| container::Style {
border: iced::Border {
color: line,
width: crate::theme::STROKE_PX,
radius: iced::border::Radius::default(),
},
..container::Style::default()
})
.into()
}
/// 固定宽宿主(量宽度用:宿主宽写死,组件内部的占比/夹取才有确定值)。
fn host<'a>(
width: f32,
@@ -214,9 +243,10 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
.state(Interaction::Hovered),
),
);
let mid = host(
let mid = framed_host(
ui,
HOST_MIN,
Some(HOST_SHORT),
HOST_SHORT,
feedback::snackbar(
ui,
Snackbar::new("已删除实例")
@@ -224,9 +254,10 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
.action("撤销", press("snackbar/undo")),
),
);
let narrow = host(
let narrow = framed_host(
ui,
HOST_NARROW,
Some(HOST_SHORT),
HOST_SHORT,
feedback::snackbar(ui, Snackbar::new("宿主 324").placement(Placement::Bottom)),
);
@@ -243,7 +274,7 @@ fn snackbar_block<'a>(ui: &'a Ui) -> Element<'a, DemoMessage> {
two_lines,
caption(
ui,
"宿主 324 ⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d)"
"宿主 324(取景框)⇒ 条 300:组件没有 min-width,宿主给多少就是多少(G17-d);贴底 = 条底距框底 12"
),
narrow,
caption(ui, "宿主 368 ⇒ 条 344:最小宽的取值口径([估])"),
@@ -289,6 +320,11 @@ fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> {
Dialog::new("已导入整合包", "共 42 个模组,缺失 0 个。")
.confirm("知道了", press("dialog/ok")),
);
// 窄宿主那格:可用宽 360 ⇒ `min(480, 360) = 360`,且不得低于官方下限 280。
let narrow = feedback::dialog(
ui,
Dialog::new("窄宿主 360", "可用宽 360 时取 min(480, 360)。").width(NARROW_HOST),
);
// 强制 hover:动作件的状态层必须来自 `interaction`(不是组件里自己乘 8%)。
let double = feedback::dialog(
ui,
@@ -307,8 +343,9 @@ fn dialog_block<'a>(ui: &'a Ui, full: bool) -> Element<'a, DemoMessage> {
.spacing(ui.space(Space::X2));
body = body.push(row![plain, single].spacing(ui.space(Space::X6)));
// 双动作 + 窄宿主并排:探针要同时量到 480 与 360 两种宽
body = body.push(row![double, narrow].spacing(ui.space(Space::X6)));
if full {
body = body.push(double);
body = body.push(caption(
ui,
"Dialog · scrim = 黑@0.32 叠 canvas 的实色(D27 预合成;详见 dialog.rs 模块文档)",