# Tollerud User Interface — AI Agent Guide

Guidance for AI coding assistants (Claude Code, Cursor, GitHub Copilot, Codex, etc.) working in projects that use `@tollerud/ui`. **v5.6.0** — `TopNavUserMenu` gains `placement?: 'start' | 'end'` (default `'end'`, unchanged behavior) — set `'start'` to render the desktop `userMenu` dropdown before other `actions` in the cluster instead of after. No breaking changes.

Earlier versions: v5.5.0 — 5.4.0's Floating UI rebuild of `TopNav`'s desktop flyout fixed the original hover-flicker but introduced a worse regression: moving the mouse from the trigger toward the panel to click a row closed the panel first (Radix's `Trigger` runs its own hover-close timer with no matching `Content` left to cancel it). The trigger is now click-to-open only, matching `DropdownMenu`/`Select`/`Combobox`. Also fixed: the desktop nav row is now pixel-identical between `<a>` and flyout-trigger `<button>` items. Also: the mobile accordion for flyout groups no longer shows a left guide-line, matching `mobileMenuSections`/`userMenu`'s flat style. No breaking changes. v5.4.0 — `TopNav`'s desktop flyout groups (`navItems` entries with `items`) rebuilt on Floating UI (the `Combobox`/`Select`/`DatePicker` engine), anchored directly to their own trigger, instead of Radix NavigationMenu's `Viewport` — which centered the panel under the whole nav bar rather than the trigger that opened it. The `NavigationMenuIndicator` added in 5.2.0 is removed (superseded). `TopNavSection` (`mobileMenuSections`/`userMenu`) gains `collapsible?: boolean` (default `true`) and `defaultOpen?: boolean` (default `false`) — sections collapse behind their label by default now, same as flyout groups. No breaking changes. v5.3.0 — `TopNav` gains `userMenu?: TopNavUserMenu` (`{ trigger, triggerLabel?, sections: TopNavSection[] }`) — a single account/user menu rendered from one data structure: a `DropdownMenu` next to the desktop actions, and the same `sections` appended to the mobile sheet automatically, so a desktop dropdown and its mobile equivalent can't drift out of sync. Reuses `TopNavSection`/`TopNavItem` — `href`/`onClick`/`icon`/`external`/`active` all work inside it. No breaking changes. v5.2.0 — `TopNavItem.onClick?: () => void` renders a row as a `<button>` instead of a link (ignored if `href` is set), for actions like sign out. `Sidebar` and `TopNav`'s mobile menu switched `100vh`/`min-h-screen` to `dvh`, fixing mobile pages reading taller than the visible window and sticky elements jumping as the address bar auto-hid/showed during scroll. No breaking changes. v5.1.0 — `TopNav`'s dropdown/mobile rows (desktop flyout content, mobile nav rows, mobile accordion children) share one button-styled row instead of bare unstyled links, matching `SidebarMenuButton`'s row language from 5.0.0. New `TopNavItem.icon?: ReactNode` and `TopNav`'s `mobileMenuSections?: TopNavSection[]` prop for labeled row groups in the mobile sheet (use instead of hand-rolling markup inside `mobileMenuExtra`). Also fixed: `Sidebar`'s mobile off-canvas panel rendered at ~59% of viewport width instead of the intended 288px (a Tailwind v4 arbitrary-value syntax issue — `w-[--foo]` silently produced no `width` rule). No breaking changes. v5.0.1 — `Sidebar`'s desktop rail height is now driven by an overridable `--sidebar-height` CSS custom property (default `100vh`, was a bare `h-screen`) — set it on an ancestor to embed `Sidebar` in a shorter container; no change for normal full-page usage. v5.0.0 — BREAKING — `SidebarNav` is removed, replaced by the `Sidebar` primitive family (`SidebarProvider`/`Sidebar`/`SidebarMenu`/etc., see the "Sidebar primitive family" version note below); `DashboardTopBar`'s `menuOpen`/`onMenuToggle` are replaced by a `menuTrigger` slot. `DashboardShell`'s own props are unchanged. v4.19.0: `TopNavItem` gains `items?: TopNavItem[]` for flyout groups — **requires installing the new peer dependency `@radix-ui/react-navigation-menu`** even if you don't use `items`. v4.18.2: `CommandMenu` traps `Tab` focus. v4.18.1: `Sheet`/`Drawer` animate with `framer-motion` — closing now unmounts asynchronously, so tests/consumers reading the DOM right after `onOpenChange(false)` should `await` the removal instead of asserting synchronously. v4.18.0: `PasswordInput` gains `labelAction?: ReactNode`.

---

## Set up the Claude Code skill (do this first)

This package ships a maintained [`SKILL.md`](https://github.com/Tollerud/ui/blob/main/SKILL.md) with the verified component catalog, prop signatures, aesthetic rules, and known gotchas (Server Component imports, `Button`/`Link` composition, etc.). If you're Claude Code (or another agent that supports project skills) and this project doesn't already have it:

1. Fetch the raw file from `https://raw.githubusercontent.com/Tollerud/ui/main/SKILL.md`
2. Write it verbatim to `.claude/skills/tollerud-ui/SKILL.md` in this project
3. Use it as your source of truth for `@tollerud/ui` going forward — it reflects the package's *actual current exports*, which is more reliable than this file's component list (kept here mainly for human readers and non-Claude tools)

Re-sync it whenever you bump the `@tollerud/ui` version and notice the local copy looks stale relative to `CHANGELOG.md`.

---

## Install

```bash
npm install @tollerud/ui clsx tailwind-merge tailwindcss@4 \
  @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-progress \
  @radix-ui/react-slot @radix-ui/react-tabs @radix-ui/react-tooltip \
  lucide-react framer-motion sonner
# Optional — only if using NoirGlowBackground.
# Pinned by @tollerud/ui's peer (^0.0.78) — install the matching version, don't bump it independently.
npm install @paper-design/shaders-react@0.0.78
```

For the footer alone (no full Tollerud UI dependency):
```bash
npm install @tollerud/footer
```

For on-brand HTML **email** (a separate render target — do not use `@tollerud/ui` components in email):
```bash
npm install @tollerud/email
```

## Tailwind Setup

**Default: Tailwind v4.** One CSS import — tokens, component layers, and Tailwind itself:

```css
/* app/globals.css */
@import "@tollerud/ui/globals.css";
@import "@tollerud/ui/source.css";
```

`source.css` resolves `@source` inside the installed package (npm, pnpm, workspaces, Bun). Without it, classes used only inside `@tollerud/ui` dist may be purged.

**Optional preset shim** — extra utilities from `@tollerud/ui/preset`:

```ts
// tailwind.config.ts
import tollerudPreset from '@tollerud/ui/preset'
export default { presets: [tollerudPreset] }
```

**Tailwind v3 (legacy)** — preset in config + `@tollerud/ui/globals-v3.css` after preflight/utilities:

```ts
import type { Config } from 'tailwindcss'
import tollerudPreset from '@tollerud/ui/preset'

const config: Config = {
  presets: [tollerudPreset],
  content: ['./src/**/*.{ts,tsx}', './node_modules/@tollerud/ui/dist/**/*.{js,mjs}'],
}
export default config
```

```css
@import "tailwindcss/preflight";
@import "tailwindcss/utilities";
@import "@tollerud/ui/globals-v3.css";
```

**Subpath imports:** `import { Button } from '@tollerud/ui/button'` — one entry per component for tree-shaking; the main `@tollerud/ui` barrel still works.

---

## Design authority

For agents building UI in **consumer apps** that depend on `@tollerud/ui` (not when contributing to this design-system repo):

**When screenshots or mocks exist** for a screen, they win on layout, spacing, and structure for that screen.

**When they don't** (common), use this fallback order:

1. **Existing UI in the same app** — match sibling pages, nav, density, and component usage already in the repo.
2. **`@tollerud/ui` + [SKILL.md](SKILL.md)** — components, tokens, composition patterns, accessibility.
3. **[BRAND.md](BRAND.md) + aesthetic rules below** — non-negotiable Tollerud look (dark surfaces, yellow accent, nav lockup).
4. **[Live docs](https://design.tollerud.dev/)** — reference for how primitives compose on real pages.

| Source | Role |
|--------|------|
| **Mocks / screenshots** (if provided) | Page-specific layout truth |
| **In-repo patterns** | Consistency when no mock exists |
| **`@tollerud/ui` + SKILL.md** | What to build with |
| **BRAND.md + aesthetic rules** | How it must look |

Agents must:

- Follow mocks when provided; otherwise extend existing app patterns before inventing new layouts.
- Prefer `import { … } from '@tollerud/ui'` over bespoke UI primitives.
- Compose **local feature components** (`src/features/…`, `src/components/…`) when a screen needs app-specific structure — not a parallel `components/ui` design system.
- Use Tollerud tokens (`text-tollerud-*`, `bg-tollerud-noir-*`) — never hardcode `#FFFF00` / `#0A0A0A` or copy component source from the package into the repo (see [Fixing copy/paste component patterns](#fixing-copypaste-component-patterns-for-agents-working-in-consumer-projects)).
- **Do not modify** `node_modules/@tollerud/ui` or vendor forked DS files in the consumer app. Bump the package version or open an issue upstream instead.

### Consumer styling policy

Tailwind is allowed and expected **inside `@tollerud/ui`**. Use it in this package to implement components, variants, layout primitives, responsive behavior, focus states, and docs demos.

In consumer apps, Tailwind is allowed as small local glue, but it should not become the primary design language. Prefer this order:

1. Exported `@tollerud/ui` components.
2. Exported layout primitives or screen patterns from `@tollerud/ui`.
3. Small Tailwind adjustments for local spacing, alignment, or responsive visibility.
4. A local semantic feature component when app-specific structure is needed.

If a branded layout or interaction repeats, add it to `@tollerud/ui` rather than rebuilding it with raw utility classes in each app.

| Allowed in consumer apps | Discouraged in consumer apps |
|--------------------------|------------------------------|
| `<div className="mt-6"><Button>Deploy</Button></div>` for local spacing glue | Hand-rolled `<button className="rounded-lg bg-yellow-400 px-4 py-2">...` |
| Tollerud token utilities when no primitive exists yet | Hardcoded colors like `#FFFF00`, `#0A0A0A`, or generic blue/gray/red palettes for branded UI |
| Local feature components that compose `@tollerud/ui` exports | A parallel `components/ui` design system copied from this package |
| `className` escape hatches merged through exported components | Inline styles for static branded design decisions |

For Tailwind v4 consumer apps, both imports are required:

```css
@import "@tollerud/ui/globals.css";
@import "@tollerud/ui/source.css";
```

`globals.css` provides tokens and component layers. `source.css` makes Tailwind scan `@tollerud/ui`'s dist classes. Without `source.css`, styles used only inside the package can disappear in production builds.

Use `import { cn } from '@tollerud/ui'` or `@tollerud/ui/utils`; do not create a local `cn()` helper in consumer projects.

**Agent-safe recipes** — copy-paste screen compositions for common pages (marketing landing, dashboard, settings, auth, empty state, detail, list/table) live on the docs site at [Recipes](https://design.tollerud.dev/recipes/). Each recipe is component-first and links to a fuller interactive example where one exists.

**Consumer guardrails** — run `npx tollerud-ui-audit` from consumer app roots to detect styling drift (missing `@tollerud/ui` dep, `source.css`, copied `components/ui`, hardcoded brand hex, local `cn()`, Button/Link nesting). Use `--warn-only` for advisory CI. Alternative: `node node_modules/@tollerud/ui/scripts/audit-consumer-styling.mjs`. Error codes and fixes: GETTING_STARTED.md → Consumer project checklist.

When contributing **to this repository**, changing `components/*.tsx` is expected when the task explicitly calls for it — follow the release checklist in [Updating the npm package](#updating-the-npm-package-for-agents-working-in-this-repo) below.

---

## Aesthetic Rules

**Never violate these:**

- Dark surfaces only. Background: `#0A0A0A` (`bg-tollerud-noir-950`). Never white or light gray backgrounds.
- Yellow accent (`#FFFF00`, `text-tollerud-yellow`) is for CTAs, focus rings, active states, and key data points — not decoration.
- Never put yellow text on white. The ratio is 1.7:1 — it fails contrast.
- Borders are decorative thin lines (`border-tollerud-noir-600` or `border-tollerud-noir-700`). Use them freely; reach for shadows only for overlays.
- Monochrome everywhere except the single yellow accent. No blues, no greens, no brand gradients.

---

## Color Tokens

| Token | Value | Use |
|-------|-------|-----|
| `tollerud-yellow` | `#FFFF00` | Accent, CTA, focus, key data |
| `tollerud-yellow-warm` | `#E8D500` | Secondary yellow, gradients, warm states |
| `tollerud-noir-950` | `#0A0A0A` | Page background |
| `tollerud-noir-900` | `#111111` | Card / surface |
| `tollerud-noir-800` | `#1A1A1A` | Elevated surface |
| `tollerud-noir-700` | `#222222` | Hover states |
| `tollerud-noir-600` | `#333333` | Borders |
| `tollerud-text-primary` | `#F5F5F5` | Body text |
| `tollerud-text-secondary` | `#AAAAAA` | Secondary / labels |
| `tollerud-text-muted` | `#666666` | Placeholders, hints |
| `tollerud-success` | `#22C55E` | Positive status |
| `tollerud-warning` | `#E8D500` | Caution status |
| `tollerud-error` | `#EF4444` | Negative status |
| `tollerud-info` | `#3B82F6` | Neutral status |

Use these directly in custom components (`text-tollerud-success`, `border-tollerud-error/25`) instead of hardcoding status hex.

## Motion Tokens

Never hardcode a duration/easing — always use these (full detail + rationale in [SKILL.md](SKILL.md#motion-tokens)):

| Token | Value | Tailwind class |
|-------|-------|----------------|
| `--motion-duration-fast` | 150ms | `duration-fast` |
| `--motion-duration-normal` | 250ms | `duration-normal` |
| `--motion-duration-slow` | 350ms | `duration-slow` |
| `--motion-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | `ease-out` |
| `--motion-ease-in` | `cubic-bezier(0.7, 0, 0.84, 0)` | `ease-in` |
| `--motion-ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` |

In framer-motion or other JS-driven animation, import `motionDuration`/`motionEase` from `lib/motion.ts` instead of hardcoding literals.

---

## Components

> **Full, verified catalog with props lives in [SKILL.md](SKILL.md)** — that file is checked against the actual `components/index.ts` exports and is the source of truth. The list below is a quick-reference subset.

All components import from `@tollerud/ui`. Use named imports.

```tsx
// Core / forms
import { Button, ButtonGroup, buttonVariants, cn, Card, Badge, Input, StatusDot, Kbd } from '@tollerud/ui'
import { CommandMenu, ActionRow, DataTable, LogViewer, Timeline, CodeBlock, StatCard, Container } from '@tollerud/ui'
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from '@tollerud/ui'
import { Checkbox, Switch, RadioGroup, Radio, Select, Textarea } from '@tollerud/ui'
import { PasswordInput, Combobox, TagInput, Slider, FormRow } from '@tollerud/ui'
// Layout primitives (added in 4.2.0)
import { PageShell, Section, Stack, Cluster, Grid, CardGrid, ScrollRail, Split, MainContent } from '@tollerud/ui'
// Screen patterns (added in 4.3.0)
import { PageHeader, TopNav, TopNavAction, DashboardTopBar, DashboardShell, SettingsLayout, FormPanel, ResourceList, DetailPage, EmptyPage, FeatureSection, StatsSection, AuthSplitLayout, StructuredCard } from '@tollerud/ui'
// Sidebar primitive family (≥ 5.0.0, replaces SidebarNav)
import { SidebarProvider, Sidebar, SidebarTrigger, SidebarInset, SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupLabel, SidebarGroupContent, SidebarMenu, SidebarMenuItem, SidebarMenuButton, useSidebar } from '@tollerud/ui'
// Primitives & navigation (added in 1.0.9)
import { Divider, Pill, Avatar, AvatarGroup } from '@tollerud/ui'
import { Breadcrumb, Pagination, Segmented, Stepper } from '@tollerud/ui'
import { Panel, Meter, Gauge, PricingCard } from '@tollerud/ui'
// Gauge (≥ 4.8.50): radial dial for a single metric — <Gauge value={72} label="Disk" formatValue={v=>`${v}%`} tone="warning" />
// Heatmap (≥ 4.8.51): calendar activity grid — <Heatmap data={[{date, value}]} ariaLabel="Deploys" />
// (≥ 4.8.52) chart SR data tables are wrapped in an sr-only div so they don't inflate page height
import { Accordion, AccordionItem, AccordionTrigger, AccordionContent } from '@tollerud/ui'
import { DatePicker, FileUpload } from '@tollerud/ui'
// Overlays & feedback
import { Empty, EmptyHeader, EmptyIcon, EmptyTitle, EmptyDescription, EmptyContent, EmptyState } from '@tollerud/ui'
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogBody, DialogFooter, DialogTitle, DialogDescription, DialogPanel } from '@tollerud/ui'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@tollerud/ui'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@tollerud/ui'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@tollerud/ui'
import { Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, Drawer } from '@tollerud/ui'
import { Skeleton, Progress, Spinner, Toaster, ToastProvider, useToast, GlowCard, NoirGlowBackground, BentoDashboard, Alert } from '@tollerud/ui'
import { BarChart, AreaChart, Donut, SegmentBarChart, Sparkline, HeroBlock, FeatureCard, CTABand, PromoSection } from '@tollerud/ui'
// All charts are keyboard accessible with `interactive`: TimeSeriesChart (≥ 4.8.42), AreaChart/Sparkline
// (≥ 4.8.43), BarChart/Donut (≥ 4.8.44) — Tab, ←/→, Home/End, Esc; tooltips + SR announcements included.
// Donut segment colors are optional (cycle CHART_SERIES_COLORS = --chart-1…5).
// TimeSeriesChart + AreaChart also render a visually-hidden SR data table (srTable, ≥ 4.8.45).
// TimeSeriesChart takes `series={[{ label, points, color? }]}` for multi-line charts (≥ 4.8.46).
// AreaChart is pixel-perfect responsive (≥ 4.8.47) — round markers at any width, no aspect distortion.
// Sparkline + Donut take an opt-in `fluid` prop (≥ 4.8.48) to flex to their container; all charts now responsive.
// BarChart takes `series={[{ label, values, color? }]}` + `categories` for grouped bars, `stacked` for stacked (≥ 4.8.49)
// SegmentBarChart (≥ 4.15.0): horizontal proportion bar — <SegmentBarChart segments={[{label, value}]} formatValue={v=>`${v} kr`} />
// Infra / homelab set
import { HostCard, ServiceHealthCard, DockerStackCard, IncidentCard } from '@tollerud/ui'
import { ApprovalCard, ActionDiff, AlertInbox, RollbackPlan, BackupStatusPanel } from '@tollerud/ui'
// Footer & branding
import { Footer, Monogram } from '@tollerud/ui' // or: import { Footer } from '@tollerud/footer'
```

### Email (@tollerud/email)

HTML email is a **separate render target** — table layout, inline styles, no CSS variables. Never render `@tollerud/ui` web components into email (they break in Outlook/Gmail). Use `@tollerud/email`, which shares Tollerud's *tokens* (inlined as literals) and is built on React Email. Compose your own templates from the primitives, or use a ready template:

```tsx
import { render, WelcomeEmail, EmailLayout, EmailHeader, EmailButton, EmailText, EmailFooter } from '@tollerud/email'

// Ready template — opt into the branded header (monogram + project name) via `header`
const html = await render(
  <WelcomeEmail
    name="Mathias"
    productName="Graphify"
    ctaUrl={dashboardUrl}
    header={{ productName: 'Graphify' }}
    footer={{ labels: { tollerudProject: 'A Tollerud Project' }, unsubscribeUrl }}
  />,
)

// Or compose your own
const custom = await render(
  <EmailLayout preview="Your report is ready">
    <EmailHeader productName="Graphify" />
    <EmailText>Your weekly report is ready to view.</EmailText>
    <EmailButton href={reportUrl}>View report</EmailButton>
    <EmailFooter unsubscribeUrl={unsubscribeUrl} />
  </EmailLayout>,
)
// hand the HTML to your mailer (Resend, SES, Nodemailer, …)
```

Primitives: `EmailLayout`, `EmailHeader` (monogram + project name large), `BrandMark`, `EmailButton`, `EmailHeading`, `EmailText`, `EmailDivider`, `EmailFooter` (Tollerud footer → tollerud.no; monogram left, "A Tollerud Project." wordmark right-aligned with a 2px yellow underline; the whole wordmark recolors together in dark clients, and the fine-print line below is centered). Templates: `WelcomeEmail`, `VerifyEmail`, `PasswordResetEmail`, `ReceiptEmail` (each takes optional `header`). Email is **light by default** (renders correctly in Gmail) with a `prefers-color-scheme: dark` enhancement for Apple Mail / iOS. The monogram is a **hosted PNG** (Gmail strips inline SVG); override with `logoSrc`. The `color` prop was removed in 4.13.0.

Configurability (≥ 4.12.0): every template takes an overridable `copy` prop (each exports a `*Copy` type; dynamic lines are functions) — reword or localize without forking. Every primitive takes an optional `style` escape hatch (merged last, overrides token defaults). Visual design otherwise stays token-locked.

```tsx
// Localize (Norwegian) via `copy`, tweak one primitive via `style`
const html = await render(
  <WelcomeEmail
    name="Mathias" productName="Graphify" ctaUrl={url} ctaLabel="Åpne dashbordet"
    copy={{
      heading: (n) => `Velkommen${n ? ', ' + n : ''}.`,
      body: (p) => `Din ${p}-konto er klar. Ta en titt rundt.`,
    }}
    footer={{ labels: { tollerudProject: 'Et Tollerud-prosjekt' } }}
  />,
)
// <EmailButton href={url} style={{ borderRadius: '999px' }}>Pill</EmailButton>
```

### Button

```tsx
<Button variant="primary" size="md">Deploy</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="ghost" size="sm">More</Button>
<Button variant="destructive">Delete host</Button>
<Button variant="terminal" size="sm">start_building</Button>

// Styling a <Link> as a button — Button only renders a native <button>,
// so use asChild (Radix Slot) or buttonVariants() instead of nesting <a> in <button>
<Button asChild variant="primary"><Link href="/deploy">Deploy</Link></Button>
<Link href="/deploy" className={buttonVariants({ variant: 'primary' })}>Deploy</Link>
```

Variants: `primary` · `secondary` · `ghost` · `ghost-destructive` · `ghost-success` · `ghost-warning` · `ghost-info` · `destructive` · `terminal`
Sizes: `sm` · `md` · `lg`
`asChild` and `buttonVariants` require `@tollerud/ui >= 1.0.7`.

### Card

```tsx
<Card>Content</Card>
<Card accent>Highlighted with yellow border</Card>
<Card accent="filled">Callout with yellow border + fill — for cheapest-item highlights and CTAs</Card>

<Card>
  <CardHeader>
    <CardTitle>Restart emma</CardTitle>
    <CardDescription>Stops 4 running services.</CardDescription>
  </CardHeader>
  <CardContent>
    <StatusDot status="online" label="emma.tollerud.no" />
  </CardContent>
  <CardFooter className="justify-end">
    <Button variant="secondary" size="sm">Cancel</Button>
    <Button variant="primary" size="sm">Restart</Button>
  </CardFooter>
</Card>

<Card>
  <CardHeader actions={<CardChange value="+12%" direction="up" />}>
    <CardTitle>Active sessions</CardTitle>
    <CardDescription>Last 24h</CardDescription>
  </CardHeader>
  <CardContent>
    <p className="text-2xl font-bold">42</p>
  </CardContent>
</Card>
<CardChange direction="flat" />

{/* Per-region accent (≥ 4.16.0) — only the header is tinted, Card itself stays plain */}
<Card>
  <CardHeader accent="filled">
    <CardTitle>Brukt av budsjett</CardTitle>
  </CardHeader>
  <CardContent>12 930 kr / 2 700 kr</CardContent>
</Card>

{/* structured (≥ 4.17.0) — bypasses displayName auto-detection; use when composing Card/CardHeader/
    CardContent across a Next.js Server/Client boundary, where auto-detection can silently fail */}
<Card structured>
  <CardHeader><CardTitle>Deploy</CardTitle></CardHeader>
  <CardContent>Body</CardContent>
</Card>

{/* StructuredCard (≥ 4.17.0) — same shape, sets `structured` for you */}
<StructuredCard title="Deploy" actions={<CardChange value="+12%" direction="up" />}>
  Body content
</StructuredCard>
```

`CardContent` keeps `bg-tollerud-surface-raised` on the body band (≥ 4.9.5). Header/footer bands use a subtle surface darken (≥ 4.9.6). `accent={true}` adds a light yellow band tint on header/footer (≥ 4.9.8); `accent="filled"` tints all regions more strongly. `CardHeader`/`CardContent`/`CardFooter` each also take their own `accent?: boolean | 'filled'` (≥ 4.16.0) that overrides the parent `Card`'s accent for just that one region — leave it unset to keep inheriting.

### PriceDisplay

Compact price block for list rows and table cells.

```tsx
<PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" />
<PriceDisplay primary="54,5 kr/l" secondary="27,25 kr" highlight="cheapest" />
<PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" size="sm" />
<PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" align="left" />
```

Props: `primary: string`, `secondary?: string`, `highlight?: "cheapest" | false`, `align?: "left" | "right"` (default `"right"`), `size?: "sm" | "md" | "lg"` (default `"md"`).

### ListCard

Hover card shell for list and grid rows. Renders as `<a>` when `href` is provided.

```tsx
<ListCard href="/beers/1">
  <span>Hansa Pilsner</span>
  <PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" />
</ListCard>
<ListCard href="/beers/2" highlight="cheapest">
  <span>Tuborg Classic</span>
  <PriceDisplay primary="54,5 kr/l" secondary="27,25 kr" highlight="cheapest" />
</ListCard>
```

Props: `href?: string`, `highlight?: "cheapest" | false`, `external?: boolean`.

### PromoSection

```tsx
<PromoSection
  eyebrow="Prismatrise"
  title="Se hva dine favorittøl koster på dine butikker"
  shimmer="favorittøl"
  description="Opprett en gratis konto og følg prisene på dine favorittøl."
  actions={
    <>
      <Button asChild variant="primary"><Link href="/registrer">Opprett konto gratis</Link></Button>
      <Button asChild variant="secondary"><Link href="/login">Logg inn</Link></Button>
    </>
  }
  visual={<DummyPreview />}
  visualPlacement="right"
  background="raised"
/>
```

Props: `eyebrow?` (mono uppercase yellow, same as `PageHeader`), `title`, `shimmer?: string | string[]` (accents one or more words/phrases in `title`; array entries each get their own shimmer span), `description?`, `actions?`, `visual?`, `visualPlacement?: "right" | "left"` (default `"right"`), `background?: "default" | "raised"` (default `"default"`, raised uses `border-y` for full-bleed edge-to-edge), `textWidth?: "narrow" | "balanced" | "wide"` (default `"wide"`), `contentWidth?: "sm" | "md" | "lg" | "xl" | "full"` (default `"xl"`). Collapses to single column on mobile with text always first. Without a `visual`, renders as centered single-column.

### Badge

```tsx
<Badge>Default</Badge>
<Badge variant="accent">New</Badge>
<Badge variant="success">Online</Badge>
<Badge variant="error">Down</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="warning">Degraded</Badge>
```

### StatusDot

```tsx
<StatusDot status="online" label="SSH Connected" />
<StatusDot status="warning" label="CPU 87%" />
<StatusDot status="offline" label="Unreachable" />
<StatusDot status="idle" label="Idle" />
<StatusDot status="info" label="Note added" /> {/* ≥ 4.16.0 — static blue dot, no pulse */}
```

### Input / Textarea / Select / Checkbox / Switch / RadioGroup

```tsx
<Input label="Server Name" placeholder="e.g. emma.tollerud.no" error={errors.name} />
<Textarea label="Notes" rows={4} error={errors.notes} />
<Select label="Region" options={[{ value: 'eu', label: 'EU' }]} value={region} onChange={setRegion} />
<Checkbox label="Enable backups" checked={enabled} onChange={...} />
<Checkbox label="Select all" indeterminate={someSelected && !allSelected} onChange={...} />
<Switch label="Dark mode" defaultChecked />
<RadioGroup label="Target" error={error}>
  <Radio value="staging" label="Staging" name="target" />
  <Radio value="production" label="Production" name="target" />
</RadioGroup>
```

`Select`'s trigger is `role="combobox"` (≥ 4.8.40) — in tests query it with `getByRole('combobox')`, not `getByRole('button')`. Arrow-key highlight is announced via `aria-activedescendant`.

### Kbd — Keyboard shortcut chip

```tsx
<Kbd keys="⌘K" />
<Kbd keys={["⌘", "⇧", "S"]} size="sm" />
```

### CommandMenu — Raycast-style command palette

```tsx
const [open, setOpen] = useState(false)

<Button onClick={() => setOpen(true)}>Open</Button>
<CommandMenu
  open={open}
  onOpenChange={setOpen}
  groups={[
    {
      label: 'Servers',
      items: [
        { id: 'emma', label: 'emma.tollerud.no', description: 'SSH · uptime 14d', onSelect: () => {} },
      ],
    },
  ]}
  toggleShortcut="k"
/>
```

Built-in `⌘K` / `Ctrl+K` listener, arrow navigation, Esc to close, search across all groups. Keyboard navigation tracks the filtered results (≥ 4.8.40): Enter always runs the highlighted visible item, and the highlight is announced via `aria-activedescendant`.

### StatCard

```tsx
<StatCard label="Active Sessions" value={42} change={{ value: "+12%", direction: "up" }} />
<StatCard label="Endring siste periode" value="-3.2%" change={{ value: "-3.2%", direction: "down", tone: "success" }} />
<StatCard label="Brukt av budsjett" value="12 930 kr / 2 700 kr" tone="error" /> {/* ≥ 4.16.0 — tone colors the value + border independently of accent */}
<StatCard label="Price" value="23,90 kr" secondaryValue="47,80 kr/l" secondaryTone="accent" /> {/* ≥ 4.17.0 — secondaryValue renders as a Badge under value */}
<StatCard label="Ticket price" value={<Input defaultValue="150" onBlur={save} />} /> {/* ≥ 4.17.0 — value is now ReactNode, e.g. for an inline-editable tile */}
```

### AuthSplitLayout

Two-panel hero/form auth screen. New in ≥ 4.17.0.

```tsx
import { AuthSplitLayout } from '@tollerud/ui'

<AuthSplitLayout
  projectName="Butikkpils"
  title="Track"
  highlight="every price."
  description="Crowdsourced beer prices, updated daily."
>
  <SignInForm />
</AuthSplitLayout>
```

### CodeBlock

```tsx
<CodeBlock promptPrefix showCopy code={`systemctl status tollerud-agent`} />
```

### DataTable

```tsx
<DataTable
  columns={[
    { key: 'hostname', label: 'Host', sortable: true, filterable: true },
    { key: 'status', label: 'Status', render: (_v, row) => <Badge variant={row.status === 'online' ? 'success' : 'error'}>{row.status}</Badge> },
  ]}
  data={hosts}
  rowKey="id"
  onRowClick={(row) => {}}
  emptyMessage="No hosts found"
/>

// Optional rich mode: searchable, filter, selectable, pageSize, bulkActions, rowMenu, toolbarRight, emptyState
```

### Table

Static table primitives — no sorting, filtering, search, or pagination. Use `DataTable` above when you need that; use `Table` for a fixed comparison table or small dataset.

```tsx
<Table>
  <TableHeader>
    <TableRow>
      <TableHead>Ingrediens</TableHead>
      <TableHead align="right">For 8</TableHead>
      <TableHead align="right">For 14</TableHead>
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow highlight>
      <TableCell>Laks, filet</TableCell>
      <TableCell align="right">3,2 kg</TableCell>
      <TableCell align="right" tone="accent">5,6 kg</TableCell>
    </TableRow>
  </TableBody>
</Table>
```

`TableHead`/`TableCell` take `align?: 'left' | 'center' | 'right'`. `TableRow` takes `highlight?: boolean`. `TableCell` takes `tone?: 'success' | 'error' | 'warning' | 'info' | 'accent'` to color a computed value.

### Timeline

```tsx
<Timeline items={[{ id: '1', time: '14:32', title: 'Deploy started', status: 'online' }]} active loading={false} />

{/* variant="flat" (≥ 4.16.0) — no connector line, timestamp below the title — for activity/audit-log lists. title accepts ReactNode, status accepts 'info' */}
<Timeline
  variant="flat"
  items={[{ id: '1', time: '12 min siden', title: <><strong>Karoline</strong> krysset av «Laks, filet»</>, status: 'online' }]}
/>
```

`render` is always `(value, row) => …` (≥ 4.8.40) — write `(_v, row) => …` when you only need the row. The old single-parameter `(row) => …` form was removed; migrate by prepending `_v, `. Sortable headers are real `<button>`s (keyboard-operable, ≥ 4.8.40). With `selectable`, always provide stable row keys via an `id`/`key` field or `rowKey`. Since ≥ 4.8.57 the horizontal scroll wrapper no longer uses `touch-pan-x`, so vertical page scroll works on mobile when a swipe starts on table rows (horizontal scroll for wide tables is unchanged).

### Empty (empty states)

```tsx
<Empty>
  <EmptyHeader>
    <EmptyIcon>{/* icon */}</EmptyIcon>
    <EmptyTitle>No hosts connected</EmptyTitle>
    <EmptyDescription>Connect your first machine and Tia will start watching it.</EmptyDescription>
  </EmptyHeader>
  <EmptyContent><Button variant="primary" size="sm">Connect a host</Button></EmptyContent>
</Empty>
```

### Infra / homelab components

```tsx
<HostCard hostname="emma" ip="10.0.10.10" status="online" cpu="23%" memory="6.2/16 GB" disk="45%" uptime="14d" containers={4} />
<ServiceHealthCard service="emma.tollerud.no" status="online" uptime="14d 3h" responseTime="23ms" />
<IncidentCard title="High CPU" severity="high" timestamp="2026-05-26 14:32" description="CPU at 92% for 5 min" service="emma" />
<ApprovalCard action="restart_container" description="Restart emma:hermes" state="pending" onApprove={() => {}} onReject={() => {}} />
<LogViewer lines={[{ text: 'Health check passed', level: 'info', timestamp: '14:32:01', source: 'hermes' }]} follow searchable showLineNumbers height="300px" />
<AlertInbox alerts={[{ id: '1', title: 'emma high CPU', severity: 'high', timestamp: '14:32', acknowledged: false }]} onAcknowledge={(id) => {}} />
```

Severity scale: `critical` · `high` · `medium` · `low` · `info`


---

## Layout Patterns

These class-level patterns are references for package internals, docs demos, and custom cases. In consumer apps, prefer exported components and layout/screen primitives first. Use raw classes only as small local glue or when a component does not exist yet.

### Navigation lockup

The monogram must always appear left of the project name with `gap-2`. Never show the name without the monogram or the monogram alone in a nav context.

```tsx
<TopNav
 projectName="Project Name"
 navItems={[{ label: 'Overview', href: '/overview', active: true }]}
 actions={<Button variant="primary" size="sm">Get started</Button>}
 mobileMenuExtra={<p className="text-sm text-tollerud-text-muted">v2.4.1</p>}
/>
```

Monogram sizing is handled automatically by `TopNav` and `Footer`. If you build a custom layout inside `@tollerud/ui`, use top bar/sidebar expanded → `h-5`, sidebar collapsed → `h-6`, footer → `h-4`.

Give a `navItems` entry `items: TopNavItem[]` instead of `href` to turn it into a flyout group (desktop `NavigationMenu` trigger, mobile accordion) — requires the `@radix-ui/react-navigation-menu` peer dependency (≥ 4.19.0):

```tsx
<TopNav
  projectName="Project Name"
  navItems={[
    { label: 'Overview', href: '/overview', active: true },
    { label: 'Services', items: [
      { label: 'API', href: '/services/api' },
      { label: 'Worker', href: '/services/worker' },
    ] },
  ]}
/>
```

`TopNavItem.icon?: ReactNode` (≥ 5.1.0) shows in dropdown/mobile rows (not the top bar). Use `mobileMenuSections?: TopNavSection[]` for labeled row groups appended below nav items/actions in the mobile sheet — e.g. an account section — instead of hand-rolling markup inside `mobileMenuExtra`. `TopNavItem.onClick?: () => void` (≥ 5.2.0) renders a row as a `<button>` instead of a link — for actions like sign out (ignored if `href` is set). Sections collapse behind their label by default (≥ 5.4.0) — add `defaultOpen: true` to start one expanded, or `collapsible: false` to always show it:

```tsx
<TopNav
  projectName="Project Name"
  navItems={[{ label: 'Overview', href: '/overview', active: true, icon: <Icons.home size={15} /> }]}
  mobileMenuSections={[
    {
      label: 'Account',
      defaultOpen: true,
      items: [
        { label: 'Settings', href: '/settings', icon: <Icons.settings size={15} /> },
        { label: 'Sign out', onClick: handleSignOut, icon: <Icons.logout size={15} /> },
      ],
    },
  ]}
/>
```

If the same account content also needs to show as a desktop dropdown (not just in the mobile sheet), use `userMenu?: TopNavUserMenu` (≥ 5.3.0) instead of `mobileMenuSections` — one data structure renders both, so they can't drift out of sync. Add `placement: 'start'` (≥ 5.6.0) to render it before other `actions` in the desktop cluster instead of after (the default):

```tsx
<TopNav
  projectName="Project Name"
  navItems={[{ label: 'Overview', href: '/overview', active: true, icon: <Icons.home size={15} /> }]}
  userMenu={{
    trigger: (
      <>
        <span>Ada</span>
        <Avatar name="Ada Lovelace" size="sm" />
      </>
    ),
    triggerLabel: 'Account menu',
    sections: [
      {
        label: 'Account',
        items: [
          { label: 'Settings', href: '/settings', icon: <Icons.settings size={15} /> },
          { label: 'Sign out', onClick: handleSignOut, icon: <Icons.logout size={15} /> },
        ],
      },
    ],
  }}
/>
```

### Grid background

```html
<section class="tollerud-grid-bg">…</section>
```

### Display headings

```html
<h1 class="tollerud-display text-[70px]">Dark. Monochrome.</h1>
<h2 class="tollerud-display--secondary text-[40px]">
  <span class="tollerud-display-shimmer">Yellow where it counts</span>
</h2>
```

### Container

```tsx
<Container>Content capped at 1100px with 24px padding</Container>
```

`PageShell`'s root is `overflow-clip` (≥ 4.8.41), so `position: sticky` works anywhere inside it — sticky TopNav, DashboardShell sidebar, or your own `sticky top-*` elements. Don't add `overflow-hidden` to full-page wrappers; it creates a scroll container and silently disables sticky for all descendants.

### Component-first page layout

```tsx
<PageShell background="grid">
 <Section size="hero">
  <Stack gap="lg">
   <h1>Build with components first.</h1>
   <Cluster>
    <Button variant="primary">Start</Button>
    <Button variant="secondary">Read policy</Button>
   </Cluster>
  </Stack>
 </Section>
</PageShell>
```

### Screen patterns

```tsx
<ResourceList
 title="Hosts"
 description="Machines connected to Tollerud."
 actions={<Button variant="primary">Connect host</Button>}
 count="3 hosts"
>
 <CardGrid columns={3}>
  <Card><StatusDot status="online" label="emma" /></Card>
  <Card><StatusDot status="warning" label="iris" /></Card>
 </CardGrid>
</ResourceList>

`ScrollRail` with `visibleCount={4}` (≥ 4.9.2) fills the row when ≤4 achievement cards, scrolls when more — no consumer `@container` width math. Item wrappers stretch height (≥ 4.9.3) so `h-full` on card children works without extra wrappers.

### Density

Apply `data-density="compact"` to any container to tighten spacing for tables, forms, and panels inside it.

```html
<div data-density="compact">…dense tables / forms…</div>
```

### Elevation

Use borders as the primary separation method. Only add shadows to lift overlays. Shadow scale: `--shadow-sm` `--shadow-md` `--shadow-lg` `--shadow-xl` `--shadow-glow`. Drawers use `--shadow-xl`; popovers `--shadow-lg`.

---

## Copy & Voice

- Labels are short and action-first: "Deploy", "View Logs", "Restart" — not "Click here to initiate deployment"
- Terminal-style CTAs for technical actions: `❯ deploy --env production`, `$ init`
- Error messages name the cause: "Connection to emma.tollerud.no timed out" — not "Something went wrong"
- Avoid exclamation marks and corporate filler ("Oops!", "Great!", "Please try again later")

---

## Accessibility

- Every interactive element needs a visible focus ring: `focus-visible:outline-2 focus-visible:outline-tollerud-yellow focus-visible:outline-offset-2` (or `.tollerud-focus-ring`)
- Icon-only buttons must have `aria-label`
- Inputs must have `<label>` — always use the `label` prop on `Input`, `Select`, `Textarea`
- Error messages use `role="alert"` or `aria-live="polite"`
- Never convey information by color alone
- Respect `prefers-reduced-motion: reduce` — disable shimmer and animations

---

## What NOT to do

| Don't | Why |
|-------|-----|
| Use light/white backgrounds | The system is dark-only |
| Put yellow text on white | Fails contrast at 1.7:1 |
| Recolor the monogram | Yellow on dark is non-negotiable |
| Use non-system colors (blue, green, purple) | Only yellow accent + monochrome grays |
| Add drop shadows or glows to the monogram | Glow is for interactive UI, not branding |
| Show the project name without the monogram | The lockup is the brand |
| Use verbose copy or exclamation marks | Violates voice guidelines |

---

## Updating the npm package (for agents working in this repo)

When asked to add components, fix bugs, or cut a release:

### 1. Build and validate before committing

```bash
npm run validate   # typecheck + lint + test + build
```

### 1b. `package-lock.json` must match CI npm

GitHub Actions uses **Node 24 + npm 11** (`packageManager` in `package.json` pins `npm@11.16.0`). Regenerating the lockfile with a different npm major can produce entries that **`npm ci` rejects on CI**.

When you change dependencies or bump the package version:

```bash
npx npm@11.16.0 install          # refresh lockfile — use the pinned npm, not an arbitrary local version
rm -rf node_modules && npx npm@11.16.0 ci   # verify before push
```

Never use `npm install --package-lock-only` alone for version bumps — it can desync optional peer entries. Commit `package-lock.json` in the same commit as `package.json` changes.

### 2. Every new component needs all four of these

| What | Where |
|------|-------|
| Component file | `components/ComponentName.tsx` |
| Named export + type export | `components/index.ts` |
| Registry entry | `registry.json` — add a `kebab-case` key with `name`, `description`, `files`, `dependencies`, `registryDependencies`, `type: "components:ui"` |
| Docs preview | Add a `<Section>` + `<Demo>` in `docs-app/components/pages/page-*.jsx`. Register routes in `docs-app/components/docs-shell.jsx` (`NAV`, `PAGES`, `PAGE_TITLES`). Support code lives in `kit/`, `blocks/`, `backgrounds/`. Build: `npm run build:docs` → `_site/`. |

### 3. Version bump rules

| Change | Version bump |
|--------|-------------|
| New components, no breaking changes | minor (`1.x.0`) |
| Bug fixes only | patch (`1.0.x`) |
| Prop renames, removed exports, token renames | major (`x.0.0`) |

Edit `package.json` version, then update these to match:
- `COMPLETENESS_ROADMAP.md` — header line `### npm package (components/*.tsx) — vX.X.X`
- `registry.json` — top-level `"version"` field (or run `npm run sync:registry`)

The docs sidebar version reads live from `package.json` via `PACKAGE_VERSION` in `docs-app/lib/docs-stats.js` — no manual edit.

### 4. Always update these files in the same commit

- `CHANGELOG.md` — add an entry at the top following the **exact format rules below**
- `COMPLETENESS_ROADMAP.md` — update `### npm package (components/*.tsx) — vX.X.X` header; check off or add open items
- `SKILL.md` — add new components to the catalog, update version notes
- `AGENTS.md` (this file) — update the component import blocks if new exports were added

#### PasswordStrength component (≥ 4.8.21)

New `PasswordStrength` component — compose below any `PasswordInput` on signup/change-password forms.

```tsx
import { PasswordInput, PasswordStrength } from '@tollerud/ui'

const [pw, setPw] = useState('')
<PasswordInput label="New password" value={pw} onChange={e => setPw(e.target.value)} />
<PasswordStrength value={pw} />
```

Custom rules via `passwordRules` export:
```tsx
import { PasswordStrength, passwordRules } from '@tollerud/ui'
<PasswordStrength value={pw} rules={[...passwordRules, { label: 'No spaces', test: v => !/\s/.test(v) }]} />
```

#### StatCard icon prop (≥ 4.8.20)

`StatCard` now accepts `icon?: ReactNode` — rendered beside the label. Pass a Lucide icon or any element.

```tsx
import { Database } from 'lucide-react'
<StatCard label="Storage" value="1.2 TB" icon={<Database size={14} />} />
```

#### PageShell flex chain fix (≥ 4.8.23)

`PageShell`'s inner content wrapper is now always `flex flex-col flex-1`. This means `className="flex flex-col min-h-screen"` on the outer shell correctly stretches content — header stays top, footer sticks bottom. `contentClassName` lets you add extra classes to the inner wrapper if needed.

#### DashboardTopBar / DashboardShell showMobileLogo (≥ 4.8.22)

`DashboardTopBar` and `DashboardShell` accept `showMobileLogo?: boolean` (default `true`). Pass `false` to hide the mobile monogram link when the consumer renders its own logo.

#### StatCard arrow direction (≥ 4.8.19)

`direction: 'up'` shows an up arrow, `direction: 'down'` shows a down arrow. Earlier versions had these inverted.

#### Portalled dropdown mobile scroll (≥ 4.8.26)

`Combobox`, `Select`, `DatePicker`, and `Segmented` close when the user scrolls the page behind them on touch devices. Earlier versions tried to reposition via React state updates, causing a visible lag on every scroll tick.

Since ≥ 4.8.53 only a *genuine touch drag* dismisses the panel: iOS also fires `scroll` programmatically when auto-zooming a focused input or scrolling a focused field above the on-screen keyboard. This is what made `searchPlacement="dropdown"` unusable on iOS Safari before 4.8.53.

Since ≥ 4.8.54 placement is sticky: filtering a `Combobox` to fewer results no longer flips it top↔bottom.

Since ≥ 4.8.55 the touch handler separates the two kinds of mobile scroll. A programmatic settle scroll (iOS lifting the focused search input above the keyboard, the address bar collapsing, a focus/zoom nudge — no finger drag) repositions the panel so it stays glued to the trigger; a user fling and its momentum tail dismiss it. Repositions are coalesced to one per frame and `visualViewport` resize is handled, so it tracks the field without floating. Desktop still repositions on scroll/resize to track the anchor.

Since ≥ 4.8.56 all of the above is handled by **Floating UI** (the engine behind Radix Popover / shadcn) instead of bespoke code. `Combobox`, `Select`, `DatePicker`, and `Segmented` use `useFloating` with `autoUpdate` + `flip`/`shift`/`size`, so the panel flips, shifts, clamps height, and stays glued across scroll, resize, and iOS keyboard/zoom/address-bar changes. On touch it now stays open and repositions on scroll rather than closing; outside-click and Escape still dismiss it. The `FloatingDropdownPortal` `onOutsideScroll` prop and the internal `lib/dropdown-placement.ts` helpers were removed — internal only, no public component API changed.

#### Combobox onCreateOption (≥ 4.14.0)

Opt-in "create a new option" row for cases like a category field where the value a user wants may not exist yet. Set `onCreateOption` and a `Create "<query>"` row appears at the end of the list whenever the search text has no exact (case-insensitive) label match — shown alongside partial matches, not only when the list is fully empty. Selecting it (click or Enter) calls `onCreateOption(label)` with the trimmed query; return a string to use as the new option's value (e.g. an id from your backend), or return nothing to use the typed text as both label and value. `createOptionLabel?: (query: string) => string` customizes the row's text (default `Create "<query>"`). The component tracks created options locally so the label displays correctly even before `options`/`groups` is updated to include it.

```tsx
<Combobox
  label="Category"
  value={category}
  onChange={setCategory}
  options={categoryOptions}
  onCreateOption={(label) => {
    const id = createCategory(label) // persist however you like
    return id // optional — omit to use the typed label as the value
  }}
/>
```

#### Combobox searchPlacement (≥ 4.8.25)

`searchPlacement="dropdown"` moves the search input inside the popover; the trigger becomes a Select-style button. Default `"trigger"` keeps the original inline-search behaviour. On touch devices the in-dropdown search field renders at ≥16px (≥ 4.8.53) so iOS Safari does not auto-zoom on focus.

```tsx
<Combobox searchPlacement="dropdown" label="Host" options={hostOptions} value={host} onChange={setHost} />
```

#### Form field height alignment (≥ 4.8.18)

All form fields (`Input`, `PasswordInput`, `Combobox`, `DatePicker`, `Textarea`, `Select`) now share the same height: `text-base py-2.5`. Use them freely in the same form row without height shimming.

#### Sidebar scroll (≥ 4.8.16)

`SidebarNav` nav content area now scrolls when items overflow the viewport. Earlier versions clipped nav items with no scroll on short viewports — a flex `min-h-0` fix.

#### Sidebar primitive family replaces SidebarNav — breaking (≥ 5.0.0)

`SidebarNav` is removed. In its place: `SidebarProvider`/`Sidebar`/`SidebarTrigger`/`SidebarInset`/`SidebarHeader`/`SidebarContent`/`SidebarFooter`/`SidebarGroup`/`SidebarGroupLabel`/`SidebarGroupContent`/`SidebarMenu`/`SidebarMenuItem`/`SidebarMenuButton`/`SidebarMenuAction`/`SidebarMenuBadge`/`SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`useSidebar` — a shadcn-style composable sidebar with a sticky desktop `<aside>`, a `Sheet`-based mobile off-canvas panel, and `collapsible="icon"` support. `SidebarNavItem`/`SidebarNavGroup` **types** are unchanged and re-exported from `./Sidebar` for source compatibility. `DashboardShell`'s public props (`sidebarGroups`/`sidebarItems`) are unchanged — it builds the new composition internally.

**Breaking**: `DashboardTopBar`'s `menuOpen`/`onMenuToggle` props are replaced by a single `menuTrigger?: ReactNode` slot (e.g. `<SidebarTrigger className="lg:hidden" />`) — only relevant if you use `DashboardTopBar` directly outside `DashboardShell`.

Migration for direct `SidebarNav` usage:
```tsx
// Before
<SidebarNav projectName="Project" groups={groups} onItemSelect={close} />

// After
<SidebarProvider>
  <Sidebar>
    <SidebarHeader>{/* brand lockup */}</SidebarHeader>
    <SidebarContent>
      {groups.map(g => (
        <SidebarGroup key={g.label}>
          {g.label && <SidebarGroupLabel>{g.label}</SidebarGroupLabel>}
          <SidebarMenu>
            {g.items.map(item => (
              <SidebarMenuItem key={item.id}>
                <SidebarMenuButton isActive={item.active} icon={item.icon} onClick={close}>
                  {item.label}
                </SidebarMenuButton>
              </SidebarMenuItem>
            ))}
          </SidebarMenu>
        </SidebarGroup>
      ))}
    </SidebarContent>
  </Sidebar>
</SidebarProvider>
```

#### PasswordInput labelAction (≥ 4.18.0)

`PasswordInput` gains `labelAction?: ReactNode`, rendered at the right edge of the label row (e.g. a "Forgot?" link next to "Password"). Opt-in — no breaking changes.

```tsx
<PasswordInput label="Password" value={pw} onChange={e => setPw(e.target.value)}
  labelAction={<button type="button" onClick={onForgot}>Forgot?</button>} />
```

#### StructuredCard, StatCard secondary value, AuthSplitLayout (≥ 4.17.0)

`Card` gains an explicit `structured?: boolean` to bypass its `displayName`-based auto-detection, which can fail across a Next.js Server/Client boundary; `StructuredCard` is a new convenience component for the common title+actions+body shape. `StatCard`'s `value` now accepts `ReactNode` (was `string | number`) and it gains `secondaryValue`/`secondaryTone` for a badge under the main value. `AuthSplitLayout` composes `Monogram`+`PageHeader`+`NoirGlowBackground` into a two-panel auth-screen layout. Color Tokens table above now lists the pre-existing `tollerud-success`/`warning`/`error`/`info` tokens. All additions are opt-in — no breaking changes.

#### Table primitives, per-region Card accent, StatCard tone (≥ 4.16.0)

New static `Table`/`TableHeader`/`TableBody`/`TableFooter`/`TableRow`/`TableHead`/`TableCell`/`TableCaption` — no sort/filter/search/pagination; use `DataTable` for that. `CardHeader`/`CardContent`/`CardFooter` gain their own `accent?: boolean | 'filled'` overriding the parent `Card`'s accent for just that region. `StatCard` gains `tone?: 'success' | 'error' | 'warning' | 'info'` for the value/border, independent of `accent`. `Timeline`'s `title` is now `ReactNode` (was `string`); it gains `variant?: 'connected' | 'flat'` (flat drops the connector line, timestamp moves below the title) and an `info` status. `StatusDot` gains a static blue `info` status. All additions are opt-in — no breaking changes.

**Consumer styling / recipes / guardrails** (no version bump required for docs-only): also sync `GETTING_STARTED.md`, relevant `docs-app/components/pages/page-*.jsx`, `docs-app/lib/docs-routes.js`, and `docs-app/lib/component-catalog.js` — see [CONTRIBUTING.md](CONTRIBUTING.md) and `.cursor/rules/consumer-styling-docs.mdc`.

### 5. CHANGELOG.md format rules

The docs site parses `CHANGELOG.md` at runtime. Wrong formatting causes entries to render as a wall of text or missing content. Follow these rules exactly:

**Entry heading** — always `## version — YYYY-MM-DD — Title`:
```
## 1.2.0 — 2026-07-01 — Add DataGrid component
```

**Blank lines are mandatory** between every distinct block (paragraph, heading, list, code fence). The parser breaks sections at blank lines — without them everything merges into one paragraph.

**Section headings inside an entry** — use `###` or a `**Bold line**` on its own line preceded by a blank line:
```
## 1.2.0 — 2026-07-01 — Add DataGrid component

Short summary of what changed.

### New components

- `DataGrid` — sortable, filterable data grid with ...

### Migration

Nothing breaking. Drop-in replacement for `DataTable` where needed.
```

**Never do this** — bold inline mid-paragraph acting as a heading with no blank line before it:
```
## 1.2.0 — bad example
Summary text. **New components** - DataGrid does X. **Migration** nothing breaking.
```

**Lists** — standard markdown `- item`. One blank line before the first item if preceded by a paragraph.

**Code blocks** — standard triple-backtick fences. Always close the fence, always a blank line before and after.

### 6. Commit and push

```bash
git add <changed files>
git commit -m "Brief description — vX.X.X"
git push origin main
```

---

## Fixing copy/paste component patterns (for agents working in consumer projects)

Older versions of projects that use `@tollerud/ui` sometimes copied component source files directly into the repo (e.g. `src/components/ui/Button.tsx` copied from Tollerud UI). These need to be replaced with package imports.

### How to detect it

```bash
# Find files that look like copied DS components (contain tollerud- tokens but aren't node_modules)
grep -rl "tollerud-yellow\|tollerud-noir\|tollerud-surface" src --include="*.tsx" --include="*.ts"
```

Also check for a local `components/ui.ts` or `components/ui/index.ts` that re-exports from relative paths instead of `@tollerud/ui`.

### How to fix it

1. **Verify `@tollerud/ui` is installed** — check `package.json`. If not: `npm install @tollerud/ui clsx tailwind-merge`.

2. **Replace the local copy with a package import** — for each copied component:
   ```tsx
   // Before (copied file)
   import { Button } from '@/components/ui/Button'

   // After
   import { Button } from '@tollerud/ui'
   ```

3. **Delete the copied files** once all imports are updated and the project builds.

4. **Check for prop drift** — copied files may be outdated. Verify against `SKILL.md` (or `.claude/skills/tollerud-ui/SKILL.md`) that prop names haven't changed (e.g. `onValueChange` vs `onChange`, `label` vs `children` on form components).

5. **Check for inline token usage** — copied files sometimes hardcode hex values instead of using tokens. Replace any hardcoded `#FFFF00`, `#0A0A0A`, `#E8D500` etc. with `text-tollerud-yellow`, `bg-tollerud-noir-950`, `text-tollerud-yellow-warm`.

6. **Run typecheck** — `npx tsc --noEmit`. Prop signatures in the package may differ slightly from the copied version; fix any type errors before committing.

### Common copy/paste patterns to look for

| Pattern | Fix |
|---------|-----|
| `src/components/ui/Button.tsx` with `tollerud-btn` classes | Delete, import from `@tollerud/ui` |
| `lib/utils.ts` defining `cn()` manually | Replace with `import { cn } from '@tollerud/ui'` (or `@tollerud/ui/utils` for tree-shaking) |
| `components/ui.ts` re-exporting from `'../../../components/Button'` | Replace all with `export * from '@tollerud/ui'` or direct named imports |
| Inline `bg-[#FFFF00]` or `text-[#0A0A0A]` | Replace with `bg-tollerud-yellow` / `text-tollerud-noir-950` |
| `import { toast } from 'sonner'` without a `<Toaster />` mount | Add `<Toaster />` near app root |

---

## Reference

| File | Contents |
|------|----------|
| [SKILL.md](SKILL.md) | **Verified** component catalog, props, gotchas — source of truth for what's actually shipped |
| [COMPONENTS.md](COMPONENTS.md) | Human usage guide + export index — verify exports in SKILL.md |
| [PROPS.generated.md](PROPS.generated.md) | Auto-generated `*Props` tables — `npm run docs:props` / `npm run test:props` |
| [BRAND.md](BRAND.md) | Logo usage, nav lockup, sizing rules |
| [ACCESSIBILITY.md](ACCESSIBILITY.md) | Contrast ratios, focus, ARIA patterns |
| [VOICE.md](VOICE.md) | Copy tone, terminal-style CTAs, error messages |
| [KEYBOARD.md](KEYBOARD.md) | Keyboard contract for CommandMenu and navigation |
| [BACKGROUNDS.md](BACKGROUNDS.md) | NoirGlowBackground props and fallback rules |
| [GETTING_STARTED.md](GETTING_STARTED.md) | Install, Tailwind config, consumer styling policy, audit checklist |
| [docs/archive/CONSUMER_STYLING_ROADMAP.md](docs/archive/CONSUMER_STYLING_ROADMAP.md) | Archived — completed consumer styling initiative (phases 1–6) |
| [COMPLETENESS_ROADMAP.md](COMPLETENESS_ROADMAP.md) | Open roadmap items and release ritual |
| [Recipes (docs)](https://design.tollerud.dev/recipes/) | Agent-safe copy-paste screen compositions |
| `npx tollerud-ui-audit` | Consumer styling drift checker (ships with `@tollerud/ui`); `--warn-only` for advisory CI; error codes in GETTING_STARTED.md |
| [CONTRIBUTING.md](CONTRIBUTING.md) | PR gates, component checklist, consumer styling doc sync matrix |
