[阶段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:
100
client/src/lib/motion.ts
Normal file
100
client/src/lib/motion.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user