From 36d65397757b32a0d40d487ea2b17cccde85e95c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:30:59 +0100 Subject: [PATCH] feat: Badge, Avatar, Tag primitives with hashColor integration Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/Avatar/Avatar.module.css | 12 +++++ .../primitives/Avatar/Avatar.tsx | 29 +++++++++++ .../primitives/Badge/Badge.module.css | 43 +++++++++++++++ .../primitives/Badge/Badge.test.tsx | 43 +++++++++++++++ src/design-system/primitives/Badge/Badge.tsx | 52 +++++++++++++++++++ .../primitives/Tag/Tag.module.css | 28 ++++++++++ src/design-system/primitives/Tag/Tag.tsx | 35 +++++++++++++ 7 files changed, 242 insertions(+) create mode 100644 src/design-system/primitives/Avatar/Avatar.module.css create mode 100644 src/design-system/primitives/Avatar/Avatar.tsx create mode 100644 src/design-system/primitives/Badge/Badge.module.css create mode 100644 src/design-system/primitives/Badge/Badge.test.tsx create mode 100644 src/design-system/primitives/Badge/Badge.tsx create mode 100644 src/design-system/primitives/Tag/Tag.module.css create mode 100644 src/design-system/primitives/Tag/Tag.tsx diff --git a/src/design-system/primitives/Avatar/Avatar.module.css b/src/design-system/primitives/Avatar/Avatar.module.css new file mode 100644 index 0000000..8e10927 --- /dev/null +++ b/src/design-system/primitives/Avatar/Avatar.module.css @@ -0,0 +1,12 @@ +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-weight: 600; + border: 1px solid; + flex-shrink: 0; +} +.sm { width: 24px; height: 24px; font-size: 9px; } +.md { width: 28px; height: 28px; font-size: 11px; } +.lg { width: 40px; height: 40px; font-size: 14px; } diff --git a/src/design-system/primitives/Avatar/Avatar.tsx b/src/design-system/primitives/Avatar/Avatar.tsx new file mode 100644 index 0000000..109ad8e --- /dev/null +++ b/src/design-system/primitives/Avatar/Avatar.tsx @@ -0,0 +1,29 @@ +import styles from './Avatar.module.css' +import { hashColor } from '../../utils/hashColor' +import { useTheme } from '../../providers/ThemeProvider' + +interface AvatarProps { + name: string + size?: 'sm' | 'md' | 'lg' + className?: string +} + +function getInitials(name: string): string { + const parts = name.split(/[\s-]+/).filter(Boolean) + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() + return name.slice(0, 2).toUpperCase() +} + +export function Avatar({ name, size = 'md', className }: AvatarProps) { + const { theme } = useTheme() + const colors = hashColor(name, theme) + return ( + + {getInitials(name)} + + ) +} diff --git a/src/design-system/primitives/Badge/Badge.module.css b/src/design-system/primitives/Badge/Badge.module.css new file mode 100644 index 0000000..baf9777 --- /dev/null +++ b/src/design-system/primitives/Badge/Badge.module.css @@ -0,0 +1,43 @@ +.badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px 8px; + border-radius: 10px; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + white-space: nowrap; + border: 1px solid transparent; + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.filled { /* default — inline styles from hashColor or semantic class */ } + +.outlined { + background: transparent !important; +} + +.dashed { + background: transparent !important; + border-style: dashed; +} + +.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); } +.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); } +.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); } +.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); } +.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); } + +.remove { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 12px; + line-height: 1; + opacity: 0.5; + padding: 0; +} +.remove:hover { opacity: 1; } diff --git a/src/design-system/primitives/Badge/Badge.test.tsx b/src/design-system/primitives/Badge/Badge.test.tsx new file mode 100644 index 0000000..6c02041 --- /dev/null +++ b/src/design-system/primitives/Badge/Badge.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { ThemeProvider } from '../../providers/ThemeProvider' +import { Badge } from './Badge' + +describe('Badge', () => { + it('renders label text', () => { + render( + + + , + ) + expect(screen.getByText('VIEWER')).toBeInTheDocument() + }) + + it('applies inline hash color when color is auto', () => { + const { container } = render( + + + , + ) + const badge = container.firstChild as HTMLElement + expect(badge.style.backgroundColor).toBeTruthy() + }) + + it('applies semantic color class when specified', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toHaveClass('error') + }) + + it('applies dashed variant class', () => { + const { container } = render( + + + , + ) + expect(container.firstChild).toHaveClass('dashed') + }) +}) diff --git a/src/design-system/primitives/Badge/Badge.tsx b/src/design-system/primitives/Badge/Badge.tsx new file mode 100644 index 0000000..335b784 --- /dev/null +++ b/src/design-system/primitives/Badge/Badge.tsx @@ -0,0 +1,52 @@ +import styles from './Badge.module.css' +import { hashColor } from '../../utils/hashColor' +import { useTheme } from '../../providers/ThemeProvider' + +type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' +type BadgeVariant = 'filled' | 'outlined' | 'dashed' + +interface BadgeProps { + label: string + variant?: BadgeVariant + color?: BadgeColor + onRemove?: () => void + className?: string +} + +export function Badge({ + label, + variant = 'filled', + color = 'auto', + onRemove, + className, +}: BadgeProps) { + const { theme } = useTheme() + const isAuto = color === 'auto' + const hashColors = isAuto ? hashColor(label, theme) : null + + const inlineStyle = isAuto + ? { + backgroundColor: variant === 'filled' ? hashColors!.bg : 'transparent', + color: hashColors!.text, + borderColor: hashColors!.border, + } + : undefined + + const classes = [ + styles.badge, + styles[variant], + !isAuto ? styles[color] : '', + className ?? '', + ].filter(Boolean).join(' ') + + return ( + + {label} + {onRemove && ( + + )} + + ) +} diff --git a/src/design-system/primitives/Tag/Tag.module.css b/src/design-system/primitives/Tag/Tag.module.css new file mode 100644 index 0000000..9406933 --- /dev/null +++ b/src/design-system/primitives/Tag/Tag.module.css @@ -0,0 +1,28 @@ +.tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border: 1px solid; + border-radius: 20px; + font-size: 11px; + font-family: var(--font-mono); +} + +.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); } +.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); } +.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); } +.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); } +.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); } + +.remove { + cursor: pointer; + opacity: 0.5; + font-size: 13px; + line-height: 1; + background: none; + border: none; + color: inherit; + padding: 0; +} +.remove:hover { opacity: 1; } diff --git a/src/design-system/primitives/Tag/Tag.tsx b/src/design-system/primitives/Tag/Tag.tsx new file mode 100644 index 0000000..2b8bdcc --- /dev/null +++ b/src/design-system/primitives/Tag/Tag.tsx @@ -0,0 +1,35 @@ +import styles from './Tag.module.css' +import { hashColor } from '../../utils/hashColor' +import { useTheme } from '../../providers/ThemeProvider' + +type TagColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto' + +interface TagProps { + label: string + onRemove?: () => void + color?: TagColor + className?: string +} + +export function Tag({ label, onRemove, color = 'auto', className }: TagProps) { + const { theme } = useTheme() + const isAuto = color === 'auto' + const hashColors = isAuto ? hashColor(label, theme) : null + + const inlineStyle = isAuto + ? { backgroundColor: hashColors!.bg, color: hashColors!.text, borderColor: hashColors!.border } + : undefined + + const classes = [styles.tag, !isAuto ? styles[color] : '', className ?? ''].filter(Boolean).join(' ') + + return ( + + {label} + {onRemove && ( + + )} + + ) +}