# Tollerud User Interface — Component Library

Human-oriented usage guide for `@tollerud/ui` **v5.6.0**. Components ship as React `.tsx` modules with matching CSS in `globals.css` / `tokens.css`.

**New here?** Install and wire Tailwind first — **[GETTING_STARTED.md](GETTING_STARTED.md)**. Then come back here for examples.

## Documentation map

| Resource | Use for |
|----------|---------|
| **[GETTING_STARTED.md](GETTING_STARTED.md)** | Install, Tailwind, paths, migration — **start here** |
| **[SKILL.md](SKILL.md)** | Authoritative export catalog, import examples, gotchas (verified against `components/index.ts`) |
| **[PROPS.generated.md](PROPS.generated.md)** | Machine-generated prop tables — run `npm run docs:props` to refresh; `npm run test:props` catches drift |
| **This file** | Narrative examples, CSS class patterns, homelab/dashboard usage |
| **[COMPLETENESS_ROADMAP.md](COMPLETENESS_ROADMAP.md)** | Open roadmap items and release ritual |

## Consumer styling policy

Use this file as a component reference, not as an invitation to rebuild branded UI in each app. React consumers should prefer exported components first, layout primitives or screen patterns when available, and Tailwind only for small local glue. If a branded structure repeats, add it to `@tollerud/ui` or compose a local semantic feature component instead of copying package internals or creating a parallel `components/ui` system.

**Agent-safe recipes** for common screens (marketing landing, dashboard, settings, auth, empty state, detail, list/table) live on the docs site at [Recipes](https://design.tollerud.dev/recipes/).

**Consumer guardrails** — run `npx tollerud-ui-audit` from your app root before shipping. The audit checks Tailwind CSS setup, copied `components/ui` trees, hardcoded brand hex values, local `cn()` helpers, and `<Button><Link>` nesting. Use `--warn-only` in CI when you want warnings without a failing exit code. Full command reference, error codes, and fixes: [GETTING_STARTED.md](GETTING_STARTED.md) → Consumer project checklist · [Guides on design.tollerud.dev](https://design.tollerud.dev/resources/consumer-checklist/).

## Component families

Use this map to pick the right layer — not every app needs infra widgets or glow backgrounds.

| Family | Components | Reach for these when… |
|--------|------------|-------------------------|
| **Core** | `Button`, `Card`, `Badge`, `Input`, `Select`, `Checkbox`, `Switch`, `Textarea`, `FormRow`, `Container`, `StatusDot`, `Kbd`, `CodeBlock`, `StatCard` | Building any screen — actions, forms, layout, status |
| **Overlays & navigation** | `Dialog`, `Sheet`, `Drawer`, `DropdownMenu`, `Tabs`, `Tooltip`, `CommandMenu`, `Alert`, `Empty`, `Toaster` | Modals, menus, multi-step flows, command palette, toasts |
| **Data & patterns** | `DataTable`, `Table`, `ActionRow`, `Timeline`, `LogViewer`, `BentoDashboard` | Tables, logs, dashboards assembled from primitives |
| **Infra / ops** | `HostCard`, `ServiceHealthCard`, `DockerStackCard`, `IncidentCard`, `ApprovalCard`, `AlertInbox`, `RollbackPlan`, `BackupStatusPanel`, `ActionDiff` | Homelab, fleet, incident, and approval UIs |
| **Visual & marketing** | `TimeSeriesChart`, `HeroBlock`, `FeatureCard`, `CTABand`, `BarChart`, `AreaChart`, `Donut`, `Sparkline`, `GlowCard`, `BentoDashboard` | Landing sections, charts, marketing blocks |
| **Optional visual** | `NoirGlowBackground` (+ `@paper-design/shaders-react` peer) | Hero backgrounds with WebGL glow — optional install |
| **Branding** | `Monogram`, `Footer` | Nav lockups and page chrome (`Footer` also as [`@tollerud/footer`](https://www.npmjs.com/package/@tollerud/footer)) |

## Export index

All symbols below resolve from `import { … } from '@tollerud/ui'` unless noted. Prop signatures: see [PROPS.generated.md](PROPS.generated.md).

**Core & forms:** `Button`, `ButtonGroup`, `buttonVariants`, `cn`, `Card`, `Badge`, `StatusDot`, `Kbd`, `Input`, `Textarea`, `Select`, `Checkbox`, `Switch`, `RadioGroup`, `Radio`, `PasswordInput`, `PasswordStrength`, `passwordRules`, `Combobox`, `TagInput`, `Slider`, `FormRow`, `Container`, `CodeBlock`, `StatCard`, `ActionRow`, `CommandMenu`

**Navigation & layout:** `PageShell`, `Section`, `Stack`, `Cluster`, `Grid`, `CardGrid`, `ScrollRail`, `Split`, `MainContent`, `PageHeader`, `TopNav`, `SidebarProvider`, `Sidebar`, `SidebarTrigger`, `SidebarInset`, `SidebarHeader`, `SidebarContent`, `SidebarFooter`, `SidebarGroup`, `SidebarGroupLabel`, `SidebarGroupContent`, `SidebarMenu`, `SidebarMenuItem`, `SidebarMenuButton`, `SidebarMenuAction`, `SidebarMenuBadge`, `SidebarMenuSub`, `SidebarMenuSubItem`, `SidebarMenuSubButton`, `useSidebar`, `DashboardTopBar`, `DashboardShell`, `SettingsLayout`, `FormPanel`, `ResourceList`, `DetailPage`, `EmptyPage`, `FeatureSection`, `StatsSection`, `Divider`, `Pill`, `Avatar`, `AvatarGroup`, `Breadcrumb`, `Pagination`, `Segmented`, `Stepper`, `Panel`, `Meter`, `Gauge`, `Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`, `DatePicker`, `FileUpload`, `PricingCard`

**Overlays & feedback:** `Alert`, `Dialog` (+ `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogBody`, `DialogFooter`, `DialogTitle`, `DialogDescription`, `DialogClose`, `DialogPanel`), `Tooltip` (+ `TooltipTrigger`, `TooltipContent`, `TooltipProvider`), `Tabs` (+ `TabsList`, `TabsTrigger`, `TabsContent`), `DropdownMenu` (+ trigger/content/item/label/separator), `Sheet` (+ `SheetTrigger`, `SheetContent`, `SheetHeader`, `SheetTitle`, `SheetDescription`, `SheetClose`), `Drawer`, `Toaster` (Sonner), `ToastProvider` / `useToast`, `Empty` (+ compound parts), `EmptyState`, `Skeleton`, `Progress`, `Spinner`

**Data & infra:** `DataTable`, `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption`, `HostCard`, `ServiceHealthCard`, `DockerStackCard`, `IncidentCard`, `ApprovalCard`, `ActionDiff`, `LogViewer`, `AlertInbox`, `Timeline`, `RollbackPlan`, `BackupStatusPanel`

**Visual & marketing:** `Monogram`, `GlowCard`, `NoirGlowBackground`, `BentoDashboard`, `TimeSeriesChart`, `TIME_SERIES_PRESETS`, `formatChartDecimal`, `formatChartNumber`, `formatChartDateShort`, `formatChartDateLong`, `BarChart`, `AreaChart`, `Donut`, `Sparkline`, `HeroBlock`, `FeatureCard`, `CTABand`

**Footer:** `Footer` — also published as [`@tollerud/footer`](https://www.npmjs.com/package/@tollerud/footer)

**Email:** HTML email is a separate render target — see the [`@tollerud/email`](#tollerudemail--html-email) sibling package below, not the `@tollerud/ui` exports above.

## NoirGlowBackground

Tollerud.no-inspired animated WebGL background using `@paper-design/shaders-react`, with CSS fallback classes.

```tsx
<HeroBlock
  eyebrow="homelab control plane"
  title="Run your stack like production."
  description="Deploy, monitor, and roll back from one keyboard-first console."
  intense
/>
```

Use `NoirGlowBackground` directly only for custom background composition that cannot be represented by an exported block yet. See `BACKGROUNDS.md` for install, props, usage rules, and fallback HTML.

## Layout primitives

These primitives give consumer apps a component-first page structure before reaching for raw Tailwind layout classes.

```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>
```

| Component | Key props | Use for |
|-----------|-----------|---------|
| `PageShell` | `background`, `density`, `contentClassName` | Full-page noir/grid/glow background. Inner wrapper is always `flex flex-col flex-1` so `min-h-screen flex flex-col` layouts work correctly. Root is `overflow-clip` (≥ 4.8.41) so `position: sticky` works for all descendants. |
| `Section` | `size`, `width` | Consistent page sections |
| `Stack` | `gap`, `align` | Vertical rhythm |
| `Cluster` | `gap`, `align`, `justify` | Wrapping actions, badges, and toolbars |
| `Grid` | `columns`, `gap` | Generic responsive grids |
| `CardGrid` | `columns`, `gap` | Card collections that should all be visible in a responsive grid |
| `ScrollRail` | `visibleCount`, `peek`, `gap`, `controls`, `fadeEdges`, `itemWidth`, `ariaLabel` | Horizontal card rows; `visibleCount` for N-fill-then-scroll (≥ 4.9.2) |
| `Split` | `ratio`, `gap`, `reverse` | Content/aside two-column layouts |
| `MainContent` | `width`, `spacing`, `density` | App route content wrapper |

### ScrollRail

Horizontal scroll rail for card rows, image strips, and overflow content. Continuous scroll (not snap-carousel). Peek and edge fades apply only when content overflows.

**Fill row with N, scroll the rest** — use `visibleCount` (≥ 4.9.2):

```tsx
<ScrollRail visibleCount={4} gap="md" peek="md" controls="auto" ariaLabel="Achievements">
  {trophies.map((t) => (
    <Card key={t.id}>…</Card>
  ))}
</ScrollRail>
```

**Fixed-pixel card strips** — use `itemWidth`:

```tsx
<ScrollRail peek="md" itemWidth={260} controls="auto" ariaLabel="Featured products">
  <Card>…</Card>
  <Card>…</Card>
</ScrollRail>
```

| Prop | Type | Default | Notes |
|------|------|---------|-------|
| `visibleCount` | `number` | — | Items visible at once; fills row when count ≤ this, scrolls when above (≥ 4.9.2) |
| `peek` | `false \| 'sm' \| 'md' \| 'lg'` | `'md'` | Sliver of next item when overflowing |
| `gap` | `'xs' \| 'sm' \| 'md' \| 'lg'` | `'md'` | Spacing between items (`--scroll-rail-gap` on scrollport) |
| `controls` | `boolean \| 'auto'` | `false` | Prev/next buttons; `'auto'` when content overflows |
| `fadeEdges` | `boolean` | `true` | Gradient masks at scroll boundaries |
| `itemWidth` | `number \| string` | — | Fixed uniform width; **`%` is track-relative** — prefer `visibleCount` for viewport columns |
| `ariaLabel` | `string` | `'Scrollable content'` | Accessible name for the scroll region |

## Screen patterns

These components assemble common page structures from the layout primitives and core components.

```tsx
<DashboardShell
  projectName="Mission Control"
  pageTitle="Overview"
  sidebarItems={[{ id: 'overview', label: 'Overview', href: '/', active: true }]}
  header={<PageHeader title="Overview" description="Fleet health at a glance." />}
>
  <StatsSection stats={[{ label: 'Hosts online', value: 3, accent: true }]} />
</DashboardShell>
```

| Component | Use for |
|-----------|---------|
| `PageHeader` | Page title, eyebrow, description, metadata, and actions |
| `TopNav` | Branded monogram lockup, nav links, and top-level actions; `TopNavAction` controls mobile placement. `TopNavItem.icon` (≥ 5.1.0) shows in dropdown/mobile rows; `TopNavItem.onClick` (≥ 5.2.0) renders the row as a button instead of a link, e.g. sign out. `mobileMenuSections` (≥ 5.1.0) adds labeled row groups to the mobile sheet, collapsed behind their label by default (≥ 5.4.0 — `collapsible`/`defaultOpen` per section); `userMenu` (≥ 5.3.0) renders a single account/user menu from one data structure — a `DropdownMenu` next to the desktop actions, and the same sections appended to the mobile sheet automatically; `placement: 'start' | 'end'` (≥ 5.6.0, default `'end'`) controls its position relative to `actions`; `mobileMenuExtra` injects fully custom content at the bottom. A `navItems` entry with `items: TopNavItem[]` becomes a flyout group — desktop panel positioned with Floating UI, anchored to its own trigger, click-to-open (≥ 5.4.0/5.5.0; previously Radix NavigationMenu's `Viewport`, which centered under the whole bar and hover-opened), mobile accordion (no left guide-line, ≥ 5.5.0) — requires the `@radix-ui/react-navigation-menu` peer (≥ 4.19.0) for the `Trigger`'s click/keyboard model |
| `Sidebar` | Collapsible sidebar primitive family (`SidebarProvider`/`Sidebar`/`SidebarMenu`/etc., ≥ 5.0.0) — replaces the removed `SidebarNav`. Sticky `<aside>` on desktop, `Sheet`-based off-canvas on mobile; `collapsible="icon"` collapses to an icon rail with tooltips. See the [Sidebar section](#sidebar) below. |
| `DashboardTopBar` | Context top bar with breadcrumb, page title, and actions. `menuTrigger` slot renders the mobile menu button (e.g. `<SidebarTrigger className="lg:hidden" />`, ≥ 5.0.0 — replaces `menuOpen`/`onMenuToggle`). `showMobileLogo={false}` hides the mobile monogram. |
| `DashboardShell` | Docs-aligned app shell built on `Sidebar`/`SidebarProvider` internally; `sidebarGroups`/`sidebarItems` props unchanged. Sidebar sticks on `lg+`. |
| `SettingsLayout` | Settings pages with section navigation |
| `FormPanel` | Titled form surface with actions and footer |
| `ResourceList` | List/table pages with filters, count, and empty state |
| `DetailPage` | Detail pages with optional aside |
| `EmptyPage` | First-run, no-results, or error pages |
| `FeatureSection` | Marketing/product feature grids |
| `StatsSection` | Metric overview sections |
| `AuthSplitLayout` | Two-panel hero/form auth screen (sign-in, sign-up, reset-password) — new in ≥ 4.17.0 |

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

## Button

| Variant | Class | React Component | Usage |
|---------|-------|----------------|-------|
| Primary | `.tollerud-btn--primary` | `<Button variant="primary">` | Main CTA |
| Secondary | `.tollerud-btn--secondary` | `<Button variant="secondary">` | Secondary action |
| Ghost | `.tollerud-btn--ghost` | `<Button variant="ghost">` | Low emphasis |
| Destructive | `.tollerud-btn--destructive` | `<Button variant="destructive">` | Delete/remove |
| Terminal | `.tollerud-btn--terminal` | `<Button variant="terminal">` | Dev tools, CLIs |

Sizes: `--sm` (28px), `--md` (40px), `--lg` (48px) — fixed height per size so text and icon-only buttons align in the same row.

```jsx
<Button variant="primary" size="md" onClick={handleClick}>
  Deploy
</Button>
<Button variant="terminal" size="sm">
  start_building
</Button>
```

`Button` renders a native `<button>`. To style a link, use `asChild` or `buttonVariants()` — see [SKILL.md](SKILL.md#3-button-only-renders-a-native-button--use-aschild-for-links--107).

**Pointer glow** — primary and terminal buttons support a magnetic cursor-following highlight. CSS ships in `globals-layers.css`; wire it once at the app root:

```tsx
'use client'
import { useEffect } from 'react'
import { initButtonGlow } from '@tollerud/ui'

export function ButtonGlowRoot() {
  useEffect(() => initButtonGlow(), [])
  return null
}
```

Add `tollerud-btn-glow` to opt in other surfaces. Disabled when `prefers-reduced-motion: reduce`.

## Button group

Fused action buttons for independent clicks — shared outer border, internal dividers, consistent height. Not a toggle; use `Segmented` for single-select view/sort controls.

```jsx
<ButtonGroup size="sm">
  <Button variant="secondary">Deploy</Button>
  <Button variant="secondary">Cancel</Button>
  <Button variant="ghost" aria-label="More options"><MoreIcon /></Button>
</ButtonGroup>
```

Props: `size?: 'sm' | 'md' | 'lg'`, `orientation?: 'horizontal' | 'vertical'`. Children must be `<Button>` elements.

## Card

| Class | React | Props |
|-------|-------|-------|
| `.tollerud-card` | `<Card>` | — |
| `.tollerud-card border-tollerud-yellow/25` | `<Card accent>` | accent: `true` |
| `.tollerud-card border-tollerud-yellow/25 bg-tollerud-yellow/5` | `<Card accent="filled">` | accent: `"filled"` |

Compound parts (≥ 4.9.4): `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`. `CardHeader` accepts `actions` (≥ 4.9.7). `CardChange` (≥ 4.9.7). `accent={true}` tints header/footer bands subtly (≥ 4.9.8); `accent="filled"` tints all regions more strongly. `CardHeader`/`CardContent`/`CardFooter` each also take their own `accent?: boolean | 'filled'` (≥ 4.16.0), overriding the parent `Card`'s accent for just that region — leave unset to keep inheriting. `Card` also takes an explicit `structured?: boolean` (≥ 4.17.0) to bypass its `displayName`-based auto-detection of `CardHeader`/`CardContent`/`CardFooter` children, which can fail across a Next.js Server/Client Component boundary.

```jsx
<Card>
  <StatusDot status="online" label="Emma — ready" />
</Card>
<Card accent>
  <p>Highlighted card with yellow border</p>
</Card>
<Card accent="filled">
  <p>Callout with yellow border + fill — cheapest-item highlights, CTAs</p>
</Card>
<Card>
  <CardHeader>
    <CardTitle>Deploy</CardTitle>
    <CardDescription>emma.tollerud.no</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter className="justify-end">
    <Button variant="primary" size="sm">Deploy</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>
{/* Per-region accent (v4.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 (v4.17.0) — explicit, safe across a Server/Client boundary */}
<Card structured>
  <CardHeader><CardTitle>Deploy</CardTitle></CardHeader>
  <CardContent>Body</CardContent>
</Card>
```

## StructuredCard

`Card` + `CardHeader` + `CardTitle` + `CardContent` composed with `structured` set explicitly — the common title+actions+body shape, safe to render from a Server Component. New in ≥ 4.17.0.

| Prop | Type |
|------|------|
| `title` | `ReactNode` |
| `actions` | `ReactNode` |
| `contentClassName` | `string` |

```jsx
<StructuredCard title="Deploy" actions={<CardChange value="+12%" direction="up" />}>
  Body content
</StructuredCard>
```

## PriceDisplay

Compact price display for list rows and table cells. Primary value rendered large, optional secondary shown as a `Badge` below.

```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" />
```

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 items. Uses `border-tollerud-noir-800 bg-tollerud-noir-900/40` base with hover transitions. Renders as `<a>` when `href` is provided — children are consumer-controlled.

```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`.

## Badge

| Variant | Class | React |
|---------|-------|-------|
| Default | `.tollerud-badge--default` | `<Badge>` |
| Accent | `.tollerud-badge--accent` | `<Badge variant="accent">` |
| Success | `.tollerud-badge--success` | `<Badge variant="success">` |
| Error | `.tollerud-badge--error` | `<Badge variant="error">` |
| Info | `.tollerud-badge--info` | `<Badge variant="info">` |
| Warning | `.tollerud-badge--warning` | `<Badge variant="warning">` |

```jsx
<Badge variant="success">Online</Badge>
<Badge variant="accent">New</Badge>
```

## StatusDot

| Status | Color | Glow |
|--------|-------|------|
| online | `#22C55E` | 6px green glow |
| offline | `#EF4444` | — |
| warning | `#E8D500` | 6px yellow glow |
| idle | `#666666` | — |
| info (v4.16.0) | `#3B82F6` | — (no pulse, like `idle`) |

```jsx
<StatusDot status="online" label="SSH Connected" />
<StatusDot status="warning" label="CPU 87%" />
<StatusDot status="info" label="Note added" />
```

## Input

```jsx
<Input
  label="Server Name"
  placeholder="e.g. emma.tollerud.no"
  error={errors.name}
/>
```

## Pill

```tsx
<Pill variant="outline">new</Pill>
<Pill variant="accent">production</Pill>
```

Variants: `outline` · `solid` · `accent`. CSS classes: `.tollerud-pill`, `.tollerud-pill--outline`, `.tollerud-pill--muted`, etc.

## StatCard

```jsx
<StatCard label="Active Sessions" value={42} icon={<Activity size={14} />}
  change={{ value: '+12%', direction: 'up' }} />

// change.tone overrides the default up=success / down=error color
<StatCard label="Price change" value="-3.2%" icon={<TrendingDown size={14} />}
  change={{ value: '-3.2%', direction: 'down', tone: 'success' }} />

// tone (v4.16.0) colors the value + border independently of accent — for a status-carrying figure
<StatCard label="Brukt av budsjett" value="12 930 kr / 2 700 kr" tone="error" />

// secondaryValue (v4.17.0) — a second unit shown as a Badge under the main value
<StatCard label="Price" value="23,90 kr" secondaryValue="47,80 kr/l" secondaryTone="accent" />

// value (v4.17.0) is now ReactNode — drop in your own <Input> for an inline-editable tile
<StatCard label="Ticket price" value={<Input defaultValue="150" onBlur={save} />} />
```

Props: `label`, `value: ReactNode` (v4.17.0 — was `string | number`), `icon?: ReactNode`, `accent?: boolean`, `tone?: 'success' | 'error' | 'warning' | 'info'`, `change?: { value?: string, direction: 'up' | 'down' | 'flat', tone?: 'success' | 'error' | 'warning' | 'info' | 'accent' }`, `secondaryValue?: ReactNode` (v4.17.0), `secondaryTone?: 'default' | 'accent' | 'success' | 'error' | 'warning' | 'info'` (v4.17.0).

## CodeBlock

```jsx
<CodeBlock
  promptPrefix
  showCopy
  code={`$ systemctl status tollerud-agent
● active (running)`}
/>
```

## Divider

```tsx
<Divider />
<Divider label="or" />
<Divider orientation="vertical" className="h-6" />
```

CSS: `.tollerud-divider`, `.tollerud-divider--accent`, `.tollerud-accent-bar` (gradient).

## Kbd — Keyboard Shortcut Chip

Raycast-inspired shortcut badge for displaying key combinations.

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

Props:
- `keys: string | string[]` — The keys to display. Separate with `+` for chords.
- `size?: "sm" | "md"` — Small variant for inline use.

CSS class: `.tollerud-kbd`, children `.tollerud-kbd__key`.

## ActionRow — Command / Action Item

A single command/action row used by `CommandMenu` and standalone.

```tsx
<ActionRow
  action={{
    id: 'deploy',
    label: 'Deploy Stack',
    description: 'emma: docker-compose up -d',
    icon: <Rocket />,
    shortcut: '↵',
    onSelect: () => deploy()
  }}
  highlighted={index === 0}
/>
```

Used as a controlled row with keyboard navigation via the `highlighted` prop.

## CommandMenu — Raycast-style Command Palette

The signature keyboard-first component. Full command palette with search, groups, keyboard navigation, and overlay.

```tsx
const [open, setOpen] = useState(false)
const servers = ['Emma', 'Miriam', 'Pia', 'Iris', 'Victoria', 'Embla']

<Button onClick={() => setOpen(true)}>Open Menu</Button>

<CommandMenu
  open={open}
  onOpenChange={setOpen}
  groups={[
    {
      label: 'Servers',
      items: servers.map((name) => ({
        id: name.toLowerCase(),
        label: `${name}.tollerud.no`,
        description: 'SSH · 4 containers · uptime 14d',
        onSelect: () => ssh(name),
      })),
    },
    {
      label: 'Actions',
      items: [
        { id: 'backup', label: 'Run Backup', description: 'rclone to JottaCloud', onSelect: () => runBackup() },
        { id: 'update', label: 'System Update', description: 'apt update && upgrade', onSelect: () => update() },
      ],
    },
  ]}
/>
```

Features:
- Built-in `⌘K` / `Ctrl+K` global listener
- Arrow key navigation, `Enter` selects, `Esc` closes — navigation tracks the *filtered* results (≥ 4.8.40), and the highlight is announced via `aria-activedescendant` and scrolled into view
- Search filters across groups, labels, and descriptions
- Auto-focus on open, body scroll lock
- Footer keyboard hints
- Optional `filter` prop for custom search logic
- `onAction` callback for analytics/tracking

Usage: Import as a client component in your page/layout.

See `KEYBOARD.md` for full keyboard contract.

## Homelab / Infrastructure Components

The Tier 2 component set — these make Tia a real infrastructure assistant. All ship from `components/*.tsx` (`@tollerud/ui`) and are showcased on the **Infrastructure** page; `HostCard`, `AlertInbox` and `ApprovalCard` also power the Mission Control dashboard. They share a five-level `SEVERITY` scale (`critical · high · medium · low · info`).

### ServiceHealthCard

```tsx
<ServiceHealthCard
  service="emma.tollerud.no"
  status="online"
  uptime="14d 3h"
  responseTime="23ms"
  version="22.04 LTS"
/>
```

Props: `service`, `status`, `uptime`, `responseTime`, `version`, `loading`.

### HostCard

```tsx
<HostCard
  hostname="emma"
  ip="10.0.10.10"
  status="online"
  cpu="23%"
  memory="6.2/16 GB"
  disk="45%"
  uptime="14d"
  containers={4}
/>
```

Props: `hostname`, `ip`, `status`, `cpu`, `memory`, `disk`, `uptime`, `containers`, `loading`.

### DockerStackCard

```tsx
<DockerStackCard
  name="monitoring"
  composePath="/hdd/config/prometheus-stack/compose.yml"
  services={[
    { name: 'prometheus', status: 'online' },
    { name: 'grafana', status: 'online' },
    { name: 'alertmanager', status: 'online' },
  ]}
/>
```

Props: `name`, `services: StackService[]`, `composePath`, `loading`.

### IncidentCard

```tsx
<IncidentCard
  title="High CPU on emma"
  severity="high"
  timestamp="2026-05-26 14:32"
  description="CPU sustained at 92% for 5 minutes"
  service="emma"
  acknowledged={false}
/>
```

Prop `severity`: `'critical' | 'high' | 'medium' | 'low' | 'info'`. Dot color and border match severity.

### ApprovalCard

```tsx
<ApprovalCard
  action="restart_container"
  description="Restart emma:tollerud-hermes container"
  source="emma → /hdd/config/tia/compose.yml"
  state="pending"
  timestamp="2026-05-26 14:35"
  onApprove={() => {}}
  onReject={() => {}}
/>
```

States: `pending` (shows Approve/Reject buttons), `approved`, `rejected`.

### ActionDiff

```tsx
<ActionDiff
  label="docker-compose.yml"
  lines={[
    { text: '    image: hermes:latest', type: 'remove', oldLine: 5 },
    { text: '    image: hermes:v2.0', type: 'add', newLine: 5 },
    { text: '    restart: unless-stopped', type: 'context', oldLine: 6, newLine: 6 },
  ]}
/>
```

Renders a unified diff. `showContext` (default true) toggles context lines off to focus on edits. Line shape: `{ type: 'add' | 'remove' | 'context', text, oldLine?, newLine? }`.

### LogViewer

```tsx
<LogViewer
  lines={[
    { text: 'Starting deployment...', level: 'info', timestamp: '14:32:01', source: 'deploy' },
    { text: 'Connection refused on port 443', level: 'error', timestamp: '14:32:05', source: 'nginx' },
    { text: 'Health check passed', level: 'info', timestamp: '14:32:10', source: 'hermes' },
  ]}
  follow
  searchable
  showLineNumbers
  height={300}
/>
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `lines` | `{ text, level, timestamp?, source? }[]` | `[]` | Log entries. `level` is one of `trace` `debug` `info` `warn` `error`. |
| `follow` | `boolean` | `false` | Auto-scroll to the newest line whenever `lines` changes. |
| `searchable` | `boolean` | `false` | Show a filter input (matches `text` + `source`) with a live match count. |
| `showLineNumbers` | `boolean` | `false` | Prefix each row with a zero-padded line number. |
| `height` | `number` | `300` | Scroll-area height in px. |

Level color coding: `trace` (muted) → `debug` (info blue) → `info` (secondary) → `warn` (yellow) → `error` (red, brightened message). Errors carry a colored left border. For a streaming/level-filter/export toolbar on top of it, see the **Logs & Console** build page.

### AlertInbox

```tsx
<AlertInbox
  alerts={[
    { id: '1', title: 'emma high CPU', severity: 'high', timestamp: '14:32', acknowledged: false },
    { id: '2', title: 'pia disk 91%', severity: 'critical', timestamp: '13:15', acknowledged: true },
  ]}
  onAcknowledge={(id) => {}}
/>
```

Features: severity count badges, hover-to-acknowledge, severity filter, scrollable list. Pass `loading` for a skeleton state; shows “No alerts — everything looks good” when empty.

### Timeline

```tsx
<Timeline
  active={true}
  items={[
    { id: '1', time: '14:32', title: 'Deployed hermes v2.0', description: 'Rolled out to emma', status: 'online',
      meta: ['success', '3s'] },
    { id: '2', time: '14:31', title: 'Restarted nginx', description: 'Config reloaded', status: 'warning',
      meta: ['warning'] },
    { id: '3', time: '14:30', title: 'SSH connection failed', description: 'emma refused connection', status: 'offline' },
  ]}
/>

{/* variant="flat" (v4.16.0) — no connector line, timestamp below the title; 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' },
    { id: '2', time: '1 t siden', title: <><strong>Mathias</strong> la til Kenneth Granstrøm</>, status: 'info' },
  ]}
/>
```

Features: status-colored dots (`online`/`offline`/`warning`/`idle`/`info`), connecting vertical lines, animated pulse for active items, metadata badges, icon support. `title` accepts `ReactNode` (v4.16.0), so an item can mix rich content — e.g. a bolded actor name — with plain text. `variant="flat"` (v4.16.0) drops the connector line, divides rows with a hairline border, and moves the timestamp below the title — suited to activity/audit-log lists; default `variant="connected"` is the original chronological-trail look shown above.

### RollbackPlan

```tsx
<RollbackPlan
  name="Rollback hermes v2.0"
  executing={true}
  steps={[
    { id: '1', label: 'Stop hermes container', status: 'success' },
    { id: '2', label: 'Restore previous image tag', status: 'running' },
    { id: '3', label: 'Start container', status: 'pending' },
    { id: '4', label: 'Run health check', status: 'pending' },
  ]}
/>
```

Step statuses: `pending` `running` `success` `failed` `skipped` — each with distinct icon and color.

### BackupStatusPanel

```tsx
<BackupStatusPanel
  jobs={[
    { name: 'emma-config', status: 'online', lastRun: '03:00', size: '4.2 GB', target: 'JottaCloud' },
    { name: 'iris-config', status: 'online', lastRun: '03:05', size: '1.8 GB', target: 'JottaCloud' },
    { name: 'miriam-media', status: 'offline', lastRun: '02:45' },
  ]}
  totalSize="6.0 GB"
  lastFullBackup="2026-05-25"
/>
```

Features: per-job status dots, size/target display, failed job warning footer. Pass `loading` for a skeleton state and `emptyState` (or rely on the default) when no jobs are configured.

## Layout & form components

### Panel

A card with a header bar (title + optional description + actions) and padded body — the structural workhorse behind log viewer, data table, and alert inbox surfaces.

```tsx
<Panel title="Compose stack" description="4 services"
  actions={<Button variant="ghost" size="sm">Edit</Button>}>
  …content…
</Panel>
```

Props: `title`, `description`, `actions`, `children`, `className`.

### Meter

A labeled progress bar with semantic tone coloring.

```tsx
<Meter label="CPU" value={23} showValue />
<Meter label="RAM" value={72} max={100} showValue tone="warning" />
```

Props: `value`, `max?` (default 100), `label?`, `showValue?`, `tone?: 'default' | 'success' | 'warning' | 'error'`.

### Gauge

Radial dial for a single metric (disk %, load, quota) — a 270° arc with a bottom gap, filling from bottom-left clockwise. Complements the linear `Meter`. Since 4.8.50.

```tsx
<Gauge value={72} label="Disk" formatValue={(v) => `${v}%`} tone="warning" />
<Gauge value={30} min={0} max={60} label="Load" fluid />
```

Props: `value`, `min?`/`max?` (default 0–100), `label?`, `formatValue?`, `tone?: 'default' | 'success' | 'warning' | 'error'` (token-backed, same as Meter), `size?` (default 160), `thickness?` (default 12), `fluid?` (scale to container, stays circular), `ariaLabel?`. Exposes `role="meter"` with `aria-valuenow`/`min`/`max`; arc animation respects `prefers-reduced-motion`.

### Heatmap

Calendar activity heatmap — GitHub-contributions style. Since 4.8.51.

```tsx
<Heatmap
  data={[{ date: '2026-01-05', value: 3 }, { date: '2026-01-06', value: 8 }]}
  ariaLabel="Deploy activity"
  formatValue={(v) => `${v} deploys`}
/>
```

Props: `data` (`{ date, value }[]`), `startDate?`/`endDate?` (default from data range), `weekStartsOn?` (`0` Sun / `1` Mon, default Mon), `colors?` (5 intensity colors, default yellow-on-noir), `formatValue?`/`formatDate?`/`locale?`, `showLegend?` (Less→More, default on), `cellSize?`/`cellGap?`, `ariaLabel?`, `srTable?`. Cells bucket into 4 intensity levels by value; the grid is `aria-hidden` with a visually-hidden data table as the screen-reader surface, and hover shows a date + value tooltip.

### SegmentBarChart

Horizontal stacked proportion bar — one rounded bar showing parts of a whole, with in-bar percentage labels and a legend grid below. Since 4.15.0.

```tsx
<SegmentBarChart
  segments={[
    { label: 'Mat & drikke', value: 6490 },
    { label: 'Drikke', value: 3150 },
    { label: 'Leie', value: 2400 },
    { label: 'Utstyr', value: 890 },
  ]}
  formatValue={(v) => `${v} kr`}
/>
```

Props: `segments` (`{ label, value, color? }[]`), `height?` (default 36), `showPercentLabels?` (default on), `minPercentLabel?` (default 8), `formatValue?`, `interactive?`, `ariaLabel?`. Colors cycle `SEGMENT_BAR_COLORS` when omitted. Legend uses one column on mobile, two on `sm+`.

### Stepper

Horizontal or vertical step indicator for wizards. Completed steps fill yellow; the current one carries a ring.

```tsx
<Stepper
  steps={[
    { label: 'Connect host' },
    { label: 'Choose stacks', description: 'Pick compose files' },
    { label: 'Finish' },
  ]}
  current={1}
/>
<Stepper steps={['Connect', 'Deploy', 'Verify']} current={0} orientation="vertical" />
```

Props: `steps: string[] | { label, description? }[]`, `current` (0-indexed), `orientation?: 'horizontal' | 'vertical'`.

### PasswordInput

A password field with a show/hide toggle. Same API as `Input` (`label`, `error`, `id`, …) plus native `<input>` props and `labelAction?: ReactNode` — rendered at the right edge of the label row, for a "Forgot?" link next to the label.

```tsx
<PasswordInput label="Password" placeholder="••••••••" error={pwError}
  labelAction={<button type="button" onClick={onForgot}>Forgot?</button>} />
```

### PasswordStrength

Strength bar + rule checklist for signup and change-password flows. Compose below a `PasswordInput` and pass the same `value`.

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

Default rules: min 8 characters, uppercase letter, lowercase letter, number, special character. Override with `rules` — use the exported `passwordRules` array as a base:

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

Props: `value: string`, `rules?: { label: string; test: (value: string) => boolean }[]`.

### Spinner

Inline loading indicator; respects `prefers-reduced-motion`.

```tsx
<Button variant="primary" disabled><Spinner size={14} /> Deploying…</Button>
```

Props: `size` (px, default 16). CSS class: `.tollerud-spinner`.

### FormRow

Accessible field wrapper — wires `aria-describedby` between label, description, error, and control.

```tsx
<FormRow label="Hostname" htmlFor="hostname" description="Unique within your network." required error={errors.hostname}>
  <Input id="hostname" placeholder="e.g. embla" />
</FormRow>
```

Props: `label?`, `description?`, `error?`, `required?`, `htmlFor?`, `children`.

### PricingCard

A single plan tier with optional badge, feature list, and CTA.

```tsx
<PricingCard
  name="Pro"
  price="$12"
  period="/month"
  description="For a growing fleet"
  features={['10 hosts', 'Approvals & rollback', 'Priority support']}
  featured
  badge="Most popular"
  ctaLabel="Upgrade to Pro"
  onCtaClick={() => {}}
/>
```

Props: `name`, `price`, `period?`, `description?`, `features?: ReactNode[]`, `ctaLabel?`, `onCtaClick?`, `featured?`, `badge?`.

## Charts

Palette-aware SVG charts — no Recharts dependency. Yellow is the highlight series; grid and axis use `--chart-grid` and `--chart-axis` from `globals.css`.

`TimeSeriesChart` (≥ 4.8.42), plus `AreaChart` and `Sparkline` when `interactive` is set (≥ 4.8.43), share one keyboard contract: Tab focuses the chart and activates the latest point, ←/→ step through points (crosshair + tooltip follow), Home/End jump to the ends, Esc clears — consumed only while a point is active, so a surrounding Dialog stays open. Keyboard selections are announced to screen readers via a polite live region. See `KEYBOARD.md` → Charts for the full contract.

`AreaChart` (≥ 4.8.43): `data` accepts `number[]` or labeled `{ value, label }[]`; `interactive`, `formatValue`, `renderTooltip(point, index, formattedValue)`, and `ariaLabel` enable the interactive layer. Static usage stays a decorative `aria-hidden` graphic. `Sparkline` interactive mode adds `formatValue` and `ariaLabel` for its tooltip and announcements.

`BarChart` (≥ 4.8.44): `interactive` makes each bar a focusable target (roving tabindex — Tab, ←/→, Home/End, Esc) with a tooltip and a per-bar aria-label; `formatValue` formats visible values, tooltips, and labels; `ariaLabel` names the group. `Donut` (≥ 4.8.44): `segment.color` optional — omitted colors cycle `CHART_SERIES_COLORS` (`--chart-1…5`); `interactive` makes legend rows focusable, highlighting the matching arc, dimming the rest, and revealing the raw value. Both move real focus, so screen readers announce bars/rows natively.

```tsx
import {
  TimeSeriesChart,
  TIME_SERIES_PRESETS,
  BarChart,
  AreaChart,
  Donut,
  Sparkline,
} from '@tollerud/ui'
```

### Choosing a chart

| Component | Use when |
|-----------|----------|
| `TimeSeriesChart` | Wide price/metric history — stepped curves, hover tooltip, range selector |
| `Sparkline` | Inline trends in tables, stat rows, or cards |
| `AreaChart` | Simple static trend — no interaction |
| `BarChart` | Category comparison with one highlighted bar |
| `Donut` | Part-to-whole breakdown with legend |

### TimeSeriesChart

Interactive wide SVG chart for `{ date, value }` points. Use `curve="step"` when values hold between samples (prices, discrete readings).

```tsx
<TimeSeriesChart
  data={[
    { date: '2026-03-23', value: 13999, label: 'Bygghjemme', meta: ['Vaskemaskin Miele WWR860'] },
    { date: '2026-04-06', value: 14250, label: 'Obs BYGG' },
    { date: '2026-04-13', value: 13800, label: 'Maxbo' },
  ]}
  curve="step"
  height={300}
  ranges={TIME_SERIES_PRESETS}
  range="3m"
/>
```

Controlled range (parent owns filter state):

```tsx
'use client'
import { useState } from 'react'
import { TimeSeriesChart, TIME_SERIES_PRESETS } from '@tollerud/ui'

export function PriceChart({ data }) {
  const [range, setRange] = useState('3m')

  return (
    <TimeSeriesChart
      data={data}
      curve="step"
      height={300}
      ranges={TIME_SERIES_PRESETS}
      range={range}
      onRangeChange={setRange}
      toolbarLeft={<span className="text-sm text-tollerud-text-secondary">Price history</span>}
    />
  )
}
```

Custom ranges — `TIME_SERIES_PRESETS` ships English labels. For Norwegian UI, pass custom `ranges` and `locale="nb-NO"`:

```tsx
const MS_DAY = 24 * 60 * 60 * 1000

const RANGES_NB = [
  { value: '3m', label: '3 mnd', durationMs: 90 * MS_DAY },
  { value: '6m', label: '6 mnd', durationMs: 180 * MS_DAY },
  { value: '1y', label: '1 år', durationMs: 365 * MS_DAY },
  { value: '2y', label: '2 år', durationMs: 730 * MS_DAY },
  { value: 'all', label: 'Alt' },
]

<TimeSeriesChart
  data={points}
  curve="step"
  ranges={RANGES_NB}
  range="3m"
  locale="nb-NO"
/>
```

`formatValue` formats Y-axis ticks, the latest-value badge, and the default tooltip — independent of `locale` (dates only). For simple affixes without a callback, use `valuePrefix` / `valueSuffix` (ignored when `formatValue` is set):

```tsx
<TimeSeriesChart data={points} locale="nb-NO" valueSuffix=" kr/l" />
```

Use `formatChartDecimal` for decimal unit rates like kr/l:

```tsx
import { TimeSeriesChart, formatChartDecimal } from '@tollerud/ui'

<TimeSeriesChart
  data={points}
  curve="step"
  locale="nb-NO"
  formatValue={(value) => formatChartDecimal(value, 'nb-NO', { suffix: ' kr/l' })}
/>
```

Custom tooltip — `renderTooltip` receives the pre-formatted value as a third argument (from `formatValue` or locale defaults):

```tsx
<TimeSeriesChart
  data={points}
  curve="step"
  renderTooltip={(point, _index, formattedValue) => (
    <div>
      <div>{formattedValue}</div>
      <div>{point.label}</div>
    </div>
  )}
/>
```

Props: `data: TimeSeriesPoint[]`, `curve?: 'linear' | 'step'`, `height?`, `yAxis?: 'left' | 'right' | 'none'`, `ranges?`, `range?`, `onRangeChange?`, `toolbarLeft?`, `formatValue?: (value: number) => string`, `valuePrefix?`, `valueSuffix?`, `formatDate?`, `formatAxisDate?`, `renderTooltip?: (point, index, formattedValue) => ReactNode`, `showLatestValue?`, `locale?` (default `en-US`), `emptyMessage?`, `ariaLabel?`.

Types: `TimeSeriesPoint` — `{ date: Date | string | number; value: number; label?; meta? }`. `TIME_SERIES_PRESETS` — English 3 mo · 6 mo · 1 yr · 2 yr · All.

### Sparkline

```tsx
<Sparkline data={[12, 18, 14, 22, 19]} width={84} height={26} color="var(--tollerud-yellow-warm, #E8D500)" />

<Sparkline
  data={[17200, 16800, 17100, 16500, 13999]}
  curve="step"
  fill
  interactive
  width={160}
  height={36}
/>
```

Props: `data: number[]`, `width?`, `height?`, `color?`, `curve?: 'linear' | 'step'`, `fill?`, `interactive?`. Deprecated aliases: `w`, `h`.

The SVG is clipped to its viewBox so stroke bleed from `strokeLinecap="round"` does not escape the element at small sizes (e.g. `84×26` with `curve="step"` and `fill`).

### AreaChart

```tsx
<AreaChart data={[28, 35, 30, 44, 52, 48, 63]} height={150} />
```

Props: `data: number[]`, `height?`. Static gradient fill + point markers — no hover.

### BarChart

```tsx
<BarChart
  data={[
    { label: 'Mon', value: 32 },
    { label: 'Tue', value: 48 },
    { label: 'Thu', value: 67, accent: true },
  ]}
  height={180}
/>
```

Props: `data: { label, value, accent? }[]`, `height?`. `accent: true` paints a bar yellow.

### Donut

```tsx
<Donut
  segments={[
    { label: 'emma', value: 45, color: '#E8D500' },
    { label: 'pia', value: 25, color: '#FFB800' },
    { label: 'iris', value: 18, color: '#666666' },
    { label: 'free', value: 12, color: '#333333' },
  ]}
  size={160}
/>
```

Props: `segments: { label, value, color }[]`, `size?`. Renders a legend with percentages.

Live demos and prop tables: [design.tollerud.dev/charts](https://design.tollerud.dev/charts/).

## Marketing blocks

Full-width page sections used on the Blocks page.

```tsx
import { HeroBlock, FeatureCard, CTABand, PromoSection } from '@tollerud/ui'
```

### HeroBlock

A landing hero on the noir glow background. Single-column by default; pass `media` for a two-column layout with a right-hand visual.

```tsx
<HeroBlock eyebrow="homelab control plane" title="Run your stack like production."
  description="Deploy, monitor and roll back from one keyboard-first console."
  actions={<Button variant="terminal">deploy --free</Button>}
  media={<img src="tia.png" alt="" />} />
```

Props: `eyebrow` (pill text), `title`, `description`, `actions`, `media` (optional right column), `minHeight`, `intense`. Pass `intense` to render `NoirGlowBackground` with loud intensity (requires `@paper-design/shaders-react` in the host app).

### FeatureCard

Icon chip + title + copy. Drop several into a `.ds-grid-3`.

```tsx
<FeatureCard icon="zap" title="Instant deploys"
  description="Push a compose file and watch it roll out with health checks." />
```

Props: `icon` (`ReactNode` — pass a Lucide icon or custom element), `title`, `description`.

### CTABand

A centered closing call-to-action with an optional accent bar.

```tsx
<CTABand title="Ship your homelab like it matters."
  description="Free for one host. No card, no telemetry, no nonsense."
  actions={<><Button variant="primary" size="lg">Get started</Button></>} />
```

Props: `title`, `description`, `actions`, `accentBar` (default true).

### PromoSection

Two-column marketing block — text on one side, any visual on the other. Collapses to single column on narrow viewports. Without a `visual` prop, renders as a centered single-column block.

```tsx
<PromoSection
  eyebrow="Prismatrise"
  title="Se hva dine favorittøl koster på dine butikker"
  description="Opprett en gratis konto og følg prisene på dine favorittøl."
  actions={<Button variant="primary">Opprett konto</Button>}
  visual={<AppPreview />}
  visualPlacement="right"
  background="raised"
  textWidth="wide"
/>
```

| Prop | Type | Default | Notes |
|------|------|---------|-------|
| `eyebrow` | `ReactNode` | — | Mono uppercase yellow label, same style as `PageHeader` |
| `title` | `ReactNode` | required | Main heading |
| `shimmer` | `string \| string[]` | — | Accents one or more words/phrases in `title` with shimmer. Array entries each get their own span; overlapping/non-matching entries are skipped. |
| `description` | `ReactNode` | — | Body paragraph |
| `actions` | `ReactNode` | — | Slot for buttons / links |
| `visual` | `ReactNode` | — | Any preview, image, or graphic |
| `visualPlacement` | `"right" \| "left"` | `"right"` | Which side the visual goes on |
| `background` | `"default" \| "raised"` | `"default"` | `"raised"` adds `bg-noir-900` + `border-y` for full-bleed edge-to-edge |
| `textWidth` | `"narrow" \| "balanced" \| "wide"` | `"wide"` | Column ratio (`1fr/1.4fr`, `1fr/1fr`, `1.4fr/1fr`) |
| `contentWidth` | `"sm" \| "md" \| "lg" \| "xl" \| "full"` | `"xl"` | Max-width of the inner content; wrapper can be full-bleed |

## Navigation primitives

### Breadcrumb

```tsx
<Breadcrumb items={[
  { label: 'Servers', href: '/servers' },
  { label: 'Embla' },
]} />
```

Props: `items: { label, href?, onClick? }[]`, `separator?`.

### Pagination

```tsx
<Pagination page={page} pageCount={20} onChange={setPage} siblingCount={1} />
```

Props: `page` (1-indexed), `pageCount`, `onChange`, `siblingCount?`.

### Segmented

Toggle between views or filter modes. Segment height is fixed per size so text and icon `label` values align in the same row.

```tsx
<Segmented
  value={view}
  onChange={setView}
  options={[
    { value: 'grid', label: 'Grid' },
    { value: 'list', label: 'List' },
  ]}
  size="sm"
/>

<Segmented value={sort} onChange={setSort} options={SORTS} collapseMobile />
```

Props: `options: { value, label, disabled? }[]`, `value`, `onChange`, `size?: 'sm' | 'md'`, `collapseMobile?`.

With `collapseMobile`, viewports below Tailwind `md` show only the selected option until tapped; options open in a dropdown overlay and collapse again after a selection. Desktop behavior is unchanged.

### Accordion

```tsx
<Accordion defaultOpen="faq-1">
  <AccordionItem value="faq-1">
    <AccordionTrigger>What is Tia?</AccordionTrigger>
    <AccordionContent>An infrastructure assistant for homelabs.</AccordionContent>
  </AccordionItem>
</Accordion>
```

Props: `multiple?`, `defaultOpen?: string | string[]`. Items use `value` to identify panels.

## Advanced forms

### TagInput

Chip-style multi-value input. Enter or comma to add; Backspace removes the last tag.

```tsx
<TagInput label="Tags" value={tags} onChange={setTags} placeholder="Add tag…" max={10} />
```

### Slider

```tsx
<Slider label="Alert threshold" showValue value={threshold} onChange={setThreshold} min={0} max={100} />
```

### DatePicker

```tsx
<DatePicker label="Schedule deployment" value={date} onChange={setDate} placeholder="Pick a date" />
```

### FileUpload

```tsx
<FileUpload label="Upload config" accept=".yaml,.json" multiple onFilesChange={handleFiles} />
<FileUpload clickLabel="Klikk for å laste opp" dragLabel="eller dra og slipp" description="PDF · maks 10 MB" />
```

## Visual & branding

### Monogram

Inline SVG brand mark. Never recolor outside the Tollerud UI rules.

```tsx
<Monogram className="h-5 w-auto" color="yellow" />
```

Props: `color?: 'yellow' | 'black' | 'white'`, plus standard SVG attributes.

### GlowCard

Mouse-tracked glow border card.

```tsx
<GlowCard glowColor="#FFFF00" intensity={0.4}>
  <p>Interactive surface</p>
</GlowCard>
```

### BentoDashboard

Composed dashboard shell — pass arrays of host stats, services, incidents, and backup jobs.

```tsx
<BentoDashboard hosts={[…]} stats={[…]} services={[…]} incidents={[…]} backups={[…]} />
```

See [PROPS.generated.md](PROPS.generated.md) for the full `BentoDashboardProps` shape.

### Progress

```tsx
<Progress value={65} />
```

Determinate progress bar (Radix-based). Use inside panels, upload flows, or deploy wizards.

## Overlays & feedback

### Toaster (Sonner)

Sonner-based toast renderer. Mount once near the app root; trigger toasts with `toast()` from `sonner`.

```tsx
import { Toaster } from '@tollerud/ui'
import { toast } from 'sonner'

<Toaster theme="dark" />
toast.success('Deployed', { description: 'hermes v2.0 is live' })
```

Props: extends Sonner `ToasterProps` plus `theme?: 'light' | 'dark' | 'system'` (default `dark`).

### ToastProvider / useToast

Context-based toasts with the Tollerud stack UI (alternative to Sonner).

```tsx
import { ToastProvider, useToast } from '@tollerud/ui'

// app root
<ToastProvider>{children}</ToastProvider>

// client component
const toast = useToast()
toast({ tone: 'success', title: 'Deployed', message: 'hermes v2.0 is live' })
```

Tones: `success` · `error` · `info` · `accent`. `title` required; `message` and `duration` (default 3800ms) optional.

### Drawer

Controlled slide-over panel — simpler API than composing `Sheet` manually.

```tsx
<Drawer open={open} onClose={() => setOpen(false)} side="right" title="Host details"
  description="emma.tollerud.no"
  footer={<Button variant="primary" size="sm">Connect</Button>}>
  …content…
</Drawer>
```

Props: `open`, `onClose`, `side` (`right` | `left`), `title`, `description`, `children`, `footer`, `width` (default 380).

### Sheet

Radix-based slide-over panel for detail views and forms. Use the compound API (`SheetTrigger`, `SheetContent`, etc.).

```tsx
<Sheet>
  <SheetTrigger asChild>
    <Button variant="ghost" size="sm">Host details</Button>
  </SheetTrigger>
  <SheetContent side="right">
    <SheetHeader>
      <SheetTitle>emma.tollerud.no</SheetTitle>
      <SheetDescription>SSH · 4 containers</SheetDescription>
    </SheetHeader>
    …content…
    <Button variant="primary" size="sm">Connect</Button>
  </SheetContent>
</Sheet>
```

`SheetContent` accepts `side?: 'left' | 'right'`. Closes on Esc or overlay click. Prefer **`Drawer`** when you want a single controlled component with `open` / `onClose` / `footer`.

Overlay and panel animate with `framer-motion` (≥ 4.18.0) using the repo's `--motion-duration-*`/`--motion-ease-*` timings, and respect `prefers-reduced-motion`. Closing unmounts asynchronously once the exit transition finishes — code that reads the DOM immediately after closing should wait for removal.

### Sidebar

Collapsible sidebar primitive family (≥ 5.0.0, replaces the removed `SidebarNav`). Shadcn-style composition: `SidebarProvider` owns state and exposes `--sidebar-width`/`--sidebar-width-icon`/`--sidebar-width-mobile` CSS vars; `Sidebar` renders a sticky `<aside>` on desktop and a `Sheet`-based off-canvas panel on mobile (via the existing `lib/use-mobile.ts`).

```tsx
<SidebarProvider>
  <Sidebar collapsible="icon">
    <SidebarHeader>
      <Monogram color="yellow" className="h-[26px] w-auto" />
      <span>Project</span>
    </SidebarHeader>
    <SidebarContent>
      <SidebarGroup>
        <SidebarGroupLabel>Servers</SidebarGroupLabel>
        <SidebarGroupContent>
          <SidebarMenu>
            <SidebarMenuItem>
              <SidebarMenuButton asChild isActive icon={<Icons.server size={15} />} tooltip="Emma">
                <Link href="/hosts/emma">Emma</Link>
              </SidebarMenuButton>
            </SidebarMenuItem>
          </SidebarMenu>
        </SidebarGroupContent>
      </SidebarGroup>
    </SidebarContent>
  </Sidebar>
  <SidebarInset>
    <DashboardTopBar projectName="Project" menuTrigger={<SidebarTrigger className="lg:hidden" />} />
    {/* page content */}
  </SidebarInset>
</SidebarProvider>
```

`collapsible`: `'offcanvas'` (default — collapses to width 0), `'icon'` (collapses to icon-only rail, `SidebarMenuButton`'s label becomes `sr-only` and `tooltip` shows on hover/focus), or `'none'` (always expanded). `SidebarTrigger` calls `toggleSidebar()` from `useSidebar()`; `Cmd/Ctrl+B` also toggles by default (`keyboardShortcut={false}` on `SidebarProvider` to disable). `SidebarMenuButton`/`SidebarMenuSubButton` accept `asChild` (via `@radix-ui/react-slot`, e.g. for `next/link`) and `isActive` (sets `aria-current="page"` + yellow accent). `SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton` render one level of nested links under a `SidebarMenuItem`.

`DashboardShell`'s `sidebarGroups`/`sidebarItems` props are unchanged — it builds this composition internally.

The desktop rail's height defaults to `100vh` via the `--sidebar-height` CSS custom property (≥ 5.0.1) so `position: sticky` works without being stretched by the surrounding flex row. Override it on any ancestor (e.g. `style={{ '--sidebar-height': '420px' }}` on `SidebarProvider` or `DashboardShell`) when embedding `Sidebar` in a shorter, bounded container — a real page shell never needs this.

### Dialog, Tooltip, Tabs, DropdownMenu

Standard Radix/shadcn composition patterns. All require a client boundary in the consuming file.

`DialogContent` accepts `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` (default `md`, `max-w-xl`). Header and footer render in bordered regions; body scrolls when content is tall. Prefer **`DialogPanel`** for controlled dialogs with `open` / `onClose` / `title` / `description` / `footer` (same ergonomics as `Drawer`).

```tsx
<Dialog open={open} onOpenChange={setOpen}>
  <DialogContent size="lg">
    <DialogHeader>
      <DialogTitle>Restart container</DialogTitle>
      <DialogDescription>This will interrupt active connections.</DialogDescription>
    </DialogHeader>
    <DialogBody>
      <Input label="Reason" />
    </DialogBody>
    <DialogFooter>
      <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="destructive">Restart</Button>
    </DialogFooter>
  </DialogContent>
</Dialog>

<DialogPanel
  open={open}
  onClose={() => setOpen(false)}
  size="md"
  title="Stop all containers?"
  description="This stops 4 running services."
  footer={<>
    <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
    <Button variant="destructive">Stop</Button>
  </>}
/>

<TooltipProvider>
  <Tooltip>
    <TooltipTrigger asChild><Button variant="ghost" size="sm">?</Button></TooltipTrigger>
    <TooltipContent>Uptime since last deploy</TooltipContent>
  </Tooltip>
</TooltipProvider>

<Tabs defaultValue="overview">
  <TabsList><TabsTrigger value="overview">Overview</TabsTrigger><TabsTrigger value="logs">Logs</TabsTrigger></TabsList>
  <TabsContent value="overview">…</TabsContent>
</Tabs>
```

### Alert

Inline status banner for persistent messages (not transient like toasts).

```tsx
<Alert tone="error" title="Connection failed" description="emma.tollerud.no timed out after 30s" />
```

Tones: `default` · `error` · `info` · `success` · `warning`.

### Combobox

Searchable single-select with keyboard navigation (↑/↓/Enter/Esc). Pass flat `options` or grouped `groups` with section titles in the dropdown.

```tsx
<Combobox label="Host" value={value} onChange={setValue}
  options={[{ value: 'emma', label: 'emma.tollerud.no' }]}
  placeholder="Search…" error={errors.host} />

<Combobox label="Target" value={target} onChange={setTarget}
  groups={[
    { label: 'Servers', options: hostOptions },
    { label: 'Actions', options: actionOptions },
  ]} />

// Let users add an option that doesn't exist yet
<Combobox label="Category" value={category} onChange={setCategory}
  options={categoryOptions}
  onCreateOption={(label) => createCategory(label)} />
```

Props: `options?: { value, label, disabled? }[]`, `groups?: { label, options }[]`, `value?`, `onChange?`, `label?`, `placeholder?`, `error?`, `filter?`, `searchPlacement?: 'trigger' | 'dropdown'`, `onCreateOption?: (label: string) => string | void`, `createOptionLabel?: (query: string) => string`.

`searchPlacement="dropdown"` turns the trigger into a Select-style button and moves the search input inside the popover — useful when the combobox sits next to other Select fields or when a cleaner trigger is preferred. The search input auto-focuses when the popover opens (fixed in 4.17.1 — earlier versions left focus on the trigger). On touch devices (≥ 4.8.53) the in-dropdown search field renders at ≥16px so iOS Safari does not auto-zoom on focus, and the popover no longer closes on the focus/zoom scroll that zoom emits — only a real touch drag dismisses it. Since 4.8.56 the popover is positioned by Floating UI (the same engine as Radix Popover / shadcn), so it flips, shifts, clamps its height, and stays glued to the trigger across scroll, resize, and mobile viewport changes (iOS keyboard/zoom/address bar). On touch it now stays open and repositions while scrolling rather than closing; outside-click and Escape still dismiss it.

`onCreateOption` (≥ 4.14.0) opts into a "create a new option" row — useful for fields like category pickers where the value a user wants may not exist yet. 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 rather than only on a true empty result. Selecting it calls `onCreateOption(label)`; return a string to use as the new option's value, or return nothing to use the typed label as the value. `createOptionLabel` customizes the row's text.

### Avatar / AvatarGroup

Stacked avatars with a `+N` overflow chip.

```tsx
<Avatar name="Mathias Tollerud" src="/avatar.jpg" size="md" />
<AvatarGroup max={3} size="md">
  <Avatar name="Emma" />
  <Avatar name="Iris" />
  <Avatar name="Pia" />
  <Avatar name="Victoria" />
</AvatarGroup>
```

`Avatar`: `src?`, `name?` (derives initials), `fallback?`, `size?: 'sm' | 'md' | 'lg' | number`. `AvatarGroup`: `max?`, `size?`, `children` (`<Avatar>` elements).

## Elevation & density

**Shadow scale** — four theme-aware tiers (deep + low-spread in dark, soft in light): `--shadow-sm` `--shadow-md` `--shadow-lg` `--shadow-xl`, plus `--shadow-glow` for the yellow interaction glow. Drawers use `--shadow-xl`, popovers `--shadow-lg`. Lean on borders first; reach for a shadow only to lift overlays off the page.

**Density** — set `data-density="compact"` on any container to tighten cards, table rows, form rows, panel headers and buttons inside it, without changing the components. Default is comfortable.

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

## Empty

Compound empty-state layout for tables, cards, and panels with no data yet.

```tsx
<Empty>
  <EmptyHeader>
    <EmptyIcon><ServerIcon className="h-6 w-6" /></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>
```

Exports: `Empty`, `EmptyHeader`, `EmptyIcon`, `EmptyTitle`, `EmptyDescription`, `EmptyContent` — each accepts standard HTML attributes + `className`.

### EmptyState

Prop-driven empty state with built-in icon names — quicker than composing `Empty` by hand.

```tsx
<EmptyState
  icon="server"
  title="No hosts connected"
  description="Connect your first machine and Tia will start watching it."
  action={<Button variant="primary" size="sm">Connect a host</Button>}
/>
<EmptyState compact accent icon="checkCircle" title="All clear" description="No open alerts." />
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `icon` | `string \| ReactNode` | `'folder'` | Built-in names: `folder`, `server`, `search`, `alert`, `bell`, `checkCircle`, `rocket` — or pass a custom icon element |
| `title` | `ReactNode` | — | Headline |
| `description` | `ReactNode` | — | Supporting copy |
| `action` | `ReactNode` | — | Primary action |
| `secondaryAction` | `ReactNode` | — | Optional second action |
| `compact` | `boolean` | `false` | Tighter padding |
| `accent` | `boolean` | `false` | Yellow-tinted surface |

CSS classes: `.tollerud-empty`, `.tollerud-empty__icon`, `.tollerud-empty__title`, `.tollerud-empty__desc`.

## DataTable

Config-driven table with optional search, segmented filter, selection, bulk actions, row menus, and pagination. Owns sort / filter / selection / pagination state internally.

```tsx
<DataTable
  data={hosts}
  rowKey="id"
  columns={[
    { key: 'hostname', label: 'Host', sortable: true, render: (_v, row) => <span>{row.hostname}</span> },
    { key: 'status', label: 'Status', sortable: true, render: (_v, row) => (
      <Badge variant={row.status === 'online' ? 'success' : 'error'}>{row.status}</Badge>
    )},
    { key: 'cpu', label: 'CPU', align: 'right', sortable: true },
  ]}
  searchable
  searchKeys={['hostname', 'ip']}
  searchPlaceholder="Search host, ip…"
  filter={{ key: 'region', allLabel: 'All regions' }}
  selectable
  pageSize={10}
  toolbarRight={<Button variant="terminal" size="sm">add_host</Button>}
  bulkActions={[
    { label: 'Restart', variant: 'ghost', onRun: (ids, clear) => { clear() } },
    { label: 'Stop', variant: 'destructive', onRun: (ids, clear) => clear() },
  ]}
  rowMenu={(row) => [
    { label: 'Connect (SSH)', onSelect: () => {} },
    { sep: true },
    { label: 'Stop host', onSelect: () => {} },
  ]}
  emptyState={
    <Empty>
      <EmptyHeader>
        <EmptyTitle>No hosts found</EmptyTitle>
        <EmptyDescription>Try clearing your filters.</EmptyDescription>
      </EmptyHeader>
    </Empty>
  }
/>
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` / `rows` | `object[]` | `[]` | Row data (`data` preferred; `rows` is an alias). |
| `rowKey` | `keyof T \| fn` | `row.id` / `row.key` | Unique key + selection id. |
| `columns` | `Column[]` | `[]` | Column spec — see below. |
| `onRowClick` | `(row) => void` | — | Row click handler. |
| `emptyMessage` | `string` | `'No data'` | Plain-text fallback when `emptyState` is not set. |
| `searchable` | `boolean` | `false` | Show the search input. |
| `searchKeys` | `string[]` | column keys | Fields the search matches. |
| `searchPlaceholder` | `string` | `'Search…'` | Search input placeholder. |
| `filter` | `{ key, options?, allLabel?, variant?: 'segmented' \| 'combobox', placeholder? }` | — | Column filter in rich mode. Default `variant` is `segmented`; use `combobox` for a searchable dropdown. |
| `selectable` | `boolean` | `false` | Row checkboxes + select-all. |
| `pageSize` | `number` | — | Rows per page (fixed unless `pageSizeOptions` is set). Enables footer row count and `Pagination` when `pageCount > 1`. |
| `pageSizeOptions` | `number[]` | — | Footer **Rows** `Select` so users can change page size; resets to page 1 on change. Initial value from `pageSize` or first option. |
| `bulkActions` | `BulkAction[]` | `[]` | Shown when rows are selected; fused with `ButtonGroup` when 2+ actions. `onRun(ids, clear)`. |
| `rowMenu` | `(row) => MenuItem[]` | — | Per-row ⋮ dropdown. |
| `toolbarRight` | `ReactNode` | — | Toolbar right slot (e.g. add button). |
| `emptyState` | `ReactNode` | — | Custom empty UI (use `<Empty>…</Empty>`). |
| `loading` | `boolean` | `false` | Skeleton rows instead of data. |
| `skeletonRows` | `number` | `5` | Skeleton row count while loading. |
| `striped` | `boolean` | `false` | Alternating row backgrounds in rich mode. |
| `pinColumns` | `boolean` | rich mode default | Pin first column and row ⋮ menu during horizontal scroll. |
| `footer` | `ReactNode` | — | Extra content in the footer bar (right of row count). |

**Pagination** — fully client-side; no `page` / `onPageChange` props. Pass `pageSize` (and optionally `pageSizeOptions`). The footer shows `Showing 1–5 of 12`. Page controls appear when `pageCount > 1`. Search and rich-mode filters reset to page 1. With `selectable`, checked rows persist across pages until cleared or bulk action runs `clear()`.

**Column:** `{ key, label?, header?, sortable?, filterable?, align?: 'left' | 'center' | 'right', width?, render?: (value, row) => ReactNode }`. Without `render`, `row[key]` is shown. `header` is an alias for `label`.

**`render` signature (breaking in 4.8.40)** — `render` is always `(value, row) => …`; `value` is `row[key]`. The old single-parameter `(row) => …` form (auto-detected from `fn.length`) was removed because default/rest parameters made detection unreliable. **Migration:** prepend `_v, ` to row-only callbacks — `render: (row) => row.name` becomes `render: (_v, row) => row.name`. TypeScript flags un-migrated callbacks (a typed row parameter is not assignable to `unknown`); in plain JS the callback silently receives the cell value instead of the row.

**Accessibility (≥ 4.8.40)** — sortable headers render a real `<button>` inside the `<th>` (Tab + Enter/Space sorts; `aria-sort` reflects state). With `selectable`, provide stable row keys (`id`/`key` field or `rowKey`) — index-fallback keys collide across pages and break selection state.

## Table

New in v4.16.0. Plain static table primitives — no sorting, filtering, search, or pagination, and no internal state at all. Use `DataTable` above when you need that interactivity; use `Table` for a fixed comparison table, a summary grid, or any small dataset that doesn't need a toolbar.

```tsx
<Table>
  <TableCaption>Skalert for 14 gjester</TableCaption>
  <TableHeader>
    <TableRow>
      <TableHead>Ingrediens</TableHead>
      <TableHead align="right">For 8</TableHead>
      <TableHead align="right">For 14 (i dag)</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>
    <TableRow>
      <TableCell>Sitron</TableCell>
      <TableCell align="right">4 stk</TableCell>
      <TableCell align="right" tone="accent">7,0 stk</TableCell>
    </TableRow>
  </TableBody>
</Table>
```

| Part | Props |
|------|-------|
| `Table` | Standard `<table>` attributes. Wraps in a `overflow-x-auto` div for narrow viewports. |
| `TableHeader` / `TableBody` / `TableFooter` | Standard `<thead>`/`<tbody>`/`<tfoot>` attributes. |
| `TableRow` | `highlight?: boolean` — tints the row, e.g. the item that triggered a custom override. |
| `TableHead` | `align?: 'left' \| 'center' \| 'right'`. |
| `TableCell` | `align?: 'left' \| 'center' \| 'right'`, `tone?: 'success' \| 'error' \| 'warning' \| 'info' \| 'accent'` — colors the value to call out a computed or changed figure, same tone vocabulary as `StatCard` and `Meter`. |
| `TableCaption` | Standard `<caption>` attributes. |

## Skeleton

```tsx
<Skeleton className="h-4 w-48" />
<Skeleton className="h-48 w-full" />
```

Variants: `variant?: 'default' | 'circular' | 'text'`. CSS class: `.tollerud-skeleton`.

## Grid Background

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

## Display Heading

```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>
<h3 class="tollerud-display--tertiary text-[28px]">Subtle hierarchy</h3>
```

## Display shimmer

Animated yellow gradient clipped to text — for hero accent lines and key metrics on **dark surfaces**. Respects `prefers-reduced-motion` (falls back to static `var(--primary)` / `var(--tollerud-yellow)`).

```html
<span class="tollerud-display-shimmer">Yellow where it counts.</span>
```

Pair with `.tollerud-display` on the parent heading. Not for body copy or light backgrounds (yellow fails contrast on white).

## Container

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

## Textarea

Multiline text input with label and error state.

```tsx
<Textarea
  id="description"
  label="Description"
  placeholder="Enter details..."
  rows={4}
/>
<Textarea
  label="Bio"
  value="..."
  error="Max 500 characters"
  onChange={...}
/>
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | — | Label text |
| `error` | `string` | — | Error message, shows error styling |
| All native `<textarea>` props | — | — | rows, cols, placeholder, etc. |

## Select

Styled native `<select>` dropdown with custom chevron.

```tsx
<Select
  label="Server"
  placeholder="Select a server"
  options={[
    { value: 'emma', label: 'Emma' },
    { value: 'miriam', label: 'Miriam' },
    { value: 'iris', label: 'Iris' },
  ]}
/>
<Select label="Status" error="Required">
  <option value="running">Running</option>
  <option value="stopped">Stopped</option>
</Select>
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | — | Label text |
| `error` | `string` | — | Error message |
| `placeholder` | `string` | — | Disabled placeholder option |
| `options` | `{value, label}[]` | — | Option items (alternative to children) |
| All native `<select>` props | — | — | value, onChange, disabled, etc. |

## Checkbox

Custom-styled checkbox with checkmark SVG and label.

```tsx
<Checkbox label="Enable backups" checked={...} onChange={...} />
<Checkbox label="Send alerts" defaultChecked />
<Checkbox label="Action" disabled />
<Checkbox label="Select all" indeterminate onChange={...} />
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | — | Label text (wraps input) |
| `indeterminate` | `boolean` | `false` | Mixed state (≥ 4.8.40) — dash indicator + native `indeterminate` property (announced as "mixed"). Use for select-all with partial selection. |
| All `<input type="checkbox">` props | — | — | checked, defaultChecked, disabled, onChange |

## Switch

Toggle switch with `role="switch"` accessibility.

```tsx
<Switch label="Dark mode" checked={...} onChange={...} />
<Switch label="Notifications" defaultChecked />
<Switch label="Beta features" disabled />
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `label` | `string` | — | Label text |
| All `<input type="checkbox">` props | — | — | checked, defaultChecked, disabled, onChange |

## RadioGroup / Radio

Fieldset-based radio group with custom dot indicator.

```tsx
<RadioGroup label="Deployment target" error={error}>
  <Radio value="staging" label="Staging" name="target" />
  <Radio value="production" label="Production" name="target" />
  <Radio value="canary" label="Canary" disabled name="target" />
</RadioGroup>
```

| RadioGroup Prop | Type | Default | Description |
|-----------------|------|---------|-------------|
| `label` | `string` | — | Group label (rendered as `<legend>`) |
| `error` | `string` | — | Error message |

| Radio Prop | Type | Default | Description |
|------------|------|---------|-------------|
| `label` | `string` | — | Radio label |
| All `<input type="radio">` props | — | — | value, checked, name, disabled |

## Footer

Footer bar with Tollerud monogram and branding link. The component is re-exported from [`@tollerud/footer`](https://www.npmjs.com/package/@tollerud/footer) — install that package directly if you need the footer without the full Tollerud UI package.

```tsx
<Footer />
<Footer layout="row" />
<Footer accent />
<Footer
  labels={{
    tollerudProject: 'A Tollerud Project',
    allRightsReserved: 'All rights reserved.',
  }}
  unstyled
  className="border-t border-tollerud-yellow/20 bg-black"
/>
```

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `layout` | `'responsive' \| 'row'` | `'responsive'` | responsive = mobile-first stacking, row = horizontal |
| `accent` | `boolean` | `false` | Yellow-tinted surface |
| `unstyled` | `boolean` | `false` | Skip all surface styling |
| `labels` | `Partial<FooterLabels>` | — | tollerudProject, attribution, allRightsReserved |
| `className` | `string` | — | Footer element |
| `style` | `CSSProperties` | — | Footer element inline styles |
| `classNameInner` | `string` | — | Inner wrapper |
| `classNameLogo` | `string` | — | SVG monogram |
| `classNameText` | `string` | — | Text paragraph |
| `classNameLink` | `string` | — | tollerud.no link |

## State matrix

| Component | Default | Hover | Focus | Active/Check | Disabled | Error | Loading |
|-----------|---------|-------|-------|-------------|----------|-------|---------|
| Button | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ (disabled) |
| Input | ✅ | — | ✅ | — | ✅ | ✅ | — |
| Textarea | ✅ | — | ✅ | — | ✅ | ✅ | — |
| Select | ✅ | — | ✅ | — | ✅ | ✅ | — |
| Checkbox | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
| Switch | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
| Radio | ✅ | ✅ | ✅ | ✅ | ✅ | — | — |
| Badge | ✅ | — | — | — | — | — | — |
| Card | ✅ | ✅ | — | — | — | — | — |
| StatusDot | ✅ | — | — | — | — | — | — |
| Footer | ✅ | — | — | — | — | — | — |

## @tollerud/email — HTML email

HTML email is a **separate render target** from the browser (table layout, inline-only styles, no CSS variables), so it ships as its own package — `@tollerud/email` — rather than through the `@tollerud/ui` exports above. It shares Tollerud's *design tokens*, not the web components: token values are inlined as literals via the generated `lib/tokens.ts`, because `var(--tollerud-*)` does not resolve in mail clients.

Built on [React Email](https://react.email/). Each project composes its own templates from the shared primitives.

```tsx
import { render, WelcomeEmail } from '@tollerud/email'

const html = await render(
  <WelcomeEmail
    name="Mathias"
    productName="Graphify"
    ctaUrl="https://app.example.com/dashboard"
    footer={{ brandName: 'Tollerud', address: 'Oslo, Norway', unsubscribeUrl }}
  />,
)
// hand `html` to your mailer (Resend, SES, Nodemailer, …)
```

**Primitives:** `EmailLayout`, `EmailHeader` (branded top: monogram + project name large — `productName`, `monogram`, `logoSrc`, `align`, `divider`), `BrandMark` (the monogram; inline SVG or hosted `src`), `EmailButton` (`variant: 'primary' | 'secondary'`), `EmailHeading` (`as: 'h1' | 'h2' | 'h3'`), `EmailText` (`tone: 'default' | 'muted' | 'fine'`), `EmailDivider` (`variant: 'default' | 'accent'`), `EmailFooter` (the Tollerud footer — monogram left, "A Tollerud Project." wordmark right-aligned with a 2px yellow underline → tollerud.no; `labels`, `address`, `unsubscribeUrl`, `links`).

**Templates:** `WelcomeEmail`, `VerifyEmail`, `PasswordResetEmail`, `ReceiptEmail` — each accepts an optional `header?: EmailHeaderProps` for the branded top, plus an overridable `copy` prop (each exports a `*Copy` type; dynamic lines are functions) for rewording or i18n.

**Configurability:** content + branding are fully prop-driven; template copy is overridable via `copy` (enables e.g. Norwegian); every primitive takes an optional `style` escape hatch (merged last, overrides the token defaults) for one-off tweaks. Visual design otherwise stays token-locked to the brand.

**Rendering:** `render`, `pretty`, `toPlainText` (re-exported from `@react-email/render`).

Theme: email is **light by default** (white card, dark text, yellow accent button) so it renders consistently everywhere including Gmail; a `@media (prefers-color-scheme: dark)` block restores the noir palette in Apple Mail / iOS Mail. The monogram is a **hosted PNG** (`BrandMark` — dark mark on light, yellow mark in dark; inline SVG was stripped by Gmail/Outlook); override with `logoSrc`. The `color` prop on `BrandMark`/`EmailHeader`/`EmailFooter` was removed in 4.13.0 (monogram color is auto-selected). See `packages/email/README.md` for client-support caveats.
