1import Link from 'next/link';2import type { ReactNode } from 'react';3import { cn, deltaClass, fmtPct } from '@/lib/format';45/** Minimal, dense UI primitives. Keep them boring; the data is the design. */67export function Card({ children, className, as: Tag = 'section', id }: { children: ReactNode; className?: string; as?: 'section' | 'div' | 'article'; id?: string }) {8 return (9 <Tag className={cn('card', className)} id={id}>10 {children}11 </Tag>12 );13}1415export function CardHeader({ title, subtitle, action, className, icon }: { title: ReactNode; subtitle?: ReactNode; action?: ReactNode; className?: string; icon?: ReactNode }) {16 return (17 <header className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)}>18 <div className="flex min-w-0 items-start gap-2">19 {icon ? <span className="mt-0.5 text-subtle">{icon}</span> : null}20 <div className="min-w-0">21 <h2 className="t-subtitle text-fg">{title}</h2>22 {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}23 </div>24 </div>25 {action ? <div className="shrink-0 text-xs">{action}</div> : null}26 </header>27 );28}2930export function Delta({ value, digits = 2, className, arrow = false }: { value: number | null | undefined; digits?: number; className?: string; arrow?: boolean }) {31 const has = value !== null && value !== undefined && Number.isFinite(value);32 const glyph = !has || Math.abs(value) < 0.00005 ? '' : value > 0 ? '▲ ' : '▼ ';33 return (34 <span className={cn('num font-medium', deltaClass(value), className)}>35 {arrow && glyph ? <span className="text-[0.7em]">{glyph}</span> : null}36 {fmtPct(value, digits)}37 </span>38 );39}4041export function Badge({ children, tone = 'neutral', className, dot = false }: { children: ReactNode; tone?: 'neutral' | 'gain' | 'loss' | 'index' | 'rarity' | 'alert' | 'gold'; className?: string; dot?: boolean }) {42 const tones: Record<string, string> = {43 neutral: 'bg-inset text-muted',44 gain: 'bg-gain-bg text-gain',45 loss: 'bg-loss-bg text-loss',46 index: 'bg-index-bg text-index',47 rarity: 'bg-rarity-bg text-rarity',48 alert: 'bg-alert-bg text-alert',49 gold: 'bg-alert-bg text-gold',50 };51 return (52 <span className={cn('inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-[11px] font-medium leading-4 tracking-wide', tones[tone], className)}>53 {dot ? <span className="h-1.5 w-1.5 rounded-full bg-current" aria-hidden /> : null}54 {children}55 </span>56 );57}5859export function Stat({ label, value, sub, className, size = 'md' }: { label: ReactNode; value: ReactNode; sub?: ReactNode; className?: string; size?: 'sm' | 'md' | 'lg' }) {60 return (61 <div className={cn('flex min-w-0 flex-col gap-0.5', className)}>62 <span className="t-label">{label}</span>63 <span className={cn('num truncate font-semibold leading-tight text-fg', size === 'lg' ? 'text-2xl' : size === 'sm' ? 'text-sm' : 'text-lg')}>{value}</span>64 {sub ? <span className="truncate text-xs text-muted">{sub}</span> : null}65 </div>66 );67}6869/** Dense KPI strip used under page headers and in the hero. */70export function StatStrip({ items, className, cols }: { items: Array<{ label: ReactNode; value: ReactNode; sub?: ReactNode; href?: string }>; className?: string; cols?: string }) {71 const grid = cols ?? (items.length >= 8 ? 'grid-cols-2 sm:grid-cols-4 lg:grid-cols-8' : items.length >= 6 ? 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6' : items.length === 5 ? 'grid-cols-2 sm:grid-cols-5' : items.length === 4 ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-3');72 return (73 <dl className={cn('grid gap-px overflow-hidden rounded-md border border-border bg-border', grid, className)}>74 {items.map((it, i) => {75 const inner = (76 <>77 <dt className="t-label">{it.label}</dt>78 <dd className="num mt-1 truncate text-[17px] font-semibold leading-none text-fg sm:text-lg">{it.value}</dd>79 {it.sub ? <dd className="mt-1 truncate text-[11px] text-muted">{it.sub}</dd> : null}80 </>81 );82 return it.href ? (83 <Link key={i} href={it.href} className="block min-w-0 bg-elevated px-3 py-2.5 hover:bg-sunken">84 {inner}85 </Link>86 ) : (87 <div key={i} className="min-w-0 bg-elevated px-3 py-2.5">88 {inner}89 </div>90 );91 })}92 </dl>93 );94}9596export function EmptyState({ title, description, action, className, compact }: { title: ReactNode; description?: ReactNode; action?: ReactNode; className?: string; compact?: boolean }) {97 return (98 <div className={cn('flex flex-col items-center justify-center gap-1.5 px-6 text-center', compact ? 'py-6' : 'py-12', className)}>99 <span className="mb-1 h-1 w-8 rounded-full bg-inset" aria-hidden />100 <p className="text-sm font-medium text-fg">{title}</p>101 {description ? <p className="max-w-md text-xs leading-relaxed text-muted">{description}</p> : null}102 {action ? <div className="mt-2">{action}</div> : null}103 </div>104 );105}106107/** Honest placeholder for metrics without enough evidence (§191–§192). */108export function Unavailable({ reason = 'Data unavailable', className }: { reason?: string; className?: string }) {109 return (110 <span className={cn('text-subtle', className)} title={reason}>111 —112 </span>113 );114}115116export function SectionTitle({ children, href, hrefLabel = 'View all', className, subtitle }: { children: ReactNode; href?: string; hrefLabel?: string; className?: string; subtitle?: ReactNode }) {117 return (118 <div className={cn('mb-3 flex items-end justify-between gap-3', className)}>119 <div className="min-w-0">120 <h2 className="t-title text-fg">{children}</h2>121 {subtitle ? <p className="mt-0.5 text-xs text-muted">{subtitle}</p> : null}122 </div>123 {href ? (124 <Link href={href} className="shrink-0 text-xs font-medium text-muted hover:text-fg">125 {hrefLabel} →126 </Link>127 ) : null}128 </div>129 );130}131132export function Skeleton({ className }: { className?: string }) {133 return <div className={cn('animate-pulse rounded-sm bg-inset', className)} aria-hidden />;134}135136/** Skeleton matching a dense table (header + n rows) to avoid layout shift. */137export function TableSkeleton({ rows = 8, className }: { rows?: number; className?: string }) {138 return (139 <div className={cn('card overflow-hidden', className)} aria-hidden>140 <div className="h-9 border-b border-border bg-elevated" />141 {Array.from({ length: rows }).map((_, i) => (142 <div key={i} className="flex items-center gap-3 border-b border-border px-3 py-2 last:border-b-0">143 <Skeleton className="h-8 w-8" />144 <Skeleton className="h-3 w-1/3" />145 <Skeleton className="ml-auto h-3 w-16" />146 <Skeleton className="h-3 w-12" />147 </div>148 ))}149 </div>150 );151}152153export function Table({ children, className, dense = true, sticky = true }: { children: ReactNode; className?: string; dense?: boolean; sticky?: boolean }) {154 return (155 <div className={cn('w-full overflow-x-auto overscroll-x-contain scrollbar-none', className)}>156 <table className={cn('w-full border-collapse text-left', dense ? 'text-[13px]' : 'text-sm', sticky && 'table-sticky md:[&_td:first-child]:static md:[&_th:first-child]:static md:[&_td:first-child]:shadow-none md:[&_th:first-child]:shadow-none')}>{children}</table>157 </div>158 );159}160export const th = 'sticky top-0 z-[1] whitespace-nowrap border-b border-border bg-elevated px-3 py-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-subtle';161export const td = 'whitespace-nowrap border-b border-border px-3 py-2 align-middle';162export const tdNum = `${td} num text-right`;163164/** Sequential page "kicker" — small uppercase label above a title. */165export function Kicker({ children, className }: { children: ReactNode; className?: string }) {166 return <p className={cn('t-label mb-2', className)}>{children}</p>;167}168169export function Divider({ className }: { className?: string }) {170 return <hr className={cn('border-0 border-t border-border', className)} />;171}172