2026-01-07 09:31:02 +00:00
|
|
|
import React from 'react';
|
|
|
|
|
import { Card } from '../ui/card';
|
|
|
|
|
import { ArrowUp, ArrowDown } from 'lucide-react';
|
2026-02-09 23:19:18 +00:00
|
|
|
import { AnimatedNumber } from '../ui/AnimatedNumber';
|
2026-01-07 09:31:02 +00:00
|
|
|
|
|
|
|
|
interface StatCardProps {
|
|
|
|
|
label: string;
|
|
|
|
|
value: string | number;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
trend?: string | number; // String like "+12%" or raw number
|
|
|
|
|
color?: 'cyan' | 'magenta' | 'lime' | 'gold' | 'red';
|
|
|
|
|
sparklineData?: number[];
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
export const StatCard: React.FC<StatCardProps> = ({
|
|
|
|
|
label,
|
|
|
|
|
value,
|
|
|
|
|
icon,
|
|
|
|
|
trend,
|
|
|
|
|
color = 'cyan',
|
|
|
|
|
sparklineData,
|
|
|
|
|
}) => {
|
2026-02-12 01:09:29 +00:00
|
|
|
/* Semantic tokens (audit P3: now uses primary/muted/success/warning/destructive) */
|
2026-01-07 09:31:02 +00:00
|
|
|
const colorMap = {
|
2026-02-07 18:34:45 +00:00
|
|
|
cyan: 'text-primary',
|
|
|
|
|
magenta: 'text-secondary',
|
|
|
|
|
lime: 'text-success',
|
|
|
|
|
gold: 'text-warning',
|
|
|
|
|
red: 'text-destructive',
|
2026-01-07 09:31:02 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const bgMap = {
|
2026-02-07 18:34:45 +00:00
|
|
|
cyan: 'bg-primary/10',
|
|
|
|
|
magenta: 'bg-secondary/10',
|
|
|
|
|
lime: 'bg-success/10',
|
|
|
|
|
gold: 'bg-warning/10',
|
|
|
|
|
red: 'bg-destructive/10',
|
2026-01-07 09:31:02 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderSparkline = (data: number[]) => {
|
2026-01-13 18:47:57 +00:00
|
|
|
if (!data || data.length < 2) return null;
|
|
|
|
|
const min = Math.min(...data);
|
|
|
|
|
const max = Math.max(...data);
|
|
|
|
|
const range = max - min || 1;
|
|
|
|
|
const width = 100;
|
|
|
|
|
const height = 40;
|
|
|
|
|
|
2026-01-26 13:12:17 +00:00
|
|
|
const getPathData = (d: number[]) => {
|
|
|
|
|
const points = d.map((val, i) => ({
|
|
|
|
|
x: (i / (d.length - 1)) * width,
|
|
|
|
|
y: height - ((val - min) / range) * height,
|
|
|
|
|
}));
|
|
|
|
|
|
2026-02-12 22:12:35 +00:00
|
|
|
const first = points[0];
|
|
|
|
|
if (!first) return '';
|
|
|
|
|
let pathStr = `M ${first.x},${first.y}`;
|
2026-01-26 13:12:17 +00:00
|
|
|
for (let i = 0; i < points.length - 1; i++) {
|
|
|
|
|
const curr = points[i];
|
|
|
|
|
const next = points[i + 1];
|
2026-02-12 22:12:35 +00:00
|
|
|
if (!curr || !next) continue;
|
2026-01-26 13:12:17 +00:00
|
|
|
const mx = (curr.x + next.x) / 2;
|
|
|
|
|
pathStr += ` C ${mx},${curr.y} ${mx},${next.y} ${next.x},${next.y}`;
|
|
|
|
|
}
|
|
|
|
|
return pathStr;
|
|
|
|
|
};
|
2026-01-07 09:31:02 +00:00
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
return (
|
|
|
|
|
<svg
|
|
|
|
|
width="100%"
|
|
|
|
|
height={height}
|
|
|
|
|
viewBox={`0 0 ${width} ${height}`}
|
2026-01-26 13:12:17 +00:00
|
|
|
className="opacity-60 overflow-visible"
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
2026-01-26 13:12:17 +00:00
|
|
|
<path
|
|
|
|
|
d={getPathData(data)}
|
2026-01-13 18:47:57 +00:00
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
2026-01-26 13:12:17 +00:00
|
|
|
strokeWidth="2.5"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
2026-01-13 18:47:57 +00:00
|
|
|
vectorEffect="non-scaling-stroke"
|
feat(web): UI premium Discord/Spotify-like — tokens, shadows, focus, layout
Plan UI premium 6–8 semaines (design system, shell, Storybook, a11y):
- Design system: DESIGN_TOKENS.md, APP_SHELL.md, FULL_LAYOUT_PAGE.md. Single source
for layout/shell (index.css), shadows (design-system.css), durations/easing.
- Tokens: shadow-cover-depth, shadow-gold-glow, shadow-fab-glow; layout max-height
(max-h-layout-drawer, max-h-layout-panel, max-h-layout-list). All duration-200/300/500
replaced by --duration-fast/normal/slow. Arbitrary shadows replaced by token classes.
- Shell & player: Sidebar, Header, GlobalPlayer, MiniPlayer, PlayerQueue, PlayerControls,
AudioPlayer use tokens; focus-visible on Sidebar, PlayerQueue, DropdownMenuTrigger/Item,
TabsTrigger. Typography: text-[10px]/[9px] → text-xs where applicable.
- ESLint: no-restricted-syntax (warn) for w-/h-/rounded-/shadow-/text-/spacing arbitrary.
- Scripts: report-arbitrary-values.mjs, capture/compare/generate visual; visual-complete.spec.ts.
- Stories full layout: Dashboard, Playlists, Library, Settings, Profile in DashboardLayout.stories.
- .cursorrules + README: DESIGN_TOKENS, APP_SHELL, visual commands, no arbitrary without justification.
- apps/web/.gitignore: e2e test artifacts (test-results-visual, playwright-report-visual).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-08 16:15:58 +00:00
|
|
|
className="drop-shadow-stat-sparkline"
|
2026-01-13 18:47:57 +00:00
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
2026-01-07 09:31:02 +00:00
|
|
|
};
|
|
|
|
|
|
2026-01-13 18:47:57 +00:00
|
|
|
const isPositive =
|
|
|
|
|
typeof trend === 'string' ? !trend.startsWith('-') : (trend || 0) >= 0;
|
2026-01-07 09:31:02 +00:00
|
|
|
const trendValue = typeof trend === 'number' ? `${Math.abs(trend)}%` : trend;
|
|
|
|
|
|
|
|
|
|
return (
|
2026-01-13 18:47:57 +00:00
|
|
|
<Card
|
2026-02-07 18:52:24 +00:00
|
|
|
variant="surface"
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
className="flex flex-col justify-between h-full p-5 relative overflow-hidden rounded-xl"
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<div className="flex justify-between items-start gap-4 relative z-10">
|
|
|
|
|
<div className="min-w-0">
|
|
|
|
|
<p className="text-xs font-medium text-muted-foreground mb-0.5">
|
2026-01-13 18:47:57 +00:00
|
|
|
{label}
|
|
|
|
|
</p>
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<h3 className="text-xl font-semibold text-foreground tracking-tight truncate">
|
2026-02-09 23:19:18 +00:00
|
|
|
{typeof value === 'number' ? (
|
|
|
|
|
<AnimatedNumber value={value} />
|
|
|
|
|
) : (
|
|
|
|
|
value
|
|
|
|
|
)}
|
2026-01-13 18:47:57 +00:00
|
|
|
</h3>
|
|
|
|
|
</div>
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<div className={`p-2 rounded-lg shrink-0 ${bgMap[color]} ${colorMap[color]}`}>
|
2026-01-13 18:47:57 +00:00
|
|
|
{icon}
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<div className="relative z-10 flex items-center gap-1.5 mt-3">
|
2026-01-13 18:47:57 +00:00
|
|
|
{trend && (
|
|
|
|
|
<div
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
className={`flex items-center gap-1 text-xs font-medium ${isPositive ? 'text-success' : 'text-destructive'}`}
|
2026-01-13 18:47:57 +00:00
|
|
|
>
|
|
|
|
|
{isPositive ? (
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<ArrowUp className="w-3 h-3 shrink-0" />
|
2026-01-13 18:47:57 +00:00
|
|
|
) : (
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<ArrowDown className="w-3 h-3 shrink-0" />
|
2026-01-13 18:47:57 +00:00
|
|
|
)}
|
style(ui): Spotify-like palette, player, sidebar and dashboard polish
- Dark palette: background 0.11, card 0.14, sidebar 0.08 (Spotify #121212 feel)
- MiniPlayer: h-16, thinner progress bar, compact cover/times, rounded actions
- Sidebar: minimal header, lighter section labels, clean nav hover (no heavy border)
- Header: h-16, rounded search pill, compact user pill and dropdown
- Dashboard: pt-20 to match header, StatCard typography and spacing tweaks
- Visual: register-page snapshot + small maxDiffPixels tolerance for font variance
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 19:19:59 +00:00
|
|
|
<span>{trendValue}</span>
|
2026-02-07 18:34:45 +00:00
|
|
|
<span className="text-muted-foreground font-normal">vs last period</span>
|
2026-01-13 18:47:57 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2026-01-07 09:31:02 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{sparklineData && (
|
2026-01-13 18:47:57 +00:00
|
|
|
<div
|
|
|
|
|
className={`absolute bottom-0 left-0 right-0 h-12 ${colorMap[color]} opacity-20 pointer-events-none`}
|
|
|
|
|
>
|
|
|
|
|
{renderSparkline(sparklineData)}
|
|
|
|
|
</div>
|
2026-01-07 09:31:02 +00:00
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
);
|
|
|
|
|
};
|