--- name: component-forge description: Build production-grade components for React, Vue 3, and Svelte 5 with all states (loading, empty, error, success, idle), TypeScript strict, WCAG 2.2 accessibility, server components (RSC), and compound component patterns. Includes scaffold script, reference patterns, and template files for React and Vue. Use when user asks to create a UI component, frontend module, or design system component. Do NOT use for page layouts (use landing-craft), routing, or state management architecture (global stores). triggers: - "create component" - "build component" - "React component" - "Vue component" - "Svelte component" - "UI component" - "button component" - "modal component" - "form component" - "accessible component" - "compound component" - "component library" negatives: - "page layout" - "routing" - "state management" - "global store" - "full page" - "landing page" - "design system colors" license: MIT compatibility: opencode metadata: workflow: frontend audience: developers version: "4.0.0" author: shokunin allowed-tools: Read Bash Write Grep Glob --- # Component Forge Build components that survive every state, at every screen size, under every edge case. Inspired by Heydon Pickering (Inclusive Components), React core team patterns, and Emil Kowalski's design engineering philosophy. ## Workflow ### Step 1: Determine component type | Type | Description | Example | |------|-------------|---------| | Presentational | Pure rendering, props-driven | Button, Card, Badge | | Composable | Wraps children with behavior | Modal, Tooltip, Accordion | | Data-fetching | Reads from API/store | UserProfile, OrderList | | Layout | Arranges children | Sidebar, Grid, Stack | | Form | Input + validation | LoginForm, SearchInput | ### Step 2: Scaffold component ```bash scripts/scaffold-component.sh Button react scripts/scaffold-component.sh Modal vue scripts/scaffold-component.sh Accordion svelte ``` > **Windows:** The scaffold script requires Git Bash or WSL. Not compatible with PowerShell or CMD. Creates: ``` Button/ Button.tsx Button.types.ts Button.test.tsx index.ts ``` ### Step 3: Implement states with discriminated union ```tsx type State = | { status: 'idle' } | { status: 'loading' } | { status: 'empty' } | { status: 'error'; error: Error } | { status: 'success'; data: T } function Profile({ userId }: { userId: string }) { const [state, setState] = useState>({ status: 'idle' }) if (state.status === 'loading') return if (state.status === 'error') return if (state.status === 'empty') return if (state.status === 'success') return return null } ``` **Every data-fetching component renders all five states.** ### Step 4: Apply accessibility checklist - [ ] All interactive elements keyboard reachable (Tab ? Enter/Space) - [ ] ARIA labels on icon-only buttons - [ ] Focus trap in modals and dialogs - [ ] Loading state announced via `aria-live="polite"` - [ ] Error state has `role="alert"` - [ ] Color is not the only differentiator (add icon/text) - [ ] Proper heading hierarchy (h1 ? h2 ? h3, no skips) - [ ] Touch targets = 44-44px - [ ] `prefers-reduced-motion` respected See [references/a11y-patterns.md](references/a11y-patterns.md) for roving tabindex, focus management, screen reader testing, and contrast ratios. ### Step 5: Handle edge cases | Edge case | Symptom | Fix | |-----------|---------|-----| | Long text | Layout break | `text-overflow: ellipsis`, `overflow-wrap: anywhere` | | Many items (100+) | Slow rendering | Virtualize (react-window, FlashList) | | Network retry | Stale data | Show old data + loading indicator | | Race condition | Wrong data after fast re-fetch | AbortController, cancel on unmount | | null vs undefined vs [] | Wrong empty check | Handle all three explicitly | See [references/react-patterns.md](references/react-patterns.md) for compound components, RSC patterns, error boundaries, and suspense. --- ## Button Architecture Every button must feel responsive. Emil Kowalski's rule: `scale(0.97)` on press. ```css .button { transition: transform 160ms cubic-bezier(0.23, 1, 0.32, 1); } .button:active { transform: scale(0.97); } ``` Scale must be subtle: 0.95-0.98. Applied to all pressable elements. ### Button state transitions | State | CSS | |-------|-----| | Default | `background: var(--color-primary)` | | Hover | `transform: translateY(-1px)` 200ms | | Active | `transform: scale(0.97)` 160ms | | Focus | `outline: 2px solid var(--accent); outline-offset: 2px` | | Disabled | `opacity: 0.5; cursor: not-allowed` | | Loading | Replace text with spinner, keep width stable | ```tsx function Button({ children, loading, ...props }: ButtonProps) { return ( ) } ``` --- ## Popover Architecture Popovers scale from their trigger. Modals stay centered. ```css /* Radix UI */ .popover { transform-origin: var(--radix-popover-content-transform-origin); } /* Base UI */ .popover { transform-origin: var(--transform-origin); } /* Modals - always centered */ .modal { transform-origin: center; } ``` Whether the user notices individually does not matter. In the aggregate, unseen details compound. --- ## Tooltip Architecture Tools must delay before appearing (prevent accidental activation). But once one tooltip is open, adjacent tooltips appear instantly with no animation. ```css .tooltip { transition: transform 125ms ease-out, opacity 125ms ease-out; transform-origin: var(--transform-origin); } .tooltip[data-instant] { transition-duration: 0ms; } ``` ```tsx function Tooltip({ children, content }: TooltipProps) { const [isOpen, setIsOpen] = useState(false) const [isInstant, setIsInstant] = useState(false) return ( { setIsOpen(open) if (!open) setIsInstant(false) }} > { if (isAnyTooltipOpen()) setIsInstant(true) }} > {children} {content} ) } ``` --- ## Modal Architecture ### Enter / exit animation timings | Phase | Duration | Easing | |-------|----------|--------| | Enter | 200-300ms | `cubic-bezier(0, 0, 0.2, 1)` | | Exit | 150-200ms | `cubic-bezier(0.4, 0, 1, 1)` | Exit must ALWAYS be faster than enter. ### Focus trap ```tsx function Modal({ open, onClose, children }: ModalProps) { const overlayRef = useRef(null) useEffect(() => { if (!open) return const previouslyFocused = document.activeElement as HTMLElement const overlay = overlayRef.current const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); return } if (e.key === 'Tab') { const focusable = overlay!.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) const first = focusable[0] const last = focusable[focusable.length - 1] if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } } } document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) previouslyFocused?.focus() } }, [open, onClose]) if (!open) return null return (
{children}
) } ``` --- ## Skeleton Loading States Skeleton loaders must match the layout dimensions of the content they replace. ```tsx function UserCardSkeleton() { return (