diff --git a/src/design-system/primitives/Card/Card.module.css b/src/design-system/primitives/Card/Card.module.css new file mode 100644 index 0000000..aa9085b --- /dev/null +++ b/src/design-system/primitives/Card/Card.module.css @@ -0,0 +1,13 @@ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.accent-amber { border-top: 3px solid var(--amber); } +.accent-success { border-top: 3px solid var(--success); } +.accent-warning { border-top: 3px solid var(--warning); } +.accent-error { border-top: 3px solid var(--error); } +.accent-running { border-top: 3px solid var(--running); } diff --git a/src/design-system/primitives/Card/Card.tsx b/src/design-system/primitives/Card/Card.tsx new file mode 100644 index 0000000..e6656d4 --- /dev/null +++ b/src/design-system/primitives/Card/Card.tsx @@ -0,0 +1,18 @@ +import styles from './Card.module.css' +import type { ReactNode } from 'react' + +interface CardProps { + children: ReactNode + accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' | 'none' + className?: string +} + +export function Card({ children, accent = 'none', className }: CardProps) { + const classes = [ + styles.card, + accent !== 'none' ? styles[`accent-${accent}`] : '', + className ?? '', + ].filter(Boolean).join(' ') + + return
{children}
+} diff --git a/src/design-system/primitives/FilterPill/FilterPill.module.css b/src/design-system/primitives/FilterPill/FilterPill.module.css new file mode 100644 index 0000000..d588449 --- /dev/null +++ b/src/design-system/primitives/FilterPill/FilterPill.module.css @@ -0,0 +1,54 @@ +.pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--bg-raised); + color: var(--text-secondary); + font-family: var(--font-body); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.pill:hover { + border-color: var(--text-faint); + color: var(--text-primary); +} + +.active { + background: var(--amber-bg); + border-color: var(--amber-light); + color: var(--amber-deep); + font-weight: 600; +} + +.dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--text-faint); + flex-shrink: 0; +} + +.label { + line-height: 1; +} + +.count { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + background: var(--bg-inset); + border-radius: 8px; + padding: 0 5px; + line-height: 1.6; +} + +.active .count { + background: var(--amber-light); +} diff --git a/src/design-system/primitives/FilterPill/FilterPill.tsx b/src/design-system/primitives/FilterPill/FilterPill.tsx new file mode 100644 index 0000000..90173f6 --- /dev/null +++ b/src/design-system/primitives/FilterPill/FilterPill.tsx @@ -0,0 +1,42 @@ +import styles from './FilterPill.module.css' + +interface FilterPillProps { + label: string + count?: number + active?: boolean + dot?: boolean + dotColor?: string + onClick?: () => void + className?: string +} + +export function FilterPill({ + label, + count, + active = false, + dot = false, + dotColor, + onClick, + className, +}: FilterPillProps) { + const classes = [ + styles.pill, + active ? styles.active : '', + className ?? '', + ].filter(Boolean).join(' ') + + return ( + + ) +} diff --git a/src/design-system/primitives/Sparkline/Sparkline.test.tsx b/src/design-system/primitives/Sparkline/Sparkline.test.tsx new file mode 100644 index 0000000..a0210db --- /dev/null +++ b/src/design-system/primitives/Sparkline/Sparkline.test.tsx @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import { Sparkline } from './Sparkline' + +describe('Sparkline', () => { + it('renders an SVG with a polyline', () => { + const { container } = render() + expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelector('polyline')).toBeInTheDocument() + }) + + it('returns null for less than 2 data points', () => { + const { container } = render() + expect(container.firstChild).toBeNull() + }) +}) diff --git a/src/design-system/primitives/Sparkline/Sparkline.tsx b/src/design-system/primitives/Sparkline/Sparkline.tsx new file mode 100644 index 0000000..93f06c7 --- /dev/null +++ b/src/design-system/primitives/Sparkline/Sparkline.tsx @@ -0,0 +1,51 @@ +interface SparklineProps { + data: number[] + color?: string + width?: number + height?: number + strokeWidth?: number + className?: string +} + +export function Sparkline({ + data, + color = 'var(--amber)', + width = 60, + height = 20, + strokeWidth = 1.5, + className, +}: SparklineProps) { + if (data.length < 2) return null + + const max = Math.max(...data) + const min = Math.min(...data) + const range = max - min || 1 + const padding = 1 + + const points = data + .map((val, i) => { + const x = (i / (data.length - 1)) * (width - padding * 2) + padding + const y = height - padding - ((val - min) / range) * (height - padding * 2) + return `${x},${y}` + }) + .join(' ') + + return ( + + ) +} diff --git a/src/design-system/primitives/StatCard/StatCard.module.css b/src/design-system/primitives/StatCard/StatCard.module.css new file mode 100644 index 0000000..4e4d4fc --- /dev/null +++ b/src/design-system/primitives/StatCard/StatCard.module.css @@ -0,0 +1,62 @@ +.card { + background: var(--bg-surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-card); + padding: 16px 18px; + overflow: hidden; +} + +.accent-amber { border-top: 3px solid var(--amber); } +.accent-success { border-top: 3px solid var(--success); } +.accent-warning { border-top: 3px solid var(--warning); } +.accent-error { border-top: 3px solid var(--error); } +.accent-running { border-top: 3px solid var(--running); } + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-muted); +} + +.sparkline { + opacity: 0.7; +} + +.valueRow { + display: flex; + align-items: baseline; + gap: 8px; +} + +.value { + font-family: var(--font-mono); + font-size: 28px; + font-weight: 600; + color: var(--text-primary); + line-height: 1; +} + +.trend { + font-size: 11px; + font-weight: 500; +} + +.trend.up { color: var(--success); } +.trend.down { color: var(--error); } +.trend.neutral { color: var(--text-muted); } + +.detail { + font-size: 11px; + color: var(--text-muted); + margin-top: 6px; +} diff --git a/src/design-system/primitives/StatCard/StatCard.tsx b/src/design-system/primitives/StatCard/StatCard.tsx new file mode 100644 index 0000000..ac13196 --- /dev/null +++ b/src/design-system/primitives/StatCard/StatCard.tsx @@ -0,0 +1,48 @@ +import styles from './StatCard.module.css' +import { Sparkline } from '../Sparkline/Sparkline' + +interface StatCardProps { + label: string + value: string | number + detail?: string + trend?: 'up' | 'down' | 'neutral' + trendValue?: string + accent?: 'amber' | 'success' | 'warning' | 'error' | 'running' + sparkline?: number[] + className?: string +} + +const TREND_ICONS = { + up: '↑', + down: '↓', + neutral: '→', +} + +export function StatCard({ + label, + value, + detail, + trend, + trendValue, + accent = 'amber', + sparkline, + className, +}: StatCardProps) { + return ( +
+
+ {label} + {sparkline && } +
+
+ {value} + {trend && trendValue && ( + + {TREND_ICONS[trend]} {trendValue} + + )} +
+ {detail &&
{detail}
} +
+ ) +}