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

100
client/src/lib/motion.ts Normal file
View File

@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'react';
/**
* Whether the viewer asked for reduced motion.
*
* Every animation in the app checks this. Vestibular disorders make sweeping
* movement genuinely unpleasant, and the setting is the user telling us so —
* so motion is skipped outright rather than merely shortened.
*/
export function usePrefersReducedMotion(): boolean {
const query = '(prefers-reduced-motion: reduce)';
const [reduced, setReduced] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches
);
useEffect(() => {
const mq = window.matchMedia(query);
const onChange = () => setReduced(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
return reduced;
}
/** Ease-out cubic: fast start, gentle settle — reads as responsive. */
const easeOut = (t: number) => 1 - Math.pow(1 - t, 3);
/**
* Animate a number from 0 to `value`.
*
* Returns the target immediately when motion is reduced, or when the value is
* not a number — a counter that ticks up to "—" would be nonsense.
*/
export function useCountUp(value: number | null, durationMs = 650): number | null {
const reduced = usePrefersReducedMotion();
const [display, setDisplay] = useState<number | null>(value);
const frame = useRef<number>();
const from = useRef(0);
useEffect(() => {
if (value == null || reduced) {
setDisplay(value);
return;
}
const start = performance.now();
const origin = from.current;
const delta = value - origin;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / durationMs);
setDisplay(origin + delta * easeOut(t));
if (t < 1) frame.current = requestAnimationFrame(tick);
else from.current = value;
};
frame.current = requestAnimationFrame(tick);
return () => {
if (frame.current) cancelAnimationFrame(frame.current);
};
}, [value, durationMs, reduced]);
return display;
}
/**
* Reveal an element once it scrolls into view.
*
* Cards below the fold animating on page load is motion the viewer never sees;
* tying it to visibility means the movement always accompanies the reveal.
*/
export function useReveal<T extends HTMLElement>() {
const ref = useRef<T | null>(null);
const [shown, setShown] = useState(false);
const reduced = usePrefersReducedMotion();
useEffect(() => {
if (reduced) {
setShown(true);
return;
}
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShown(true);
observer.disconnect();
}
},
{ rootMargin: '0px 0px -40px 0px' }
);
observer.observe(el);
return () => observer.disconnect();
}, [reduced]);
return { ref, shown };
}