From ebf5fc6cb73b1a7c0d823ad1857592ee5f9a0d18 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:32:43 +0100 Subject: [PATCH] feat: InfoCallout, EmptyState, CodeBlock primitives Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/CodeBlock/CodeBlock.module.css | 53 ++++++++++++++++ .../primitives/CodeBlock/CodeBlock.test.tsx | 20 ++++++ .../primitives/CodeBlock/CodeBlock.tsx | 62 +++++++++++++++++++ .../EmptyState/EmptyState.module.css | 32 ++++++++++ .../primitives/EmptyState/EmptyState.tsx | 21 +++++++ .../InfoCallout/InfoCallout.module.css | 52 ++++++++++++++++ .../primitives/InfoCallout/InfoCallout.tsx | 25 ++++++++ 7 files changed, 265 insertions(+) create mode 100644 src/design-system/primitives/CodeBlock/CodeBlock.module.css create mode 100644 src/design-system/primitives/CodeBlock/CodeBlock.test.tsx create mode 100644 src/design-system/primitives/CodeBlock/CodeBlock.tsx create mode 100644 src/design-system/primitives/EmptyState/EmptyState.module.css create mode 100644 src/design-system/primitives/EmptyState/EmptyState.tsx create mode 100644 src/design-system/primitives/InfoCallout/InfoCallout.module.css create mode 100644 src/design-system/primitives/InfoCallout/InfoCallout.tsx diff --git a/src/design-system/primitives/CodeBlock/CodeBlock.module.css b/src/design-system/primitives/CodeBlock/CodeBlock.module.css new file mode 100644 index 0000000..de27d44 --- /dev/null +++ b/src/design-system/primitives/CodeBlock/CodeBlock.module.css @@ -0,0 +1,53 @@ +.wrapper { + position: relative; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border); +} + +.pre { + margin: 0; + padding: 12px 14px; + background: var(--bg-inset); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + color: var(--text-primary); + overflow-x: auto; + white-space: pre; +} + +.copyBtn { + position: absolute; + top: 6px; + right: 8px; + padding: 2px 8px; + background: var(--bg-raised); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + z-index: 1; +} + +.copyBtn:hover { + border-color: var(--amber); + color: var(--amber); +} + +.line { + display: block; +} + +.lineNum { + display: inline-block; + width: 2.5em; + color: var(--text-faint); + user-select: none; + text-align: right; + margin-right: 12px; + font-size: 11px; +} diff --git a/src/design-system/primitives/CodeBlock/CodeBlock.test.tsx b/src/design-system/primitives/CodeBlock/CodeBlock.test.tsx new file mode 100644 index 0000000..44257e0 --- /dev/null +++ b/src/design-system/primitives/CodeBlock/CodeBlock.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { CodeBlock } from './CodeBlock' + +describe('CodeBlock', () => { + it('renders content in a pre element', () => { + render() + expect(screen.getByText(/"key"/)).toBeInTheDocument() + }) + + it('pretty-prints JSON when language is json', () => { + render() + expect(screen.getByText(/"a":/)).toBeInTheDocument() + }) + + it('shows copy button when copyable', () => { + render() + expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument() + }) +}) diff --git a/src/design-system/primitives/CodeBlock/CodeBlock.tsx b/src/design-system/primitives/CodeBlock/CodeBlock.tsx new file mode 100644 index 0000000..c41d17a --- /dev/null +++ b/src/design-system/primitives/CodeBlock/CodeBlock.tsx @@ -0,0 +1,62 @@ +import { useState } from 'react' +import styles from './CodeBlock.module.css' + +interface CodeBlockProps { + content: string + language?: string + copyable?: boolean + lineNumbers?: boolean + className?: string +} + +export function CodeBlock({ + content, + language = 'text', + copyable = false, + lineNumbers = false, + className, +}: CodeBlockProps) { + const [copied, setCopied] = useState(false) + + let formatted = content + if (language === 'json') { + try { + formatted = JSON.stringify(JSON.parse(content), null, 2) + } catch { + // invalid JSON — show as-is + } + } + + const lines = formatted.split('\n') + + function handleCopy() { + navigator.clipboard.writeText(formatted).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 1500) + }) + } + + return ( +
+ {copyable && ( + + )} +
+        {lineNumbers
+          ? lines.map((line, i) => (
+              
+                {i + 1}
+                {line + (i < lines.length - 1 ? '\n' : '')}
+              
+            ))
+          : formatted}
+      
+
+ ) +} diff --git a/src/design-system/primitives/EmptyState/EmptyState.module.css b/src/design-system/primitives/EmptyState/EmptyState.module.css new file mode 100644 index 0000000..7504ff5 --- /dev/null +++ b/src/design-system/primitives/EmptyState/EmptyState.module.css @@ -0,0 +1,32 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 40px 24px; + gap: 8px; +} + +.icon { + font-size: 32px; + color: var(--text-faint); + margin-bottom: 4px; +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--text-secondary); +} + +.description { + font-size: 12px; + color: var(--text-muted); + max-width: 300px; + line-height: 1.5; +} + +.action { + margin-top: 8px; +} diff --git a/src/design-system/primitives/EmptyState/EmptyState.tsx b/src/design-system/primitives/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..35fd0bc --- /dev/null +++ b/src/design-system/primitives/EmptyState/EmptyState.tsx @@ -0,0 +1,21 @@ +import styles from './EmptyState.module.css' +import type { ReactNode } from 'react' + +interface EmptyStateProps { + icon?: ReactNode + title: string + description?: string + action?: ReactNode + className?: string +} + +export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +
{title}
+ {description &&
{description}
} + {action &&
{action}
} +
+ ) +} diff --git a/src/design-system/primitives/InfoCallout/InfoCallout.module.css b/src/design-system/primitives/InfoCallout/InfoCallout.module.css new file mode 100644 index 0000000..19c3562 --- /dev/null +++ b/src/design-system/primitives/InfoCallout/InfoCallout.module.css @@ -0,0 +1,52 @@ +.callout { + padding: 10px 14px; + border-radius: var(--radius-sm); + border-left: 3px solid; + font-size: 12px; + line-height: 1.5; +} + +.amber { + background: var(--amber-bg); + border-left-color: var(--amber); + color: var(--amber-deep); +} + +.success { + background: var(--success-bg); + border-left-color: var(--success); + color: var(--success); +} + +.warning { + background: var(--warning-bg); + border-left-color: var(--warning); + color: var(--warning); +} + +.error { + background: var(--error-bg); + border-left-color: var(--error); + color: var(--error); +} + +.info { + background: var(--running-bg); + border-left-color: var(--running); + color: var(--running); +} + +.title { + font-weight: 600; + margin-bottom: 4px; +} + +.body { + color: var(--text-secondary); +} + +.amber .body { color: var(--amber-deep); } +.success .body { color: var(--success); } +.warning .body { color: var(--warning); } +.error .body { color: var(--error); } +.info .body { color: var(--running); } diff --git a/src/design-system/primitives/InfoCallout/InfoCallout.tsx b/src/design-system/primitives/InfoCallout/InfoCallout.tsx new file mode 100644 index 0000000..3ae55dc --- /dev/null +++ b/src/design-system/primitives/InfoCallout/InfoCallout.tsx @@ -0,0 +1,25 @@ +import styles from './InfoCallout.module.css' +import type { ReactNode } from 'react' + +type InfoCalloutVariant = 'amber' | 'success' | 'warning' | 'error' | 'info' + +interface InfoCalloutProps { + children: ReactNode + variant?: InfoCalloutVariant + title?: string + className?: string +} + +export function InfoCallout({ + children, + variant = 'amber', + title, + className, +}: InfoCalloutProps) { + return ( +
+ {title &&
{title}
} +
{children}
+
+ ) +}