UI Component Library Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a React component library (components/ui/) that wraps the CSS variable/class system, enforcing consistent styling across all forms, dialogs, cards, and interactive elements. Every feature consumer imports components, never raw CSS class strings.
Architecture: Three layers — design tokens (CSS variables in tokens.css), template CSS classes (in components.css), and React components (in components/ui/). Components map typed props to CSS classes. Zero hardcoded colors. Fork-safe: swap templates/default/ to rebrand.
Tech Stack: React, TypeScript, CSS custom properties, forwardRef, ComponentPropsWithoutRef
Task 1: CSS class definitions for form elements
Add new CSS classes to components.css for inputs, textareas, selects, dialogs, form fields, tabs, and badge variants. These classes use existing CSS variables from tokens.css.
Files:
- Modify:
frontend/src/templates/default/styles/components.css
Step 1: Add input classes after the .btn--lg block (after line 330)
/* Form inputs */
.input {
display: block;
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
font-family: var(--font-family-body);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
transition: var(--transition-colors), border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
}
.input:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input::placeholder {
color: var(--color-text-tertiary);
}
.input--inline {
background-color: transparent;
border: none;
border-bottom: 1px solid transparent;
border-radius: 0;
padding: var(--space-xs) 0;
}
.input--inline:hover {
border-bottom-color: var(--color-border-primary);
}
.input--inline:focus {
border-bottom-color: var(--color-border-focus);
box-shadow: none;
}
.input--error {
border-color: var(--color-status-error);
}
.input--error:focus {
border-color: var(--color-status-error);
box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.15);
}
/* Textarea */
.textarea {
display: block;
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
font-family: var(--font-family-body);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
transition: var(--transition-colors), border-color var(--transition-fast);
resize: vertical;
}
.textarea:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
}
.textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.textarea::placeholder {
color: var(--color-text-tertiary);
}
.textarea--error {
border-color: var(--color-status-error);
}
/* Select */
.select {
display: block;
width: 100%;
padding: var(--space-sm) var(--space-md);
font-size: var(--font-size-sm);
font-family: var(--font-family-body);
color: var(--color-text-primary);
background-color: var(--color-surface-secondary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
transition: var(--transition-colors), border-color var(--transition-fast);
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%236b7280' d='M3 5l3 3 3-3'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right var(--space-md) center;
padding-right: var(--space-2xl);
}
.select:focus {
outline: none;
border-color: var(--color-border-focus);
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
}
.select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.select--error {
border-color: var(--color-status-error);
}
/* Checkbox */
.checkbox {
width: 1rem;
height: 1rem;
border-radius: var(--radius-sm);
border: 1px solid var(--color-border-secondary);
background-color: var(--color-surface-secondary);
accent-color: var(--color-brand-primary);
cursor: pointer;
}
.checkbox:focus {
outline: 2px solid var(--color-border-focus);
outline-offset: 2px;
}Step 2: Add dialog classes
/* Dialog */
.dialog {
background-color: var(--color-surface-primary);
color: var(--color-text-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 0;
max-width: 28rem;
width: 100%;
box-shadow: 0 4px 24px var(--color-shadow-dark);
}
.dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
.dialog__header {
padding: var(--space-xl) var(--space-xl) 0;
}
.dialog__title {
font-size: var(--font-size-lg);
font-weight: var(--font-weight-medium);
margin: 0;
}
.dialog__body {
padding: var(--space-lg) var(--space-xl);
}
.dialog__footer {
padding: 0 var(--space-xl) var(--space-xl);
display: flex;
justify-content: flex-end;
gap: var(--space-md);
}Step 3: Add form field classes
/* Form field */
.form-field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.form-field__label {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-primary);
}
.form-field__error {
font-size: var(--font-size-xs);
color: var(--color-status-error);
}
.form-field__hint {
font-size: var(--font-size-xs);
color: var(--color-text-tertiary);
}Step 4: Add badge variant classes (extend existing .badge block around line 332)
.badge--success {
background-color: var(--color-status-success);
color: #ffffff;
}
.badge--warning {
background-color: var(--color-status-warning);
color: #ffffff;
}
.badge--error {
background-color: var(--color-status-error);
color: #ffffff;
}
.badge--info {
background-color: var(--color-status-info);
color: #ffffff;
}
.badge--sm {
padding: 0.125rem var(--space-sm);
font-size: 0.6875rem;
}
.badge--outline {
background-color: transparent;
border: 1px solid var(--color-border-primary);
color: var(--color-text-primary);
}Step 5: Add tab classes
/* Tabs */
.tabs__list {
display: flex;
gap: var(--space-xs);
border-bottom: 1px solid var(--color-border-primary);
}
.tabs__tab {
padding: var(--space-sm) var(--space-lg);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: var(--transition-colors);
margin-bottom: -1px;
}
.tabs__tab:hover {
color: var(--color-text-primary);
}
.tabs__tab--active {
color: var(--color-brand-primary);
border-bottom-color: var(--color-brand-primary);
}
.tabs__panel {
padding: var(--space-xl) 0;
}Step 6: Add ghost button variant (missing from current btn system)
.btn--ghost {
background-color: transparent;
color: var(--color-text-secondary);
border-color: transparent;
}
.btn--ghost:hover:not(:disabled) {
background-color: var(--color-surface-tertiary);
color: var(--color-text-primary);
}Step 7: Commit
git add frontend/src/templates/default/styles/components.css
git commit -m "feat: add CSS classes for form inputs, dialog, tabs, badge variants, and ghost button"Task 2: Button component
Files:
- Create:
frontend/src/components/ui/Button.tsx - Test:
frontend/src/components/ui/__tests__/Button.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '../Button';
describe('Button', () => {
it('renders with default variant and size', () => {
render(<Button>Click me</Button>);
const btn = screen.getByRole('button', { name: 'Click me' });
expect(btn.className).toContain('btn');
expect(btn.className).not.toContain('btn--');
});
it('applies variant class', () => {
render(<Button variant="primary">Submit</Button>);
expect(screen.getByRole('button').className).toContain('btn--primary');
});
it('applies size class', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button').className).toContain('btn--sm');
});
it('merges custom className', () => {
render(<Button className="mt-4">Spaced</Button>);
const btn = screen.getByRole('button');
expect(btn.className).toContain('btn');
expect(btn.className).toContain('mt-4');
});
it('renders as anchor when as="a"', () => {
render(<Button as="a" href="/test">Link</Button>);
const link = screen.getByRole('link', { name: 'Link' });
expect(link.tagName).toBe('A');
expect(link.className).toContain('btn');
});
it('shows loading state', () => {
render(<Button loading>Save</Button>);
const btn = screen.getByRole('button');
expect(btn).toBeDisabled();
});
it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLButtonElement | null>;
render(<Button ref={ref}>Ref</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
it('passes through native button props', async () => {
const onClick = vi.fn();
render(<Button onClick={onClick}>Native</Button>);
await userEvent.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
});Step 2: Run test to verify it fails
Run: cd frontend && npx vitest run src/components/ui/__tests__/Button.test.tsx
Expected: FAIL — module not found
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef, ReactNode } from 'react';
type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
type ButtonBaseProps = {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
children: ReactNode;
};
type ButtonAsButton = ButtonBaseProps &
Omit<ComponentPropsWithoutRef<'button'>, keyof ButtonBaseProps> & {
as?: 'button';
};
type ButtonAsAnchor = ButtonBaseProps &
Omit<ComponentPropsWithoutRef<'a'>, keyof ButtonBaseProps> & {
as: 'a';
};
type ButtonProps = ButtonAsButton | ButtonAsAnchor;
function buildClassName(variant?: ButtonVariant, size?: ButtonSize, className?: string): string {
const classes = ['btn'];
if (variant) classes.push(`btn--${variant}`);
if (size && size !== 'md') classes.push(`btn--${size}`);
if (className) classes.push(className);
return classes.join(' ');
}
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>(
(props, ref) => {
const { as, variant, size, loading, children, className, ...rest } = props;
const cls = buildClassName(variant, size, className);
if (as === 'a') {
const anchorProps = rest as ComponentPropsWithoutRef<'a'>;
return (
<a ref={ref as React.Ref<HTMLAnchorElement>} className={cls} {...anchorProps}>
{children}
</a>
);
}
const buttonProps = rest as ComponentPropsWithoutRef<'button'>;
return (
<button
ref={ref as React.Ref<HTMLButtonElement>}
className={cls}
disabled={loading || buttonProps.disabled}
{...buttonProps}
>
{loading && <span className="btn__spinner" aria-hidden="true" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';Step 4: Run test to verify it passes
Run: cd frontend && npx vitest run src/components/ui/__tests__/Button.test.tsx
Expected: PASS
Step 5: Commit
git add frontend/src/components/ui/Button.tsx frontend/src/components/ui/__tests__/Button.test.tsx
git commit -m "feat: add Button component with variant, size, and polymorphic render"Task 3: Input component
Files:
- Create:
frontend/src/components/ui/Input.tsx - Test:
frontend/src/components/ui/__tests__/Input.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Input } from '../Input';
describe('Input', () => {
it('renders with default class', () => {
render(<Input data-testid="input" />);
expect(screen.getByTestId('input').className).toContain('input');
});
it('applies inline variant', () => {
render(<Input variant="inline" data-testid="input" />);
expect(screen.getByTestId('input').className).toContain('input--inline');
});
it('applies error class', () => {
render(<Input error data-testid="input" />);
expect(screen.getByTestId('input').className).toContain('input--error');
});
it('merges custom className', () => {
render(<Input className="w-1/2" data-testid="input" />);
const el = screen.getByTestId('input');
expect(el.className).toContain('input');
expect(el.className).toContain('w-1/2');
});
it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLInputElement | null>;
render(<Input ref={ref} data-testid="input" />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
});
it('passes through native props', () => {
render(<Input type="email" placeholder="Email" data-testid="input" />);
const el = screen.getByTestId('input') as HTMLInputElement;
expect(el.type).toBe('email');
expect(el.placeholder).toBe('Email');
});
});Step 2: Run test to verify it fails
Run: cd frontend && npx vitest run src/components/ui/__tests__/Input.test.tsx
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef } from 'react';
type InputVariant = 'default' | 'inline';
interface InputProps extends Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
variant?: InputVariant;
error?: boolean;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ variant = 'default', error, className, ...props }, ref) => {
const classes = ['input'];
if (variant === 'inline') classes.push('input--inline');
if (error) classes.push('input--error');
if (className) classes.push(className);
return <input ref={ref} className={classes.join(' ')} {...props} />;
}
);
Input.displayName = 'Input';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Input.tsx frontend/src/components/ui/__tests__/Input.test.tsx
git commit -m "feat: add Input component with inline variant and error state"Task 4: Textarea component
Files:
- Create:
frontend/src/components/ui/Textarea.tsx - Test:
frontend/src/components/ui/__tests__/Textarea.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Textarea } from '../Textarea';
describe('Textarea', () => {
it('renders with default class', () => {
render(<Textarea data-testid="ta" />);
expect(screen.getByTestId('ta').className).toContain('textarea');
});
it('applies error class', () => {
render(<Textarea error data-testid="ta" />);
expect(screen.getByTestId('ta').className).toContain('textarea--error');
});
it('auto-resizes when enabled', async () => {
render(<Textarea autoResize data-testid="ta" />);
const ta = screen.getByTestId('ta') as HTMLTextAreaElement;
await userEvent.type(ta, 'hello');
expect(ta.style.height).toBeDefined();
});
it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLTextAreaElement | null>;
render(<Textarea ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement);
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef, useCallback, useRef, useEffect } from 'react';
interface TextareaProps extends ComponentPropsWithoutRef<'textarea'> {
error?: boolean;
autoResize?: boolean;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ error, autoResize, className, onChange, ...props }, ref) => {
const internalRef = useRef<HTMLTextAreaElement | null>(null);
const classes = ['textarea'];
if (error) classes.push('textarea--error');
if (className) classes.push(className);
const resize = useCallback(() => {
const el = internalRef.current;
if (el && autoResize) {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}
}, [autoResize]);
useEffect(() => {
resize();
}, [resize]);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
resize();
onChange?.(e);
},
[onChange, resize]
);
return (
<textarea
ref={(el) => {
internalRef.current = el;
if (typeof ref === 'function') ref(el);
else if (ref) ref.current = el;
}}
className={classes.join(' ')}
onChange={handleChange}
{...props}
/>
);
}
);
Textarea.displayName = 'Textarea';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Textarea.tsx frontend/src/components/ui/__tests__/Textarea.test.tsx
git commit -m "feat: add Textarea component with auto-resize and error state"Task 5: Select component
Files:
- Create:
frontend/src/components/ui/Select.tsx - Test:
frontend/src/components/ui/__tests__/Select.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Select } from '../Select';
describe('Select', () => {
it('renders with default class', () => {
render(
<Select data-testid="sel">
<option>A</option>
</Select>
);
expect(screen.getByTestId('sel').className).toContain('select');
});
it('applies error class', () => {
render(
<Select error data-testid="sel">
<option>A</option>
</Select>
);
expect(screen.getByTestId('sel').className).toContain('select--error');
});
it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLSelectElement | null>;
render(
<Select ref={ref}>
<option>A</option>
</Select>
);
expect(ref.current).toBeInstanceOf(HTMLSelectElement);
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef } from 'react';
interface SelectProps extends ComponentPropsWithoutRef<'select'> {
error?: boolean;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ error, className, children, ...props }, ref) => {
const classes = ['select'];
if (error) classes.push('select--error');
if (className) classes.push(className);
return (
<select ref={ref} className={classes.join(' ')} {...props}>
{children}
</select>
);
}
);
Select.displayName = 'Select';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Select.tsx frontend/src/components/ui/__tests__/Select.test.tsx
git commit -m "feat: add Select component with error state"Task 6: Dialog component
Files:
- Create:
frontend/src/components/ui/Dialog.tsx - Test:
frontend/src/components/ui/__tests__/Dialog.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Dialog } from '../Dialog';
// Mock HTMLDialogElement methods (jsdom doesn't support them)
beforeEach(() => {
HTMLDialogElement.prototype.showModal = vi.fn();
HTMLDialogElement.prototype.close = vi.fn();
});
describe('Dialog', () => {
it('renders title when provided', () => {
render(
<Dialog open onClose={() => {}}>
<Dialog.Header title="Confirm" />
<Dialog.Body>Content</Dialog.Body>
</Dialog>
);
expect(screen.getByText('Confirm')).toBeDefined();
});
it('calls onClose on cancel event', () => {
const onClose = vi.fn();
render(
<Dialog open onClose={onClose}>
<Dialog.Body>Content</Dialog.Body>
</Dialog>
);
const dialog = screen.getByRole('dialog');
dialog.dispatchEvent(new Event('cancel'));
expect(onClose).toHaveBeenCalled();
});
it('renders footer with children', () => {
render(
<Dialog open onClose={() => {}}>
<Dialog.Body>Content</Dialog.Body>
<Dialog.Footer>
<button>OK</button>
</Dialog.Footer>
</Dialog>
);
expect(screen.getByRole('button', { name: 'OK' })).toBeDefined();
});
it('applies dialog CSS class', () => {
render(
<Dialog open onClose={() => {}}>
<Dialog.Body>Content</Dialog.Body>
</Dialog>
);
expect(screen.getByRole('dialog').className).toContain('dialog');
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { useRef, useEffect, ReactNode, forwardRef } from 'react';
interface DialogProps {
open: boolean;
onClose: () => void;
className?: string;
children: ReactNode;
}
interface DialogHeaderProps {
title: string;
}
interface DialogBodyProps {
children: ReactNode;
className?: string;
}
interface DialogFooterProps {
children: ReactNode;
className?: string;
}
function DialogHeader({ title }: DialogHeaderProps) {
return (
<div className="dialog__header">
<h3 className="dialog__title">{title}</h3>
</div>
);
}
function DialogBody({ children, className }: DialogBodyProps) {
const classes = ['dialog__body'];
if (className) classes.push(className);
return <div className={classes.join(' ')}>{children}</div>;
}
function DialogFooter({ children, className }: DialogFooterProps) {
const classes = ['dialog__footer'];
if (className) classes.push(className);
return <div className={classes.join(' ')}>{children}</div>;
}
const DialogRoot = forwardRef<HTMLDialogElement, DialogProps>(
({ open, onClose, className, children }, ref) => {
const internalRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<Element | null>(null);
useEffect(() => {
const dialog = internalRef.current;
if (!dialog) return;
if (open) {
previousFocusRef.current = document.activeElement;
dialog.showModal();
} else {
dialog.close();
}
return () => {
(previousFocusRef.current as HTMLElement)?.focus?.();
};
}, [open]);
const classes = ['dialog'];
if (className) classes.push(className);
return (
<dialog
ref={(el) => {
internalRef.current = el;
if (typeof ref === 'function') ref(el);
else if (ref) ref.current = el;
}}
className={classes.join(' ')}
onCancel={(e) => {
e.preventDefault();
onClose();
}}
>
{children}
</dialog>
);
}
);
DialogRoot.displayName = 'Dialog';
export const Dialog = Object.assign(DialogRoot, {
Header: DialogHeader,
Body: DialogBody,
Footer: DialogFooter,
});Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Dialog.tsx frontend/src/components/ui/__tests__/Dialog.test.tsx
git commit -m "feat: add Dialog component with Header, Body, Footer subcomponents"Task 7: FormField component
Files:
- Create:
frontend/src/components/ui/FormField.tsx - Test:
frontend/src/components/ui/__tests__/FormField.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { FormField } from '../FormField';
describe('FormField', () => {
it('renders label', () => {
render(
<FormField label="Email">
<input />
</FormField>
);
expect(screen.getByText('Email')).toBeDefined();
});
it('renders error message', () => {
render(
<FormField label="Email" error="Required">
<input />
</FormField>
);
expect(screen.getByText('Required')).toBeDefined();
});
it('renders required indicator', () => {
render(
<FormField label="Email" required>
<input />
</FormField>
);
expect(screen.getByText('*')).toBeDefined();
});
it('renders hint text', () => {
render(
<FormField label="Email" hint="We won't share this">
<input />
</FormField>
);
expect(screen.getByText("We won't share this")).toBeDefined();
});
it('renders children', () => {
render(
<FormField label="Name">
<input data-testid="inner" />
</FormField>
);
expect(screen.getByTestId('inner')).toBeDefined();
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { ReactNode } from 'react';
interface FormFieldProps {
label?: string;
error?: string;
hint?: string;
required?: boolean;
className?: string;
children: ReactNode;
}
export function FormField({ label, error, hint, required, className, children }: FormFieldProps) {
const classes = ['form-field'];
if (className) classes.push(className);
return (
<div className={classes.join(' ')}>
{label && (
<label className="form-field__label">
{label}
{required && <span aria-hidden="true"> *</span>}
</label>
)}
{children}
{error && <span className="form-field__error" role="alert">{error}</span>}
{hint && !error && <span className="form-field__hint">{hint}</span>}
</div>
);
}Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/FormField.tsx frontend/src/components/ui/__tests__/FormField.test.tsx
git commit -m "feat: add FormField component with label, error, hint, required"Task 8: Badge component
Files:
- Create:
frontend/src/components/ui/Badge.tsx - Test:
frontend/src/components/ui/__tests__/Badge.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Badge } from '../Badge';
describe('Badge', () => {
it('renders with default class', () => {
render(<Badge>Draft</Badge>);
expect(screen.getByText('Draft').className).toContain('badge');
});
it('applies variant class', () => {
render(<Badge variant="success">Published</Badge>);
expect(screen.getByText('Published').className).toContain('badge--success');
});
it('applies size class', () => {
render(<Badge size="sm">Tag</Badge>);
expect(screen.getByText('Tag').className).toContain('badge--sm');
});
it('merges custom className', () => {
render(<Badge className="ml-2">Label</Badge>);
const el = screen.getByText('Label');
expect(el.className).toContain('badge');
expect(el.className).toContain('ml-2');
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef } from 'react';
type BadgeVariant = 'default' | 'draft' | 'featured' | 'success' | 'warning' | 'error' | 'info' | 'outline';
type BadgeSize = 'sm' | 'md';
interface BadgeProps extends ComponentPropsWithoutRef<'span'> {
variant?: BadgeVariant;
size?: BadgeSize;
}
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
({ variant = 'default', size, className, children, ...props }, ref) => {
const classes = ['badge'];
if (variant !== 'default') classes.push(`badge--${variant}`);
if (size === 'sm') classes.push('badge--sm');
if (className) classes.push(className);
return (
<span ref={ref} className={classes.join(' ')} {...props}>
{children}
</span>
);
}
);
Badge.displayName = 'Badge';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Badge.tsx frontend/src/components/ui/__tests__/Badge.test.tsx
git commit -m "feat: add Badge component with variant and size props"Task 9: Card component
Files:
- Create:
frontend/src/components/ui/Card.tsx - Test:
frontend/src/components/ui/__tests__/Card.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Card } from '../Card';
describe('Card', () => {
it('renders with default card class', () => {
render(<Card data-testid="card">Content</Card>);
expect(screen.getByTestId('card').className).toContain('card');
});
it('applies variant class', () => {
render(<Card variant="draft" data-testid="card">Content</Card>);
expect(screen.getByTestId('card').className).toContain('card--draft');
});
it('applies hoverable class', () => {
render(<Card hoverable data-testid="card">Content</Card>);
expect(screen.getByTestId('card').className).toContain('card--hoverable');
});
it('renders as anchor when as="a"', () => {
render(<Card as="a" href="/test" data-testid="card">Link Card</Card>);
const el = screen.getByTestId('card');
expect(el.tagName).toBe('A');
expect(el.className).toContain('card--link');
});
it('renders subcomponents', () => {
render(
<Card>
<Card.Body data-testid="body">Body content</Card.Body>
<Card.Footer data-testid="footer">Footer content</Card.Footer>
</Card>
);
expect(screen.getByTestId('body')).toBeDefined();
expect(screen.getByTestId('footer')).toBeDefined();
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef, ReactNode } from 'react';
type CardVariant = 'default' | 'draft' | 'featured';
type CardBaseProps = {
variant?: CardVariant;
hoverable?: boolean;
children: ReactNode;
};
type CardAsDiv = CardBaseProps &
Omit<ComponentPropsWithoutRef<'div'>, keyof CardBaseProps> & {
as?: 'div';
};
type CardAsAnchor = CardBaseProps &
Omit<ComponentPropsWithoutRef<'a'>, keyof CardBaseProps> & {
as: 'a';
};
type CardProps = CardAsDiv | CardAsAnchor;
function CardBody({ className, children, ...props }: ComponentPropsWithoutRef<'div'>) {
const classes = ['card__body'];
if (className) classes.push(className);
return <div className={classes.join(' ')} {...props}>{children}</div>;
}
function CardMedia({ className, children, ...props }: ComponentPropsWithoutRef<'div'>) {
const classes = ['card__media'];
if (className) classes.push(className);
return <div className={classes.join(' ')} {...props}>{children}</div>;
}
function CardFooter({ className, children, ...props }: ComponentPropsWithoutRef<'div'>) {
const classes = ['card__footer'];
if (className) classes.push(className);
return <div className={classes.join(' ')} {...props}>{children}</div>;
}
function buildCardClassName(variant?: CardVariant, hoverable?: boolean, asAnchor?: boolean, className?: string): string {
const classes = ['card'];
if (variant && variant !== 'default') classes.push(`card--${variant}`);
if (hoverable) classes.push('card--hoverable');
if (asAnchor) classes.push('card--link');
if (className) classes.push(className);
return classes.join(' ');
}
const CardRoot = forwardRef<HTMLDivElement | HTMLAnchorElement, CardProps>(
(props, ref) => {
const { as, variant, hoverable, children, className, ...rest } = props;
const cls = buildCardClassName(variant, hoverable, as === 'a', className);
if (as === 'a') {
const anchorProps = rest as ComponentPropsWithoutRef<'a'>;
return (
<a ref={ref as React.Ref<HTMLAnchorElement>} className={cls} {...anchorProps}>
{children}
</a>
);
}
const divProps = rest as ComponentPropsWithoutRef<'div'>;
return (
<div ref={ref as React.Ref<HTMLDivElement>} className={cls} {...divProps}>
{children}
</div>
);
}
);
CardRoot.displayName = 'Card';
export const Card = Object.assign(CardRoot, {
Body: CardBody,
Media: CardMedia,
Footer: CardFooter,
});Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Card.tsx frontend/src/components/ui/__tests__/Card.test.tsx
git commit -m "feat: add Card component with Body, Media, Footer subcomponents"Task 10: Grid component
Files:
- Create:
frontend/src/components/ui/Grid.tsx - Test:
frontend/src/components/ui/__tests__/Grid.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Grid } from '../Grid';
describe('Grid', () => {
it('renders with grid class', () => {
render(<Grid data-testid="grid">Items</Grid>);
expect(screen.getByTestId('grid').className).toContain('grid');
});
it('applies responsive variant', () => {
render(<Grid variant="responsive" data-testid="grid">Items</Grid>);
expect(screen.getByTestId('grid').className).toContain('grid--responsive');
});
it('applies column variants', () => {
render(<Grid variant="3-col" data-testid="grid">Items</Grid>);
expect(screen.getByTestId('grid').className).toContain('grid--3-col');
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef } from 'react';
type GridVariant = 'responsive' | '1-col' | '2-col' | '3-col';
interface GridProps extends ComponentPropsWithoutRef<'div'> {
variant?: GridVariant;
}
export const Grid = forwardRef<HTMLDivElement, GridProps>(
({ variant, className, children, ...props }, ref) => {
const classes = ['grid'];
if (variant) classes.push(`grid--${variant}`);
if (className) classes.push(className);
return (
<div ref={ref} className={classes.join(' ')} {...props}>
{children}
</div>
);
}
);
Grid.displayName = 'Grid';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Grid.tsx frontend/src/components/ui/__tests__/Grid.test.tsx
git commit -m "feat: add Grid component with responsive and column variants"Task 11: Tabs component
Files:
- Create:
frontend/src/components/ui/Tabs.tsx - Test:
frontend/src/components/ui/__tests__/Tabs.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Tabs } from '../Tabs';
describe('Tabs', () => {
const renderTabs = (activeTab = 'one', onTabChange = vi.fn()) => {
return render(
<Tabs activeTab={activeTab} onTabChange={onTabChange}>
<Tabs.List>
<Tabs.Tab value="one">Tab One</Tabs.Tab>
<Tabs.Tab value="two">Tab Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="one">Panel One</Tabs.Panel>
<Tabs.Panel value="two">Panel Two</Tabs.Panel>
</Tabs>
);
};
it('renders tab buttons', () => {
renderTabs();
expect(screen.getByText('Tab One')).toBeDefined();
expect(screen.getByText('Tab Two')).toBeDefined();
});
it('shows active panel', () => {
renderTabs('one');
expect(screen.getByText('Panel One')).toBeDefined();
expect(screen.queryByText('Panel Two')).toBeNull();
});
it('applies active class to active tab', () => {
renderTabs('one');
expect(screen.getByText('Tab One').className).toContain('tabs__tab--active');
expect(screen.getByText('Tab Two').className).not.toContain('tabs__tab--active');
});
it('calls onTabChange when tab clicked', async () => {
const onTabChange = vi.fn();
renderTabs('one', onTabChange);
await userEvent.click(screen.getByText('Tab Two'));
expect(onTabChange).toHaveBeenCalledWith('two');
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { createContext, useContext, ReactNode } from 'react';
interface TabsContextValue {
activeTab: string;
onTabChange: (value: string) => void;
}
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext(): TabsContextValue {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs subcomponents must be used within <Tabs>');
return ctx;
}
interface TabsProps {
activeTab: string;
onTabChange: (value: string) => void;
className?: string;
children: ReactNode;
}
interface TabsListProps {
className?: string;
children: ReactNode;
}
interface TabsTabProps {
value: string;
className?: string;
children: ReactNode;
}
interface TabsPanelProps {
value: string;
className?: string;
children: ReactNode;
}
function TabsRoot({ activeTab, onTabChange, className, children }: TabsProps) {
const classes = ['tabs'];
if (className) classes.push(className);
return (
<TabsContext.Provider value={{ activeTab, onTabChange }}>
<div className={classes.join(' ')}>{children}</div>
</TabsContext.Provider>
);
}
function TabsList({ className, children }: TabsListProps) {
const classes = ['tabs__list'];
if (className) classes.push(className);
return <div className={classes.join(' ')} role="tablist">{children}</div>;
}
function TabsTab({ value, className, children }: TabsTabProps) {
const { activeTab, onTabChange } = useTabsContext();
const isActive = activeTab === value;
const classes = ['tabs__tab'];
if (isActive) classes.push('tabs__tab--active');
if (className) classes.push(className);
return (
<button
type="button"
role="tab"
aria-selected={isActive}
className={classes.join(' ')}
onClick={() => onTabChange(value)}
>
{children}
</button>
);
}
function TabsPanel({ value, className, children }: TabsPanelProps) {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
const classes = ['tabs__panel'];
if (className) classes.push(className);
return (
<div role="tabpanel" className={classes.join(' ')}>
{children}
</div>
);
}
export const Tabs = Object.assign(TabsRoot, {
List: TabsList,
Tab: TabsTab,
Panel: TabsPanel,
});Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Tabs.tsx frontend/src/components/ui/__tests__/Tabs.test.tsx
git commit -m "feat: add Tabs component with List, Tab, Panel subcomponents"Task 12: Checkbox component
Files:
- Create:
frontend/src/components/ui/Checkbox.tsx - Test:
frontend/src/components/ui/__tests__/Checkbox.test.tsx
Step 1: Write the test
import { render, screen } from '@testing-library/react';
import { Checkbox } from '../Checkbox';
describe('Checkbox', () => {
it('renders with checkbox class', () => {
render(<Checkbox data-testid="cb" />);
expect(screen.getByTestId('cb').className).toContain('checkbox');
});
it('forwards ref', () => {
const ref = { current: null } as React.RefObject<HTMLInputElement | null>;
render(<Checkbox ref={ref} />);
expect(ref.current).toBeInstanceOf(HTMLInputElement);
expect(ref.current?.type).toBe('checkbox');
});
});Step 2: Run test to verify it fails
Step 3: Write implementation
import { forwardRef, ComponentPropsWithoutRef } from 'react';
interface CheckboxProps extends Omit<ComponentPropsWithoutRef<'input'>, 'type'> {}
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
({ className, ...props }, ref) => {
const classes = ['checkbox'];
if (className) classes.push(className);
return <input ref={ref} type="checkbox" className={classes.join(' ')} {...props} />;
}
);
Checkbox.displayName = 'Checkbox';Step 4: Run test, verify pass
Step 5: Commit
git add frontend/src/components/ui/Checkbox.tsx frontend/src/components/ui/__tests__/Checkbox.test.tsx
git commit -m "feat: add Checkbox component"Task 13: Barrel export and move existing components
Files:
- Create:
frontend/src/components/ui/index.ts - Move:
frontend/src/components/EmptyState.tsx→ update to useButton - Move:
frontend/src/components/LoadingSkeletons.tsx→ update to remove hardcoded Tailwind colors
Step 1: Create barrel export
export { Button } from './Button';
export { Input } from './Input';
export { Textarea } from './Textarea';
export { Select } from './Select';
export { Checkbox } from './Checkbox';
export { Dialog } from './Dialog';
export { FormField } from './FormField';
export { Badge } from './Badge';
export { Card } from './Card';
export { Grid } from './Grid';
export { Tabs } from './Tabs';Step 2: Update EmptyState to use Button
In frontend/src/components/EmptyState.tsx, replace the raw className="btn btn--primary mt-4" with the Button component import.
import Link from 'next/link';
import { Button } from '@/components/ui';
interface EmptyStateProps {
title: string;
description?: string;
action?: {
label: string;
href: string;
};
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<div className="empty-state" data-testid="empty-state">
<h2 className="empty-state__title">{title}</h2>
{description && (
<p className="empty-state__description">{description}</p>
)}
{action && (
<Link href={action.href} data-testid="empty-state-action">
<Button variant="primary" className="mt-4">{action.label}</Button>
</Link>
)}
</div>
);
}Step 3: Commit
git add frontend/src/components/ui/index.ts frontend/src/components/EmptyState.tsx
git commit -m "feat: add barrel export for ui components, update EmptyState to use Button"Task 14: Migrate ConfirmDialog to use Dialog component
Files:
- Modify:
frontend/src/components/ConfirmDialog.tsx
Step 1: Read current implementation and refactor
Replace the inline-styled <dialog> with the new Dialog component. Keep the context/hook API unchanged.
The ConfirmDialogInner function should use:
import { Dialog } from '@/components/ui';
import { Button } from '@/components/ui';Replace lines 92-132 (the <dialog> element and its contents) with:
<Dialog open={true} onClose={onCancel}>
<Dialog.Body>
<h3 className="dialog__title">{title}</h3>
<p className="text-sm mb-xl" style={{ color: 'var(--color-text-secondary)' }}>
{message}
</p>
</Dialog.Body>
<Dialog.Footer>
<Button
ref={cancelRef}
variant="secondary"
size="sm"
onClick={onCancel}
data-testid="confirm-cancel"
>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'danger' : 'primary'}
size="sm"
onClick={onConfirm}
data-testid="confirm-ok"
>
{confirmLabel}
</Button>
</Dialog.Footer>
</Dialog>Remove the inline style objects entirely — all styling now comes from .dialog CSS class.
Step 2: Run existing tests to verify nothing breaks
Run: cd frontend && npx vitest run
Expected: All tests pass
Step 3: Commit
git add frontend/src/components/ConfirmDialog.tsx
git commit -m "refactor: migrate ConfirmDialog to use Dialog and Button components"Task 15: Migrate AltTextDialog to use Dialog and Input
Files:
- Modify:
frontend/src/modules/editor/components/AltTextDialog.tsx
Replace the inline-styled <dialog>, input, and button elements with Dialog, Input, and Button from @/components/ui. Remove all inline style objects.
Step 1: Read and refactor
Step 2: Run tests
Step 3: Commit
git add frontend/src/modules/editor/components/AltTextDialog.tsx
git commit -m "refactor: migrate AltTextDialog to Dialog, Input, Button components"Task 16: Migrate editor forms (StoryEditorForm, ProjectEditorForm, PageEditorForm)
Files:
- Modify:
frontend/src/modules/editor/components/StoryEditorForm.tsx - Modify:
frontend/src/modules/editor/components/ProjectEditorForm.tsx - Modify:
frontend/src/modules/editor/components/PageEditorForm.tsx
For each file:
- Import
{ Button, Input, Select, Checkbox, Badge, FormField }from@/components/ui - Replace all
bg-indigo-600/bg-red-600button classes with<Button variant="primary">/<Button variant="danger"> - Replace all
className="mt-1 block w-full rounded-md border-gray-300..."inputs with<Input /> - Replace all
className="h-4 w-4 rounded border-gray-300 text-indigo-600..."checkboxes with<Checkbox /> - Replace all inline
style={{ backgroundColor: 'var(--color-status-success)', color: 'white' }}badges with<Badge variant="success"> - Wrap labeled inputs in
<FormField label="...">
Step 1: Migrate StoryEditorForm.tsx
Step 2: Migrate ProjectEditorForm.tsx
Step 3: Migrate PageEditorForm.tsx
Step 4: Run tests
Run: cd frontend && npx vitest run
Step 5: Commit
git add frontend/src/modules/editor/components/StoryEditorForm.tsx \
frontend/src/modules/editor/components/ProjectEditorForm.tsx \
frontend/src/modules/editor/components/PageEditorForm.tsx
git commit -m "refactor: migrate editor forms to ui components, remove inline Tailwind colors"Task 17: Migrate engagement components (CommentThread, CommentSection)
Files:
- Modify:
frontend/src/modules/engagement/components/CommentThread.tsx - Modify:
frontend/src/modules/engagement/components/CommentSection.tsx
- Replace inline-styled textareas with
<Textarea /> - Replace
bg-blue-600buttons with<Button variant="primary"> - Remove all inline
styleobjects forbackgroundColor,borderColor,color
Step 1: Migrate both files
Step 2: Run tests
Step 3: Commit
git add frontend/src/modules/engagement/components/CommentThread.tsx \
frontend/src/modules/engagement/components/CommentSection.tsx
git commit -m "refactor: migrate comment components to Textarea and Button"Task 18: Migrate resume forms
Files:
- Modify:
frontend/src/modules/resume/components/ContactForm.tsx - Modify:
frontend/src/modules/resume/components/WorkExperienceForm.tsx - Modify:
frontend/src/modules/resume/components/EducationForm.tsx - Modify:
frontend/src/modules/resume/components/SkillsForm.tsx - Modify:
frontend/src/modules/resume/components/AchievementsForm.tsx - Delete:
frontend/src/modules/resume/shared.ts(after allinlineInputusages removed)
- Replace all
${inlineInput}usages with<Input variant="inline" /> - Replace badge patterns in SkillsForm with
<Badge variant="outline"> - Delete
shared.tsonce no imports remain
Step 1: Migrate all five forms
Step 2: Verify shared.ts has no remaining importers, delete it
Run: grep -r "from.*shared" frontend/src/modules/resume/
Step 3: Run tests
Step 4: Commit
git add frontend/src/modules/resume/components/
git rm frontend/src/modules/resume/shared.ts
git commit -m "refactor: migrate resume forms to Input component, delete shared.ts"Task 19: Migrate admin pages (sections.tsx, tailor.tsx, index.tsx)
Files:
- Modify:
frontend/src/pages/admin/sections.tsx - Modify:
frontend/src/pages/admin/tailor.tsx - Modify:
frontend/src/pages/admin/index.tsx
sections.tsx:
- Replace
bg-green-600,bg-indigo-600,bg-red-600buttons with<Button variant="primary">,<Button variant="danger"> - Replace
border-gray-300 dark:border-gray-600inputs/selects with<Input />,<Select /> - Wrap labeled fields in
<FormField>
tailor.tsx:
- Replace inline-styled tab navigation with
<Tabs>component - Replace inline-styled textareas with
<Textarea /> - Replace inline-styled buttons with
<Button> - Replace inline-styled inputs with
<Input /> - Replace inline-styled selects with
<Select />
index.tsx:
- Replace any ad-hoc tab implementation with
<Tabs>component
Step 1: Migrate sections.tsx
Step 2: Migrate tailor.tsx
Step 3: Migrate index.tsx
Step 4: Run tests
Step 5: Commit
git add frontend/src/pages/admin/sections.tsx \
frontend/src/pages/admin/tailor.tsx \
frontend/src/pages/admin/index.tsx
git commit -m "refactor: migrate admin pages to ui components, replace all hardcoded colors"Task 20: Migrate ImageBubbleMenu
Files:
- Modify:
frontend/src/modules/editor/components/ImageBubbleMenu.tsx
- Replace inline-styled input with
<Input /> - Replace inline-styled width-preset buttons with
<Button variant="ghost">with active state
Step 1: Migrate
Step 2: Run tests
Step 3: Commit
git add frontend/src/modules/editor/components/ImageBubbleMenu.tsx
git commit -m "refactor: migrate ImageBubbleMenu to Input and Button components"Task 21: Update docs-site plan and _meta.ts
Files:
- Modify:
docs-site/pages/plans/_meta.ts
Step 1: Add plan entry
Add '2026-03-22-ui-component-library': 'UI Component Library' to the _meta.ts file at the top of the list.
Step 2: Commit
git add docs-site/pages/plans/2026-03-22-ui-component-library.md docs-site/pages/plans/_meta.ts
git commit -m "docs: add UI component library design plan"Task 22: Final verification
Step 1: Run full lint
cd frontend && npm run lintStep 2: Run full test suite
make test-frontend-unitStep 3: Run format
make formatStep 4: Grep for remaining hardcoded color classes
Search for any remaining bg-indigo-600, bg-blue-600, bg-red-600, bg-green-600 in .tsx files that should have been migrated. Loading animations and skeletons may keep some Tailwind grays for animation — that’s acceptable as those are structural, not brand colors.
grep -r "bg-indigo-600\|bg-blue-600\|bg-red-600\|bg-green-600" frontend/src/ --include="*.tsx"Step 5: Fix any remaining issues and commit
Step 6: Grep for remaining inline style objects using CSS variables
grep -r "backgroundColor: 'var(--color" frontend/src/ --include="*.tsx"Remaining hits should only be in components where dynamic conditional styling genuinely requires inline styles (e.g., ImageBubbleMenu active state toggles). All static styling should use CSS classes.