[阶段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

@@ -169,3 +169,36 @@
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.viz {
transition: box-shadow 0.2s var(--ease), border-color 0.2s var(--ease);
animation: viz-in 0.45s var(--ease) both;
}
.viz:hover {
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
@keyframes viz-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: none; }
}
.chart-grid > .viz:nth-child(2) { animation-delay: 60ms; }
.chart-grid > .viz:nth-child(3) { animation-delay: 120ms; }
.chart-grid > .viz:nth-child(4) { animation-delay: 180ms; }
.chart-grid > .viz:nth-child(n + 5) { animation-delay: 220ms; }
.viz-toggle {
transition: border-color 0.15s var(--ease), color 0.15s var(--ease),
background 0.15s var(--ease);
}
.viz-toggle:hover {
background: var(--surface-0);
}
@media (prefers-reduced-motion: reduce) {
.viz { animation: none; transition: none; }
}

View File

@@ -0,0 +1,25 @@
.ring {
position: relative;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ring svg {
position: absolute;
inset: 0;
}
.ring-fill {
transition: stroke-dashoffset 1.1s cubic-bezier(0.22, 1, 0.36, 1),
stroke 0.3s ease;
}
.ring-center {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.1rem;
text-align: center;
}

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import { usePrefersReducedMotion } from '../../lib/motion';
import './Ring.css';
interface RingProps {
/** 01; values above 1 are drawn as a full ring plus an overshoot mark. */
progress: number | null;
size?: number;
thickness?: number;
children?: React.ReactNode;
label?: string;
}
/**
* A goal ring.
*
* The track is a lighter step of the fill's own ramp rather than plain grey,
* so the pair reads as one meter at a glance. Progress past 100% keeps the
* ring full and adds a small cap mark instead of wrapping a second lap, which
* would read as a much smaller number than it is.
*/
function Ring({ progress, size = 132, thickness = 11, children, label }: RingProps) {
const reduced = usePrefersReducedMotion();
const [shown, setShown] = useState(reduced ? progress ?? 0 : 0);
useEffect(() => {
if (progress == null) return;
if (reduced) { setShown(progress); return; }
// One frame's delay so the browser paints the empty ring first and the
// transition actually runs.
const id = requestAnimationFrame(() => setShown(progress));
return () => cancelAnimationFrame(id);
}, [progress, reduced]);
const radius = (size - thickness) / 2;
const circumference = 2 * Math.PI * radius;
const clamped = Math.min(1, Math.max(0, shown));
const offset = circumference * (1 - clamped);
const over = (progress ?? 0) > 1;
return (
<div className="ring" style={{ width: size, height: size }}>
<svg width={size} height={size} role="img" aria-label={label}>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--ring-track)"
strokeWidth={thickness}
/>
<circle
className={reduced ? undefined : 'ring-fill'}
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={over ? 'var(--status-good)' : 'var(--accent)'}
strokeWidth={thickness}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={offset}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</svg>
<div className="ring-center">{children}</div>
</div>
);
}
export default Ring;

View File

@@ -0,0 +1,22 @@
.spark {
display: block;
overflow: visible;
}
.spark-empty {
display: inline-block;
width: 68px;
height: 22px;
}
/* Draw the line on rather than fading it in — the stroke reveal traces the
shape the reader is about to interpret. */
.spark-path {
stroke-dasharray: 240;
stroke-dashoffset: 240;
animation: spark-draw 0.9s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
@keyframes spark-draw {
to { stroke-dashoffset: 0; }
}

View File

@@ -0,0 +1,89 @@
import { useMemo } from 'react';
import { usePrefersReducedMotion } from '../../lib/motion';
import './Sparkline.css';
interface SparklineProps {
values: Array<number | null | undefined>;
/** Points to keep, newest last. */
points?: number;
width?: number;
height?: number;
label?: string;
}
/**
* A tiny trend beside a stat.
*
* The line sits in a de-emphasised ink and only the latest point wears the
* accent, so the sparkline reads as context for the number it accompanies
* rather than competing with it. It is decorative-by-position but not
* decorative-by-meaning, so it carries an accessible label and is hidden from
* the tree only when one is not supplied.
*/
function Sparkline({ values, points = 12, width = 68, height = 22, label }: SparklineProps) {
const reduced = usePrefersReducedMotion();
const geometry = useMemo(() => {
const recent = values.slice(-points);
const present = recent.filter((v): v is number => v != null);
if (present.length < 2) return null;
const min = Math.min(...present);
const max = Math.max(...present);
const span = max - min || 1;
const step = width / Math.max(recent.length - 1, 1);
const coords: Array<[number, number] | null> = recent.map((v, i) =>
v == null ? null : [i * step, height - ((v - min) / span) * (height - 4) - 2]
);
// Nulls break the path rather than being interpolated: a flat segment
// across a day the device recorded nothing would be an invented value.
let d = '';
let pen = false;
for (const c of coords) {
if (!c) { pen = false; continue; }
d += `${pen ? 'L' : 'M'}${c[0].toFixed(1)},${c[1].toFixed(1)} `;
pen = true;
}
const last = [...coords].reverse().find((c): c is [number, number] => c != null);
return { d: d.trim(), last, length: recent.length };
}, [values, points, width, height]);
if (!geometry) return <span className="spark-empty" aria-hidden="true" />;
return (
<svg
className="spark"
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
>
<path
className={reduced ? undefined : 'spark-path'}
d={geometry.d}
fill="none"
stroke="var(--spark-ink)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
{geometry.last && (
<circle
cx={geometry.last[0]}
cy={geometry.last[1]}
r={2.5}
fill="var(--accent)"
stroke="var(--surface-1)"
strokeWidth={1.5}
/>
)}
</svg>
);
}
export default Sparkline;

View File

@@ -4,12 +4,28 @@
border-radius: var(--radius);
padding: 0.85rem 1rem;
box-shadow: var(--shadow);
transition: transform 0.2s var(--ease), box-shadow 0.2s var(--ease),
border-color 0.2s var(--ease);
}
.tile:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
border-color: var(--border-strong);
}
.tile-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.3rem;
min-height: 22px;
}
.tile-label {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.35rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -20,7 +36,9 @@
font-weight: 650;
color: var(--text-primary);
line-height: 1.15;
font-variant-numeric: tabular-nums;
/* Proportional figures on purpose: tabular-nums gives every digit the width
of a zero, which makes a large standalone number look loose. Tabular is
reserved for columns that must align (tables, axis ticks). */
}
.tile-unit {
@@ -32,7 +50,7 @@
.tile-meter {
height: 4px;
background: var(--surface-0);
background: var(--ring-track);
border-radius: 999px;
margin-top: 0.55rem;
overflow: hidden;
@@ -42,6 +60,7 @@
height: 100%;
background: var(--accent);
border-radius: 999px;
transition: width 1s var(--ease);
}
.tile-detail {
@@ -66,10 +85,29 @@
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
gap: 0.75rem;
}
/* Cards arrive in sequence rather than all at once, so the eye is led across
the group instead of being hit by the whole grid at the same instant. */
.tile-grid > * {
animation: tile-in 0.42s var(--ease) both;
}
.tile-grid > *:nth-child(1) { animation-delay: 0ms; }
.tile-grid > *:nth-child(2) { animation-delay: 45ms; }
.tile-grid > *:nth-child(3) { animation-delay: 90ms; }
.tile-grid > *:nth-child(4) { animation-delay: 135ms; }
.tile-grid > *:nth-child(5) { animation-delay: 180ms; }
.tile-grid > *:nth-child(6) { animation-delay: 225ms; }
.tile-grid > *:nth-child(n + 7) { animation-delay: 270ms; }
@keyframes tile-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
@media (max-width: 560px) {
.tile-grid {
grid-template-columns: repeat(2, 1fr);
@@ -78,3 +116,16 @@
font-size: 1.3rem;
}
}
@media (prefers-reduced-motion: reduce) {
.tile,
.tile-meter-fill {
transition: none;
}
.tile:hover {
transform: none;
}
.tile-grid > * {
animation: none;
}
}

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>}