From 757afdc94148bea548e347d95d3d95014ade95e1 Mon Sep 17 00:00:00 2001 From: ericwyuan Date: Sun, 23 Aug 2026 22:48:09 +0800 Subject: [PATCH] =?UTF-8?q?[=E9=98=B6=E6=AE=B59]=20=E7=95=8C=E9=9D=A2?= =?UTF-8?q?=E6=89=93=E7=A3=A8=EF=BC=9Ahero=20=E5=9C=86=E7=8E=AF=E3=80=81sp?= =?UTF-8?q?arkline=E3=80=81=E9=AA=A8=E6=9E=B6=E5=B1=8F=E3=80=81=E5=8A=A8?= =?UTF-8?q?=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 参考了 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 --- client/src/components/Skeleton.css | 66 +++++++++++++ client/src/components/Skeleton.tsx | 26 ++++++ client/src/components/charts/Chart.css | 33 +++++++ client/src/components/charts/Ring.css | 25 +++++ client/src/components/charts/Ring.tsx | 71 ++++++++++++++ client/src/components/charts/Sparkline.css | 22 +++++ client/src/components/charts/Sparkline.tsx | 89 ++++++++++++++++++ client/src/components/charts/StatTile.css | 59 +++++++++++- client/src/components/charts/StatTile.tsx | 27 +++++- client/src/lib/motion.ts | 100 ++++++++++++++++++++ client/src/pages/Achievements.tsx | 10 +- client/src/pages/Daily.tsx | 3 +- client/src/pages/Dashboard.css | 104 +++++++++++++++++++++ client/src/pages/Dashboard.tsx | 92 +++++++++++++++++- client/src/pages/Pages.css | 76 +++++++++++++++ client/src/pages/Sleep.tsx | 9 +- client/src/pages/Trends.tsx | 3 +- client/src/theme.css | 62 ++++++++---- 18 files changed, 839 insertions(+), 38 deletions(-) create mode 100644 client/src/components/Skeleton.css create mode 100644 client/src/components/Skeleton.tsx create mode 100644 client/src/components/charts/Ring.css create mode 100644 client/src/components/charts/Ring.tsx create mode 100644 client/src/components/charts/Sparkline.css create mode 100644 client/src/components/charts/Sparkline.tsx create mode 100644 client/src/lib/motion.ts create mode 100644 client/src/pages/Dashboard.css diff --git a/client/src/components/Skeleton.css b/client/src/components/Skeleton.css new file mode 100644 index 0000000..7c5e5d6 --- /dev/null +++ b/client/src/components/Skeleton.css @@ -0,0 +1,66 @@ +.skeleton-group { + display: grid; + gap: 0.75rem; +} + +.skeleton-tile { + grid-template-columns: repeat(auto-fit, minmax(148px, 1fr)); +} + +.skeleton-chart { + grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); + gap: 1rem; +} + +.skeleton-row { + grid-template-columns: 1fr; + gap: 0.5rem; +} + +.skeleton { + background: var(--surface-1); + border: 1px solid var(--border); + border-radius: var(--radius); + position: relative; + overflow: hidden; +} + +.skeleton-tile .skeleton { height: 92px; } +.skeleton-chart .skeleton { height: 268px; } +.skeleton-row .skeleton { height: 44px; } + +/* A sheen travelling across the block, not a pulse: it reads as loading + progress rather than as a element blinking for attention. */ +.skeleton::after { + content: ''; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent, + color-mix(in srgb, var(--text-primary) 6%, transparent), + transparent + ); + animation: sheen 1.4s ease-in-out infinite; +} + +@keyframes sheen { + to { transform: translateX(100%); } +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +@media (prefers-reduced-motion: reduce) { + .skeleton::after { animation: none; } +} diff --git a/client/src/components/Skeleton.tsx b/client/src/components/Skeleton.tsx new file mode 100644 index 0000000..0a2b204 --- /dev/null +++ b/client/src/components/Skeleton.tsx @@ -0,0 +1,26 @@ +import './Skeleton.css'; + +interface SkeletonProps { + /** How many placeholder blocks to draw. */ + count?: number; + variant?: 'tile' | 'chart' | 'row'; +} + +/** + * Placeholder shaped like the content that is coming. + * + * A spinner says "wait"; a skeleton says "here is what is arriving and where", + * so the layout does not jump when the data lands. + */ +function Skeleton({ count = 4, variant = 'tile' }: SkeletonProps) { + return ( +