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 ( +
+ ); +} + +export default Skeleton; diff --git a/client/src/components/charts/Chart.css b/client/src/components/charts/Chart.css index 92614f8..3f49360 100644 --- a/client/src/components/charts/Chart.css +++ b/client/src/components/charts/Chart.css @@ -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; } +} diff --git a/client/src/components/charts/Ring.css b/client/src/components/charts/Ring.css new file mode 100644 index 0000000..9bf1495 --- /dev/null +++ b/client/src/components/charts/Ring.css @@ -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; +} diff --git a/client/src/components/charts/Ring.tsx b/client/src/components/charts/Ring.tsx new file mode 100644 index 0000000..3764100 --- /dev/null +++ b/client/src/components/charts/Ring.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import { usePrefersReducedMotion } from '../../lib/motion'; +import './Ring.css'; + +interface RingProps { + /** 0–1; 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 ( +所有指标都已隐藏,点上面的标签重新显示。
diff --git a/client/src/theme.css b/client/src/theme.css index 7d08492..aab90b9 100644 --- a/client/src/theme.css +++ b/client/src/theme.css @@ -47,8 +47,16 @@ --accent-solid: #256abf; --accent-soft: #eef4fd; + /* Sparkline ink is de-emphasised so the number it accompanies stays the + figure; the ring track is a lighter step of the fill's own ramp so the + pair reads as one meter. */ + --spark-ink: #9ec5f4; + --ring-track: #dfe9f7; + --radius: 10px; --shadow: 0 1px 2px rgba(0, 0, 0, 0.04), 0 1px 8px rgba(0, 0, 0, 0.03); + --shadow-lift: 0 2px 4px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.07); + --ease: cubic-bezier(0.22, 1, 0.36, 1); } /* Dark steps are the same hues re-stepped for the dark surface — selected and @@ -57,14 +65,17 @@ @media (prefers-color-scheme: dark) { :root:where(:not([data-theme='light'])) { color-scheme: dark; - --surface-0: #131312; - --surface-1: #1a1a19; - --surface-2: #232322; - --border: #34342f; - --border-strong: #45443e; - --text-primary: #ffffff; - --text-secondary: #c3c2b7; - --text-muted: #8f8e85; + /* A cool near-black rather than a warm one: the blue-black surface is + what reads as a fitness app, and the series steps were re-validated + against it (all four still clear 3:1 and every CVD gate). */ + --surface-0: #0f1115; + --surface-1: #181b21; + --surface-2: #1f232b; + --border: #262a33; + --border-strong: #363b47; + --text-primary: #e9ecf1; + --text-secondary: #a9b1bd; + --text-muted: #78818f; --series-1: #3987e5; --series-2: #d95926; @@ -73,25 +84,32 @@ --series-5: #d55181; --series-6: #008300; - --grid: #2c2c28; + --grid: #22262e; --accent: #3987e5; --accent-solid: #1c5cab; - --accent-soft: #1d2938; + --accent-soft: #17233a; + + --spark-ink: #4a6f9e; + --ring-track: #2c3a4d; --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5); } } :root[data-theme='dark'] { color-scheme: dark; - --surface-0: #131312; - --surface-1: #1a1a19; - --surface-2: #232322; - --border: #34342f; - --border-strong: #45443e; - --text-primary: #ffffff; - --text-secondary: #c3c2b7; - --text-muted: #8f8e85; + /* A cool near-black rather than a warm one: the blue-black surface is + what reads as a fitness app, and the series steps were re-validated + against it (all four still clear 3:1 and every CVD gate). */ + --surface-0: #0f1115; + --surface-1: #181b21; + --surface-2: #1f232b; + --border: #262a33; + --border-strong: #363b47; + --text-primary: #e9ecf1; + --text-secondary: #a9b1bd; + --text-muted: #78818f; --series-1: #3987e5; --series-2: #d95926; @@ -100,10 +118,14 @@ --series-5: #d55181; --series-6: #008300; - --grid: #2c2c28; + --grid: #22262e; --accent: #3987e5; --accent-solid: #1c5cab; - --accent-soft: #1d2938; + --accent-soft: #17233a; + + --spark-ink: #4a6f9e; + --ring-track: #2c3a4d; --shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + --shadow-lift: 0 2px 6px rgba(0, 0, 0, 0.5); }