fix(design): M61 r1 收口 —— 官方 emphasized/弹簧/高程 + 冻结测试

reviewer r1 判 p2-2items(fix-then-merge),本轮按条目收口:

P2-1 冻结测试补全(变异实测证明原来可静默漂移):
- tests/type_scale_pins.rs:baseline 15 档 (size,line_height,weight,tracking,family)
  与 Expressive 强调 15 档逐值对拍,期望值独立于实现重写
- tests/token_pins.rs:49 条 tone(light+dark 全表)、7 条 easing 曲线参数、
  16 档时长、9 组弹簧、28 个 IconKind 下标、形状阶梯
- 自证:TitleMedium 16→17 与 EmphasizedDecelerate 换曲线两次变异均 FAILED

P2-2 emphasized 口径事实错误(用户点名):
- 取回 A 级官方值(androidx TypeScaleTokens v0_103 的 *Emphasized* 四元组):
  label-*/title-medium/title-small 强调是 700(此前给 600)、body-large 0.5→0.15、
  title-medium 0.2→0.15、body-medium 0.2→0.25、display-large -0.2→0
- TypeRole::emphasized() 改为官方表(typography/emphasized.rs);
  原「只加一档字重」规则改名 emphasized_simplified() 并标注非官方
- 顺带按官方订正 baseline tracking(display-large -0.2、body-medium 0.2、
  title-medium 0.2)与 TitleLarge 的 Brand 族归属
- crate 文档新增「baseline MD3 vs M3 Expressive 差异清单」+ 未支持能力 gap

弹簧改为两套 token 结构(用户追加令):
- motion::spring(scheme × category × speed) 二维表 12 格:
  standard spatial ζ=0.9 (k 1400/700/300)、expressive spatial ζ=0.6~0.8 (k 800/380/200)、
  effects 两套同值 ζ=1.0 (k 3800/1600/800),均取 A 级 MotionTokens 原值
- Spring 改存阻尼比(与 Compose 令牌同口径),9 个具名常量

中文字重覆盖(用户追加令):
- FontFace 带 WeightCoverage(可变区间/静态表),Platform::resolve_weight 给
  Native/Nearest/Synthetic 三档结论;雅黑无 500、苹方无 700 两处 gap 显式登记

component_map 高程按官方回调(P2 并入 finding):
- FilledCard L2→L0、OutlinedCard L1→L0、Menu L4→L2、Dialog L5→L3(Menu 圆角同时
  按 MenuTokens 改 4dp);新增官方值 pin 测试 + R4「全屏都在飘」守门断言

P3 四条:
- 同族容器撞色:彩度按共享基准等比压缩(Palettes.reference_chroma),
  primary/secondary-container 不再同值,且强调色覆盖时表面逐像素不变
- femtovg 引用改函数名(行号已漂);删掉 contrast_aa 的死代码后门
- 骨架数值跟 M63:nav 56→44、titlebar 40→48,control 40/touch 48 换算依据入文档
- 图标折算口径订正为「居中描边,视觉外扩 <1px」
This commit is contained in:
AskaEth committed 2026-11-01 15:58:19 +08:00
1 parent 65ca0ed29a
commit 83e19e58f0
20 files changed
+1367 -234

No files matched your search

+20
View File
@@ -57,6 +57,12 @@ pub struct Palettes {
pub neutral_variant: TonalPalette,
/// 错误调色板。
pub error: TonalPalette,
/// 彩度压缩的**共享基准**(取建方案时的 primary 彩度,强调色覆盖时**不变**)。
///
/// 为什么单独存一份:`from_accent` 换掉 primary 后,中性表面不该跟着变色调
/// (否则「换强调色不动底色」这条语义就破了),而彩度压缩又必须用同一个基准才能
/// 保证 primary/secondary 之间的相对灰度——两者靠这个字段同时成立。
reference_chroma: f32,
}
impl Palettes {
@@ -77,6 +83,7 @@ impl Palettes {
neutral: primary.with_chroma(NEUTRAL_CHROMA),
neutral_variant: primary.with_chroma(NEUTRAL_VARIANT_CHROMA),
error: TonalPalette::new(ERROR_HUE_DEG, ERROR_CHROMA),
reference_chroma: primary.chroma(),
}
}
@@ -84,6 +91,7 @@ impl Palettes {
///
/// 这是「用户自定义强调色」的正确语义——换强调色不该让整个界面底色变脏;
/// 也因为 tone 轴固定,任何色相都能自动满足 WCAG AA(不需要再算一遍对比度)。
/// `reference_chroma` 也保持原值,所以中性色与 baseline 逐像素一致(有测试钉住)。
pub fn with_accent(self, accent: Color) -> Self {
let accent_primary = TonalPalette::from_color(accent);
Self {
@@ -107,6 +115,18 @@ impl Palettes {
PaletteSlot::Error => self.error,
}
}
/// 方案级取色:以 [`Palettes::reference_chroma`] 为**共享基准**压所有调色板(见
/// [`TonalPalette::tone_scaled`]),保证同 tone 下 primary/secondary/tertiary 不会撞色,
/// 且换强调色时中性表面逐像素不变。
pub fn tone(&self, slot: PaletteSlot, tone: f32) -> Color {
self.slot(slot).tone_scaled(tone, self.reference_chroma)
}
/// 彩度压缩的共享基准(调试/测试用)。
pub fn reference_chroma(&self) -> f32 {
self.reference_chroma
}
}
#[cfg(test)]
+1 -1
View File
@@ -191,7 +191,7 @@ impl Scheme {
SchemeFlavor::Light => entry.light,
SchemeFlavor::Dark => entry.dark,
};
palettes.slot(entry.slot).tone(tone)
palettes.tone(entry.slot, tone)
});
Self { flavor, colors }
}
+8
View File
@@ -7,6 +7,9 @@ use super::Color;
/// gamut 判定容差:线性 RGB 允许露出这么点负值/溢出(浮点误差),别因此误判「越界」。
const GAMUT_EPS: f32 = 1.0e-4;
/// sRGB 里能取到的 OKLCh 彩度上限(超过它没有意义,二分搜索的初始上界)。
pub(crate) const GAMUT_CHROMA_CAP: f32 = 0.5;
/// sRGB 编码值(0..1)→ 线性光强。
pub(crate) fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.040_45 {
@@ -147,6 +150,11 @@ pub(crate) fn chroma_in_gamut(lstar: f32, hue_deg: f32, chroma_max: f32) -> f32
lo
}
/// 该 tone / 色相下 sRGB 能表达的最大彩度(P3.2 的「按比例压彩度」用它做基准)。
pub(crate) fn max_chroma(lstar: f32, hue_deg: f32) -> f32 {
chroma_in_gamut(lstar, hue_deg, GAMUT_CHROMA_CAP)
}
/// OKLCh 坐标(色相用度,0..360)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Oklch {
+18 -1
View File
@@ -59,6 +59,17 @@ impl TonalPalette {
/// 二分裁剪——所以 tone 0 恒为纯黑、tone 100 恒为纯白,且同 tone 的 WCAG 对比度
/// 与色相无关(这是对比度门禁能跨种子稳定的原因)。
pub fn tone(self, tone: f32) -> Color {
self.tone_scaled(tone, self.chroma)
}
/// 带「共享彩度基准」的取色:**所有调色板按同一比例压彩度进 gamut**。
///
/// `reference_chroma` 一般是方案里最饱和那条调色板(primary)的彩度。压缩系数
/// `f = min(1, C_max(tone,hue) / reference)`,每条调色板取 `C_req × f`——
/// 这样 `primary` 与 `secondary` 在高 tone(tone 90/96 这类浅容器)上不会被**一起裁到**
/// 同一个 gamut 上限而撞色,`secondary` 永远比 `primary` 更灰(M3 的语义就靠这个)。
/// 单独用 [`TonalPalette::tone`] 时没有基准,等价于 `reference = 自身彩度`(直接裁剪)。
pub fn tone_scaled(self, tone: f32, reference_chroma: f32) -> Color {
let lstar = tone.clamp(0.0, 100.0);
// 端点直接给纯黑/纯白:近黑处的 gamut 容差会放进微小彩度,导致 tone 0 偏色。
if lstar <= 0.0 {
@@ -67,7 +78,13 @@ impl TonalPalette {
if lstar >= 100.0 {
return Color::WHITE;
}
let chroma = space::chroma_in_gamut(lstar, self.hue_deg, self.chroma);
let c_max = space::max_chroma(lstar, self.hue_deg);
let factor = if reference_chroma > 1.0e-6 {
(c_max / reference_chroma).min(1.0)
} else {
1.0
};
let chroma = (self.chroma * factor).min(c_max).max(0.0);
let lightness = space::oklab_lightness_for_tone(chroma, self.hue_deg, lstar);
Color::from_linear_f32(space::oklch_to_linear(lightness, chroma, self.hue_deg))
}