---
name: tollerud-ui
description: Use @tollerud/ui (Tollerud User Interface / Tollerud UI) correctly — components, props, Tailwind tokens, aesthetic rules, and known gotchas (Server Component imports, Button/Link composition). Trigger whenever a project imports from @tollerud/ui or @tollerud/footer, or when building UI that should match the Tollerud noir aesthetic.
---

# @tollerud/ui — Tollerud UI Skill

Dark, monochrome + single yellow-accent UI library ("noir" aesthetic). **v4.8.39** — Combobox dropdown search focus fully fixed inside Dialog (focusout capture-phase interception). This skill documents the package's **actual current exports** (verified against `components/index.ts` in the source repo) — not aspirational docs. If you see a component referenced elsewhere that isn't listed below, it does not exist yet; don't import it.

---

## Install & setup

```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
npm install @paper-design/shaders-react
```

As of **v4.0.1**, install is **npm-only** — `import { Button } from '@tollerud/ui'` or subpaths like `@tollerud/ui/button`. Copy-via-shadcn registry CLI is unsupported. As of **v4.0.0**, `@tollerud/ui/globals-v4.css` is removed — use `globals.css` only. Brand assets live at `@tollerud/ui/brand/*`. As of **v3.1.1**, `.tollerud-display-shimmer` ships in `globals.css` for animated hero accent text. As of **v3.1.0**, `Monogram` ships as an inline SVG component (`color`: `yellow` | `black` | `white`). As of **v3.0.0**, the package is **ESM-only** (no CJS `require` entry). As of **v2.0.0**, Radix, Lucide, Framer Motion, and Sonner are **required peers** (not bundled).

Apply the Tailwind preset when you need extra utilities from `@tollerud/ui/preset` — `globals.css` already includes tokens and component layers for v4:

```css
/* app/globals.css — Tailwind v4 (default) */
@import "@tollerud/ui/globals.css";
@import "@tollerud/ui/source.css";
```

As of **v1.4.0**, charts (`BarChart`, `AreaChart`, `Donut`, `Sparkline`) and marketing blocks (`HeroBlock`, `FeatureCard`, `CTABand`) ship in the package. As of **v1.3.0**, `@tollerud/ui/globals.css` is the Tailwind v4 entry. Tailwind colors are under `tollerud.*` only (`text-tollerud-yellow`, `bg-tollerud-surface-raised`, etc.) — not `tia-*`.

**Tailwind v3 legacy:** `@import "@tollerud/ui/globals-v3.css"` after preflight/utilities, with `tailwind.config.ts` preset + content paths.

**Subpath imports (tree-shaking):** `@tollerud/ui/button`, `@tollerud/ui/dialog`, `@tollerud/ui/utils`. The main `@tollerud/ui` barrel still works. Import `cn` from `@tollerud/ui` by default; use `@tollerud/ui/utils` only when tree-shaking without the barrel.

**Greenfield setup:** copy-paste agent prompts (new Next.js project, add to existing app, footer-only) live in [GETTING_STARTED.md](GETTING_STARTED.md) → Start with an AI agent. After agent setup, run `npx tollerud-ui-audit`.

---

## Consumer styling policy

Tailwind is part of `@tollerud/ui` on purpose. Use it freely **inside this package** to implement components, variants, layout primitives, focus states, tokens, and docs-only demos.

In consumer apps, use Tailwind as a small escape hatch, not as the primary design language. The default order is:

1. Use exported `@tollerud/ui` components.
2. Use exported `@tollerud/ui` layout primitives or screen patterns when available.
3. Use Tailwind only for small local glue, such as one-off margins, alignment, or responsive visibility.
4. If branded structure repeats, add a component to `@tollerud/ui` or create a local semantic feature component; do not rebuild a parallel design system with raw utility classes.

Allowed consumer Tailwind:

```tsx
<div className="mt-6">
  <Button variant="primary">Deploy</Button>
</div>
```

Discouraged consumer Tailwind:

```tsx
<button className="rounded-lg bg-yellow-400 px-4 py-2 text-black">
  Deploy
</button>
```

```tsx
<section className="min-h-screen bg-black px-6 py-24">
  <div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-3">
    {/* branded cards built by hand */}
  </div>
</section>
```

Consumer apps must import the package CSS for Tailwind v4:

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

`globals.css` provides Tailwind, tokens, and component layers. `source.css` makes Tailwind scan the installed package so utility classes used only inside `@tollerud/ui` are generated.

Use `cn` from `@tollerud/ui` or `@tollerud/ui/utils`; do not reimplement a local `cn()` helper in consumer projects.

---

## Critical gotchas (read before writing code)

### 1. Server Components — just import normally (≥ 1.0.8)
`@tollerud/ui` ships as a single bundled file marked `'use client'`. As of **v1.0.8**, importing anything from it — components, hooks, or plain helpers like `buttonVariants` and `cn` — works fine from a Server Component file; the import itself doesn't force your file to become a Client Component, since you're just pulling in already-client-bundled code or plain functions.
- **If you're on `< 1.0.8`, upgrade first** — older versions crash on any import from a Server Component (the bundle wasn't marked `'use client'`, despite containing hook-based components).

### 2. Tailwind v4 — Alert tone colors missing (`@source` path issue)

`Alert` uses raw Tailwind color classes (`bg-red-500/5`, `border-red-500/30`, `text-red-400`, etc.) for its `error`, `info`, and `success` tones. In Tailwind v4, if your `@source` path doesn't correctly resolve to `node_modules/@tollerud/ui/dist`, these classes are never scanned and the colors silently don't apply.

**Fix (≥ 1.1.4):** `globals.css` now ships these classes in `@layer utilities` so they're always present regardless of your `@source` config. Just make sure you're importing `globals.css`:

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

If you're on `< 1.1.4`, add this to your own CSS as a workaround:

```css
@layer utilities {
  .bg-red-500\/5   { background-color: rgb(239 68 68 / 0.05); }
  .bg-blue-500\/5  { background-color: rgb(59 130 246 / 0.05); }
  .bg-green-500\/5 { background-color: rgb(34 197 94 / 0.05); }
  .border-red-500\/30   { border-color: rgb(239 68 68 / 0.3); }
  .border-blue-500\/30  { border-color: rgb(59 130 246 / 0.3); }
  .border-green-500\/30 { border-color: rgb(34 197 94 / 0.3); }
  .text-red-400   { color: rgb(248 113 113); }
  .text-blue-400  { color: rgb(96 165 250); }
  .text-green-400 { color: rgb(74 222 128); }
}
```

### 3. `<Button>` only renders a native `<button>` — use `asChild` for links (≥ 1.0.7)
`Button` extends `ButtonHTMLAttributes<HTMLButtonElement>` and has no `href`. **Never nest `<a>` inside `<button>` or vice versa** — it's invalid HTML and breaks accessibility. Two ways to style a `<Link>`/`<a>` like a Button:

```tsx
// Option A — asChild (Radix Slot merges Button's classes/props onto the child)
import { Button } from '@tollerud/ui'
import Link from 'next/link'

<Button asChild variant="primary">
  <Link href="/deploy">Deploy</Link>
</Button>
```

```tsx
// Option B — buttonVariants() when wrapping is awkward
import { buttonVariants } from '@tollerud/ui'
import Link from 'next/link'

<Link href="/deploy" className={buttonVariants({ variant: 'primary', size: 'md' })}>
  Deploy
</Link>
```

For a real `<button>` (form submit, logout, toggle, dialog/menu trigger), just use `<Button>` directly — no `asChild` needed.

### 4. `cn` is exported — use it, don't reimplement
`@tollerud/ui` exports `cn` (clsx + tailwind-merge). Use it for conditional/merged class names instead of template strings or writing your own helper.

---

## Aesthetic rules (never violate)

- **Dark surfaces only.** Page background `#0A0A0A` / `bg-tollerud-noir-950`. Never white or light-gray backgrounds.
- **Yellow is for meaning, not decoration** — CTAs, focus rings, active states, key data points (`text-tollerud-yellow`, `#FFFF00`).
- **Never put yellow text on white** — contrast ratio is ~1.7:1, fails WCAG.
- **Borders over shadows.** Use `border-tollerud-noir-600` / `border-tollerud-noir-700` for separation; reserve shadows/glow for overlays (drawers, popovers, dialogs).
- **Strict monochrome + single accent.** No blues, greens, purples, or brand gradients — yellow is the only chromatic color (status colors like success/error/info exist as semantic exceptions inside `Badge`, `IncidentCard`, `Alert`, etc.).

## Color tokens

| Token | Value | Use |
|---|---|---|
| `tollerud-yellow` | `#FFFF00` | Accent, CTA, focus ring, 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 text / labels |
| `tollerud-text-muted` | `#666666` | Placeholders, hints |
| `tollerud-success` | `#22C55E` | Positive status text/border (up changes, success badges) |
| `tollerud-warning` | `#E8D500` | Caution status text/border |
| `tollerud-error` | `#EF4444` | Negative status text/border (down changes, destructive states) |
| `tollerud-info` | `#3B82F6` | Neutral/informational status text/border |

Used directly (`text-tollerud-success`, `border-tollerud-error/25`, etc.) in custom components — don't hardcode status hex like `#22C55E`. Also the `tone` vocabulary for `Badge`, `CardChange`, `StatCard`, `StatusDot`, and `Table`'s `TableCell`.

Standard focus ring (apply to every interactive element): `focus-visible:outline-2 focus-visible:outline-tollerud-yellow focus-visible:outline-offset-2`

## Motion tokens

Every transition/animation uses these tokens — never a raw `ms` value or a bare `transition-*` class with no duration.

| Token | Value | Tailwind class | Use |
|---|---|---|---|
| `--motion-duration-fast` | 150ms | `duration-fast` | Hover/focus color, border, opacity, small transforms — the default for interactive chrome |
| `--motion-duration-normal` | 250ms | `duration-normal` | Overlay enter/exit (Sheet, Dialog, TopNav menu, CommandMenu), sliding panels |
| `--motion-duration-slow` | 350ms | `duration-slow` | Larger/more deliberate movement: value-bar fills (Meter, Progress, PasswordStrength), decorative glow fades |
| `--motion-ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | `ease-out` | Entrances — element appearing or growing into place |
| `--motion-ease-in` | `cubic-bezier(0.7, 0, 0.84, 0)` | `ease-in` | Exits — element leaving or shrinking away |
| `--motion-ease-in-out` | `cubic-bezier(0.4, 0, 0.2, 1)` | `ease-in-out` | Default for anything that isn't a clear enter/exit (color/border transitions) |

The Tailwind preset maps `duration-fast/normal/slow` and `ease-out/in/in-out` to these exact values — always prefer the class over an arbitrary value (`duration-[150ms]`) or Tailwind's numeric scale (`duration-150`), both of which silently drift from the token if it ever changes.

In CSS (`globals-layers.css`), use `var(--motion-duration-*)` / `var(--motion-ease-*)` directly. `--transition-fast/normal/slow` also exist as shorthand aliases (`<duration> <easing>` in one var) — they resolve to the same tokens, not independent values.

In JS-driven animation (framer-motion, etc.) where CSS custom properties aren't available, import `motionDuration` / `motionEase` from `lib/motion.ts` — hand-mirrored constants of the same tokens (duration in seconds, easing as cubic-bezier arrays). Don't hardcode duration/easing literals in a `transition` prop.

**Documented exception:** chart value-transitions (`BarChart`, `Gauge` bar/arc fills on data update) use `duration-500 ease-out`, outside the fast/normal/slow scale. Data visualizations read better with a longer, self-consistent fill timing than UI chrome does — this is intentional, not drift.

---

## Component catalog (verified against actual exports)

Import everything as named exports from `@tollerud/ui`.

### Core / forms

```tsx
import {
  Button, buttonVariants, cn,
  Card, Badge, StatusDot, Kbd,
  Input, Textarea, Select, Checkbox, Switch, RadioGroup, Radio,
  PasswordInput, Combobox, TagInput, Slider, FormRow,
  Container, CodeBlock, StatCard, StructuredCard, ActionRow, CommandMenu,
} from '@tollerud/ui'
```

### Layout primitives

```tsx
import {
  PageShell, Section, Stack, Cluster,
  Grid, CardGrid, ScrollRail, Split, MainContent,
} from '@tollerud/ui'
```

**PageShell** — `as?: 'div' | 'main'`, `background?: 'plain' | 'grid' | 'glow'`, `density?: 'comfortable' | 'compact'`, `contentClassName?: string`. Full-page dark shell. The inner content wrapper is always `flex flex-col flex-1` so a `flex flex-col min-h-screen` outer shell correctly stretches content to fill the viewport. Use `contentClassName` to add extra classes to the inner wrapper. The root uses `overflow-clip` (≥ 4.8.41) so `position: sticky` works for all descendants — before 4.8.41, `overflow-hidden` silently disabled sticky for everything inside the shell.

**Section** — `as?: 'section' | 'div' | 'article' | 'header' | 'footer'`, `size?: 'sm' | 'md' | 'lg' | 'hero'`, `width?: 'narrow' | 'default' | 'wide' | 'full'`. Consistent vertical rhythm and width constraints.

**Stack** — vertical layout. `gap?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'`, `align?: 'start' | 'center' | 'end' | 'stretch'`.

**Cluster** — wrapping horizontal layout for actions, badges, and toolbars. `gap?: 'xs' | 'sm' | 'md' | 'lg'`, `align?`, `justify?: 'start' | 'center' | 'end' | 'between'`.

**Grid / CardGrid** — responsive grid primitives. `columns?: 1 | 2 | 3 | 4 | 'auto'` for `Grid`; `columns?: 2 | 3 | 4 | 'auto'` for `CardGrid`.

**ScrollRail** — horizontal scroll rail for card rows, image strips, and wide content (≥ 4.9.0). `visibleCount?: number` (≥ 4.9.2) — N items fill the row; extras scroll with viewport-relative sizing (no consumer `@container` hacks). `peek?: false | 'sm' | 'md' | 'lg'` (default `'md'`, applied only when overflowing), `gap?: 'xs' | 'sm' | 'md' | 'lg'`, `controls?: boolean | 'auto'`, `fadeEdges?: boolean` (default `true`), `itemWidth?: number | string` (fixed pixels; **`%` is track-relative** — use `visibleCount` for column layouts), `ariaLabel?: string`, `trackClassName?: string`. Scrollport exposes `--scroll-rail-gap`, `--scroll-rail-peek`, `--scroll-rail-peek-inset`.

**Split** — responsive two-column layout. `ratio?: 'equal' | 'content' | 'sidebar'`, `gap?: 'md' | 'lg' | 'xl'`, `align?: 'start' | 'center' | 'stretch'`, `reverse?: boolean`.

**MainContent** — `as?: 'main' | 'div'`, `width?: 'narrow' | 'default' | 'wide' | 'full'`, `spacing?: 'none' | 'sm' | 'md' | 'lg'`, `density?: 'comfortable' | 'compact'`.

```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
import {
  PageHeader, TopNav, TopNavAction, DashboardShell, SettingsLayout,
  FormPanel, ResourceList, DetailPage, EmptyPage,
  FeatureSection, StatsSection, AuthSplitLayout,
} 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,
  SidebarMenuAction, SidebarMenuBadge,
  SidebarMenuSub, SidebarMenuSubItem, SidebarMenuSubButton,
  useSidebar,
} from '@tollerud/ui'
```

Use these before rebuilding common pages with raw Tailwind:

- **PageHeader** — title block with `eyebrow`, `description`, `actions`, `meta`, `align`, `size`. Use `shimmer` (or `titleAccent`) to shimmer one word mid-sentence when `title` is a string — e.g. `title="Keep beer prices honest." shimmer="honest"`. Use `titleShimmer` for a second full shimmer line, or `PageHeaderShimmer` inside `title` for full control.
- **TopNav** — branded monogram lockup with `projectName`, `navItems`, `actions`, `sticky`, `maxWidth` (`default` | `wide` | `full` | `false`). Below `lg`, nav links and menu actions open in a modal overlay (backdrop, focus trap, Esc to close). `TopNavItem` accepts `icon?: ReactNode` (shown in dropdown/mobile rows, not the top bar) and `onClick?: () => void` (≥ 5.2.0 — renders the row as a `<button>` instead of a link, e.g. sign out; ignored if `href` is set). Wrap actions in `TopNavAction` with `mobile?: 'inline' | 'menu' | 'hidden'` (default `menu`) to keep a primary CTA inline next to the menu toggle. Use `mobileMenuSections?: TopNavSection[]` (≥ 5.1.0) for labeled row groups in the mobile sheet (e.g. an account section) — styled consistently, no hand-rolled dividers needed; sections collapse behind their label by default (≥ 5.4.0 — set `defaultOpen` to start expanded, or `collapsible: false` to always show). Use `userMenu?: TopNavUserMenu` (≥ 5.3.0 — `{ trigger, triggerLabel?, sections: TopNavSection[], placement?: 'start' | 'end' }`) for 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 — no separate desktop/mobile definitions to keep in sync. `placement` (≥ 5.6.0, default `'end'`) controls whether it renders before or after `actions` in the desktop cluster. Use `mobileMenuExtra?: ReactNode` for anything more custom, injected at the very bottom of the mobile sheet, separated by a divider.
- **Sidebar** (≥ 5.0.0, replaces `SidebarNav`) — `SidebarProvider`/`Sidebar`/`SidebarTrigger`/`SidebarInset`/`SidebarHeader`/`SidebarContent`/`SidebarFooter`/`SidebarGroup`/`SidebarGroupLabel`/`SidebarGroupContent`/`SidebarMenu`/`SidebarMenuItem`/`SidebarMenuButton`/`SidebarMenuAction`/`SidebarMenuBadge`/`SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`useSidebar`. Sticky `<aside>` on desktop, `Sheet`-based off-canvas on mobile. `collapsible?: 'offcanvas' | 'icon' | 'none'` (default `offcanvas`); `'icon'` collapses `SidebarMenuButton` to icon-only with a `tooltip` prop. `Cmd/Ctrl+B` toggles by default.
- **DashboardTopBar** — context top bar with `breadcrumb`, `pageTitle`, `actions`, `menuTrigger?: ReactNode` slot (≥ 5.0.0, replaces `menuOpen`/`onMenuToggle` — e.g. `<SidebarTrigger className="lg:hidden" />`). `showMobileLogo?: boolean` (default `true`) hides the mobile monogram when the consumer renders its own.
- **DashboardShell** — docs-aligned app frame (default `variant="sidebar"`) with `sidebarGroups`, `sidebarItems`, `pageTitle`, `topActions`, `header`, `contentWidth`, `density`, `showMobileLogo`. Use `variant="topnav"` for horizontal TopNav layout. The sidebar sticks on `lg+` (≥ 4.8.41 — earlier versions scrolled it away with the page).
- **SettingsLayout** — settings page with `title`, `description`, `actions`, `navItems`, `activeId`, `onNavSelect`, and optional `tone="danger"` on nav items.
- **FormPanel** — titled form surface with `description`, `actions`, `footer`, `children`.
- **ResourceList** — list/table page wrapper with `title`, `description`, `actions`, `filters`, `count`, `emptyState`.
- **DetailPage** — detail header + primary content + optional `aside`.
- **EmptyPage** — full-page empty state using `EmptyState` on a `PageShell`.
- **FeatureSection** — section header + `features` rendered with `FeatureCard`.
- **StatsSection** — section header + `stats` rendered with `StatCard`.
- **AuthSplitLayout** (≥ 4.17.0) — two-panel hero/form auth screen. See full prop docs above under `StatCard`/`StructuredCard`/`AuthSplitLayout`.

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

### Agent-safe recipes

Copy-paste screen compositions live on the docs site at **Recipes** (`/recipes/`). Each recipe is a code-only file snippet — live component demos live on **Screen patterns**; fuller product screens link to Examples.

| Recipe | Primary components | Live demo / example |
|--------|-------------------|---------------------|
| Marketing landing | `PageShell`, `HeroBlock`, `FeatureSection`, `CTABand`, `Footer` | Screen patterns → FeatureSection; Blocks |
| Dashboard overview | `DashboardShell`, `StatsSection`, `HostCard` | Screen patterns → DashboardShell; Mission Control |
| Settings | `SettingsLayout`, `FormPanel` | Screen patterns → SettingsLayout; Settings example (polished demo) |
| Auth | `PageShell`, `FormPanel` | Screen patterns → FormPanel; Sign in |
| Empty state | `EmptyPage` | Components → EmptyState; Screen patterns → EmptyPage |
| Detail | `DetailPage` | Screen patterns → DetailPage |
| List / table | `ResourceList`, `DataTable` | Screen patterns → ResourceList; Data Table |

Reserve Tailwind for small local glue (`mt-6`, `flex justify-end`) — not for rebuilding page structure. See the escape-hatch recipe and Getting started → Consumer styling policy.

### Consumer guardrails

**Self-audit** from a consumer app root:

```bash
npx tollerud-ui-audit
# monorepo: npx tollerud-ui-audit ./apps/web
# without npx: node node_modules/@tollerud/ui/scripts/audit-consumer-styling.mjs
# advisory CI (exit 0 even with errors): npx tollerud-ui-audit --warn-only
```

**Checks:** missing `@tollerud/ui` dependency, missing `globals.css` / `source.css`, `components/ui` clones, `tollerud-*` classes without package imports, hardcoded `#FFFF00` / `#0A0A0A` / `#E8D500`, local `cn()`, `<Button><Link>` nesting, and local `components/ui` re-export shims.

**Error codes:** findings print `ERROR [code]` or `WARN [code]` — full code→fix table in GETTING_STARTED.md → Consumer project checklist (`missing-ui-dep`, `missing-source-css`, `local-ui-clone`, `button-link-nesting`, `ui-reexport-shim`, etc.).

**Anti-patterns:** copied `Button.tsx`, `bg-yellow-400` CTAs, hand-built `min-h-screen bg-black` page grids, missing `<Toaster />`.

**When a pattern is missing:** compose a local feature component under `src/features/…` that wraps `@tollerud/ui` exports — do not fork primitives into `components/ui`. See GETTING_STARTED.md → Consumer project checklist.

**Button** — `variant`: `primary` · `secondary` · `ghost` · `ghost-destructive` · `ghost-success` · `ghost-warning` · `ghost-info` · `destructive` · `terminal`. `size`: `sm` · `md` · `lg`. `asChild?: boolean`. `loading?: boolean` — shows an inline `Spinner`, disables the button, and sets `aria-busy`. Each size uses a fixed height so text and icon-only buttons align in the same row. Primary and terminal support pointer-following glow when you call `initButtonGlow()` once at the app root (requires `globals.css`; respects `prefers-reduced-motion`). Ghost semantic variants match `Badge` tones — ghost at rest, semantic tint on hover/focus. Use `ghost-destructive` for archive/deactivate, `ghost-success` for approve/enable, `ghost-warning` for pause/caution, `ghost-info` for details/view.
```tsx
<Button variant="primary" size="md">Deploy</Button>
<Button variant="ghost-destructive">Archive</Button>
<Button variant="ghost-success">Approve</Button>
<Button variant="destructive">Delete host</Button>
<Button variant="terminal" size="sm">start_building</Button>

// app/providers.tsx — optional, once near root
'use client'
import { useEffect } from 'react'
import { initButtonGlow } from '@tollerud/ui'

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

Opt-in on any element: add class `tollerud-btn-glow`. Subpath: `import { initButtonGlow } from '@tollerud/ui/button-glow'`.

**ButtonGroup** — fused action buttons (independent clicks, not a toggle). Wrap `<Button>` children; `size?: 'sm' | 'md' | 'lg'`, `orientation?: 'horizontal' | 'vertical'`. Use **`Segmented`** when one option should stay selected.
```tsx
<ButtonGroup size="sm">
  <Button variant="secondary">Deploy</Button>
  <Button variant="secondary">Cancel</Button>
  <Button variant="ghost" aria-label="More"><MoreIcon /></Button>
</ButtonGroup>
```

**Card** — `accent?: boolean | 'filled'`, `density?: 'comfortable' | 'compact'`, `structured?: boolean` (≥ 4.17.0). `accent={true}` = yellow border + subtle yellow header/footer band tint on structured cards (≥ 4.9.8); `accent="filled"` = yellow border + stronger yellow fill on all regions. Plain `<div>` wrapper — safe to nest in `<Link>`. Compound parts: `CardHeader` (`actions`), `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, `CardChange`. `CardHeader`/`CardContent`/`CardFooter` each also take their own `accent?: boolean | 'filled'` (≥ 4.16.0) that overrides the parent `Card`'s accent for just that region — omit it to keep inheriting. By default `Card` auto-detects "structured" layout (no shell padding, delegated to `CardHeader`/`CardContent`/`CardFooter`) from `child.type.displayName` — that detection can fail to survive a Next.js Server/Client Component boundary. Pass `structured` explicitly (`true` or `false`) to bypass the auto-detection when composing `Card` across that boundary, or use `StructuredCard` for the common case.
```tsx
<Card accent>Highlighted with yellow border</Card>
<Card accent="filled">Callout with yellow fill</Card>
<Card>
  <CardHeader actions={<CardChange value="+12%" direction="up" />}>
    <CardTitle>Active sessions</CardTitle>
    <CardDescription>Last 24h</CardDescription>
  </CardHeader>
  <CardContent>…</CardContent>
  <CardFooter className="justify-end">
    <Button variant="primary" size="sm">Deploy</Button>
  </CardFooter>
</Card>
{/* Only the header is tinted — Card itself is plain */}
<Card>
  <CardHeader accent="filled"><CardTitle>Over budget</CardTitle></CardHeader>
  <CardContent>…</CardContent>
</Card>
```

**PriceDisplay** — compact price block: `primary: string` (large value), `secondary?: string` (Badge below), `highlight?: "cheapest" | false`, `align?: "left" | "right"` (default `"right"`), `size?: "sm" | "md" | "lg"` (default `"md"`). Designed 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" />
```

**ListCard** — hover card shell: `href?: string` (renders as `<a>`), `highlight?: "cheapest" | false` (yellow border tint), `external?: boolean`. Children are consumer-controlled.
```tsx
<ListCard href="/beers/1">
  <span>Hansa Pilsner</span>
  <PriceDisplay primary="58,0 kr/l" secondary="29,00 kr" />
</ListCard>
```

**Badge** — `variant`: `default` · `accent` · `success` · `error` · `info` · `warning`.
```tsx
<Badge variant="success">Online</Badge>
```

**StatusDot** — `status`: `online` · `offline` · `warning` · `idle` · `info` (≥ 4.16.0, exported as `Status`); `label?`, `noPulse?`. `info` is a static blue dot (no pulse, like `idle`).
```tsx
<StatusDot status="online" label="SSH Connected" />
```

**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>
```
All form primitives require a `label` (renders an actual `<label>` for a11y) and accept `error?: string`.

**Kbd** — keyboard shortcut chip. `keys: string | string[]`, `size?: 'sm' | 'md'`.
```tsx
<Kbd keys={["⌘", "⇧", "S"]} size="sm" />
```

**ActionRow** / **CommandMenu** — Raycast-style command palette.
```tsx
const [open, setOpen] = useState(false)
<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
/>
```

**StatCard** — `label`, `value: ReactNode` (≥ 4.17.0 — was `string | number`), `icon?: ReactNode`, `change?: { value?: string; direction: 'up' | 'down' | 'flat'; tone?: 'success' | 'error' | 'warning' | 'info' | 'accent' }`, `accent?: boolean`, `tone?: 'success' | 'error' | 'warning' | 'info'` (≥ 4.16.0), `secondaryValue?: ReactNode`, `secondaryTone?: 'default' | 'accent' | 'success' | 'error' | 'warning' | 'info'` (≥ 4.17.0). `change.tone` overrides the default color (up=success, down=error, flat=info). `flat` defaults label to `—` when `value` is omitted. `tone` colors the value text + border independently of `accent` — use it for a status-carrying figure (e.g. an over-budget total) without the yellow brand-accent look; `tone` takes precedence if both are somehow set, so pick one per card. Pass a `ReactNode` `value` (e.g. an `<Input>`) for an inline-editable tile — `StatCard` renders it as-is and doesn't own save state. `secondaryValue` renders as a small `Badge` under the value — use it for a second unit (e.g. price per liter next to a kr price).
```tsx
<StatCard label="Active Sessions" value={42} icon={<Activity size={14} />} change={{ value: '+12%', direction: 'up' }} />
<StatCard label="Endring siste periode" value="-3.2%" change={{ value: '-3.2%', direction: 'down', tone: 'success' }} />
<StatCard label="Uptime" value="99.9%" change={{ direction: 'flat' }} />
<StatCard label="Brukt av budsjett" value="12 930 kr / 2 700 kr" tone="error" />
<StatCard label="Price" value="23,90 kr" secondaryValue="47,80 kr/l" secondaryTone="accent" />
<StatCard label="Ticket price" value={<Input defaultValue="150" onBlur={save} />} />
```

**StructuredCard** (≥ 4.17.0) — `title: ReactNode`, `actions?: ReactNode`, `contentClassName?: string`. Composes `Card` (with `structured` set explicitly) + `CardHeader` + `CardTitle` + `CardContent` — the common "title + actions + body" shape, safe to render from a Server Component and pass across a Client boundary (see `Card`'s `structured` prop below).
```tsx
<StructuredCard title="Deploy" actions={<CardChange value="+12%" direction="up" />}>
  Body content
</StructuredCard>
```

**AuthSplitLayout** (≥ 4.17.0) — two-panel hero/form auth screen. `projectName: string`, `eyebrow?: string`, `title: string`, `highlight: string` (shimmered word(s) in `title`, passed to `PageHeader`'s `shimmer`), `description: string`, `decoration?: ReactNode` (flourish element in the hero panel), `glow?: boolean` (default `true`, renders `NoirGlowBackground`), `homeHref?: string`.
```tsx
<AuthSplitLayout
  projectName="Butikkpils"
  eyebrow="41 priser fra 13 butikker"
  title="Track"
  highlight="every price."
  description="Crowdsourced beer prices, updated daily."
>
  <SignInForm />
</AuthSplitLayout>
```

**CodeBlock** — `code?`, `promptPrefix?`, `showCopy?`. Renders a `<pre>`.
```tsx
<CodeBlock promptPrefix showCopy code={`systemctl status tollerud-agent`} />
```

**Container** — `as?: 'div' | 'section' | 'article' | 'main' | 'header' | 'footer'`, capped width + padding.

**PasswordInput** — same API as `Input` (label, error, id, …) plus built-in show/hide toggle and `labelAction?: ReactNode`, rendered at the right edge of the label row (e.g. a "Forgot?" link).

**PasswordStrength** — `value`, `rules?: PasswordRule[]`. Strength bar + rule checklist for signup/change-password flows. Compose below a `PasswordInput`. Default rules: min 8 chars, uppercase, lowercase, number, special character. Export `passwordRules` to extend the defaults.
```tsx
const [pw, setPw] = useState('')
<PasswordInput label="New password" value={pw} onChange={e => setPw(e.target.value)} />
<PasswordStrength value={pw} />
// Custom rules:
<PasswordStrength value={pw} rules={[...passwordRules, { label: 'No spaces', test: v => !/\s/.test(v) }]} />
```
```tsx
<PasswordInput label="Password" placeholder="Enter password" error={errors.password} />
```

**Combobox** — searchable single-select. Flat `options: { value, label, disabled? }[]` or grouped `groups: { label, options }[]` with section titles in the dropdown. `value?`, `onChange?`, `placeholder?`, `filter?`, `label?`, `error?`, `searchPlacement?: 'trigger' | 'dropdown'` (default `'trigger'`; `'dropdown'` moves search inside the popover, trigger looks like a Select button), `onCreateOption?: (label: string) => string | void` (opt-in "create a new option" row, see below), `createOptionLabel?: (query: string) => string` (customizes that row's text).
```tsx
<Combobox label="Connect to host" value={host} onChange={setHost} options={hostOptions} />

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

// Let users add an option that doesn't exist yet — a "Create <query>" row appears
// whenever the search text has no exact label match (alongside partial matches too)
<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
  }}
/>
```

**TagInput** — chip-style multi-value input. `value?: string[]`, `onChange?`, `max?`, `placeholder?`, `label?`, `error?`. Enter/comma to add, Backspace to remove last.
```tsx
<TagInput label="Tags" value={tags} onChange={setTags} placeholder="Add tag…" max={10} />
```

**Slider** — native range input styled with yellow thumb. `label?`, `showValue?`, `onChange?: (value: number) => void`, plus all native `<input type="range">` props.
```tsx
<Slider label="Alert threshold" showValue value={threshold} onChange={setThreshold} min={0} max={100} />
```

**FormRow** — accessible field wrapper. `label?`, `description?`, `error?`, `required?`, `htmlFor?`. Wires `aria-describedby` automatically.
```tsx
<FormRow label="Hostname" htmlFor="hostname" description="Unique within your network." required error={errors.hostname}>
  <Input id="hostname" placeholder="e.g. embla" />
</FormRow>
```

### Navigation & layout primitives

```tsx
import {
  Divider, Pill, Avatar, AvatarGroup,
  Breadcrumb, Pagination, Segmented, Stepper,
  Panel, Meter, Gauge, Heatmap, SegmentBarChart, Accordion, AccordionItem, AccordionTrigger, AccordionContent,
  DatePicker, FileUpload, PricingCard,
} from '@tollerud/ui'
```

**Divider** — `orientation?: 'horizontal' | 'vertical'`, `label?: ReactNode`.
```tsx
<Divider />
<Divider label="or" />
<Divider orientation="vertical" className="h-6" />
```

**Pill** — `variant?: 'outline' | 'solid' | 'accent'`.
```tsx
<Pill variant="accent">production</Pill>
```

**Avatar / AvatarGroup** — `src?`, `name?` (derives initials), `fallback?`, `size?: 'sm' | 'md' | 'lg'`. `AvatarGroup` takes `max?` and shows a +N chip.
```tsx
<Avatar name="Mathias Tollerud" size="md" />
<AvatarGroup max={3}><Avatar name="Emma" /><Avatar name="Iris" /></AvatarGroup>
```

**Breadcrumb** — `items: { label, href?, onClick? }[]`, `separator?`.
```tsx
<Breadcrumb items={[{ label: 'Servers', href: '/servers' }, { label: 'Embla' }]} />
```

**Pagination** — `page` (1-indexed), `pageCount`, `onChange`, `siblingCount?`.
```tsx
<Pagination page={page} pageCount={20} onChange={setPage} />
```

**Segmented** — `options: { value, label, disabled? }[]`, `value`, `onChange`, `size?: 'sm' | 'md'`, `collapseMobile?`. Segment height is fixed per size so text and icon labels align.
```tsx
<Segmented value={view} onChange={setView} options={[{ value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} />

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

With `collapseMobile`, viewports below `md` show only the selected label (with chevron) until tapped; options open in a dropdown overlay and collapse again after a selection. Desktop is unchanged.

**Stepper** — `steps: { label, description? }[]`, `current` (0-indexed), `orientation?: 'horizontal' | 'vertical'`.
```tsx
<Stepper steps={onboardingSteps} current={1} orientation="vertical" />
```

**Panel** — `title?`, `description?`, `actions?: ReactNode` (renders in header), `children` (body with padding).
```tsx
<Panel title="Overview" description="Live metrics" actions={<Button size="sm">Refresh</Button>}>…</Panel>
```

**Meter** — `value`, `max?` (default 100), `label?`, `showValue?`, `tone?: 'default' | 'success' | 'warning' | 'error'`.
```tsx
<Meter value={72} label="RAM" showValue tone="warning" />
```

**Gauge** (≥ 4.8.50) — radial dial for a single metric. `value`, `min?`/`max?` (0–100), `label?`, `formatValue?`, `tone?` (same tones as Meter), `size?`, `thickness?`, `fluid?`. `role="meter"` with aria value range.
```tsx
<Gauge value={72} label="Disk" formatValue={(v) => `${v}%`} tone="warning" />
```

**SegmentBarChart** (≥ 4.15.0) — horizontal stacked proportion bar for category breakdowns. `segments: { label, value, color? }[]`, `formatValue?`, `showPercentLabels?`, `minPercentLabel?`, `interactive?`, `height?`. Colors cycle `SEGMENT_BAR_COLORS` (yellow-on-noir intensity).
```tsx
<SegmentBarChart
  segments={[
    { label: 'Mat & drikke', value: 6490 },
    { label: 'Drikke', value: 3150 },
  ]}
  formatValue={(v) => `${v} kr`}
/>
```

**Accordion** — compound. `multiple?: boolean`, `defaultOpen?: string | string[]`. Items use `value` prop to identify.
```tsx
<Accordion defaultOpen="faq-1">
  <AccordionItem value="faq-1">
    <AccordionTrigger>What is Tia?</AccordionTrigger>
    <AccordionContent>An infrastructure assistant for homelabs.</AccordionContent>
  </AccordionItem>
</Accordion>
```

**DatePicker** — `value?: Date | null`, `onChange?`, `label?`, `error?`, `placeholder?`, `formatDate?`.
```tsx
<DatePicker label="Schedule deployment" value={date} onChange={setDate} />
```

**FileUpload** — `accept?`, `multiple?`, `onFilesChange?`, `label?`, `description?`, `error?`, `clickLabel?`, `dragLabel?`.
```tsx
<FileUpload label="Upload config" accept=".yaml,.json" onFilesChange={handleFiles} />
<FileUpload clickLabel="Klikk for å laste opp" dragLabel="eller dra og slipp" />
```

**PricingCard** — `name`, `price`, `period?`, `description?`, `features?: ReactNode[]`, `ctaLabel?`, `onCtaClick?`, `featured?`, `badge?`.
```tsx
<PricingCard name="Pro" price="$9" period="/month" features={['Unlimited servers']} featured badge="Most popular" />
```

### Overlays (Radix-based, all need `'use client'` boundary in the *consumer* component)

```tsx
import {
  Dialog, DialogTrigger, DialogContent, DialogHeader, DialogBody, DialogFooter, DialogTitle, DialogDescription, DialogClose, DialogPanel,
  Tooltip, TooltipTrigger, TooltipContent, TooltipProvider,
  Tabs, TabsList, TabsTrigger, TabsContent,
  DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel,
  Sheet, SheetTrigger, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetClose,
  Drawer, Toaster, ToastProvider, useToast,
} from '@tollerud/ui'
```
- `Dialog` / `Sheet` / `DropdownMenu` follow the standard shadcn/Radix composition pattern — `Trigger` wraps the activating element with `asChild`. `DialogContent` takes `size?: 'sm' | 'md' | 'lg' | 'xl' | 'full'` (default `md`) and partitions `DialogHeader` / body / `DialogFooter` with bordered regions and a scrollable body. Prefer **`DialogPanel`** for controlled dialogs: `open`, `onClose`, `title`, `description`, `footer`, `size` (≥ 4.9.1, same ergonomics as `Drawer`). `Sheet` takes `side?: 'left' | 'right'`.
- `Drawer` — controlled API: `open`, `onClose`, `side`, `title`, `description`, `footer`, `width`.
- `Tooltip` requires a `<TooltipProvider>` ancestor.
- `Toaster` — Sonner renderer; mount once, call `toast()` from `sonner`.
- `ToastProvider` + `useToast` — context API: `toast({ tone, title, message?, duration? })`.

### Empty states & loading

```tsx
import {
  Empty, EmptyHeader, EmptyIcon, EmptyTitle, EmptyDescription, EmptyContent,
  EmptyState, Skeleton, Progress, Spinner,
} from '@tollerud/ui'

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

**EmptyState** — prop-driven shortcut: `icon` (built-in name or custom element), `title`, `description`, `action`, `secondaryAction`, `compact`, `accent`.
```tsx
<EmptyState icon="server" title="No hosts connected" description="Connect your first machine."
  action={<Button variant="primary" size="sm">Connect</Button>} />
```

**Spinner** — `size?: number` (px, default 16). Inline loading indicator.

### Visual / decorative

```tsx
import {
  GlowCard, NoirGlowBackground, BentoDashboard,
  BarChart, AreaChart, TimeSeriesChart, TIME_SERIES_PRESETS, Donut, Sparkline,
  HeroBlock, FeatureCard, CTABand,
} from '@tollerud/ui'
```
- **GlowCard** — `children`, `className?`, `glowColor?`, `intensity?: number`. Mouse-tracked glow card.
- **NoirGlowBackground** — animated WebGL shader background (needs `@paper-design/shaders-react`). `shape?: 'corners' | 'wave' | 'dots' | 'truchet' | 'ripple' | 'blob' | 'sphere'`, `intensity?: 'subtle' | 'medium' | 'loud'`, `speed?: 'still' | 'slow' | 'medium' | 'fast'`, `grain?: 'none' | 'soft' | 'high'`, `colors?: string[]`, `forceCssFallback?: boolean`.
- **BentoDashboard** — composed dashboard shell taking arrays of `HostCardProps`, `StatCardProps`, `ServiceHealthCardProps`, incidents, `BackupJob[]`.

**Charts** — palette-aware SVG; yellow highlights one series. No Recharts. Full usage + prop tables: [COMPONENTS.md](COMPONENTS.md#charts) · [design.tollerud.dev/charts](https://design.tollerud.dev/charts/).

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

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

// Norwegian unit rate — dates via locale, numbers via formatValue
<TimeSeriesChart
  data={ratePoints}
  curve="step"
  locale="nb-NO"
  formatValue={(v) => formatChartDecimal(v, 'nb-NO', { suffix: ' kr/l' })}
/>

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

`TimeSeriesChart` — stepped/linear curves, hover crosshair + tooltip, `ranges` + `TIME_SERIES_PRESETS` (English labels), `locale` (default `en-US`, dates only), `valuePrefix` / `valueSuffix` or `formatValue` for numbers, `formatChartDecimal` helper for rates, `toolbarLeft`, `renderTooltip(point, index, formattedValue)`. Norwegian apps: custom `ranges` + `locale="nb-NO"`. Keyboard accessible (≥ 4.8.42): Tab focuses the chart and activates the latest point, ←/→ step, Home/End jump, Esc clears (consumed only while a point is active — safe inside Dialogs); keyboard selections are announced via a polite live region. `Sparkline` — `curve`, `fill`, `interactive` for table-row micro charts; interactive mode (≥ 4.8.43) adds a tooltip, the chart keyboard contract, `formatValue`, and `ariaLabel`. `AreaChart` — static by default; `interactive` (≥ 4.8.43) adds crosshair + tooltip + keyboard nav, `data` accepts `number[]` or `{ value, label }[]`, plus `formatValue` / `renderTooltip` / `ariaLabel`.

**Marketing blocks**
```tsx
<HeroBlock eyebrow="homelab" title="Run your stack" intense actions={<Button>Deploy</Button>} />
<FeatureCard icon={<Zap size={20} />} title="Instant deploys" description="…" />
<CTABand title="Ship it" actions={<Button variant="primary">Get started</Button>} />
<PromoSection
  eyebrow="Feature"
  title="Two-column callout"
  description="Body text here."
  actions={<Button variant="primary">Get started</Button>}
  visual={<img src="/preview.png" />}
  visualPlacement="right"
  background="raised"
  textWidth="wide"
/>
```
`HeroBlock.intense` mounts `NoirGlowBackground` (needs `@paper-design/shaders-react`). `PromoSection` — `shimmer` accents word(s) in `title` (`string` or `string[]`); same API on `PageHeader`/`titleAccent`; eyebrow is mono uppercase yellow matching `PageHeader`; `visualPlacement?: "right" | "left"`, `background?: "default" | "raised"` (raised uses `border-y` for full-bleed), `textWidth?: "narrow" | "balanced" | "wide"`, `contentWidth?: "sm" | "md" | "lg" | "xl" | "full"` (default `"xl"`). Collapses to single column on mobile; visual always renders below text on mobile. Falls back to centered single-column when no `visual` prop is passed.

### Data & tables

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

// Simple: sort + per-column text filters
<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"
/>

// Rich: search, segmented filter, selection, bulk actions, row menus, pagination
<DataTable
  columns={[
    { key: 'hostname', header: 'Host', sortable: true, render: (_v, row) => row.hostname },
    { key: 'status', label: 'Status', render: (_v, row) => <Badge>{row.status}</Badge> },
  ]}
  data={hosts}
  rowKey="id"
  searchable
  searchKeys={['hostname', 'ip']}
  filter={{ key: 'region', allLabel: 'All regions' }}
  // filter={{ key: 'region', allLabel: 'All regions', variant: 'combobox', placeholder: 'Filter region…' }}
  selectable
  pageSize={10}
  pageSizeOptions={[10, 25, 50]}
  striped
  pinColumns
  bulkActions={[{ label: 'Restart', variant: 'ghost', onRun: (ids, clear) => { clear() } }]}
  // Two or more bulk actions render fused in ButtonGroup automatically
  rowMenu={(row) => [{ label: 'View logs', onSelect: () => {} }]}
  toolbarRight={<Button size="sm">Add host</Button>}
  emptyState={<Empty>...</Empty>}
/>
```

Column headings use `label` or `header`. `render` is always `(value, row) => …` (≥ 4.8.40; `value` is `row[key]` — write `(_v, row) => …` when you only need the row). The pre-4.8.40 single-parameter `(row) => …` form is gone: it silently receives the cell **value** now, so migrate by prepending `_v, ` to the parameter list. Sortable headers render a real `<button>` (keyboard-operable, `aria-sort` on the `<th>`). If rows are `selectable`, give them a stable `id`/`key` field or pass `rowKey` — index-fallback keys collide across pages.

**Pagination** — pass `pageSize` (fixed) or add `pageSizeOptions={[10, 25, 50]}` for a compact inline footer **Rows** `Select`. Page state is internal. Footer shows `Showing 1–5 of N`; controls appear when `pageCount > 1`. Search/filter resets page to 1. Selection spans pages when `selectable` is set.

**Table** (≥ 4.16.0) — plain static table primitives with no sorting, filtering, search, or pagination: `Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption`. Use this for a fixed comparison table or small dataset; use `DataTable` when you need the interactivity. `TableHead`/`TableCell` take `align?: 'left' | 'center' | 'right'`. `TableRow` takes `highlight?: boolean` to tint the row. `TableCell` takes `tone?: 'success' | 'error' | 'warning' | 'info' | 'accent'` to color a computed value.
```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>
```

### Infra / homelab set

```tsx
import {
  HostCard, ServiceHealthCard, DockerStackCard, IncidentCard, ApprovalCard,
  ActionDiff, LogViewer, AlertInbox, Timeline, RollbackPlan, BackupStatusPanel,
} from '@tollerud/ui'
import type { IncidentSeverity } from '@tollerud/ui'
```

```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" />
<DockerStackCard name="hermes" services={[{ name: 'web', status: 'online' }]} composePath="compose.yml" />
<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={() => {}} />
<ActionDiff label="docker-compose.yml" lines={[{ text: 'image: nginx:1.27', type: 'add', newLine: 12 }]} />
<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) => {}} />
<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' (blue) */}
<Timeline
  variant="flat"
  items={[{ id: '1', time: '12 min siden', title: <><strong>Karoline</strong> krysset av «Laks, filet»</>, status: 'online' }]}
/>
<RollbackPlan name="hermes-v2.1-rollback" steps={[{ id: '1', label: 'Stop container', status: 'success' }]} executing />
<BackupStatusPanel jobs={[{ name: 'nightly-db', status: 'online', lastRun: '02:00', nextRun: 'tomorrow 02:00', size: '4.2 GB' }]} totalSize="120 GB" />
```

`IncidentSeverity` = `critical | high | medium | low | info` · `Status` (StatusDot/HostCard/etc) = `online | offline | warning | idle` · `LogLevel` = `debug | info | warn | error | trace` · `RollbackStepStatus` = `pending | running | success | failed | skipped` · `ApprovalState` = `pending | approved | rejected`.

### Footer & branding

```tsx
import { Button, Footer, TopNav } from '@tollerud/ui'

<Footer layout="responsive" accent labels={{ tollerudProject: 'A Tollerud Project' }} />
```
The monogram must always sit left of the project name with `gap-2`. Never show the name without the monogram, or the monogram alone, in nav contexts.

```tsx
<TopNav
  projectName="Project Name"
  navItems={[{ label: 'Overview', href: '/overview', active: true }]}
  actions={
    <>
      <TopNavAction mobile="inline">
        <Button variant="primary" size="sm">Get started</Button>
      </TopNavAction>
      <TopNavAction mobile="menu">
        <Button variant="secondary" size="sm">Sign in</Button>
      </TopNavAction>
    </>
  }
/>
```

`TopNavAction` `mobile` values: `inline` (header bar on mobile), `menu` (overlay panel, default), `hidden` (desktop only). Unwrapped `actions` children default to `menu` on mobile.

**Monogram** — `color?: 'yellow' | 'black' | 'white'` (default `yellow`), `size?: number`, `title?: string` (default `'Tollerud'`). Brand avatars: `@tollerud/ui/brand/tollerud-avatar.svg`, `brand/tollerud-avatar-full.svg` (+ `.png` variants).
Monogram sizing is handled automatically by `TopNav` and `Footer`; use `Monogram` directly only for custom brand moments.

---

## Layout utility classes

These are low-level class references for package internals, docs demos, and custom cases. In consumer apps, prefer exported components and screen/layout primitives first; use these classes only when no component covers the need yet.

```html
<section class="tollerud-grid-bg">…</section>
<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>
<div data-density="compact">…dense tables / forms…</div>
```

Shadow scale: `--shadow-sm` `--shadow-md` `--shadow-lg` `--shadow-xl` `--shadow-glow`. Drawers/Sheets use `--shadow-xl`; popovers/tooltips `--shadow-lg`. Borders are the default separator — only reach for shadows on overlays.

---

## Copy & voice

- Action-first labels: "Deploy", "View Logs", "Restart" — not "Click here to initiate deployment"
- Terminal-style CTAs for technical actions: `❯ deploy --env production`, `$ init`
- Name the cause in errors: "Connection to emma.tollerud.no timed out" — not "Something went wrong"
- No exclamation marks or corporate filler ("Oops!", "Great!", "Please try again later")

## Accessibility checklist

- Every interactive element gets a visible focus ring (see token section above, or class `.tollerud-focus-ring`)
- Icon-only buttons need `aria-label`
- Always pass `label` to `Input` / `Select` / `Textarea` / `Checkbox` / `Switch` / `Radio` — never rely on placeholder-as-label
- Error text uses `role="alert"` or `aria-live="polite"`
- Never convey state by color alone — pair `StatusDot` / `Badge` color with text/icon
- Respect `prefers-reduced-motion: reduce` — disable shimmer/pulse/shader animations

## What NOT to do

| Don't | Why |
|---|---|
| Use light/white backgrounds | System is dark-only |
| Put yellow text on white | Fails contrast at ~1.7:1 |
| Recolor or add glow to the monogram | Yellow-on-dark branding is non-negotiable; glow is for interactive UI only |
| Introduce non-system chromatic colors (blue, green, purple) for decoration | Only the yellow accent + monochrome grays (status semantics are the lone exception) |
| Nest `<a>`/`<Link>` inside `<Button>` (or vice versa) | Invalid HTML — use `asChild` or `buttonVariants()` instead |
| Import a component name you saw in older docs without checking it exists | Verify against this catalog or `components/index.ts`. Common renames: `EmptyState` → `Empty`, `Toast` → `Toaster` + `sonner`'s `toast()`, `Drawer` → `Sheet`. Charts (`BarChart`, `AreaChart`, `Donut`, `Sparkline`) and marketing blocks (`HeroBlock`, `FeatureCard`, `CTABand`) ship since **v1.4.0**. Brand assets: `@tollerud/ui/brand/*` (not repo root). |
| Hardcode a transition duration/easing (`duration-[150ms]`, `duration-150`, a bare `transition-colors` with no duration, `ease: 'easeOut'` in framer-motion) | Use the Motion tokens (`duration-fast/normal/slow`, `ease-out/in/in-out`, or `lib/motion.ts` for JS-driven animation) — see Motion tokens above |

---

## Version notes

- **`TopNav` `userMenu` `placement` (≥ 5.6.0)** — `TopNavUserMenu.placement?: 'start' | 'end'` (default `'end'`, unchanged) controls whether the desktop `userMenu` dropdown renders before or after `actions` in the cluster. No breaking changes.

- **`TopNav` flyout: click-to-open only, pixel-perfect row alignment (≥ 5.5.0)** — 5.4.0's Floating UI rebuild fixed the hover-flicker but introduced a worse regression: moving the mouse from the trigger toward the panel to click a row closed it first (Radix's `Trigger` still runs its own hover-close timer with no `Content` left to cancel it). The trigger is now click-to-open only — matching `DropdownMenu`/`Select`/`Combobox`, which are all click-triggered too. Also: the desktop nav row is now pixel-identical across `<a>` and flyout-trigger `<button>` items (two separate box-model quirks — inline vs block box height, and inline-vs-block-level formatting inside the parent `<li>` — both fixed with explicit heights). Also: the mobile accordion for flyout groups no longer shows a left guide-line, matching `mobileMenuSections`/`userMenu`'s flat style. No breaking changes.

- **`TopNav` desktop flyouts rebuilt on Floating UI; collapsible mobile sections (≥ 5.4.0)** — Flyout groups (`navItems` entries with `items`) now position their panel with Floating UI (the `Combobox`/`Select`/`DatePicker` engine), anchored to their own trigger, instead of Radix NavigationMenu's `Viewport` — which centered content under the whole nav bar rather than the trigger that opened it, causing a hover-flicker/collapse glitch and a detached indicator caret that could throw off the trigger row's height. The `NavigationMenuIndicator` from 5.2.0 is removed (no longer needed — the panel is anchored directly to its trigger now). Also: `TopNavSection` (`mobileMenuSections`/`userMenu`) gains `collapsible?: boolean` (default `true`) and `defaultOpen?: boolean` (default `false`) — sections collapse behind their label by default, same as flyout groups, instead of always rendering expanded. No breaking changes.

- **`TopNav` `userMenu` (≥ 5.3.0)** — `userMenu?: TopNavUserMenu` (`{ trigger: ReactNode, triggerLabel?: string, sections: TopNavSection[] }`) 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's rows automatically. Reuses `TopNavSection`/`TopNavItem` (`href`/`onClick`/`icon`/`external`/`active` all work) — no more hand-duplicating a desktop dropdown and its mobile equivalent. No breaking changes.

- **`TopNav` `onClick` rows + flyout indicator; `dvh` viewport fixes (≥ 5.2.0)** — `TopNavItem.onClick?: () => void` renders the row as a `<button>` instead of a link (ignored if `href` is set) — for actions like sign out that previously had no first-class styled option. Desktop flyout groups now render a `NavigationMenuIndicator` caret that tracks the open trigger and bridges into the panel below, instead of the panel floating disconnected under the whole nav bar. Also: `Sidebar` and `TopNav`'s mobile menu switched `100vh`/`min-h-screen` to `dvh` — `vh` on mobile is computed against the viewport with the address bar hidden, so pages read taller than the visible window and sticky elements visibly jumped as the bar auto-hid/showed during scroll. No breaking changes.

- **`TopNav` mobile menu row styling + `mobileMenuSections`; `Sidebar` mobile drawer width fix (≥ 5.1.0)** — `TopNav`'s dropdown/mobile surfaces (desktop flyout content, mobile nav rows, mobile accordion children) now share one button-styled row (40px min-height, rounded hover/active surface, optional `icon`) instead of bare unstyled links — matches the row language `SidebarMenuButton` shipped in 5.0.0. New `TopNavItem.icon?: ReactNode` and `TopNav`'s `mobileMenuSections?: TopNavSection[]` prop (labeled row groups below nav items/actions, styled like `SidebarGroupLabel` — use this instead of hand-rolling dividers/labels inside `mobileMenuExtra`). Also fixed: `Sidebar`'s mobile off-canvas panel was rendering at ~59% of viewport width instead of the intended 288px, because `w-[--sidebar-width-mobile]` used Tailwind v3's square-bracket variable shorthand, unsupported in this project's Tailwind v4 (silently produced no `width` rule at all). No breaking changes; both are additive/visual fixes.

- **`Sidebar` desktop rail height is overridable (≥ 5.0.1)** — the rail's height is driven by a `--sidebar-height` CSS custom property (default `100vh`, unchanged behavior for normal full-page usage). Override it on any ancestor (`SidebarProvider`, `DashboardShell`, etc.) via `style={{ '--sidebar-height': '420px' }}` when embedding `Sidebar` in a shorter container — e.g. a docs preview box. No prop change; fixes a real bug where the rail silently ignored a bounding container and rendered at full viewport height instead.

- **`Sidebar` primitive family replaces `SidebarNav` — Breaking (≥ 5.0.0)** — `SidebarNav` is removed. Use `SidebarProvider`/`Sidebar`/`SidebarTrigger`/`SidebarInset`/`SidebarHeader`/`SidebarContent`/`SidebarFooter`/`SidebarGroup`/`SidebarGroupLabel`/`SidebarGroupContent`/`SidebarMenu`/`SidebarMenuItem`/`SidebarMenuButton`/`SidebarMenuAction`/`SidebarMenuBadge`/`SidebarMenuSub`/`SidebarMenuSubItem`/`SidebarMenuSubButton`/`useSidebar` instead — a shadcn-style composable sidebar (sticky desktop `<aside>`, `Sheet`-based mobile off-canvas, `collapsible="icon"` support, `Cmd/Ctrl+B` shortcut). `SidebarNavItem`/`SidebarNavGroup` types are unchanged and still exported (now from `./Sidebar`). `DashboardShell`'s public props (`sidebarGroups`/`sidebarItems`) are unchanged — it builds the new composition internally. **Breaking**: `DashboardTopBar`'s `menuOpen`/`onMenuToggle` are replaced by a `menuTrigger?: ReactNode` slot — only relevant if you use `DashboardTopBar` directly outside `DashboardShell`. Also fixed: `lib/bypass-modal-scroll-lock.ts` (used by `DropdownMenu`) is rewritten as a ref callback — the previous `useLayoutEffect`-based version only attempted to attach once, at the wrapper's initial mount, which is almost always before a dropdown's first open, so it silently never worked in the common case; it now reattaches correctly on every open.

- **`TopNav` flyout groups (≥ 4.19.0)** — `TopNavItem` gains `items?: TopNavItem[]`; setting it turns an entry into a flyout group (desktop: `NavigationMenu` trigger + panel; mobile: accordion section in the hamburger menu). **Requires a new peer dependency**, `@radix-ui/react-navigation-menu` — install it alongside the other Radix peers even if you don't use `items` yet, since `TopNav` now imports it unconditionally. The mobile hamburger panel also moved from CSS `@keyframes` to `framer-motion` (same async-unmount note as `Sheet` in 4.18.1). No change to existing `href`-only nav items.

- **`CommandMenu` traps Tab focus (≥ 4.18.2)** — `Tab`/`Shift+Tab` can no longer move keyboard focus outside the open command palette (via `@radix-ui/react-focus-scope`). Previously only `Escape`/click-outside closed it and `Tab` could leak focus into the page behind it. No prop change.

- **`Sheet` animates with framer-motion (≥ 4.18.1)** — `Sheet`/`Drawer` overlay + panel now animate via `framer-motion` (the peer dependency was previously declared but unused) instead of CSS `@keyframes`, using the same `--motion-duration-*`/`--motion-ease-*` timings. Closing now unmounts asynchronously after the exit transition finishes rather than synchronously — tests reading the DOM right after close should `await` removal (`await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())`). `prefers-reduced-motion` behavior is unchanged. No prop changes.

- **`PasswordInput` `labelAction` (≥ 4.18.0)** — new `labelAction?: ReactNode` prop, rendered at the right edge of the label row (e.g. a "Forgot?" link next to "Password"). Both docs demos already passed this prop, but it was silently dropped onto the native `<input>` via `...props` — `PasswordInputProps` never declared it. No breaking changes.

- **Dropdown-search autofocus fix (≥ 4.17.1)** — `Combobox`'s `searchPlacement="dropdown"` search input (and any other autofocus-on-mount behavior inside a floating popover — `Select`, `DatePicker`, `Segmented`) now actually receives focus when the popover opens. `FloatingDropdownPortal` previously hid its panel with `visibility: hidden` until Floating UI finished positioning; browsers refuse to focus `visibility: hidden` descendants, so the focus call silently failed and focus stayed on the trigger. It now hides with `opacity: 0` + `pointer-events: none` instead. No API change.

- **`StructuredCard`, `StatCard` secondary value, `AuthSplitLayout` (≥ 4.17.0)** — `Card` gains an explicit `structured?: boolean` to bypass its `displayName`-based auto-detection (unreliable 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 the two-panel auth-screen layout. Also: `SKILL.md`/`AGENTS.md` Color tokens tables now list the pre-existing `tollerud-success`/`warning`/`error`/`info` tokens. No breaking changes — all opt-in.

- **`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'` that overrides the parent `Card`'s accent for just that region. `StatCard` gains `tone?: 'success' | 'error' | 'warning' | 'info'` for coloring the value independently of `accent`. `Timeline`'s `title` is now `ReactNode` (was `string`) and it gains `variant?: 'connected' | 'flat'` (flat drops the connector line, puts the timestamp below the title) plus an `info` status. `StatusDot` gains an `info` status (static blue dot). No breaking changes — all opt-in.

- **`SegmentBarChart` + DataTable toolbar fix (≥ 4.15.0)** — new `SegmentBarChart` component: horizontal stacked proportion bar with in-bar % labels and a responsive legend grid; colors cycle `SEGMENT_BAR_COLORS` (yellow-on-noir). `DataTable` `toolbarRight` now aligns end on desktop when search/filter are absent.
- **`Combobox` create-option support (≥ 4.14.0)** — new `onCreateOption?: (label: string) => string | void` prop: when set, 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 just 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?: (query: string) => string` customizes the row's text (default `Create "<query>"`). Opt-in — no change for existing usage.
- **Motion token consolidation (≥ 4.13.3)** — every component now uses the `duration-fast/normal/slow` + `ease-out/in/in-out` Tailwind classes (mapped to `--motion-duration-*`/`--motion-ease-*` in the preset) instead of arbitrary values (`duration-[150ms]`), Tailwind's numeric scale (`duration-150`), or bare `transition-*` with no duration. `lib/motion.ts` exports the same tokens as JS constants for framer-motion (`StatusDot`). `--transition-fast/normal/slow` in `globals-layers.css` are now aliases of the motion tokens rather than a separately hardcoded set. No API change — visual timing on a handful of components shifted slightly (e.g. 200ms → 150ms, 300ms → 350ms) to land on a token.
- **`@tollerud/email` dark-mode fixes (≥ 4.13.2)** — `EmailFooter`'s "A Tollerud Project" link now shares the `muted` class so it recolors with the surrounding text under `prefers-color-scheme: dark` (previously it kept the light-mode color in Apple Mail / iOS, so the wordmark showed two colors), and its fine-print line (`© YEAR · address · Unsubscribe`) is now center-aligned. `ReceiptEmail`'s line items / total were missed by the 4.13.0 light-first migration — they now carry the `text` class (were dark-on-dark in Apple Mail / iOS), and the total is `textPrimary` instead of the yellow accent (was unreadable on the white card). `EmailText`'s `fine` tone is now true fine print (`xs` + muted color + `fine` class) instead of a shrunk `muted`, and `VerifyEmail` wraps the raw link so long tokens don't stretch the card. A render-test suite (`packages/email/src/email.test.tsx`) now guards the dark-mode contract. No API change.
- **`@paper-design/shaders-react` peer → `^0.0.77` (≥ 4.13.1)** — the optional peer (used by `NoirGlowBackground`) is pinned by `@tollerud/ui` and moves in lockstep. Consumers should install `@paper-design/shaders-react@0.0.77`; don't bump it independently of `@tollerud/ui`.
- **`@tollerud/email` light-first + Gmail fixes (≥ 4.13.0)** — email is now light by default (white card, dark text, yellow button) so it renders correctly in Gmail, where the old dark-first design broke (white bg, mis-colored button, missing monogram). A `@media prefers-color-scheme: dark` block restores the noir look in Apple Mail / iOS. The monogram is a hosted PNG (Gmail strips inline SVG), auto dark-on-light / yellow-on-dark; override with `logoSrc`. **Breaking:** the `color` prop was removed from `BrandMark`/`EmailHeader`/`EmailFooter` and `BrandMarkColor` is no longer exported.
- **`@tollerud/email` footer polish (≥ 4.12.1)** — `EmailFooter` wordmark is right-aligned (monogram stays left), reads "A Tollerud Project." with a period, and the tollerud.no underline is 2px. No API change.
- **`@tollerud/email` overridable copy + style hatches (≥ 4.12.0)** — templates take a `copy` prop (each exports a `*Copy` type; dynamic lines are functions, static lines strings) to reword or localize (e.g. Norwegian) without forking. Every primitive takes an optional `style` prop merged **last**, overriding token defaults for one-off tweaks. Both non-breaking; visual design stays token-locked by default.
- **`@tollerud/email` branded header + footer (≥ 4.11.0)** — new `EmailHeader` (monogram + project name large, optional divider) and `BrandMark` (the monogram; inline SVG by default, `logoSrc` for a hosted image where Outlook/Gmail strip SVG). All templates take an optional `header?: EmailHeaderProps`. **Breaking:** `EmailFooter` is now the real Tollerud footer (monogram + "A Tollerud Project" → tollerud.no); its `brandName` prop was replaced by `labels` (`tollerudProject`/`attribution`/`allRightsReserved`) — `address`/`unsubscribeUrl`/`links` unchanged.
- **`@tollerud/email` package (≥ 4.10.0)** — HTML email is a separate render target, so it ships as its own package, not through `@tollerud/ui`. It shares *tokens* (not components) via the generated `lib/tokens.ts` — token values are inlined as literals because `var(--tollerud-*)` doesn't resolve in mail clients. Built on React Email. Templates: `WelcomeEmail`, `VerifyEmail`, `PasswordResetEmail`, `ReceiptEmail`. Render with the re-exported `render`. See `packages/email/README.md`. Do **not** import web `@tollerud/ui` components into emails — they break in Outlook/Gmail.
- **`asChild` / `buttonVariants` require `@tollerud/ui >= 1.0.7`**
- **Server Component import safety requires `@tollerud/ui >= 1.0.8`** (earlier versions crash when imported into a Server Component file — the bundle wasn't marked `'use client'`)
- **19 new components (`Divider`, `Pill`, `Avatar`/`AvatarGroup`, `Breadcrumb`, `Pagination`, `Segmented`, `Stepper`, `Panel`, `Meter`, `FormRow`, `Accordion`, `Slider`, `PasswordInput`, `Combobox`, `DatePicker`, `FileUpload`, `TagInput`, `PricingCard`) require `>= 1.0.9`**
- **`Combobox` + `DatePicker` close on window resize (≥ 1.1.0)** — earlier versions left the popover open and misaligned after viewport changes
- **`Segmented` icon segments match text height (≥ 4.5.1)** — earlier versions rendered icon-only segments ~4px shorter than text segments
- **`Button` icon-only matches text height per size (≥ 4.5.2)** — earlier versions sized from padding + content, so icon-only buttons could render shorter than labeled buttons
- **`Combobox` `searchPlacement` (≥ 4.8.25)** — `'dropdown'` moves the search input inside the popover; the trigger becomes a button like `Select`. Default `'trigger'` preserves prior behaviour.
- **`Combobox` input uses `text-sm` (≥ 4.8.14)** — earlier versions used `text-base` (16px), mismatching the 14px dropdown items
- **`StatCard` `change.tone` (≥ 4.8.15)** — optional `'success' | 'error' | 'warning' | 'info' | 'accent'` overrides the default up=green / down=red coloring
- **`SidebarNav` scroll fix (≥ 4.8.16)** — nav content area now scrolls when items overflow the viewport height (missing `min-h-0` on the flex child prevented the scroll context from forming)
- **`StatCard` arrow direction fix (≥ 4.8.19)** — `direction: 'up'` now shows an up arrow (was showing down arrow due to inverted `rotate-180` condition)
- **Form field height alignment (≥ 4.8.18)** — all form field triggers (`Input`, `PasswordInput`, `Combobox`, `DatePicker`, `Textarea`, `Select`) use `text-base py-2.5`. Earlier versions mixed `text-sm`/`text-base` and `py-2`/`py-2.5`, causing height differences when combining field types.
- **Chart SR-table layout fix (≥ 4.8.52)** — the visually-hidden data table (`TimeSeriesChart`/`AreaChart`/`Heatmap`) no longer inflates page height. It's wrapped in an `sr-only` div now (an `sr-only` `<table>` ignored `height:1px` and left a large empty area below the page). No API change.
- **`Heatmap` (≥ 4.8.51)** — calendar activity grid (`data` of `{ date, value }`): week columns × weekday rows, intensity-bucketed cells, hover tooltip, Less→More legend, and an SR data table. `weekStartsOn`, `colors`, `startDate`/`endDate`, `formatValue`/`formatDate`.
- **`Gauge` (≥ 4.8.50)** — radial dial for a single metric (see the Gauge prop entry above).
- **Stacked/grouped `BarChart` (≥ 4.8.49)** — pass `series={[{ label, values, color? }]}` + `categories={[...]}` for grouped bars, add `stacked` for stacked. Palette-cycled, legend, focusable bars/columns with tooltips. Single-series `data` unchanged.
- **All charts responsive (≥ 4.8.48)** — `Sparkline` and `Donut` gain an opt-in `fluid` prop (fill container width / scale ring to fit, both non-breaking); `TimeSeriesChart`, `AreaChart`, `BarChart`, `Meter` are already fluid-width. Sparkline fluid keeps dots round (pixel-plotted); Donut fluid stays circular (uniform scale, legend wraps).
- **`AreaChart` renders pixel-perfect (≥ 4.8.47)** — it now measures its width and plots in pixels instead of stretching a fixed `viewBox`, so point markers stay round and strokes uniform at any container width. No API change.
- **Multi-series `TimeSeriesChart` (≥ 4.8.46)** — pass `series={[{ label, points, color? }]}` instead of `data` for multiple lines: shared crosshair, stacked color-swatch tooltip, palette-cycled colors (`--chart-1…5`), legend, and a per-series column in the SR table. Series must be index-aligned (same dates by position). `data` (single series) is unchanged; area fill + latest badge are single-series only.
- **Chart screen-reader data tables (≥ 4.8.45)** — `TimeSeriesChart` (default on) and `AreaChart` (default = `interactive`) render a visually-hidden `<table>` of every point's value via the `srTable` prop, so SR users get the numbers, not just the chart label. `BarChart`/`Donut` are intentionally excluded (their per-bar aria-labels / legend list already expose the data).
- **`BarChart` + `Donut` interactivity (≥ 4.8.44)** — `BarChart interactive` makes bars focusable (roving tabindex: Tab, ←/→, Home/End, Esc) with tooltips and per-bar aria-labels; new `formatValue`/`ariaLabel`. `Donut` segment `color` is optional (cycles `--chart-1…5`, exported as `CHART_SERIES_COLORS`); `interactive` makes legend rows focusable — active row highlights its arc, dims others, reveals the raw value. BarChart/Meter transitions respect `prefers-reduced-motion`. Static usage unchanged.
- **`AreaChart` + `Sparkline` interactivity (≥ 4.8.43)** — both support the shared chart keyboard contract (Tab, ←/→, Home/End, Esc) with tooltips and screen-reader announcements when `interactive` is set. `AreaChart` gains `interactive` / `formatValue` / `renderTooltip` / `ariaLabel` and labeled-point data (`{ value, label }[]`); `Sparkline` gains `formatValue` / `ariaLabel`. Static usage is unchanged and stays `aria-hidden`.
- **`TimeSeriesChart` keyboard navigation (≥ 4.8.42)** — Tab focuses the chart (latest point active), ←/→ step through points with crosshair + tooltip, Home/End jump, Esc clears without closing a surrounding Dialog. Screen readers hear keyboard-selected points via a live region. Hover/touch behavior unchanged.
- **`position: sticky` works inside `PageShell` (≥ 4.8.41)** — the shell root previously used `overflow-hidden`, which made it the sticky containing scroll ancestor and silently disabled sticky for every descendant (`DashboardShell` sidebar, `TopNav sticky`, `DashboardTopBar`, consumer sticky elements). Now `overflow-clip`. The `DashboardShell` sidebar wrapper also gained `lg:self-start` so it isn't flex-stretched to the full content height (a sticky element as tall as its containing block has zero travel room).
- **Keyboard & screen-reader pass (≥ 4.8.40)** — `Combobox`/`Select`/`CommandMenu` announce the arrow-key highlight via `aria-activedescendant`; `Select`'s trigger is `role="combobox"` (query it with `getByRole('combobox')` in tests, not `'button'`). `DataTable` sortable headers are real `<button>`s inside the `<th>`. Escape on an open `Combobox`/`Select` no longer closes a surrounding Dialog. `CommandMenu` Enter now runs the highlighted *filtered* result. Focus rings use `var(--tollerud-yellow-warm)` instead of hardcoded `#E8D500`. `Checkbox` gains an `indeterminate` prop (dash indicator + native mixed state); `DataTable`'s select-all header checkbox uses it automatically for partial page selection. **Breaking:** `DataTable` `Column.render` is always `(value, row)` — the row-only `(row) => …` form was removed; migrate with `(_v, row) => …`.
- **Portalled dropdowns work inside Radix Dialog (≥ 4.8.36–4.8.39)** — `Combobox`, `Select`, and `DatePicker` dropdowns no longer close the dialog on click, and `Combobox` `searchPlacement="dropdown"` auto-focuses the search input even when used inside a Dialog. Four-part fix across 4.8.36–4.8.39: stop `pointerdown`/`focusin` from reaching Radix's document handlers; fix focus timing with callback ref + `queueMicrotask`; upgrade escape hatch to `useLayoutEffect`; add capture-phase `focusout` listener on `document` to stop Radix's second focus-redirection handler (which fires on `focusout` before `focusin` even gets a chance). No consumer API changes.
- **`GlowCard` visibility fix (≥ 4.8.35)** — glow was invisible when wrapping solid-background surfaces. Overlay now renders above content with `mix-blend-mode: screen`, making the radial gradient visible through any child background.
- **First-class a11y + `Button` loading + `Card` asChild (≥ 4.8.34)** — all form fields with `error` now wire `aria-invalid` + `aria-describedby`; all form fields accept `required` with visual `*` and `aria-required`. `Button` gains `loading` prop (Spinner + `aria-busy`). `Tooltip` opens/closes on keyboard focus/blur. `Card` gains `asChild`. `FileUpload` and `Combobox` are now wrapped with `forwardRef`.
- **`Card` density + `GlowCard` glow fix (≥ 4.8.33)** — `density="compact"` now works (reduces to `p-3`); `[[data-density=compact]_&]:p-3` handles wrapper-based density. `GlowCard` `intensity` now controls overlay opacity (was incorrectly used as gradient color stop).
- **Docs demos translated to English (4.8.32)** — all demo copy, labels, and values in `docs-app/` are now in English.
- **Multi-word shimmer (≥ 4.8.31)** — `shimmer` on both `PromoSection` and `PageHeader` now accepts `string | string[]`. Array entries each get their own shimmer span; overlapping/non-matching entries are silently skipped.
- **`PromoSection` `shimmer` prop + PageHeader eyebrow (≥ 4.8.30)** — `shimmer="word"` accents one word in the title; eyebrow now matches `PageHeader` (mono uppercase yellow, not a Pill).
- **`PromoSection` mobile fix + `contentWidth` (≥ 4.8.29)** — collapses to single column below `sm:`, text always first on mobile, `overflow-hidden` on visual slot. `contentWidth?: "sm"|"md"|"lg"|"xl"|"full"` (default `"xl"`) caps inner content while the wrapper can go full-bleed.
- **`PromoSection` added (≥ 4.8.28)** — two-column marketing block; `visualPlacement`, `background`, `textWidth` control layout.
- **`PriceDisplay` + `ListCard` added (≥ 4.8.27)** — new consumer-facing components for list rows.
- **`Card` `accent="filled"` (≥ 4.8.27)** — adds yellow fill in addition to border tint; existing `accent={true}` unchanged.
- **`Sparkline` SVG clip fix (≥ 4.8.27)** — `overflow="hidden"` prevents stroke bleed at small sizes with `curve="step"` and `fill`.
- **Portalled dropdown mobile scroll fix (≥ 4.8.26)** — `Combobox`, `Select`, `DatePicker`, and `Segmented` now close on outside scroll on touch devices instead of lagging to reposition.
- **iOS Safari Combobox fix (≥ 4.8.53)** — `searchPlacement="dropdown"` was unusable on iOS: the 14px auto-focused search input triggered auto-zoom, and the zoom's scroll event immediately dismissed the panel. The in-dropdown search input now renders ≥16px on coarse pointers (`pointer-coarse:text-base`, desktop unchanged), and portalled dropdowns only dismiss on a genuine touch drag. Also stops non-search dropdowns from closing when iOS scrolls a focused field above the keyboard.
- **Mobile dropdown float/flip fix (≥ 4.8.54)** — follow-up to 4.8.53. Placement is sticky on portalled dropdowns (`Combobox`, `Select`, `DatePicker`, `Segmented`): filtering a `Combobox` down to one result no longer flips the panel from top to bottom.
- **Mobile dropdown detach fix (≥ 4.8.55)** — follow-up to 4.8.54, which placed the panel once and never repositioned, leaving it stranded away from its trigger after iOS scrolled the focused search input above the keyboard. On touch the hook now separates a programmatic settle scroll (keyboard/address-bar/focus — repositions to stay glued to the trigger) from a user fling and its momentum tail (dismisses). Repositions are coalesced per animation frame and `visualViewport` resize is handled, so the panel tracks the field without floating. Desktop still tracks the anchor on scroll/resize.
- **Portalled dropdowns use Floating UI (≥ 4.8.56)** — supersedes the bespoke positioning in 4.8.26–4.8.55. `Combobox`, `Select`, `DatePicker`, and `Segmented` are now positioned by Floating UI (the engine behind Radix Popover / shadcn) with `autoUpdate` + `flip`/`shift`/`size`, so they flip, shift, clamp height, and stay glued to the trigger across scroll, resize, and iOS keyboard/zoom/address-bar changes. On touch they now stay open and reposition on scroll instead of closing (the 4.8.26 close-on-scroll workaround is gone); outside-click and Escape still dismiss. The internal `lib/dropdown-placement.ts` helpers and the `FloatingDropdownPortal` `onOutsideScroll` prop were removed (internal only — no public component API changed).
- **`DataTable` touch scroll fix (≥ 4.8.57)** — removed `touch-pan-x` from the horizontal scroll wrapper, which blocked vertical page scroll on mobile when a swipe started on table rows or headers. Horizontal scroll for wide tables is unchanged (`overflow-x-auto` + `overscroll-x-contain`).
- **`ScrollRail` (≥ 4.9.0)** — horizontal scroll rail for card rows, image strips, and overflow content. `visibleCount` (≥ 4.9.2) for N-fill-then-scroll layouts; `peek`/`controls`/`fadeEdges` only when overflowing; `itemWidth` for fixed-pixel strips. Item wrappers stretch slot height (≥ 4.9.3) so `h-full` on children works without extra markup. Does not use `touch-pan-x`.
- **`PriceDisplay` `size` (≥ 4.9.4)** — `size?: "sm" | "md" | "lg"` scales primary value and secondary badge together. **≥ 4.9.5:** primary size classes are literal (fixes production purge).
- **`CardChange` `flat` (≥ 4.9.9)** — `direction: "flat"` for unchanged: minus icon, default `text-tollerud-info`, label defaults to `—`.
- **`Card` compound parts (≥ 4.9.4)** — `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter` with darker header/footer bands. Plain `<Card>` padding unchanged. **≥ 4.9.5:** `CardContent` explicitly keeps the raised surface background. **≥ 4.9.6:** header/footer bands use subtle `color-mix` on raised surface instead of `noir-950`. **≥ 4.9.7:** `CardChange` up/down chip + `CardHeader` `actions` slot; `StatCard` uses `CardChange` internally. **≥ 4.9.8:** `accent={true}` adds subtle yellow band tint on header/footer (body stays raised).
- **`DataTable` selection borders (≥ 4.9.4)** — selected rows use a uniform background instead of per-cell inset rings (no doubled borders between adjacent cells).
- **`Dialog` layout + sizes (≥ 4.9.1)** — `DialogContent` `size` presets (`sm`–`full`, default `md` / `max-w-xl`), bordered `DialogHeader`/`DialogFooter`, scrollable body, new `DialogBody` slot, and `DialogPanel` controlled helper (`open`/`onClose`/`title`/`description`/`footer`).
- **`TopNav` `mobileMenuExtra` slot (≥ 4.8.24)** — inject arbitrary content at the bottom of the mobile nav sheet, separated by a divider.
- Always pin to the latest patch and check `CHANGELOG.md` in the design-system repo for breaking changes (e.g. the 1.0.5 yellow token rename: `tollerud-yellow-bright` → `tollerud-yellow`, old `tollerud-yellow` `#E8D500` → `tollerud-yellow-warm`)

---

## CHANGELOG.md format rules

The docs site at `design.tollerud.dev` parses `CHANGELOG.md` at runtime. Wrong formatting causes entries to render as a wall of text. Follow these rules whenever you write a changelog entry:

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

**Blank lines are required** between every distinct block. The parser splits sections at blank lines — missing blank lines cause everything to merge into one paragraph.

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

Short summary.

### New components

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

### Migration

Nothing breaking.
```

**Never** write bold inline mid-paragraph as a heading substitute with no blank line before it — it will merge with surrounding text into one unreadable block.
