From c2f61543f11d6aceec12621f2781bdfdd9dda2a0 Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Wed, 18 Mar 2026 09:26:35 +0100
Subject: [PATCH] feat: Button and Input primitives
---
.../primitives/Button/Button.module.css | 47 +++++++++++++++++++
.../primitives/Button/Button.test.tsx | 29 ++++++++++++
.../primitives/Button/Button.tsx | 31 ++++++++++++
.../primitives/Input/Input.module.css | 29 ++++++++++++
src/design-system/primitives/Input/Input.tsx | 23 +++++++++
5 files changed, 159 insertions(+)
create mode 100644 src/design-system/primitives/Button/Button.module.css
create mode 100644 src/design-system/primitives/Button/Button.test.tsx
create mode 100644 src/design-system/primitives/Button/Button.tsx
create mode 100644 src/design-system/primitives/Input/Input.module.css
create mode 100644 src/design-system/primitives/Input/Input.tsx
diff --git a/src/design-system/primitives/Button/Button.module.css b/src/design-system/primitives/Button/Button.module.css
new file mode 100644
index 0000000..6f6f24a
--- /dev/null
+++ b/src/design-system/primitives/Button/Button.module.css
@@ -0,0 +1,47 @@
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ font-family: var(--font-body);
+ font-weight: 500;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: all 0.15s;
+ white-space: nowrap;
+}
+
+.btn:disabled { opacity: 0.6; cursor: not-allowed; }
+
+.sm { padding: 4px 10px; font-size: 11px; }
+.md { padding: 6px 14px; font-size: 12px; }
+
+.primary {
+ background: var(--amber);
+ color: white;
+ border: none;
+}
+.primary:hover:not(:disabled) { background: var(--amber-deep); }
+
+.secondary {
+ background: none;
+ border: 1px solid var(--border);
+ color: var(--text-secondary);
+}
+.secondary:hover:not(:disabled) { border-color: var(--text-faint); color: var(--text-primary); }
+
+.danger {
+ background: none;
+ border: 1px solid var(--error-border);
+ color: var(--error);
+}
+.danger:hover:not(:disabled) { background: var(--error-bg); }
+
+.ghost {
+ background: none;
+ border: none;
+ color: var(--text-secondary);
+}
+.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
+
+.hiddenText { visibility: hidden; }
diff --git a/src/design-system/primitives/Button/Button.test.tsx b/src/design-system/primitives/Button/Button.test.tsx
new file mode 100644
index 0000000..c72858c
--- /dev/null
+++ b/src/design-system/primitives/Button/Button.test.tsx
@@ -0,0 +1,29 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { Button } from './Button'
+
+describe('Button', () => {
+ it('renders children text', () => {
+ render()
+ expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
+ })
+
+ it('fires onClick', async () => {
+ const onClick = vi.fn()
+ const user = userEvent.setup()
+ render()
+ await user.click(screen.getByRole('button'))
+ expect(onClick).toHaveBeenCalledOnce()
+ })
+
+ it('shows spinner when loading', () => {
+ render()
+ expect(screen.getByRole('status')).toBeInTheDocument()
+ })
+
+ it('is disabled when loading', () => {
+ render()
+ expect(screen.getByRole('button')).toBeDisabled()
+ })
+})
diff --git a/src/design-system/primitives/Button/Button.tsx b/src/design-system/primitives/Button/Button.tsx
new file mode 100644
index 0000000..14be273
--- /dev/null
+++ b/src/design-system/primitives/Button/Button.tsx
@@ -0,0 +1,31 @@
+import styles from './Button.module.css'
+import { Spinner } from '../Spinner/Spinner'
+import type { ButtonHTMLAttributes, ReactNode } from 'react'
+
+interface ButtonProps extends ButtonHTMLAttributes {
+ variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
+ size?: 'sm' | 'md'
+ loading?: boolean
+ children: ReactNode
+}
+
+export function Button({
+ variant = 'secondary',
+ size = 'md',
+ loading = false,
+ children,
+ className,
+ disabled,
+ ...rest
+}: ButtonProps) {
+ return (
+
+ )
+}
diff --git a/src/design-system/primitives/Input/Input.module.css b/src/design-system/primitives/Input/Input.module.css
new file mode 100644
index 0000000..1fe5bf8
--- /dev/null
+++ b/src/design-system/primitives/Input/Input.module.css
@@ -0,0 +1,29 @@
+.wrap { position: relative; }
+
+.icon {
+ position: absolute;
+ left: 10px;
+ top: 50%;
+ transform: translateY(-50%);
+ color: var(--text-faint);
+ font-size: 13px;
+ pointer-events: none;
+}
+
+.input {
+ width: 100%;
+ padding: 6px 12px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg-raised);
+ color: var(--text-primary);
+ font-family: var(--font-body);
+ font-size: 12px;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.input::placeholder { color: var(--text-faint); }
+.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
+
+.hasIcon { padding-left: 30px; }
diff --git a/src/design-system/primitives/Input/Input.tsx b/src/design-system/primitives/Input/Input.tsx
new file mode 100644
index 0000000..f04af76
--- /dev/null
+++ b/src/design-system/primitives/Input/Input.tsx
@@ -0,0 +1,23 @@
+import styles from './Input.module.css'
+import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
+
+interface InputProps extends InputHTMLAttributes {
+ icon?: ReactNode
+}
+
+export const Input = forwardRef(
+ ({ icon, className, ...rest }, ref) => {
+ return (
+
+ {icon && {icon}}
+
+
+ )
+ },
+)
+
+Input.displayName = 'Input'