[阶段9] 界面打磨:hero 圆环、sparkline、骨架屏、动效

参考了 itsdrchen/Garmin-AI-Coach 与主流健康 App(Apple 活动圆环、
Oura/Whoop 的评分环、Garmin Connect 的卡内趋势)的做法。

hero 区(每个视图只有一个 hero 数字):
- 步数目标圆环,超额时转为 status-good 色并保持满环 + 端点标记,
  而不是绕第二圈 —— 绕圈会让 101% 看起来比 100% 还少
- 圆环轨道用填充色同色系的浅阶而非中性灰,两者才读作同一个量器

sparkline(12 点,卡片内嵌):
- 线用去强调色,只有最新一点用强调色,让它成为数字的背景而非对手
- null 处断开而不插值 —— 设备没记录的那天连一条平线过去等于编数据

骨架屏取代转圈:
- 形状与将要出现的内容一致,数据到位时布局不跳
- 用扫光而非闪烁,读起来像进度而不是元素在求关注

动效:
- 卡片/图表依次入场(45ms 递增),引导视线扫过而不是整屏同时砸下来
- 数字滚动到位、圆环扫过、sparkline 描线、悬停抬升、按下微缩
- 全部尊重 prefers-reduced-motion:直接跳过而非缩短时长 ——
  引起不适的是位移本身,不是它持续多久

深色改为冷调(借鉴参考项目):
- 表面从暖近黑 #1a1a19 改为冷近黑 #181b21,更像运动 App
- 换表面意味着原有校验作废,已用校验器对新表面重跑:四个系列色
  全部通过含 3:1 对比度;文字亦逐个复核(正文 14.56:1、
  次要 7.98、弱化 4.38、按钮白字 6.63)

fix(a11y): 大号独立数字不应使用 tabular-nums
- 等宽数字让每个字形都占 0 的宽度,大字号下显得松散;
  等宽只保留给需要纵向对齐的表格列与坐标轴刻度

fix: 数字滚动动画吞掉了小数位
- StatTile 内部按 decimals 格式化,而调用方已经先 round 过一次,
  距离显示成 "9 km"、久坐成 "11 小时"。改为传原值 + decimals

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
ericwyuan
2026-08-23 22:48:09 +08:00
parent bfda1cd017
commit 757afdc941
18 changed files with 839 additions and 38 deletions

View File

@@ -1,10 +1,12 @@
import { ReactNode } from 'react';
import { useCountUp } from '../../lib/motion';
import Sparkline from './Sparkline';
import './StatTile.css';
export type Status = 'good' | 'warning' | 'serious' | 'critical';
/* Status is carried by an icon plus a label, never by colour alone — the
light-surface status steps are deliberately below 3:1. */
light-surface status steps sit below 3:1 by design. */
const STATUS_ICON: Record<Status, string> = {
good: '●',
warning: '▲',
@@ -22,21 +24,36 @@ interface StatTileProps {
statusLabel?: string;
/** 01; draws a goal meter under the value. */
progress?: number | null;
/** Recent history for an inline trend. */
trend?: Array<number | null | undefined>;
decimals?: number;
}
function StatTile({
label, value, unit, detail, status, statusLabel, progress,
label, value, unit, detail, status, statusLabel, progress, trend, decimals = 0,
}: StatTileProps) {
const numeric = typeof value === 'number' ? value : null;
const animated = useCountUp(numeric);
const display =
value == null
? '—'
: typeof value === 'number'
? value.toLocaleString(undefined, { maximumFractionDigits: 1 })
: numeric != null
? (animated ?? numeric).toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
})
: value;
return (
<div className="tile">
<div className="tile-label">{label}</div>
<div className="tile-head">
<span className="tile-label">{label}</span>
{trend && trend.some((v) => v != null) && (
<Sparkline values={trend} label={`${label}近期走势`} />
)}
</div>
<div className="tile-value">
{display}
{unit && value != null && <span className="tile-unit">{unit}</span>}