---
title: "Ui Components"
description: "UI component library patterns for shadcn/ui and Radix Primitives. Use when building accessible component libraries, customizing shadcn components, using Radix unstyled primitives, or creating design system foundations."
canonical: "https://orchestkit.yonyon.ai/docs/reference/skills/ui-components"
---

# Ui Components

UI component library patterns for shadcn/ui and Radix Primitives. Use when building accessible component libraries, customizing shadcn components, using Radix unstyled primitives, or creating design system foundations.

<span className="badge badge-gray">Reference</span> <span className="badge badge-yellow">medium</span>

> **Not directly invocable** — no slash command and no model auto-selection. An agent loads it explicitly via `Read()`.

<ContextualSkillSidebar slug="ui-components" />

> **Ui Components** UI component library patterns for shadcn/ui and Radix Primitives. Use when building accessible component libraries, customizing shadcn components, using Radix unstyled primitives, or creating design system foundations.


# UI Components

Patterns for building accessible UI component libraries with shadcn/ui and Radix Primitives, as a thin wrap over the first-party docs: quick-start recipes, key decisions, anti-patterns, and the house delta. Vendor mechanics (CVA variants, cn() utility, component extension, asChild composition, dialog/menu patterns, data-attribute styling) are upstream's job; see [Upstream coverage (do not restate)](#upstream-coverage-do-not-restate). Each remaining category has individual rule files in `rules/` loaded on-demand.

## Quick Reference

| Category | Rules | Impact | When to Use |
|----------|-------|--------|-------------|
| [shadcn/ui](#shadcnui) | 1 | HIGH | v4 styles, preset codes, style detection |
| [Design System](#design-system) | 4 | HIGH | W3C tokens, OKLCH theming, spacing scales, typography, component states, animation |
| [Design System Components](#design-system-components) | 1 | HIGH | Atomic design, CVA variants, accessibility, Storybook |
| [Forms](#forms) | 2 | HIGH | React Hook Form v7, Zod validation, Server Actions |
| [Modern CSS & Tooling](#modern-css--tooling) | 3 | HIGH | CSS cascade layers, Tailwind v4, Storybook CSF3 |
| [UX Foundations](#ux-foundations) | 4 | HIGH | Visual hierarchy, typography thresholds, color system, empty states |

**Total: 15 rules across 6 categories.** Radix primitive mechanics and shadcn
customization tutorials are first-party documented; see
[Upstream coverage (do not restate)](#upstream-coverage-do-not-restate) and
[references/ork-delta.md](references/ork-delta.md) for what stays ours.

## Quick Start

```tsx
// CVA variant system with cn() utility
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
)
```

```tsx
// Radix Dialog with asChild composition
import { Dialog } from 'radix-ui'

<Dialog.Root>
  <Dialog.Trigger asChild>
    <Button>Open</Button>
  </Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="fixed inset-0 bg-black/50" />
    <Dialog.Content className="data-[state=open]:animate-in">
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Description>Description</Dialog.Description>
      <Dialog.Close>Close</Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>
```

## shadcn/ui

Beautifully designed, accessible components built on CVA variants, cn() utility, and OKLCH theming.

| Rule | File | Key Pattern |
|------|------|-------------|
| v4 Styles | `rules/shadcn-v4-styles.md` | 6 styles (Vega→Luma), preset codes, style detection, class mapping |

Customization, form, and data-table walkthroughs are upstream's job now; see
[Upstream coverage (do not restate)](#upstream-coverage-do-not-restate). Our
conventions on top of them live in [references/ork-delta.md](references/ork-delta.md).

### v4 Style System

shadcn CLI v4 ships 6 visual styles. Each rewrites component class names — not just CSS variables.

| Style | Character | Best For |
|-------|-----------|----------|
| **Vega** | Balanced radius, clean lines | General purpose (successor to New York) |
| **Nova** | Compact padding, reduced margins | Dense dashboards, admin panels |
| **Maia** | Soft, rounded, generous spacing | Consumer-facing, friendly apps |
| **Lyra** | Sharp, zero radius, monospace pairs | Editorial, developer tools |
| **Mira** | Ultra-compact, minimal chrome | Spreadsheets, data-heavy interfaces |
| **Luma** | Extreme rounding (`rounded-4xl`), soft elevation (`shadow-md` + ring), breathable layouts | Polished native-app feel, macOS Tahoe-inspired |

Configure visually at [ui.shadcn.com/create](https://ui.shadcn.com/create) → pick style, theme, fonts, icons, then copy the generated command. **Do not hardcode preset codes in docs** — they're tied to a specific style snapshot and can drift.

### shadcn CLI v4 (Apr 2026) — new commands

| Command | Purpose |
|---------|---------|
| `npx shadcn@latest apply &lt;style&gt;` | Apply a published style (e.g. `luma`, `nova`, `lyra`) to the current project — re-skins existing components without re-adding them |
| `npx shadcn@latest info` | Show resolved config: registry, style, tokens, components present, Tailwind version |
| `npx shadcn@latest skills` | List the `shadcn/skills` registry — Claude Code- and Cursor-ready skill packs that bundle CLI commands with agent guidance |
| `npx shadcn@latest build` | Build a custom registry (already-documented) — pair with `apply` to ship a private style |

**Detection:** Read `components.json` → `"style"` field (e.g., `"radix-luma"`, `"base-nova"`). Old `"new-york"` and `"default"` styles are superseded by Vega.

## Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Color format | OKLCH for perceptually uniform theming |
| Class merging | Always use cn() for Tailwind conflicts |
| Extending components | Wrap, don't modify source files |
| Variants | Use CVA for type-safe multi-axis variants |
| Styling approach | Data attributes + Tailwind arbitrary variants |
| Composition | Use `asChild` to avoid wrapper divs |
| Animation | CSS-only with data-state selectors |
| Form components | Combine with react-hook-form |
| Destructive confirmations | AlertDialog, never plain Dialog (see [references/ork-delta.md](references/ork-delta.md)) |

## Anti-Patterns (FORBIDDEN)

- **Modifying shadcn source**: Wrap and extend instead of editing generated files
- **Skipping cn()**: Direct string concatenation causes Tailwind class conflicts
- **Inline styles over CVA**: Use CVA for type-safe, reusable variants
- **Wrapper divs**: Use `asChild` to avoid extra DOM elements
- **Missing Dialog.Title**: Every dialog must have an accessible title
- **Positive tabindex**: Using `tabindex > 0` disrupts natural tab order
- **Color-only states**: Use data attributes + multiple indicators
- **Manual focus management**: Use Radix built-in focus trapping

## Upstream coverage (do not restate)

These topics were removed from this skill on 2026-07-31 because a first-party
source owns them. Point sessions at the source; keep only floors, scars, and
house decisions here (they live in [references/ork-delta.md](references/ork-delta.md)).

| Topic | First-party source |
|-------|--------------------|
| shadcn setup, init checklist, adding components | vercel:shadcn (marketplace skill), https://ui.shadcn.com/docs/installation |
| shadcn customization, CVA variants, cn() utility, component extension | vercel:shadcn (marketplace skill), https://ui.shadcn.com/docs and https://cva.style/docs |
| OKLCH theming variables, light/dark CSS variable sets | https://ui.shadcn.com/docs/theming |
| Dark mode toggle with next-themes | https://ui.shadcn.com/docs/dark-mode and https://github.com/pacocoursey/next-themes |
| Data table with TanStack Table | https://ui.shadcn.com/docs/components/data-table and https://tanstack.com/table |
| Form field wrappers and validation states | https://ui.shadcn.com/docs/components/form |
| Radix Dialog and AlertDialog patterns | https://www.radix-ui.com/primitives/docs/components/dialog and .../components/alert-dialog |
| Radix asChild / Slot composition | https://www.radix-ui.com/primitives/docs/guides/composition |
| Radix data-attribute styling and focus management | https://www.radix-ui.com/primitives/docs/guides/styling |
| Dropdown menu, popover, tooltip, hover card | https://www.radix-ui.com/primitives/docs/components/dropdown-menu (and sibling component pages) |
| Radix accessibility audit checklist | https://www.radix-ui.com/primitives/docs/overview/accessibility plus `ork:accessibility` |

## Detailed Documentation

| Resource | Description |
|----------|-------------|
| [scripts/](scripts/) | Templates: CVA component, extended button, dialog, dropdown, theme CSS |
| [references/ork-delta.md](references/ork-delta.md) | Ork floors, scars, and house decisions kept out of upstream docs |

## Design System

Design token architecture, spacing, typography, and interactive component states.

| Rule | File | Key Pattern |
|------|------|-------------|
| Token Architecture | `rules/design-system-tokens.md` | W3C tokens, OKLCH colors, Tailwind @theme |
| Spacing Scale | `rules/design-system-spacing.md` | 8px grid, Tailwind space-1 to space-12 |
| Typography Scale | `rules/design-system-typography.md` | Font sizes, weights, line heights |
| Component States | `rules/design-system-states.md` | Hover, focus, active, disabled, loading, animation presets |

## Design System Components

Component architecture patterns with atomic design and accessibility.

| Rule | File | Key Pattern |
|------|------|-------------|
| Component Architecture | `rules/design-system-components.md` | Atomic design, CVA variants, WCAG 2.1 AA, Storybook |

## Forms

React Hook Form v7 with Zod validation and React 19 Server Actions.

| Rule | File | Key Pattern |
|------|------|-------------|
| React Hook Form | `rules/forms-react-hook-form.md` | useForm, field arrays, Controller, wizards, file uploads |
| Zod & Server Actions | `rules/forms-validation-zod.md` | Zod schemas, Server Actions, useActionState, async validation |

## Modern CSS & Tooling

Modern CSS patterns, Tailwind v4, and component documentation tooling for 2026.

| Rule | File | Key Pattern |
|------|------|-------------|
| CSS Cascade Layers | `rules/css-cascade-layers.md` | @layer ordering, specificity-free overrides, third-party isolation |
| Tailwind v4 | `rules/tailwind-v4-patterns.md` | CSS-first @theme, native container queries, @max-* variants |
| Storybook Docs | `rules/storybook-component-docs.md` | CSF3 stories, play() interaction tests, Chromatic visual regression |

## UX Foundations

Cognitive-science-grounded UI/UX principles with specific numeric thresholds for production-quality interfaces.

| Rule | File | Key Pattern |
|------|------|-------------|
| Visual Hierarchy | `rules/visual-hierarchy.md` | Button tiers, de-emphasis, F/Z scan, Von Restorff, proximity, max-width |
| Typography Thresholds | `rules/typography-thresholds.md` | 65ch line length, 1.4–1.6 line height, rem units, modular type scale |
| Color System | `rules/color-system.md` | OKLCH 9-shade scales, semantic categories, no true black, brand-tinted neutrals |
| Empty States | `rules/empty-states.md` | Skeleton-first, icon + headline + description + CTA, cause-specific tone |

## Related Skills

- `ork:accessibility` - WCAG compliance and React Aria patterns
- `ork:testing-unit` - Component testing patterns


---

## Rules (15)

### Build a color system with OKLCH, 9-shade scales, and semantic categories — HIGH


## Color System Architecture

**Incorrect — true black, pure grey, and unstructured colors:**
```css
/* WRONG: True black creates harsh contrast and eye strain */
color: #000000;

/* WRONG: Pure neutral grey feels lifeless (no brand personality) */
background: #808080;

/* WRONG: Unstructured hex soup — no scale, no semantics */
--primary: #0066cc;
--secondary: #aaaaaa;
--error: #ff0000;
```

**Correct — OKLCH scale with brand-tinted neutrals:**
```css
@theme {
  /* PRIMARY — 9-shade OKLCH scale (50→950) */
  --color-brand-50:  oklch(0.97 0.02 250);
  --color-brand-100: oklch(0.93 0.04 250);
  --color-brand-200: oklch(0.86 0.07 250);
  --color-brand-300: oklch(0.76 0.10 250);
  --color-brand-400: oklch(0.65 0.13 250);
  --color-brand-500: oklch(0.55 0.15 250);  /* base */
  --color-brand-600: oklch(0.46 0.15 248);  /* hue shift darker */
  --color-brand-700: oklch(0.38 0.14 246);
  --color-brand-800: oklch(0.30 0.12 244);
  --color-brand-950: oklch(0.18 0.08 240);

  /* NEUTRALS — tinted with brand hue (not pure grey) */
  --color-neutral-50:  oklch(0.98 0.005 250);  /* off-white, not #ffffff */
  --color-neutral-900: oklch(0.18 0.01  250);  /* dark text, not #000000 */

  /* SEMANTIC — success / error / warning / info */
  --color-success: oklch(0.55 0.15 145);
  --color-error:   oklch(0.55 0.18  25);
  --color-warning: oklch(0.65 0.15  75);
  --color-info:    oklch(0.55 0.12 230);

  /* SURFACE — temper max contrast */
  --color-background: oklch(0.98 0.005 250);  /* slate-50 equivalent */
  --color-foreground: oklch(0.18 0.01  250);  /* slate-900 equivalent */
}
```

### Color Categories

| Category | Purpose | Example Tokens |
|----------|---------|----------------|
| Brand/Accent | Primary identity, CTAs | `brand-500`, `brand-600` |
| Semantic | Status communication | `success`, `error`, `warning`, `info` |
| Neutral | Text, backgrounds, borders | `neutral-50` → `neutral-950` |

### Shade Scale Rules

| Shade | Lightness (OKLCH L) | Usage |
|-------|---------------------|-------|
| 50 | ~0.97 | Tinted backgrounds |
| 100–200 | 0.90–0.86 | Hover backgrounds |
| 300–400 | 0.76–0.65 | Borders, disabled states |
| 500 | ~0.55 | Base/default (AA on white) |
| 600–700 | 0.46–0.38 | Hover states for base |
| 800–950 | 0.30–0.18 | Dark mode surfaces, deep text |

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Color notation | OKLCH — perceptually uniform, easy to adjust programmatically |
| Shade count | 9 shades per hue (50, 100, 200, 300, 400, 500, 600, 700, 800, 950) |
| True black | Never `#000000` — use `neutral-950` (oklch ~0.18) |
| Neutral tinting | Tint greys with brand hue (cool brand = cool neutrals) |
| Hue rotation | Shift hue 2–6° darker as lightness decreases to maintain saturation |
| Background | Off-white (`neutral-50`) not pure white — reduces eye strain |
| Max contrast | Body text `neutral-900` on `neutral-50` — not pure black on white |


### Use CSS Cascade Layers for predictable style precedence without specificity wars — HIGH


# CSS Cascade Layers (`@layer`)

Cascade layers give you explicit control over which styles win, regardless of specificity or source order within each layer. Styles in later layers always beat earlier layers.

## Recommended Layer Order

```css
/* Declare layer order once at the top of your entry CSS file */
@layer reset, base, tokens, components, utilities, overrides;
```

| Layer | Purpose | Example |
|-------|---------|---------|
| `reset` | Normalize browser defaults | `*, *::before \{ box-sizing: border-box; margin: 0; \}` |
| `base` | Element-level defaults | `body \{ font-family: var(--font-sans); \}` |
| `tokens` | Design tokens / CSS custom properties | `:root \{ --color-primary: oklch(0.6 0.2 250); \}` |
| `components` | Component-scoped styles | `.card \{ border-radius: var(--radius-md); \}` |
| `utilities` | Tailwind or utility classes | `.sr-only \{ position: absolute; ... \}` |
| `overrides` | Page-specific or one-off overrides | `.hero-banner .card \{ padding: 3rem; \}` |

## Assigning Third-Party CSS to Early Layers

Push third-party styles into a low-priority layer so your styles always win:

```css
/* Import third-party CSS into the reset layer */
@import url('normalize.css') layer(reset);
@import url('some-library/styles.css') layer(base);
```

## Unlayered Styles

Styles outside any `@layer` always beat layered styles. Use this sparingly for truly global escape hatches.

## Incorrect -- Fighting specificity with !important and deep nesting

```css
/* Specificity war — fragile and hard to maintain */
.page-wrapper .content-area .sidebar .card .card-header h2 {
  color: blue;
}

.card-header h2 {
  color: red !important; /* Only way to override the above */
}
```

## Correct -- Clean layer structure with clear precedence

```css
@layer reset, base, tokens, components, utilities, overrides;

@layer components {
  .card-header h2 {
    color: var(--color-heading);
  }
}

@layer overrides {
  /* Wins over components layer regardless of specificity */
  .card-header h2 {
    color: var(--color-accent);
  }
}
```

## Key Rules

- Declare all layers in a single `@layer` statement at the top of your entry CSS
- Later layers beat earlier layers regardless of selector specificity
- Assign third-party CSS to early layers (`reset` or `base`) for clean overrides
- Never use `!important` to fight specificity — restructure layers instead
- Unlayered CSS beats all layers — keep it minimal
- Tailwind v4 uses layers internally; place custom layers around it accordingly

Reference: [MDN @layer](https://developer.mozilla.org/en-US/docs/Web/CSS/@layer)


### Structure scalable component libraries using atomic design and composition patterns — HIGH


## Design System Component Architecture

**Incorrect — unstructured components without patterns:**
```tsx
// WRONG: No variant system, inline styles
function Button({ type, children }) {
  const style = type === 'primary'
    ? { background: 'blue', color: 'white', padding: '10px 20px' }
    : { background: 'gray', color: 'black', padding: '10px 20px' };
  return <button style={style}>{children}</button>;
}

// WRONG: Wrapper divs instead of composition
<div className="dialog-wrapper">
  <div className="dialog-overlay" />
  <div className="dialog-content">
    <button onClick={close}>Close</button>
  </div>
</div>
```

**Correct — Atomic Design with CVA variants:**
```tsx
// Atomic Design hierarchy
// Atoms -> Molecules -> Organisms -> Templates -> Pages

// Atom: Button with CVA variants
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 px-3',
        lg: 'h-11 px-8',
      },
    },
    defaultVariants: { variant: 'default', size: 'default' },
  }
)
```

### Atomic Design Levels

| Level | Description | Examples |
|-------|-------------|----------|
| Atoms | Indivisible primitives | Button, Input, Label, Icon |
| Molecules | Simple compositions | FormField, SearchBar, Card |
| Organisms | Complex compositions | Navigation, Modal, DataTable |
| Templates | Page layouts | DashboardLayout, AuthLayout |
| Pages | Specific instances | HomePage, SettingsPage |

### WCAG 2.1 Level AA Requirements

| Requirement | Threshold |
|-------------|-----------|
| Normal text contrast | 4.5:1 minimum |
| Large text contrast | 3:1 minimum |
| UI components | 3:1 minimum |

### Accessibility Essentials

- **Keyboard Navigation**: All interactive elements must be keyboard accessible
- **Focus Management**: Use focus traps in modals, maintain logical focus order
- **Semantic HTML**: Use `&lt;button&gt;`, `&lt;nav&gt;`, `&lt;main&gt;` instead of generic divs
- **ARIA Attributes**: `aria-label`, `aria-expanded`, `aria-controls`, `aria-live`
- **No positive tabindex**: Using `tabindex > 0` disrupts natural tab order

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Component architecture | Atomic Design (scalable hierarchy) |
| Variant management | CVA (Class Variance Authority) |
| Documentation | Storybook (interactive component playground) |
| Composition | Use `asChild` to avoid wrapper divs |
| Extending components | Wrap, don't modify source files |
| Class merging | Always use cn() for Tailwind conflicts |


### Use 8px grid spacing scale for consistent component and layout spacing — MEDIUM


## 8px Grid Spacing System

**Incorrect -- arbitrary pixel values:**
```tsx
// WRONG: Random spacing with no system
<div style={{ padding: '13px', marginBottom: '7px', gap: '11px' }}>
  <h2 style={{ marginTop: '19px' }}>Title</h2>
  <p style={{ padding: '5px 9px' }}>Content</p>
</div>

// WRONG: Mixing spacing systems
<div className="p-3 mb-[7px] gap-[11px]">
```

**Correct -- 8px grid spacing scale:**

### Spacing Scale

| Token | Value | Tailwind | Use Case |
|-------|-------|----------|----------|
| `micro` | 4px | `p-1`, `gap-1` | Icon-to-label gaps, inline element spacing |
| `tight` | 8px | `p-2`, `gap-2` | Compact lists, tight form fields, badge padding |
| `compact` | 12px | `p-3`, `gap-3` | Card padding (small), button group gaps |
| `default` | 16px | `p-4`, `gap-4` | Standard padding, form field gaps, paragraph spacing |
| `comfortable` | 24px | `p-6`, `gap-6` | Card padding (large), section gaps within a panel |
| `loose` | 32px | `p-8`, `gap-8` | Page section separation, modal padding |
| `section` | 48px | `p-12`, `gap-12` | Major page sections, hero spacing |

### Component Spacing Guide

```tsx
// Card with consistent spacing
<Card className="p-6 space-y-4">          {/* comfortable padding, default internal gaps */}
  <CardHeader className="space-y-2">       {/* tight gaps between title/description */}
    <CardTitle>Title</CardTitle>
    <CardDescription>Description</CardDescription>
  </CardHeader>
  <CardContent className="space-y-4">      {/* default gaps between content blocks */}
    <p>Body text</p>
  </CardContent>
  <CardFooter className="gap-2">           {/* tight gaps between action buttons */}
    <Button variant="outline">Cancel</Button>
    <Button>Submit</Button>
  </CardFooter>
</Card>
```

### Layout Spacing

```tsx
// Page layout with section spacing
<main className="space-y-12 px-8 py-12">   {/* section gaps, loose horizontal padding */}
  <section className="space-y-6">           {/* comfortable gaps within section */}
    <h1 className="mb-4">Page Title</h1>    {/* default gap below heading */}
    <div className="grid gap-6">            {/* comfortable grid gaps */}
      {items.map(item => <Card key={item.id} />)}
    </div>
  </section>
</main>
```

### Rules

- All spacing values must be multiples of 4px
- Use Tailwind spacing utilities, never arbitrary `px` values
- Nest spacing: outer containers use larger values, inner elements use smaller
- Consistent gap hierarchy: section (48px) > panel (24-32px) > content (16px) > elements (8px) > micro (4px)

Key decisions:
- Base unit: 8px (with 4px half-step for micro adjustments)
- Never use odd pixel values or non-grid-aligned spacing
- Prefer `gap` and `space-y`/`space-x` over individual margins
- Scale spacing with viewport using responsive utilities (`gap-4 md:gap-6 lg:gap-8`)


### Define all interactive component states with consistent visual feedback patterns — HIGH


## Interactive Component States

**Incorrect -- button with only default state:**
```tsx
// WRONG: No hover, focus, disabled, or loading states
function Button({ children, onClick }) {
  return (
    <button onClick={onClick} className="bg-blue-500 text-white px-4 py-2 rounded">
      {children}
    </button>
  );
}
// User gets no feedback on hover, no focus ring for keyboard users,
// no visual change when disabled, no loading indicator
```

**Correct -- button with all 6 states plus animation:**

### Required States for Interactive Components

| State | Visual Indicator | Purpose |
|-------|-----------------|---------|
| Default | Base styling | Resting appearance |
| Hover | Subtle background shift, cursor change | Indicates interactivity |
| Focus | Visible ring (2px offset) | Keyboard navigation feedback |
| Active/Pressed | Scale down or darken | Confirms click/tap registered |
| Disabled | Reduced opacity, no pointer events | Shows unavailability |
| Loading | Spinner + disabled interaction | Async operation in progress |

### TypeScript Interface

```typescript
interface ComponentStateProps {
  isDisabled?: boolean;
  isLoading?: boolean;
  // Default, hover, focus, active are handled via CSS
}
```

### Tailwind State Classes

```tsx
function Button({ children, onClick, isDisabled, isLoading }: ComponentStateProps & {
  children: React.ReactNode;
  onClick?: () => void;
}) {
  return (
    <button
      onClick={onClick}
      disabled={isDisabled || isLoading}
      className={cn(
        // Default
        "bg-primary text-primary-foreground px-4 py-2 rounded-md font-medium",
        "inline-flex items-center justify-center gap-2",
        // Hover
        "hover:bg-primary/90",
        // Focus (visible ring for keyboard, not mouse)
        "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
        // Active
        "active:scale-[0.98] active:bg-primary/80",
        // Disabled
        "disabled:opacity-50 disabled:pointer-events-none",
        // Transition
        "transition-all duration-150 ease-in-out",
      )}
    >
      {isLoading && <Spinner className="h-4 w-4 animate-spin" />}
      {children}
    </button>
  );
}
```

### Motion Presets

| Context | Animation Name | CSS/Tailwind | Duration | Easing |
|---------|---------------|--------------|----------|--------|
| Page transitions | `pageFade` | `animate-in fade-in` | 200ms | ease-out |
| Modals | `modalContent` | `animate-in zoom-in-95 fade-in` | 200ms | ease-out |
| List items | `staggerItem` | `animate-in slide-in-from-bottom-2` | 150ms | ease-out |
| Card hover | `cardHover` | `hover:-translate-y-1 hover:shadow-lg` | 200ms | ease-in-out |
| Button tap | `tapScale` | `active:scale-[0.98]` | 100ms | ease-in |
| Toast enter | `toastSlideIn` | `animate-in slide-in-from-right` | 300ms | ease-out |

```tsx
// Staggered list animation
{items.map((item, i) => (
  <div
    key={item.id}
    className="animate-in slide-in-from-bottom-2 fade-in"
    style={{ animationDelay: `${i * 50}ms`, animationFillMode: "both" }}
  >
    {item.content}
  </div>
))}
```

### Accessibility Contrast Requirements

| Element | Minimum Ratio | Target Ratio | WCAG Level |
|---------|--------------|--------------|------------|
| Body text | 4.5:1 | 7:1 | AA / AAA |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 | AA / AAA |
| UI components (borders, icons) | 3:1 | 4.5:1 | AA |
| Focus indicators | 3:1 | 4.5:1 | AA |

### State Checklist for New Components

Every interactive component must define:
1. **Default** -- base visual appearance
2. **Hover** -- `hover:` modifier with subtle visual shift
3. **Focus** -- `focus-visible:ring-2` (never remove focus outlines)
4. **Active** -- `active:` feedback (scale, darken, or both)
5. **Disabled** -- `disabled:opacity-50 disabled:pointer-events-none`
6. **Loading** -- spinner icon, disabled interaction, aria-busy="true"

Key decisions:
- Always use `focus-visible` (not `focus`) to avoid showing rings on mouse click
- Keep transitions under 200ms for interactive feedback (longer feels sluggish)
- Use `prefers-reduced-motion` media query to disable animations for accessibility
- Test all states in Storybook with a dedicated "States" story per component


### Define consistent design tokens to enable global theme changes without visual drift — HIGH


## Design System Token Architecture

**Incorrect — hardcoded values and CSS variable abuse:**
```tsx
// WRONG: Hardcoded colors
<div className="bg-[#0066cc] text-[#ffffff]">

// WRONG: CSS variables in className (use semantic tokens instead)
<div className="bg-[var(--color-primary)]">

// WRONG: No token structure
const styles = { color: '#333', padding: '17px', fontSize: '15px' };
```

**Correct — W3C design token structure with Tailwind @theme:**
```typescript
const tokens = {
  colors: {
    primary: { base: "#0066cc", hover: "#0052a3" },
    semantic: { success: "#28a745", error: "#dc3545" }
  },
  spacing: { xs: "4px", sm: "8px", md: "16px", lg: "24px" }
};
```

```css
/* Tailwind @theme directive (recommended) */
@theme {
  --color-primary: oklch(0.55 0.15 250);
  --color-primary-hover: oklch(0.45 0.15 250);
  --color-text-primary: oklch(0.15 0 0);
  --color-background: oklch(0.98 0 0);
  --spacing-xs: 4px;
  --spacing-sm: 8px;
  --spacing-md: 16px;
  --spacing-lg: 24px;
}
```

```tsx
// Components use Tailwind utilities (correct)
<div className="bg-primary text-text-primary p-md">
```

### Token Categories

| Category | Examples | Scale |
|----------|----------|-------|
| Colors | `blue.500`, `text.primary`, `feedback.error` | 50-950 |
| Typography | `fontSize.base`, `fontWeight.semibold` | xs-5xl |
| Spacing | `spacing.4`, `spacing.8` | 0-24 (4px base) |
| Border Radius | `borderRadius.md`, `borderRadius.full` | none-full |
| Shadows | `shadow.sm`, `shadow.lg` | xs-xl |

### Design System Layers

| Layer | Description | Examples |
|-------|-------------|----------|
| Design Tokens | Foundational design decisions | Colors, spacing, typography |
| Components | Reusable UI building blocks | Button, Input, Card, Modal |
| Patterns | Common UX solutions | Forms, Navigation, Layouts |
| Guidelines | Rules and best practices | Accessibility, naming, APIs |

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Token format | W3C Design Tokens (industry standard) |
| Color format | OKLCH for perceptually uniform theming |
| Styling approach | Tailwind `@theme` directive |
| Spacing base | 4px system |
| Dark mode | Tailwind `@theme` with CSS variables |


### Apply consistent typography scale with semantic size roles for readable hierarchies — MEDIUM


## Typography Scale

**Incorrect -- arbitrary font sizes:**
```tsx
// WRONG: Random sizes with no hierarchy
<h1 style={{ fontSize: '27px', fontWeight: 400 }}>Title</h1>
<p style={{ fontSize: '15px', lineHeight: '1.3' }}>Body</p>
<span style={{ fontSize: '11px' }}>Caption</span>

// WRONG: Mixing Tailwind and arbitrary values
<h1 className="text-[27px] font-normal leading-[1.3]">Title</h1>
```

**Correct -- semantic typography scale:**

### Size Scale

| Token | Size | Tailwind | Semantic Role |
|-------|------|----------|---------------|
| `caption` | 12px | `text-xs` | Captions, timestamps, helper text |
| `secondary` | 14px | `text-sm` | Secondary text, labels, metadata |
| `body` | 16px | `text-base` | Body copy, primary content |
| `large` | 18px | `text-lg` | Large body, lead paragraphs |
| `subheading` | 20px | `text-xl` | Section subheadings |
| `heading` | 24px | `text-2xl` | Card/panel headings |
| `page-title` | 30px | `text-3xl` | Page titles, hero headings |

### Weight Pairing Guide

| Weight | Tailwind | Use With |
|--------|----------|----------|
| Normal (400) | `font-normal` | Body text, paragraphs, descriptions |
| Medium (500) | `font-medium` | Labels, nav items, subtle emphasis |
| Semibold (600) | `font-semibold` | Headings, card titles, table headers |
| Bold (700) | `font-bold` | Primary emphasis, key metrics, CTAs |

### Line Height Rules

| Context | Tailwind | Ratio | When |
|---------|----------|-------|------|
| Headings | `leading-tight` | 1.25 | Short, large text (h1-h3) |
| Body | `leading-normal` | 1.5 | Standard paragraphs, lists |
| Long-form | `leading-relaxed` | 1.625 | Articles, documentation, dense content |

### Correct Usage

```tsx
// Page with consistent typography hierarchy
<main>
  <h1 className="text-3xl font-semibold leading-tight">
    Page Title
  </h1>
  <p className="text-lg font-normal leading-normal text-muted-foreground">
    Lead paragraph with larger body text.
  </p>

  <section>
    <h2 className="text-2xl font-semibold leading-tight">Section Heading</h2>
    <p className="text-base font-normal leading-normal">
      Standard body text for primary content.
    </p>
    <span className="text-sm font-medium text-muted-foreground">
      Label or metadata
    </span>
    <p className="text-xs text-muted-foreground">
      Caption or timestamp
    </p>
  </section>
</main>
```

### Rules

- Never use arbitrary font sizes -- always use the scale tokens
- Pair weights intentionally: body=normal, labels=medium, headings=semibold
- Use `leading-tight` for headings, `leading-normal` for body, `leading-relaxed` for long-form
- Maximum 2 font families per project (1 sans-serif + 1 monospace is ideal)
- Responsive scaling: `text-2xl md:text-3xl lg:text-4xl` for page titles

Key decisions:
- Base size: 16px (`text-base`) -- browser default, accessible
- Scale ratio: ~1.25 (Major Third) for harmonious progression
- Weight hierarchy: normal &lt; medium &lt; semibold &lt; bold (never skip 2+ levels)
- Line height decreases as font size increases


### Design intentional empty states with clear structure, actionable CTAs, and skeleton-first loading — HIGH


## Empty State Patterns

**Incorrect — blank screen and unusable no-results state:**
```tsx
// WRONG: Renders nothing when list is empty
function ProjectList({ projects }) {
  return (
    <ul>
      {projects.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

// WRONG: Clinical message with no guidance or action
{projects.length === 0 && <p>No projects.</p>}

// WRONG: Flash empty state before data loads (jarring UX)
{!isLoading && data.length === 0 && <EmptyState />}
{isLoading && <Spinner />}
```

**Correct — skeleton-first, then structured empty state:**
```tsx
// RIGHT: Skeleton while loading → empty state only if data is truly absent
function ProjectList({ isLoading, projects }: Props) {
  if (isLoading) return <ProjectListSkeleton />

  if (projects.length === 0) return <EmptyProjects />

  return (
    <ul className="space-y-2">
      {projects.map(p => <li key={p.id}>{p.name}</li>)}
    </ul>
  )
}

// RIGHT: Structured empty state — icon + headline + description + CTA
function EmptyProjects() {
  return (
    <div
      className="flex flex-col items-center justify-center gap-4 py-16 text-center"
      role="status"
      aria-label="No projects yet"
    >
      {/* 1. Illustration or icon */}
      <div className="rounded-full bg-muted p-4">
        <FolderPlusIcon className="h-8 w-8 text-muted-foreground" aria-hidden />
      </div>

      {/* 2. Encouraging headline (not clinical) */}
      <h2 className="text-lg font-semibold text-foreground">
        Create your first project
      </h2>

      {/* 3. Context description */}
      <p className="max-w-sm text-sm text-muted-foreground">
        Projects help you organise work and collaborate with your team.
        Get started in under a minute.
      </p>

      {/* 4. Primary action */}
      <Button>
        <PlusIcon className="mr-2 h-4 w-4" aria-hidden />
        New project
      </Button>
    </div>
  )
}
```

### Empty State Taxonomy

| Cause | Tone | CTA Example |
|-------|------|-------------|
| First-time (onboarding) | Encouraging, welcoming | "Create your first project" |
| No search results | Neutral, helpful | "Try different keywords or clear filters" |
| Error / failed to load | Reassuring, recovery | "Something went wrong — try again" |
| Permission / access | Clear, non-alarming | "Ask your admin for access" |

### Structure Checklist

```
[x] Icon or illustration (visual anchor)
[x] Headline — actionable, not clinical ("Create X" not "No X found")
[x] Description — 1-2 sentences of context
[x] Primary CTA button — one clear next step
[ ] Optional: secondary link for help docs
```

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Blank screen | Never acceptable — always design an empty state |
| Loading order | Skeleton first → empty state if truly empty (no flash) |
| Headline tone | Encouraging and action-oriented, never clinical |
| CTA count | One primary action maximum per empty state |
| Role attribute | `role="status"` on the container for screen reader announcement |
| Cause-specific | Different empty states for first-time, no-results, error, permissions |


### Build performant forms with React Hook Form v7 controlled renders and validation — HIGH


## React Hook Form Patterns

Production form patterns with React Hook Form v7 for controlled rendering, field arrays, wizards, and file uploads.

**Incorrect — uncontrolled form with useEffect fetch:**
```tsx
// WRONG: Fetching in useEffect, manual state management
function BadForm() {
  const [email, setEmail] = useState('');
  const [errors, setErrors] = useState({});

  useEffect(() => {
    // Manual validation on every render...
    if (!email.includes('@')) setErrors({ email: 'Invalid' });
  }, [email]);

  return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}
```

**Correct — React Hook Form with Zod resolver:**
```tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const userSchema = z.object({
  email: z.email('Please enter a valid email'),
  password: z.string().min(8, 'Minimum 8 characters'),
  confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

type UserForm = z.infer<typeof userSchema>;

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm<UserForm>({
    resolver: zodResolver(userSchema),
    defaultValues: { email: '', password: '', confirmPassword: '' },
    mode: 'onBlur', // Validate on blur, not every keystroke
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)} noValidate>
      <input
        {...register('email')}
        aria-invalid={!!errors.email}
        aria-describedby={errors.email ? 'email-error' : undefined}
      />
      {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Submitting...' : 'Sign Up'}
      </button>
    </form>
  );
}
```

**Field arrays for dynamic fields:**
```tsx
import { useFieldArray } from 'react-hook-form';

function OrderForm() {
  const { control, register } = useForm({
    defaultValues: { items: [{ productId: '', quantity: 1 }] },
  });
  const { fields, append, remove } = useFieldArray({ control, name: 'items' });

  return (
    <>
      {fields.map((field, index) => (
        <div key={field.id}> {/* Use field.id, NOT index */}
          <input {...register(`items.${index}.productId`)} />
          <input type="number" {...register(`items.${index}.quantity`, { valueAsNumber: true })} />
          <button type="button" onClick={() => remove(index)}>Remove</button>
        </div>
      ))}
      <button type="button" onClick={() => append({ productId: '', quantity: 1 })}>Add</button>
    </>
  );
}
```

**Controller for third-party components:**
```tsx
import { Controller } from 'react-hook-form';

<Controller
  name="date"
  control={control}
  render={({ field, fieldState }) => (
    <DatePicker
      value={field.value}
      onChange={field.onChange}
      onBlur={field.onBlur}
      error={fieldState.error?.message}
    />
  )}
/>
```

**Key rules:**
- Always provide `defaultValues` (prevents uncontrolled-to-controlled warnings)
- Use `mode: 'onBlur'` for better performance (not every keystroke)
- Use `field.id` as key in field arrays, never index
- Use `Controller` for non-native inputs (date pickers, selects, rich text)
- Add `noValidate` to form element when using Zod (disable browser validation)
- Use `aria-invalid` and `role="alert"` for accessibility


### Validate forms with Zod type-safe schemas on both client and server sides — HIGH


## Zod Validation & Server Actions

Type-safe validation with Zod schemas shared between client forms and React 19 Server Actions.

**Incorrect — client-only validation without server check:**
```tsx
// WRONG: Client validation only — bypassable with DevTools
function ContactForm() {
  const handleSubmit = (e: FormEvent) => {
    if (!email.includes('@')) return; // Client-only, easily bypassed!
    fetch('/api/contact', { body: JSON.stringify({ email }) });
  };
}
```

**Correct — shared Zod schema for client AND server:**
```typescript
// schemas/contact.ts — Shared validation (client + server)
import { z } from 'zod';

export const contactSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.email('Please enter a valid email'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
});

export type ContactForm = z.infer<typeof contactSchema>;
```

**Server Action with Zod validation (React 19):**
```typescript
// actions.ts
'use server';
import { contactSchema } from '@/schemas/contact';

export async function submitContact(formData: FormData) {
  const result = contactSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  });

  if (!result.success) {
    // z.treeifyError() replaces deprecated .flatten() (Zod 4).
    // Shape differs: per-field messages live at tree.properties[field].errors
    const tree = z.treeifyError(result.error);
    return { errors: tree.properties };
  }

  await saveContact(result.data);
  return { success: true };
}

// Component with useActionState
'use client';
import { useActionState } from 'react';
import { submitContact } from './actions';

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, null);

  return (
    <form action={formAction}>
      <input name="name" />
      {state?.errors?.name && <span role="alert">{state.errors.name.errors[0]}</span>}

      <input name="email" />
      {state?.errors?.email && <span role="alert">{state.errors.email.errors[0]}</span>}

      <textarea name="message" />
      {state?.errors?.message && <span role="alert">{state.errors.message.errors[0]}</span>}

      <button type="submit" disabled={isPending}>
        {isPending ? 'Sending...' : 'Send'}
      </button>
    </form>
  );
}
```

**Advanced Zod patterns:**
```typescript
// Async validation (username availability)
const usernameSchema = z.object({
  username: z.string()
    .min(3, 'At least 3 characters')
    .refine(async (value) => {
      const available = await checkUsernameAvailability(value);
      return available;
    }, 'Username already taken'),
});

// Use mode: 'onBlur' to avoid async validation on every keystroke
useForm({ resolver: zodResolver(usernameSchema), mode: 'onBlur' });

// Transform + validate
const priceSchema = z.object({
  price: z.string()
    .transform((val) => parseFloat(val))
    .pipe(z.number().positive('Must be positive')),
});

// Discriminated union for conditional fields
const paymentSchema = z.discriminatedUnion('method', [
  z.object({ method: z.literal('card'), cardNumber: z.string().length(16) }),
  z.object({ method: z.literal('paypal'), paypalEmail: z.email() }),
]);
```

**Key rules:**
- Share Zod schemas between client and server — single source of truth
- Always validate on server even if client validation passes (never trust client)
- Use `safeParse` (not `parse`) for server actions to return errors instead of throwing
- Use `z.infer&lt;typeof schema&gt;` for automatic TypeScript types
- Async validation: combine with `mode: 'onBlur'` to avoid excessive API calls
- Custom error messages in every `.email()`, `.min()`, `.refine()` call


### shadcn/ui v4 Style System — 6 Styles + Preset Codes — HIGH


## shadcn/ui v4 Style System

shadcn CLI v4 ships 6 visual styles that rewrite component class names — not just CSS variables. Each style defines its own radius, elevation, spacing, and visual weight. Detect the project's style from `components.json` and apply the correct classes.

**Incorrect — hardcoding classes without checking project style:**
```tsx
// Assumes rounded-md everywhere — wrong for Luma (rounded-4xl) or Lyra (rounded-none)
const Card = ({ children }: CardProps) => (
  <div className="rounded-lg border p-4 shadow-sm">
    {children}
  </div>
)
```

**Correct — reading project style and applying matching classes:**
```tsx
// 1. Detect style: Read components.json → "style" field
//    "radix-luma" | "radix-vega" | "base-nova" | etc.

// 2. Apply style-correct classes:
// Luma:  rounded-4xl, shadow-md + ring-1 ring-foreground/5, gap-6 py-6
// Vega:  rounded-lg, shadow-sm, gap-4 py-4 (balanced, general purpose)
// Nova:  rounded-md, no shadow, px-2 py-1 (compact dashboards)
// Maia:  rounded-xl, shadow-sm, gap-5 py-5 (soft, consumer)
// Lyra:  rounded-none, no shadow, gap-4 py-4 (sharp, editorial)
// Mira:  rounded-sm, no shadow, px-1 py-0.5 (ultra-dense)

const Card = ({ children }: CardProps) => (
  <div className="rounded-4xl border shadow-md ring-1 ring-foreground/5 p-6">
    {children}
  </div>
)
```

### Preset Codes

All style + theme + font + icon choices encode into a shareable 7-char preset code:

**Incorrect — using deprecated style names:**
```json
{
  "style": "new-york"
}
```

**Correct — using v4 style names and preset codes:**
```bash
# Initialize with preset (encodes all 10 design system params)
npx shadcn@latest init --preset b2D0xPaDb

# Preview changes before applying
npx shadcn@latest add button --diff
```

```json
{
  "style": "radix-luma"
}
```

### Style Detection Pattern

```typescript
import { readFileSync } from 'fs'

// Read components.json to detect active style
const config = JSON.parse(readFileSync('components.json', 'utf-8'))
const style = config.style // "radix-luma", "base-nova", etc.
const styleName = style.split('-').pop() // "luma", "nova", etc.
```

### Style Reference

| Style | Radius | Elevation | Spacing | Best For |
|-------|--------|-----------|---------|----------|
| Vega | `rounded-lg` | `shadow-sm` | Balanced | General purpose |
| Nova | `rounded-md` | None | Compact | Dense dashboards |
| Maia | `rounded-xl` | `shadow-sm` | Generous | Consumer apps |
| Lyra | `rounded-none` | None | Balanced | Editorial, dev tools |
| Mira | `rounded-sm` | None | Ultra-dense | Spreadsheets, data |
| Luma | `rounded-4xl` | `shadow-md` + ring | Breathable | Polished native-app |

Configure visually at [ui.shadcn.com/create](https://ui.shadcn.com/create). Old `"new-york"` and `"default"` are superseded by Vega.


### Storybook CSF3 stories with play() interaction tests and Chromatic visual regression — HIGH


# Storybook Component Documentation (2026)

Every component state should be a story, every story a visual test. Use CSF3 format, `play()` functions for interaction testing, and Chromatic for CI visual regression.

## CSF3 Story Format

```tsx
// Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta = {
  title: 'Components/Button',
  component: Button,
  tags: ['autodocs'],
  argTypes: {
    variant: { control: 'select', options: ['default', 'destructive', 'outline'] },
    size: { control: 'select', options: ['sm', 'default', 'lg'] },
  },
} satisfies Meta<typeof Button>

export default meta
type Story = StoryObj<typeof meta>

// One story per visual state
export const Default: Story = {
  args: { children: 'Click me', variant: 'default' },
}

export const Destructive: Story = {
  args: { children: 'Delete', variant: 'destructive' },
}

export const Loading: Story = {
  args: { children: 'Saving...', loading: true, disabled: true },
}
```

## play() Functions for Interaction Testing

Test component behavior in isolation without a full E2E framework:

```tsx
import { expect, userEvent, within } from 'storybook/test'

export const FormSubmission: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await userEvent.type(canvas.getByLabelText('Email'), 'user@example.com')
    await userEvent.type(canvas.getByLabelText('Password'), 'securepass')
    await userEvent.click(canvas.getByRole('button', { name: /sign in/i }))
    await expect(canvas.getByText('Welcome back!')).toBeInTheDocument()
  },
}
```

## Chromatic CI Visual Regression

```yaml
# .github/workflows/chromatic.yml
- uses: chromaui/action@latest
  with:
    projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
    onlyChanged: true        # Only snapshot changed stories
    exitZeroOnChanges: true   # Don't fail CI, flag for review
```

## Incorrect -- No stories, manual visual testing only

```tsx
// Component exists but no stories
// "I'll just check it in the browser"
// No automated visual regression — bugs ship silently

// Or: stories without interaction coverage
export const Default: Story = { args: { open: true } }
// Never tests open/close flow, form validation, error states
```

## Correct -- Story per state, play() for interactions, Chromatic in CI

```tsx
// Every meaningful state is a story
export const Empty: Story = { args: { items: [] } }
export const WithItems: Story = { args: { items: mockItems } }
export const Loading: Story = { args: { loading: true } }
export const Error: Story = { args: { error: 'Failed to load' } }

// Interactive flows tested with play()
export const AddItem: Story = {
  args: { items: [] },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement)
    await userEvent.click(canvas.getByRole('button', { name: /add/i }))
    await expect(canvas.getByText('New Item')).toBeInTheDocument()
  },
}
```

## Key Rules

- Use CSF3 format (`satisfies Meta&lt;typeof Component&gt;`) for type safety
- Add `tags: ['autodocs']` for automatic documentation generation
- Create one story per meaningful visual state (empty, loading, error, populated)
- Use `play()` functions to test interactions without E2E overhead
- Run Chromatic in CI for automated visual regression on every PR
- Keep stories co-located with components (`Component.stories.tsx`)

Reference: [Storybook Docs](https://storybook.js.org/docs)


### Tailwind v4 CSS-first configuration and native container queries — HIGH


# Tailwind v4 Patterns (2026)

Tailwind v4 moves configuration to CSS, drops `tailwind.config.js`, and adds native container query support without plugins.

## CSS-First Configuration

All theme customization lives in CSS via `@theme`:

```css
/* app.css — replaces tailwind.config.js */
@import "tailwindcss";

@theme {
  --color-primary: oklch(0.6 0.2 250);
  --color-secondary: oklch(0.7 0.15 200);
  --font-sans: "Inter", system-ui, sans-serif;
  --radius-lg: 0.75rem;
  --breakpoint-xs: 30rem;
}
```

No `tailwind.config.js`, no `resolveConfig`, no JavaScript theme access at build time.

## Native Container Queries

Container queries are built-in — no `@tailwindcss/container-queries` plugin needed.

### Basic Usage

```tsx
{/* Parent declares containment */}
<div className="@container">
  {/* Children respond to parent's width */}
  <div className="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
    <Card />
  </div>
</div>
```

### Named Containers

```tsx
{/* Named container */}
<div className="@container/card">
  <p className="text-sm @md/card:text-base @lg/card:text-lg">
    Responds to the card container width
  </p>
</div>
```

### Max-Width Container Queries

```tsx
{/* Max-width variant — styles apply below the breakpoint */}
<div className="@container">
  <nav className="@max-md:hidden">Desktop only nav</nav>
  <nav className="@md:hidden">Mobile nav</nav>
</div>
```

## Incorrect -- Using tailwind.config.js in v4

```js
// tailwind.config.js — WRONG in v4, this file is ignored
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#3b82f6',
      },
    },
  },
  plugins: [
    require('@tailwindcss/container-queries'), // Plugin not needed in v4
  ],
}
```

```tsx
// Using plugin-based container syntax — unnecessary in v4
<div className="@container">
  <div className="@[480px]:flex"> {/* Old plugin syntax */}
    Content
  </div>
</div>
```

## Correct -- CSS-first @theme and native @container

```css
/* app.css */
@import "tailwindcss";

@theme {
  --color-primary: oklch(0.6 0.2 250);
  --color-surface: oklch(0.98 0 0);
}
```

```tsx
<div className="@container/sidebar">
  <div className="flex flex-col @sm/sidebar:flex-row @md/sidebar:grid @md/sidebar:grid-cols-2">
    <Widget className="@max-sm/sidebar:p-2 @sm/sidebar:p-4" />
  </div>
</div>
```

## Key Rules

- Use `@theme` in CSS for all configuration — no `tailwind.config.js`
- Container queries are native — do not install `@tailwindcss/container-queries`
- Use `@sm:`, `@md:`, `@lg:` variants for container-width breakpoints
- Use `@max-*:` variants for max-width container queries
- Name containers with `@container/&lt;name&gt;` for targeted queries
- Use `/name` suffix on variants to target specific named containers
- Migrate existing `tailwind.config.js` to `@theme` block when upgrading to v4

Reference: [Tailwind CSS v4 Docs](https://tailwindcss.com/docs)


### Apply numeric typography thresholds for line length, line height, and font scale — HIGH


## Typography Thresholds

**Incorrect — unconstrained text and cascading em bugs:**
```tsx
// WRONG: Unbounded paragraph width causes too-long lines (eye fatigue)
<p className="w-full text-base">Long body copy...</p>

// WRONG: em units for font-size cause cascading multiplication
<div style={{ fontSize: '1.2em' }}>
  <p style={{ fontSize: '1.2em' }}>  {/* Actually 1.44× root — bug! */}
    Nested text
  </p>
</div>

// WRONG: Line height too tight for body text
<p className="leading-tight text-base">Body copy with 1.25 line height</p>

// WRONG: Font weight 500 for "emphasis" — too subtle
<strong className="font-medium">Important</strong>

// WRONG: Inline links without underline (accessibility failure)
<a className="text-primary no-underline">Click here</a>
```

**Correct — constrained width, rem scale, proper line height:**
```tsx
// RIGHT: Max-width on paragraph containers (50-75ch ideal, 65ch default)
<p className="max-w-prose text-base leading-relaxed">
  Body copy with constrained line length and proper line height.
</p>

// RIGHT: Heading gets tighter line height
<h1 className="text-3xl font-bold leading-tight">Page Heading</h1>
<h2 className="text-2xl font-semibold leading-snug">Section Title</h2>

// RIGHT: Inline links always underlined
<a className="text-primary underline underline-offset-2 hover:text-primary/80">
  Inline link
</a>
```

### Type Scale (Tailwind — modular, 1.25 Major Third ratio)

```css
/* In your global CSS or @theme block */
@theme {
  --font-size-xs:   0.64rem;   /* ~10px */
  --font-size-sm:   0.8rem;    /* ~13px */
  --font-size-base: 1rem;      /* 16px  */
  --font-size-lg:   1.25rem;   /* ~20px */
  --font-size-xl:   1.563rem;  /* ~25px */
  --font-size-2xl:  1.953rem;  /* ~31px */
  --font-size-3xl:  2.441rem;  /* ~39px */
}
```

### Threshold Reference

| Property | Threshold | Notes |
|----------|-----------|-------|
| Line length — print | 50–75 ch | Use `max-w-prose` (65ch) |
| Line length — screen | 60–100 ch | UI panels can go wider |
| Line height — body | 1.4–1.6× | `leading-relaxed` = 1.625 |
| Line height — headings | 1.2–1.3× | `leading-tight` = 1.25 |
| Line height — minimum | 1.2× | Never below this |
| Font weight — body | 400 (regular) | `font-normal` |
| Font weight — emphasis | 600+ (semibold) | `font-semibold` minimum |
| Font weight — headings | 700 (bold) | `font-bold` |
| Font size units | rem only | Never em for font-size |

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Font size units | rem only — em cascades multiplicatively |
| Line length | `max-w-prose` (65ch) on all paragraph containers |
| Link underlines | Always underlined for inline links — no exceptions |
| Type scale | Derive all sizes from a modular scale ratio (1.25 or 1.333) |
| UI font | Sans-serif for UI chrome; proportional serif optional for long-form |


### Design visual hierarchy using weight, contrast, and spatial relationships — not color alone — HIGH


## Visual Hierarchy & Layout

**Incorrect — competing primaries and flat hierarchy:**
```tsx
// WRONG: Two primary buttons side-by-side (creates decision paralysis)
<div className="flex gap-2">
  <button className="bg-primary text-white px-4 py-2 rounded">Save</button>
  <button className="bg-primary text-white px-4 py-2 rounded">Cancel</button>
</div>

// WRONG: Emphasizing everything equally — nothing stands out
<h1 className="font-bold text-xl">Title</h1>
<p className="font-bold text-xl">Body copy that competes with heading</p>
<span className="font-bold text-xl">Label also fighting for attention</span>
```

**Correct — three-tier button hierarchy with de-emphasized secondary:**
```tsx
// RIGHT: One primary CTA. Secondary is outlined. Tertiary is ghost/text.
<div className="flex items-center gap-3">
  {/* Primary — full color, highest visual weight */}
  <Button variant="default">Save changes</Button>

  {/* Secondary — outline, reduced weight */}
  <Button variant="outline">Preview</Button>

  {/* Tertiary — ghost/text, lowest weight */}
  <Button variant="ghost">Cancel</Button>
</div>

// RIGHT: De-emphasize secondary content rather than only boosting primary
<h1 className="text-2xl font-bold text-foreground">Page title</h1>
<p className="text-base text-muted-foreground">Supporting description</p>
<span className="text-sm text-muted-foreground/70">Metadata label</span>
```

### Hierarchy Principles

| Principle | Rule | Rationale |
|-----------|------|-----------|
| Button tiers | primary → outline → ghost | One primary per view maximum |
| De-emphasis | Mute secondary content | Easier than boosting everything |
| F/Z scan path | Critical info top-left → right | Matches natural eye movement |
| Von Restorff | Isolate ONE element per view | Uniqueness signals importance |
| Proximity | Group related elements closely | Spacing communicates relationship |
| Max-width | Contain layout, don't fill screen | Prevents unreadable line lengths |

### Layout Rules

```tsx
// RIGHT: Contain content width for readability
<main className="max-w-4xl mx-auto px-4">
  {/* Never stretch to 100vw on wide screens */}
</main>

// RIGHT: Use deliberate spacing to show/break relationships
<section className="space-y-1">   {/* Tight = related items */}
  <label>Email</label>
  <input type="email" />
</section>
<section className="mt-8">       {/* Gap = new section */}
  <label>Password</label>
  <input type="password" />
</section>
```

### Key Decisions

| Decision | Recommendation |
|----------|----------------|
| Grayscale test | Design in grayscale first — hierarchy must work without color |
| Button count | Maximum ONE primary button per view |
| Emphasis strategy | De-emphasize secondary; don't over-emphasize primary |
| Von Restorff | Use isolation sparingly — max one "different" element per view |
| Readability cap | `max-w-prose` or `max-w-4xl` on all content containers |



---

## References (1)

### Ork Delta

# UI Components Skill: OrchestKit Delta

Ork-specific floors, scars, and house decisions for `src/skills/ui-components`.
Vendor mechanics (shadcn/ui install and customization walkthroughs, Radix
primitive API tours, CVA and tailwind-merge tutorials, next-themes setup,
TanStack Table wiring, OKLCH variable listings) are deliberately not restated
here. See the section "Upstream coverage (do not restate)" in SKILL.md for the
first-party source that owns each removed topic.

## Wrap shadcn components; never edit the generated files under components/ui
Why: House convention carried since the v2.0 consolidation of shadcn-patterns
and radix-primitives into src/skills/ui-components (metadata.json, Feb 2026).
Upstream treats generated component files as yours to edit; ork forbids it
(this SKILL.md's FORBIDDEN list) because edited generated files silently lose
every fix the next `npx shadcn@latest add` or `apply &lt;style&gt;` would bring, the
same generated-file-drift class this repo guards against in plugins/. The
convention was asserted by the test-cases.json case `shadcn-customization`
until the 2026-07-31 wrap-plus-delta thinning removed the rule file it traced
to. A working wrapper (loading-state Button extension with ref forwarding)
survives in `scripts/extended-button.tsx`.
Upstream: vercel:shadcn (marketplace skill), https://ui.shadcn.com/docs

## Reach for the scripts/ templates before re-deriving wrapper code
Why: House decision from the 2026-07-31 thinning of src/skills/ui-components.
The five deleted reference files (cva-variant-system, component-extension,
aschild-composition, dialog-modal-patterns, dropdown-menu-patterns) each
restated the same Button, Dialog, and DropdownMenu wrappers that already exist
as runnable templates in this skill: `scripts/cva-component.tsx`,
`scripts/extended-button.tsx`, `scripts/custom-dialog.tsx`,
`scripts/custom-dropdown.tsx`, `scripts/composed-trigger.tsx`, and
`scripts/custom-theme.css`. The prose copies had already diverged from the
templates in class lists and ref handling. Keep exactly one copy: the
templates. Use them first, then the upstream docs; never a fresh derivation.
Upstream: context7 /radix-ui/website and https://ui.shadcn.com/docs/components

## Use AlertDialog, never plain Dialog, for destructive confirmations
Why: House test contract since skill v2.0. The test-cases.json case
`radix-dialog` asserted "Uses AlertDialog (not Dialog) for destructive
confirmations" until the 2026-07-31 thinning removed the case with the rule
file it traced to; the decision now lives in this file and in the SKILL.md
Key Decisions table. The scar behind it: Dialog closes on overlay click, so a
stray click confirms nothing but dismisses the guard, while AlertDialog forces
an explicit Cancel or Action choice.
Upstream: https://www.radix-ui.com/primitives/docs/components/alert-dialog

## Keep the skill eval's graded assertions satisfiable from SKILL.md alone
Why: `tests/evals/skills/ui-components.eval.yaml` grades sessions on CVA
variants, cn() merging, asChild composition, Dialog.Title presence, portal
rendering, data-state styling, and OKLCH theming. The 2026-07-31 thinning
deleted the reference files that restated those topics, but the graded
contract did not move: SKILL.md's Quick Start, Key Decisions, and
Anti-Patterns sections must keep carrying enough signal to pass. If this eval
regresses after a future trim, restore signal to SKILL.md; do not resurrect
vendor tutorials.
Upstream: per-topic first-party sources in the SKILL.md section "Upstream coverage (do not restate)"
