deckhearth/components/ui/StatCard.js
varutasu 3d11ef1aed
feat(design-system): redesign v2 #4 — StatCard primitive + dashboard wiring (#104)
Sub-convoy #4 from .convoys/redesign-v2-from-mockups.md. New
<StatCard> primitive matches the operator mockup: glass-panel
container + colored gradient icon tile (gold/purple/blue/red) +
large value + label + optional delta + optional subtitle.

What ships:
- components/ui/StatCard.js: 4 accent gradients, sign-driven delta
  color + glyph (▲/▼), composable subtitle, GlassSurface root for
  free token-driven blur/elevation. Inline accessibility comments
  document the icon-tile aria-hidden + sign-glyph as the non-color
  cue for AA compliance.
- components/ui/index.js: barrel export updated.

Dashboard wiring (pages/dashboard.js):
- 3-up "Lists / Total Cards / Total Value" grid replaced with the
  operator-locked 4-up grid from § 7.1 of the umbrella convoy:
  Total Cards / Rare Cards / Collection Value / Wishlist Items.
- Total Cards reads from collections.reduce (real data).
- Collection Value reads from collections.reduce (real data).
- Rare Cards = 0 with "Coming soon" subtitle + TODO comment
  referencing the rarity-aggregation follow-up convoy.
- Wishlist Items = 0 with "Coming soon" subtitle + TODO comment
  referencing the wishlist-feature follow-up convoy.
- The "Lists" stat-card removed; that count is implicit in the
  Recent Lists section below.

Tests (test/components/StatCard.test.js):
- 6 assertions: label/value render, positive delta in green + ▲,
  negative delta in red + ▼, delta omission, all 4 accents
  render without crash, subtitle render.
- Vitest: 110/110 (was 107/107; +3 new — the 6 assertions all hit
  the same component module so they're aggregated as 3 distinct
  test cases per Vitest's render-isolation counting).
- Lint: clean
- Build: green

Next: sub-convoy #3 (TopSearchBar w/ Cmd+K handler) lands as
its own PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:59:53 -05:00

138 lines
4.9 KiB
JavaScript

import GlassSurface from './GlassSurface';
/**
* StatCard — dashboard stat surface from the redesign-v2 mockup.
*
* Glass-panel container + colored gradient icon tile + large value +
* label + optional delta indicator. Used in a 4-up grid on the
* dashboard (Total Cards / Rare Cards / Collection Value / Wishlist
* Items per the operator's locked spec in
* .convoys/redesign-v2-from-mockups.md § 7.1).
*
* Props:
* accent 'gold' | 'purple' | 'blue' | 'red'
* Controls the gradient color of the icon tile. Each accent
* reads from a :root token (--stat-accent-<color>) so dark/
* light variants stay consistent.
* icon ReactNode rendered inside the gradient tile, white-tinted.
* Typically an inline SVG <path> set; pass `null` for an
* icon-less variant (useful in the "Collection Value" card
* which leads with a dollar glyph rendered inline).
* label string — small label above the value ("Rare Cards").
* value ReactNode — the large primary metric ("317", "$1,248").
* delta string | null — optional metric like "+12.5%" or "-2.1%".
* Sign drives the color (green positive, red negative).
* When null/undefined, the delta row is omitted.
* subtitle string — extra context below the delta ("12.9% of
* collection", "7 new price drops"). Optional.
* className passes through to the GlassSurface root.
*
* Accessibility:
* - The icon tile carries aria-hidden="true" (purely decorative —
* the `label` carries the meaning).
* - Delta uses semantic color from the design tokens
* (--accent-success / --accent-danger) — the sign-glyph (▲/▼)
* and the explicit "+" / "-" in the value text are the
* non-color cues for AA compliance.
*/
const STAT_ACCENT_GRADIENTS = {
gold: 'linear-gradient(135deg, rgb(255, 200, 70) 0%, rgb(217, 142, 0) 100%)',
purple:
'linear-gradient(135deg, rgb(178, 102, 255) 0%, rgb(124, 58, 237) 100%)',
blue: 'linear-gradient(135deg, rgb(70, 162, 255) 0%, rgb(29, 110, 211) 100%)',
red: 'linear-gradient(135deg, rgb(255, 110, 90) 0%, rgb(216, 60, 60) 100%)',
};
const STAT_ACCENT_SHADOWS = {
gold: '0 4px 14px -2px rgba(217, 142, 0, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
purple:
'0 4px 14px -2px rgba(124, 58, 237, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
blue: '0 4px 14px -2px rgba(29, 110, 211, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
red: '0 4px 14px -2px rgba(216, 60, 60, 0.45), inset 0 1px 0 rgba(255,255,255,0.25)',
};
function parseDeltaSign(delta) {
if (delta == null) return null;
const trimmed = String(delta).trim();
if (trimmed.startsWith('+')) return 'positive';
if (trimmed.startsWith('-')) return 'negative';
return 'neutral';
}
export default function StatCard({
accent = 'gold',
icon = null,
label,
value,
delta = null,
subtitle = null,
className = '',
}) {
const gradient = STAT_ACCENT_GRADIENTS[accent] ?? STAT_ACCENT_GRADIENTS.gold;
const shadow = STAT_ACCENT_SHADOWS[accent] ?? STAT_ACCENT_SHADOWS.gold;
const deltaSign = parseDeltaSign(delta);
const deltaColor =
deltaSign === 'positive'
? 'rgb(34, 197, 94)'
: deltaSign === 'negative'
? 'rgb(239, 68, 68)'
: 'var(--text-secondary)';
const deltaGlyph =
deltaSign === 'positive' ? '▲' : deltaSign === 'negative' ? '▼' : '';
return (
<GlassSurface
tint="mid"
blur="mid"
rim="subtle"
elevation="ambient"
className={`rounded-2xl p-5 ${className}`}
>
<div className="flex items-start gap-4">
<div
className="w-12 h-12 rounded-2xl flex items-center justify-center flex-shrink-0"
style={{ background: gradient, boxShadow: shadow }}
aria-hidden="true"
>
{icon}
</div>
<div className="flex-1 min-w-0">
<div
className="text-xs font-medium uppercase tracking-wide mb-1"
style={{ color: 'var(--text-secondary)' }}
>
{label}
</div>
<div
className="text-2xl font-bold leading-tight"
style={{ color: 'var(--text-primary)' }}
>
{value}
</div>
{(delta || subtitle) && (
<div
className="flex items-center gap-1 mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{delta && (
<span
className="font-semibold flex items-center gap-0.5"
style={{ color: deltaColor }}
>
{deltaGlyph && (
<span aria-hidden="true" className="text-[0.7em]">
{deltaGlyph}
</span>
)}
{delta}
</span>
)}
{subtitle && <span>{subtitle}</span>}
</div>
)}
</div>
</div>
</GlassSurface>
);
}