frontend-design-saas

$npx mdskill add cloudflare/vibesdk/frontend-design-saas

Build S-tier SaaS UIs with Stripe/Linear/Vercel-inspired design patterns

  • Solves UI design challenges for dashboards, settings, billing, and admin tools
  • Leverages neutral design tokens, spacing, typography, and accessibility standards
  • Applies patterns from leading SaaS products like Stripe, Vercel, and Notion
  • Delivers code-ready UI shells, components, and layouts for rapid development

SKILL.md

.github/skills/frontend-design-saasView on GitHub ↗
---
name: frontend-design-saas
description: S-tier SaaS dashboard and product UI reference. Use this skill when building application shells, data tables, settings panels, billing pages, dashboards, auth flows, admin tools, or any internal/customer-facing SaaS product UI. Inspired by Stripe, Linear, Vercel, Airbnb, Notion. Covers neutral-led design tokens, sidebar+content shells, dense data UIs, form-heavy configuration pages, command palettes, empty states, and the accessibility (WCAG AA+) bar these products clear.
---

# SaaS Design System (Stripe / Linear / Vercel lineage)

> **AI-Optimized Design Reference** for building S-tier SaaS dashboards, admin consoles, billing pages, settings UIs, data-dense application shells, and product surfaces.
>
> Tonal anchors: `stripe.com/dashboard`, `linear.app`, `vercel.com/dashboard`, `notion.so/teamspace`, `clerk.com`, `planetscale.com/console`.

---

## Quick Reference (TL;DR)

```
Brand Accent:    #5E6AD2 (indigo) - replace with product brand
Neutrals:        #FFFFFF → #F8FAFC → #F1F5F9 → #E2E8F0 → #94A3B8 → #475569 → #0F172A
Text:            #0F172A (primary) / #475569 (muted) / #94A3B8 (subtle)
Border:          #E2E8F0 (default) / #CBD5E1 (strong)
Font Sans:       "Inter", "Geist", system-ui (NOT Arial, NOT Roboto)
Font Mono:       "JetBrains Mono", "Geist Mono", ui-monospace
Base Spacing:    4px (scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64)
Border Radius:   Buttons/inputs = 6-8px (NOT pill), Cards = 8-12px, Modals = 12-16px
Shadow:          Tight, low-spread, single-direction (NOT diffuse marketing shadows)
Density:         Compact-to-comfortable; row heights 36-44px, button heights 32-36px
```

The fastest way to get this wrong: use marketing-page aesthetics (huge type, generous whitespace, pill buttons, gradient hero shadows) in a product surface. SaaS UIs are **dense, neutral, fast, and quiet**. The brand color appears in <5% of the pixels and earns its weight by marking only what's interactive or active.

---

## 1. Brand Foundation

### Design Philosophy

| Principle | Description |
|-----------|-------------|
| **Users First** | Workflows, keyboard navigation, and information density beat decoration. |
| **Quiet by Default** | Neutrals dominate; the brand color is reserved for primary action, active state, and focus. |
| **Speed & Density** | Snappy transitions (≤200ms), compact row heights, no entrance animations on every page load. |
| **Opinionated Defaults** | Single canonical pattern per surface (one button hierarchy, one date picker, one empty state). |
| **Meticulous Craft** | 1px borders aligned to pixel grid, focus rings 2px offset 2px, motion uses ease-out not linear. |
| **Accessible (WCAG AA+)** | 4.5:1 text contrast, 3:1 UI element contrast, every interactive surface keyboard-reachable. |
| **Predictable** | Same component looks/behaves identically on every page; no marketing-grade variants. |

### Visual Identity Rules

- **Do not use pure black on pure white.** Use `#0F172A` on `#FFFFFF` (or `#0B1220` on `#0A0E1A` in dark mode).
- **Brand color is the accent, never the dominant.** If the brand color fills more than ~5% of the viewport in steady state, something is wrong.
- **Avoid pill buttons** (`border-radius: 9999px`) for SaaS actions. Pills read as marketing/consumer; SaaS uses `6-8px` radius for buttons and inputs.
- **No glassmorphism, no blur, no gradient borders.** The aesthetic is precision, not richness.
- **Shadows are tight and downward.** Not diffuse, not multi-direction. Reserved for floating layers (popovers, modals, dropdowns).
- **One font.** Inter / Geist / IBM Plex Sans / native system stack. Avoid mixing display + body fonts; SaaS uses a single workhorse sans.
- **Mono for IDs, code, tokens, money.** Tabular figures (`font-variant-numeric: tabular-nums`) for any column of numbers.

### Reference apps to mentally calibrate against

| Surface to build | Look at |
|---|---|
| Application shell | Linear, Vercel, Stripe Dashboard |
| Data table | Stripe `/payments`, Linear backlog, PlanetScale branches |
| Settings page | Vercel project settings, Clerk dashboard, GitHub repo settings |
| Billing / invoice | Stripe billing, Vercel usage |
| Empty state | Linear, Notion, Vercel deployments |
| Command palette | Linear ⌘K, Vercel ⌘K, Raycast |
| Auth | Clerk hosted pages, Stripe Connect onboarding |
| Onboarding checklist | Stripe Atlas, Vercel project import |

---

## 2. Color System

### 2.1 Neutral Scale (the backbone)

SaaS UIs are 80–90% neutrals. The neutral scale carries the entire interface; brand is sprinkled on top. Use a slightly cool neutral (slate) by default — it reads more "product" than warm grays which feel marketing/lifestyle.

| Token | Hex | Usage |
|---|---|---|
| `--neutral-0` | `#FFFFFF` | Page background (light mode), surface lifted onto canvas |
| `--neutral-50` | `#F8FAFC` | Canvas background, alternating row stripe |
| `--neutral-100` | `#F1F5F9` | Hover background, subtle fill, table header |
| `--neutral-200` | `#E2E8F0` | **Default border**, divider |
| `--neutral-300` | `#CBD5E1` | Strong border, input border on focus-within parent |
| `--neutral-400` | `#94A3B8` | **Subtle text** (placeholders, disabled, captions) |
| `--neutral-500` | `#64748B` | Tertiary text, icon default |
| `--neutral-600` | `#475569` | **Muted text** (descriptions, secondary labels) |
| `--neutral-700` | `#334155` | Strong icon, hover text |
| `--neutral-800` | `#1E293B` | Heading in dense UIs, code text |
| `--neutral-900` | `#0F172A` | **Primary text**, primary icons |
| `--neutral-950` | `#020617` | Highest contrast (rare; only for critical labels) |

### 2.2 Brand Accent (single color, used sparingly)

Pick **one** accent. Use a 6-step ramp so hover/active/focused/disabled all derive from the same hue. Defaults below use indigo (`#5E6AD2`, Linear's accent) — swap the hue for your brand but keep the structure.

| Token | Hex | Usage |
|---|---|---|
| `--brand-50` | `#EEF0FB` | Selected row background, badge background, focus ring background |
| `--brand-100` | `#DDE0F7` | Hover on brand surfaces |
| `--brand-200` | `#B9C0EF` | Disabled brand button, decorative |
| `--brand-500` | `#7B85DC` | Brand hover surface stroke |
| `--brand-600` | `#5E6AD2` | **Primary brand** — default button bg, link text, focus ring, active nav item |
| `--brand-700` | `#4A56C0` | Hover state for primary button |
| `--brand-800` | `#3A45A8` | Active/pressed state |
| `--brand-900` | `#2A3284` | High-contrast brand text on light background |

### 2.3 Semantic / Status Colors

Used for state — never for decoration. Each has a `text`, `bg`, `border` triplet so badges and inline messages stay legible.

| State | `text` | `bg` (filled) | `bg-subtle` (badge) | `border` |
|---|---|---|---|---|
| **Success** | `#15803D` | `#16A34A` | `#DCFCE7` | `#86EFAC` |
| **Warning** | `#A16207` | `#CA8A04` | `#FEF9C3` | `#FDE68A` |
| **Error / Destructive** | `#B91C1C` | `#DC2626` | `#FEE2E2` | `#FCA5A5` |
| **Info** | `#1D4ED8` | `#2563EB` | `#DBEAFE` | `#93C5FD` |
| **Neutral (default badge)** | `#334155` | `#64748B` | `#F1F5F9` | `#CBD5E1` |

Rules:
- Inline form errors: `text` color on `bg-subtle` background, `border` for the input.
- Toasts: `bg-subtle` panel, `text` heading, `--neutral-700` body.
- Status badges: `text` on `bg-subtle` + 1px `border`. Never use solid `bg` for badges (too loud).

### 2.4 Data Visualization Palette

When you need >1 color for charts. Categorical — visually distinct, similar luminance. Avoid the brand color in this palette so brand reads as "interactive" elsewhere.

```
--chart-1: #2563EB  (blue)
--chart-2: #16A34A  (green)
--chart-3: #CA8A04  (amber)
--chart-4: #DC2626  (red)
--chart-5: #9333EA  (violet)
--chart-6: #0891B2  (cyan)
--chart-7: #DB2777  (pink)
--chart-8: #65A30D  (lime)
```

### 2.5 Dark Mode Mapping

SaaS dark modes are **not just inverted**. They are slightly desaturated, slightly lifted, with carefully tuned elevation. Use `#0A0E1A` (or `#0B0F1C`) as the canvas — NEVER `#000000`. Pure black creates harsh contrast and exposes any banding in shadows.

| Token | Light | Dark |
|---|---|---|
| `--bg-canvas` | `#F8FAFC` | `#0A0E1A` |
| `--bg-surface` | `#FFFFFF` | `#111827` |
| `--bg-elevated` | `#FFFFFF` | `#1F2937` |
| `--bg-overlay` (popover/modal) | `#FFFFFF` | `#1F2937` |
| `--text-primary` | `#0F172A` | `#F1F5F9` |
| `--text-muted` | `#475569` | `#94A3B8` |
| `--text-subtle` | `#94A3B8` | `#64748B` |
| `--border-default` | `#E2E8F0` | `#1F2937` |
| `--border-strong` | `#CBD5E1` | `#374151` |
| `--brand-bg-subtle` | `#EEF0FB` | `rgba(94, 106, 210, 0.16)` |
| `--brand-fg` | `#5E6AD2` | `#A5B4FC` |

In dark mode the **brand color shifts lighter** (toward `--brand-500/400` instead of `--brand-600/700`) to maintain contrast against the dark canvas.

---

## 3. Typography

### 3.1 Font Stack

```css
--font-sans: "Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
             "SF Pro Text", system-ui, sans-serif;
--font-mono: "JetBrains Mono", "Geist Mono", "SF Mono", "Menlo", "Consolas",
             ui-monospace, monospace;
```

Hard rules:
- **Never use `Arial`, `Helvetica`, `Roboto`, or `Times`** in a SaaS UI. These read as default/unstyled. If you can't load a webfont, fall through to `system-ui` instead.
- **Never mix two display fonts.** One sans + one mono is the entire stack.
- **`font-feature-settings: "cv11", "ss01", "ss03"`** for Inter (or whatever stylistic sets your font has) — small caps and alternate forms make tables look intentional.
- **`font-variant-numeric: tabular-nums`** on EVERY column that contains numbers (money, dates, IDs, counts). Non-tabular figures make rows shimmer.

### 3.2 Type Scale (SaaS-tuned — denser than marketing)

| Token | Size | Line Height | Weight | Letter Spacing | Use |
|---|---|---|---|---|---|
| `text-2xs` | 11px / 0.6875rem | 1.36 (15px) | 500 | 0.04em | Microlabels (uppercase metadata, table column tags) |
| `text-xs` | 12px / 0.75rem | 1.33 (16px) | 400/500 | 0 | Captions, badge text, table secondary |
| `text-sm` | 13px / 0.8125rem | 1.38 (18px) | 400/500 | 0 | **Default UI text**: table rows, buttons, form labels |
| `text-base` | 14px / 0.875rem | 1.43 (20px) | 400/500 | 0 | Body text, paragraph copy in panels |
| `text-md` | 15px / 0.9375rem | 1.47 (22px) | 400/500 | 0 | Comfortable reading (descriptions, modals) |
| `text-lg` | 16px / 1rem | 1.5 (24px) | 500/600 | -0.005em | Card headings, sidebar section labels |
| `text-xl` | 18px / 1.125rem | 1.44 (26px) | 600 | -0.01em | Subsection headings |
| `text-2xl` | 20px / 1.25rem | 1.4 (28px) | 600 | -0.015em | Page subheadings |
| `text-3xl` | 24px / 1.5rem | 1.33 (32px) | 600 | -0.02em | Page title (h1 in dashboards) |
| `text-4xl` | 30px / 1.875rem | 1.2 (36px) | 600/700 | -0.025em | Marketing-adjacent hero in settings |

**Critical**: the default UI size in SaaS is **13px or 14px**, NOT 16px. Stripe is 14px. Linear is 13px. Vercel is 14px. 16px feels too big and creates pages that scroll forever.

### 3.3 Weights

| Weight | Value | Use |
|---|---|---|
| Regular | 400 | Body, table cells, descriptions |
| Medium | 500 | Default for buttons, labels, table headers, navigation, badge text |
| Semibold | 600 | Headings, emphasized stats, active nav |
| Bold | 700 | Rare — only marketing-adjacent surfaces or KPI numbers |

**Never** use weight 800 or 900 in a SaaS UI.

### 3.4 Letter Spacing

| Context | Value |
|---|---|
| Body / table | 0 |
| Headings ≥18px | -0.01em to -0.025em (tighter as size grows) |
| Microlabels / uppercase | 0.04em to 0.06em |
| Mono | 0 |

### 3.5 Text Rendering

```css
html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
}

.numeric, table, [data-numeric] {
  font-variant-numeric: tabular-nums;
}
```

---

## 4. Spacing System

### 4.1 Base Unit

```css
--space-unit: 4px;
```

### 4.2 Scale

| Token | px | rem | Common Use |
|---|---|---|---|
| `space-0` | 0 | 0 | Reset |
| `space-px` | 1 | — | Hairlines, dividers |
| `space-0.5` | 2 | 0.125 | Tight icon-to-text gaps |
| `space-1` | 4 | 0.25 | Inline tight |
| `space-1.5` | 6 | 0.375 | — |
| `space-2` | 8 | 0.5 | Default tight gap, icon-text, badge padding |
| `space-2.5` | 10 | 0.625 | Input vertical padding (compact) |
| `space-3` | 12 | 0.75 | Input vertical padding (default), small card |
| `space-4` | 16 | 1 | **Default card/panel padding**, form field gaps |
| `space-5` | 20 | 1.25 | Section subheading gap |
| `space-6` | 24 | 1.5 | Card padding (comfortable), section internal |
| `space-8` | 32 | 2 | Page section gap, large card padding |
| `space-10` | 40 | 2.5 | Major section gap |
| `space-12` | 48 | 3 | Page-level section gap |
| `space-16` | 64 | 4 | Hero, max page-section |

**Density convention** (which scale value to default to):

- Buttons: `8px 12px` (sm), `8px 14px` (md), `10px 16px` (lg) — vertical padding stays tight
- Inputs: `8px 12px` (sm), `9px 12px` (md), `10px 14px` (lg)
- Cards: `16px` (sm), `20-24px` (md), `32px` (lg / settings panel)
- Table cells: `10px 16px` (compact), `12px 16px` (default), `16px 20px` (comfortable)
- Page padding: `24px` (mobile) → `32px` (desktop) → `48px` (wide settings)

---

## 5. Border Radius

| Token | Value | Use |
|---|---|---|
| `--radius-none` | 0 | Table cells, full-bleed sections |
| `--radius-sm` | 4px | Badges, tags, small chips |
| `--radius-md` | 6px | **Default**: buttons, inputs, dropdowns, menu items |
| `--radius-lg` | 8px | Cards, panels, popovers |
| `--radius-xl` | 12px | Modals, large surfaces, sheets |
| `--radius-2xl` | 16px | Marketing-adjacent (pricing card, onboarding) |
| `--radius-full` | 9999px | Avatars, status dots, **NOT buttons** |

Rules:
- **Buttons in SaaS UIs use 6px, not pill (`9999px`).** Pills are for marketing/consumer apps. The exception is a "tag" or "chip" inside a table cell.
- **Match input and button radius.** If button = 6px, input = 6px. Visual consistency in form rows.
- **Cards never share radius with modals.** Cards = 8px, modals = 12px+. This is how the modal reads as a different elevation layer.

---

## 6. Shadow System (Elevation)

SaaS shadows are **tight, downward, single-direction**. They mark elevation; they do not decorate. No glows, no colored shadows, no inset highlights, no large spread radii.

### 6.1 Elevation Tokens

```css
/* Light mode */
--shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);
--shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05),
              0 1px 3px 0 rgba(15, 23, 42, 0.06);
--shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06),
              0 4px 6px -2px rgba(15, 23, 42, 0.04);
--shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05),
              0 10px 15px -3px rgba(15, 23, 42, 0.08);
--shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06),
              0 20px 25px -5px rgba(15, 23, 42, 0.10);
--shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);

/* Focus ring (NOT a shadow, but the only "glow" allowed) */
--ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);
--ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);

/* Dark mode: shadows almost invisible; elevation is conveyed via background lightening */
--shadow-xs-dark:  0 1px 2px 0 rgba(0, 0, 0, 0.30);
--shadow-sm-dark:  0 1px 3px 0 rgba(0, 0, 0, 0.40);
--shadow-md-dark:  0 4px 8px -2px rgba(0, 0, 0, 0.50);
--shadow-lg-dark:  0 12px 20px -6px rgba(0, 0, 0, 0.55);
```

### 6.2 Elevation → Component Mapping

| Layer | Shadow | Examples |
|---|---|---|
| 0 (flat on canvas) | none | Sidebar, page body, inline rows |
| 1 (resting card) | `--shadow-xs` or none + 1px border | Default card on `--bg-canvas` |
| 2 (hovered card) | `--shadow-sm` | Card with `:hover` state |
| 3 (dropdown, popover) | `--shadow-md` | Menu, popover, select dropdown |
| 4 (modal, dialog) | `--shadow-xl` | Dialog, slide-over |
| 5 (toast over modal) | `--shadow-2xl` | Toast, command palette over modal backdrop |

### 6.3 Border-vs-Shadow Decision

For most cards, **prefer a 1px border over a shadow**. Borders are crisper at any DPI and read more "product". Use shadow only when:
- The element is floating over content (popover, modal, dropdown, toast)
- A drag-and-drop ghost is rendered
- A row is "lifted" to indicate selection while dragging

---

## 7. Animation System

### 7.1 Principle

SaaS motion is **fast, ease-out, and only on user-initiated events**. No entrance animations on page mount, no decorative parallax, no stagger on every grid. Motion's job is to confirm an action happened, not to entertain.

### 7.2 Easing

```css
/* Default — used for most state transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);   /* expo-out, snappy */

/* For things that recoil (modal entrance, popover) */
--ease-out-back: cubic-bezier(0.34, 1.56, 0.64, 1);

/* Press / active (deceleration into rest) */
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);

/* Linear — for indeterminate progress / spinners only */
--ease-linear: linear;
```

**Do NOT use** `ease`, `ease-in`, or unspecified defaults. They feel sluggish in SaaS contexts.

### 7.3 Duration Scale

| Token | ms | Use |
|---|---|---|
| `--duration-75` | 75 | Color change, opacity flip (button press color) |
| `--duration-100` | 100 | Hover background fill |
| `--duration-150` | 150 | **Default** — most transitions |
| `--duration-200` | 200 | Dropdown/popover enter |
| `--duration-250` | 250 | Modal/dialog enter |
| `--duration-300` | 300 | Slide-over (drawer) enter |
| `--duration-500` | 500 | Skeleton shimmer cycle |

**Anything longer than 300ms in a product UI is wrong.** Marketing pages can take more time; product UIs cannot.

### 7.4 Standard Transitions

```css
/* Color/border (default for all interactive elements) */
button, a, input, select, textarea, [role="button"] {
  transition:
    background-color var(--duration-150) var(--ease-out),
    border-color var(--duration-150) var(--ease-out),
    color var(--duration-150) var(--ease-out),
    box-shadow var(--duration-150) var(--ease-out);
}

/* Modal/dialog */
.dialog-enter { animation: dialog-enter 250ms var(--ease-out-back); }
@keyframes dialog-enter {
  from { opacity: 0; transform: translateY(8px) scale(0.98); }
  to   { opacity: 1; transform: translateY(0) scale(1); }
}

/* Popover/dropdown */
.popover-enter { animation: popover-enter 150ms var(--ease-out); }
@keyframes popover-enter {
  from { opacity: 0; transform: translateY(-4px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Toast slide-in */
.toast-enter { animation: toast-enter 200ms var(--ease-out); }
@keyframes toast-enter {
  from { opacity: 0; transform: translateX(8px); }
  to   { opacity: 1; transform: translateX(0); }
}

/* Skeleton shimmer */
.skeleton {
  background: linear-gradient(90deg, var(--neutral-100) 25%, var(--neutral-200) 50%, var(--neutral-100) 75%);
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
}
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}
```

### 7.5 What NOT to Animate

- **Page mount.** Render synchronously. The page is the priority, not the motion.
- **Sidebar nav items individually staggering in.** Render the whole nav at once.
- **Tab content fade.** Switching tabs is an instant context change; fade hides the change.
- **Table rows.** A 1000-row table that fades in row-by-row is unusable.

### 7.6 What SHOULD Animate

- Button hover (background-color 100ms)
- Focus ring (box-shadow 150ms)
- Modal/dialog enter and exit (250ms)
- Popover/dropdown enter (150ms)
- Toast slide-in (200ms)
- Skeleton shimmer (loading states)
- Accordion expand/collapse (height 200ms)
- Optimistic UI confirmations (checkmark scale-in 150ms)

### 7.7 `prefers-reduced-motion`

```css
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
```

Always include. SaaS users may have vestibular sensitivity or use the OS-level reduce-motion setting.

---

## 8. Layout System

### 8.1 The Canonical SaaS Shell

```
┌─────────────────────────────────────────────────────────────────┐
│ [Topbar: workspace switcher | search ⌘K | user menu] 48px       │
├──────────────┬──────────────────────────────────────────────────┤
│              │                                                  │
│              │   Page header                                    │
│              │   ┌────────────────────────────────────────────┐ │
│              │   │ Title             [primary action] [⋯ menu]│ │
│   Sidebar    │   │ Description / breadcrumb                   │ │
│   (240-     │   └────────────────────────────────────────────┘ │
│    280px)    │                                                  │
│              │   Page body (cards, tables, settings)            │
│   Nav items  │                                                  │
│   Sections   │   ┌──────────────────────────────────────────┐   │
│   Footer     │   │  Card / Panel                            │   │
│              │   └──────────────────────────────────────────┘   │
│              │                                                  │
└──────────────┴──────────────────────────────────────────────────┘
```

This is the Stripe/Linear/Vercel/Notion layout. Three regions:

1. **Topbar** (48–56px): workspace/team switcher on the left, global search (`⌘K`) center or right, user menu on far right. Stays fixed during scroll.
2. **Sidebar** (240–280px): primary nav. Collapsible to a 56px icon rail on narrow screens. Stays fixed during scroll.
3. **Content** (remaining width, max ~1200px centered for readability OR full-width for tables): page header → page body.

Variations:
- **Linear-style** has no topbar — the sidebar takes the whole left edge, search lives inside the content header.
- **Notion-style** has only a sidebar, page-level breadcrumbs at top of content.
- **Settings-style** uses a *secondary* sidebar (settings nav) inside the content area, so two sidebars are visible.

### 8.2 Container Widths

| Token | px | Use |
|---|---|---|
| `--container-sm` | 640 | Single-column forms, auth, dialogs |
| `--container-md` | 768 | Settings forms, account pages |
| `--container-lg` | 1024 | Dashboard pages, default content max |
| `--container-xl` | 1280 | Wide dashboards with sidebars-in-content |
| `--container-2xl` | 1536 | Data tables, monitoring views (full-bleed) |

**Tables get `--container-2xl` or full-bleed.** Settings forms get `--container-md` (max ~720px reading column).

### 8.3 Breakpoints

| Name | min-width | Behavior |
|---|---|---|
| `sm` | 640px | Form inputs reach full width on phones |
| `md` | 768px | Two-column form layouts kick in |
| `lg` | 1024px | Sidebar appears (below this, sidebar becomes a sheet/drawer) |
| `xl` | 1280px | Sidebar stays expanded; data tables show all columns |
| `2xl` | 1536px | Multi-column dashboards |

Mobile rule: the sidebar collapses to a hamburger that opens a full-height sheet. The topbar's search and user menu remain.

### 8.4 Grid Patterns

```css
/* Page header — title left, actions right */
.page-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 24px;
}

/* Two-column form (label left, input right) — settings page idiom */
.form-row {
  display: grid;
  grid-template-columns: minmax(0, 240px) minmax(0, 1fr);
  gap: 24px;
  padding: 20px 0;
  border-bottom: 1px solid var(--border-default);
}
.form-row:last-child { border-bottom: none; }
.form-row__label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.form-row__hint  { font-size: 12px; color: var(--text-muted); margin-top: 4px; }

/* Card grid (3-col on desktop, 2 on tablet, 1 on mobile) */
.card-grid {
  display: grid;
  gap: 16px;
  grid-template-columns: 1fr;
}
@media (min-width: 768px) { .card-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .card-grid { grid-template-columns: repeat(3, 1fr); } }

/* KPI strip — auto-fit, never wider than 280, never narrower than 200 */
.kpi-strip {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 12px;
}
```

### 8.5 Sidebar Anatomy

```
[Workspace switcher]      32px row, hover bg, dropdown chevron
─────────────────────
SECTION LABEL             11px uppercase, --neutral-500, 8px y-padding
  [icon] Item 1           36px row, 13px text, 8px gap, hover bg-100
  [icon] Item 2           selected: bg-brand-50 + brand-700 text
  [icon] Item 3
─────────────────────
SECTION LABEL
  [icon] Item 4
─────────────────────
                          (stretches to fill)
[avatar] user@email       Bottom; opens user menu upward
```

Key spec:
- Sidebar bg: `--bg-canvas` (slightly tinted from page surface) OR `--bg-surface` flat
- Nav items: 36px tall, 8px horizontal padding, 6px radius, gap 8px between icon + text
- Active item: `bg: var(--brand-50)`, `color: var(--brand-700)`, **no border, no left-accent stripe** (Linear-style minimal)
- Section labels: `text-2xs` uppercase, `--neutral-500`, 8px y-padding
- Width: 256px default, 56px collapsed (icons only)

---

## 9. Dark Mode

### 9.1 Detection

```html
<script>
  (function() {
    var stored = localStorage.getItem('theme');
    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    var dark = stored === 'dark' || (stored !== 'light' && prefersDark);
    document.documentElement.classList.toggle('dark', dark);
  })();
</script>
```

Inline in `<head>` BEFORE any stylesheets to prevent flash-of-light-theme on dark-preferred users.

### 9.2 CSS Token Mapping

```css
:root {
  --bg-canvas: #F8FAFC;
  --bg-surface: #FFFFFF;
  --bg-elevated: #FFFFFF;
  --text-primary: #0F172A;
  --text-muted: #475569;
  --text-subtle: #94A3B8;
  --border-default: #E2E8F0;
  --border-strong: #CBD5E1;
  --brand-fg: #5E6AD2;
  --brand-bg-subtle: #EEF0FB;
}

.dark {
  --bg-canvas: #0A0E1A;
  --bg-surface: #111827;
  --bg-elevated: #1F2937;
  --text-primary: #F1F5F9;
  --text-muted: #94A3B8;
  --text-subtle: #64748B;
  --border-default: #1F2937;
  --border-strong: #374151;
  --brand-fg: #A5B4FC;
  --brand-bg-subtle: rgba(94, 106, 210, 0.16);
}
```

### 9.3 Three-Way Theme Toggle

Always offer `system | light | dark`. The user expects to match OS preference by default but be able to override.

```javascript
function setTheme(mode /* 'system' | 'light' | 'dark' */) {
  if (mode === 'system') {
    localStorage.removeItem('theme');
    const dark = matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.classList.toggle('dark', dark);
  } else {
    localStorage.setItem('theme', mode);
    document.documentElement.classList.toggle('dark', mode === 'dark');
  }
}

matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    document.documentElement.classList.toggle('dark', e.matches);
  }
});
```

### 9.4 Dark-Mode-Specific Rules

- **Borders go invisible.** In dark mode, a 1px border with `rgba(255, 255, 255, 0.08)` reads cleaner than the inverted neutral. Use a slightly translucent border for surfaces, solid `--border-default` for input fields.
- **No drop shadows.** Or, dramatically reduce them. In dark mode, elevation is conveyed by *lighter* surface backgrounds, not by darker shadows underneath.
- **Brand color shifts lighter.** `--brand-fg` becomes `#A5B4FC` (brand-300/400 range) so it has enough contrast against the dark canvas.
- **Image / chart luminance.** If you render charts/screenshots inside a card, the card background should be `--bg-elevated` (lighter than canvas) so the artwork doesn't appear floating on void.

---

## 10. Accessibility

### 10.1 Color Contrast

Minimum ratios:
- **Body text on background: 4.5:1** (WCAG AA Normal Text)
- **Large text (≥18px or 14px bold) on background: 3:1**
- **UI components (button border, input border, focus ring) against adjacent color: 3:1**
- **Non-text indicators (icons that convey state): 3:1**

Verify the included palette:
- `#0F172A` on `#FFFFFF` = 19.3:1 ✓
- `#475569` (muted) on `#FFFFFF` = 7.5:1 ✓
- `#94A3B8` (subtle) on `#FFFFFF` = 3.6:1 ✓ (large text / decorative only)
- `#5E6AD2` (brand) on `#FFFFFF` = 5.7:1 ✓
- `#FFFFFF` on `#5E6AD2` (brand button) = 5.7:1 ✓

### 10.2 Focus States

```css
/* Default focus ring for all interactive elements */
*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--brand-fg);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* For elements with their own visual treatment (buttons, inputs), use box-shadow ring */
button:focus-visible,
[role="button"]:focus-visible,
a:focus-visible {
  outline: none;
  box-shadow: var(--ring-focus);
}

input:focus-visible,
textarea:focus-visible,
select:focus-visible {
  outline: none;
  border-color: var(--brand-fg);
  box-shadow: var(--ring-focus);
}
```

**Never remove the focus ring without replacing it.** This is the single most common SaaS accessibility regression.

### 10.3 Keyboard Map (every SaaS app should implement)

| Key | Action |
|---|---|
| `⌘K` / `Ctrl+K` | Open command palette |
| `/` | Focus the page search input |
| `g` then `d` | Go to dashboard (vim-style nav, optional but Linear/GitHub do it) |
| `?` | Show keyboard shortcut cheat sheet |
| `Esc` | Close modal, dismiss popover, blur input |
| `Tab` / `Shift+Tab` | Move focus forward/back |
| `Enter` | Activate focused button/link; submit form when input focused |
| `Space` | Toggle checkbox/switch; activate button |
| `↑` / `↓` | Navigate list items, table rows, menu items |
| `j` / `k` | Same as ↓ / ↑ (vim-style; optional) |

### 10.4 Semantic Patterns

- Buttons use `<button>`, links use `<a href>`. **Never** use a `<div onClick>` for either.
- Form fields have a visible `<label>` (or `aria-label` if visually replaced by a placeholder pattern).
- Required fields: visible `*` marker AND `aria-required="true"`.
- Error messages: linked via `aria-describedby`, role `alert` for the first error.
- Tables: `<th scope="col">` headers, optional `<caption>` for context.
- Modals: trap focus, restore focus to trigger on close, `aria-modal="true"`, `role="dialog"`, labeled by heading.
- Toasts: `role="status"` for non-critical, `role="alert"` for errors.

### 10.5 Touch Targets

Minimum 44×44px (WCAG 2.5.5). On desktop SaaS, buttons can be 32-36px tall as long as the **clickable hit area** (via padding) reaches 44px.

```css
.icon-button {
  position: relative;
  width: 32px;
  height: 32px;
}
.icon-button::before {
  content: '';
  position: absolute;
  inset: -6px;  /* extends hit area to 44x44 */
}
```

### 10.6 Screen Reader Utilities

```css
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.sr-only-focusable:focus {
  position: static;
  width: auto;
  height: auto;
  clip: auto;
  white-space: normal;
}
```

Use `.sr-only` for "Skip to main content" links and icon-only button labels.

---

## 11. Decorative Elements (Used Sparingly)

SaaS UIs avoid heavy decoration. The few decorative idioms that DO appear:

### 11.1 Section Dividers

```css
.divider {
  height: 1px;
  background: var(--border-default);
  margin: 24px 0;
}

/* Vertical divider (e.g., between toolbar groups) */
.divider-v {
  width: 1px;
  background: var(--border-default);
  align-self: stretch;
  margin: 0 8px;
}
```

### 11.2 Inline Pills / Status Dots

```css
/* Status dot (used in tables: ● Active) */
.status-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 9999px;
  vertical-align: middle;
  margin-right: 6px;
}
.status-dot--success { background: #16A34A; }
.status-dot--warning { background: #CA8A04; }
.status-dot--error   { background: #DC2626; }
.status-dot--neutral { background: #94A3B8; }

/* Pulsing dot for "live" / "syncing" */
.status-dot--pulse {
  position: relative;
}
.status-dot--pulse::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: inherit;
  animation: pulse-ring 1.6s ease-out infinite;
}
@keyframes pulse-ring {
  0%   { transform: scale(1);   opacity: 0.6; }
  100% { transform: scale(2.5); opacity: 0; }
}
```

### 11.3 Empty-State Iconography Placeholder

For empty states, draw a single 1.5px-stroke line icon at 48–64px size in `--neutral-300`. Do not use 3D illustrations or stock vectors — they break the "quiet product" register.

```html
<div class="empty-state">
  <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor"
       stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"
       style="color: var(--neutral-300);">
    <!-- single line-art icon (e.g., a Lucide icon) -->
  </svg>
  <h3>No customers yet</h3>
  <p>Customers appear here after their first successful payment.</p>
  <button>Create test customer</button>
</div>
```

### 11.4 Subtle Background Grid (for empty canvases)

For dashboard backgrounds, an OPTIONAL very subtle dot grid is acceptable:

```css
.canvas-grid {
  background-color: var(--bg-canvas);
  background-image: radial-gradient(circle, var(--neutral-200) 1px, transparent 1px);
  background-size: 24px 24px;
  background-position: 0 0;
}
```

Opacity: never above 30%. Most SaaS apps omit this entirely.

### 11.5 Keyboard Shortcut Chips

```css
.kbd {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 2px 6px;
  font-family: var(--font-mono);
  font-size: 11px;
  line-height: 1.2;
  color: var(--text-muted);
  background: var(--bg-surface);
  border: 1px solid var(--border-default);
  border-bottom-width: 2px;  /* mechanical key look */
  border-radius: 4px;
  vertical-align: middle;
}
```

Usage: `<span class="kbd">⌘</span><span class="kbd">K</span>` inside tooltips, menu items, command palette.

---

## 12. Component Quick Reference

### Buttons

| Variant | Background | Text | Border | Use |
|---|---|---|---|---|
| `primary` | `--brand-600` | `#FFFFFF` | none | The one primary action per surface |
| `secondary` | `--bg-surface` | `--text-primary` | `--border-default` 1px | Most actions ("Cancel", "Save", "Edit") |
| `ghost` | transparent | `--text-primary` | none | Toolbar buttons, table row actions |
| `destructive` | `#DC2626` | `#FFFFFF` | none | Delete, revoke, irreversible |
| `destructive-outline` | `--bg-surface` | `#B91C1C` | `#FCA5A5` | Less aggressive destructive |
| `link` | none | `--brand-600` | none, underline on hover | Inline navigation actions |

Sizes: `sm` (28px tall, 12px text), `md` (32px tall, 13px text — **default**), `lg` (40px tall, 14px text).

### Inputs

| State | Border | Background | Shadow |
|---|---|---|---|
| default | `--border-default` | `--bg-surface` | none |
| hover | `--border-strong` | `--bg-surface` | none |
| focus | `--brand-fg` | `--bg-surface` | `--ring-focus` |
| error | `#DC2626` | `--bg-surface` | `--ring-error` |
| disabled | `--border-default` | `--neutral-50` | none, `opacity: 0.6` |

Sizes match buttons: `sm` (28px), `md` (32px default), `lg` (40px).

### Cards

| Variant | Background | Border | Padding | Shadow |
|---|---|---|---|---|
| default | `--bg-surface` | 1px `--border-default` | 20px | none |
| interactive | `--bg-surface` | 1px `--border-default` | 20px | hover: `--shadow-sm` |
| ghost | none | none | 0 | none — for grouping without enclosure |
| elevated | `--bg-elevated` | none | 24px | `--shadow-md` |
| outline-only | none | 1px `--border-default` | 16px | none — for placeholder/empty slots |

### Badges / Status Pills

| Variant | Background | Text | Border |
|---|---|---|---|
| neutral | `--neutral-100` | `--neutral-700` | none |
| brand | `--brand-50` | `--brand-700` | none |
| success | `#DCFCE7` | `#15803D` | none |
| warning | `#FEF9C3` | `#A16207` | none |
| error | `#FEE2E2` | `#B91C1C` | none |
| info | `#DBEAFE` | `#1D4ED8` | none |

Sizing: padding `2px 8px`, font 11–12px, weight 500, radius 4px (NOT pill). Optional leading status dot.

---

## 13. File Structure Recommendation

```
project/
├── src/
│   ├── styles/
│   │   ├── tokens.css           # All CSS custom properties (one file, single source of truth)
│   │   ├── base.css             # Reset, html/body, typography defaults, focus
│   │   └── globals.css          # @import the above + utility classes
│   ├── components/
│   │   ├── primitives/          # Lowest-level, design-token-aware
│   │   │   ├── Button.tsx
│   │   │   ├── Input.tsx
│   │   │   ├── Select.tsx
│   │   │   ├── Checkbox.tsx
│   │   │   ├── Switch.tsx
│   │   │   ├── Badge.tsx
│   │   │   ├── Card.tsx
│   │   │   ├── Avatar.tsx
│   │   │   ├── Tooltip.tsx
│   │   │   ├── Kbd.tsx
│   │   │   └── Spinner.tsx
│   │   ├── overlays/            # Floating layers
│   │   │   ├── Dialog.tsx
│   │   │   ├── Sheet.tsx
│   │   │   ├── Popover.tsx
│   │   │   ├── DropdownMenu.tsx
│   │   │   ├── Toast.tsx
│   │   │   └── CommandPalette.tsx
│   │   ├── data/                # Data display
│   │   │   ├── DataTable.tsx
│   │   │   ├── Pagination.tsx
│   │   │   ├── KpiCard.tsx
│   │   │   └── EmptyState.tsx
│   │   ├── forms/
│   │   │   ├── FormField.tsx
│   │   │   ├── FormRow.tsx
│   │   │   └── FormSection.tsx
│   │   └── shell/               # The app frame
│   │       ├── AppShell.tsx
│   │       ├── Sidebar.tsx
│   │       ├── Topbar.tsx
│   │       ├── PageHeader.tsx
│   │       └── Breadcrumb.tsx
│   └── lib/
│       ├── theme.ts             # Theme toggle (system/light/dark)
│       ├── shortcuts.ts         # Global keyboard handler
│       └── format.ts            # Money, date, relative time helpers
├── tailwind.config.ts           # Tokens mirrored from tokens.css
└── tsconfig.json
```

Rules:
- **`tokens.css` is the single source of truth.** `tailwind.config.ts` reads from it (or duplicates its values, but only as a mirror).
- **Primitives have no business logic.** They accept props, render styled DOM. State lives in feature components that wrap them.
- **No component crosses layer boundaries.** A primitive can't import from `shell/`; `shell/` can't import from `data/`.

---

## 14. Full CSS Custom Properties

Copy into `styles/tokens.css`:

```css
:root {
  /* ── Brand ── */
  --brand-50:  #EEF0FB;
  --brand-100: #DDE0F7;
  --brand-200: #B9C0EF;
  --brand-500: #7B85DC;
  --brand-600: #5E6AD2;
  --brand-700: #4A56C0;
  --brand-800: #3A45A8;
  --brand-900: #2A3284;
  --brand-fg: var(--brand-600);
  --brand-bg-subtle: var(--brand-50);

  /* ── Neutrals (slate) ── */
  --neutral-0:   #FFFFFF;
  --neutral-50:  #F8FAFC;
  --neutral-100: #F1F5F9;
  --neutral-200: #E2E8F0;
  --neutral-300: #CBD5E1;
  --neutral-400: #94A3B8;
  --neutral-500: #64748B;
  --neutral-600: #475569;
  --neutral-700: #334155;
  --neutral-800: #1E293B;
  --neutral-900: #0F172A;
  --neutral-950: #020617;

  /* ── Semantic surfaces ── */
  --bg-canvas:   var(--neutral-50);
  --bg-surface:  var(--neutral-0);
  --bg-elevated: var(--neutral-0);
  --bg-hover:    var(--neutral-100);

  --text-primary: var(--neutral-900);
  --text-muted:   var(--neutral-600);
  --text-subtle:  var(--neutral-400);

  --border-default: var(--neutral-200);
  --border-strong:  var(--neutral-300);

  /* ── State ── */
  --success-fg:  #15803D;
  --success-bg:  #DCFCE7;
  --warning-fg:  #A16207;
  --warning-bg:  #FEF9C3;
  --error-fg:    #B91C1C;
  --error-bg:    #FEE2E2;
  --info-fg:     #1D4ED8;
  --info-bg:     #DBEAFE;

  /* ── Chart palette ── */
  --chart-1: #2563EB;
  --chart-2: #16A34A;
  --chart-3: #CA8A04;
  --chart-4: #DC2626;
  --chart-5: #9333EA;
  --chart-6: #0891B2;
  --chart-7: #DB2777;
  --chart-8: #65A30D;

  /* ── Typography ── */
  --font-sans: "Inter", "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
               "SF Pro Text", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", "Geist Mono", "SF Mono", "Menlo", "Consolas",
               ui-monospace, monospace;

  /* ── Spacing (multiples of 4) ── */
  --space-unit: 4px;

  /* ── Radius ── */
  --radius-none: 0;
  --radius-sm:   4px;
  --radius-md:   6px;
  --radius-lg:   8px;
  --radius-xl:   12px;
  --radius-2xl:  16px;
  --radius-full: 9999px;

  /* ── Shadows ── */
  --shadow-xs:  0 1px 2px 0 rgba(15, 23, 42, 0.04);
  --shadow-sm:  0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06);
  --shadow-md:  0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04);
  --shadow-lg:  0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08);
  --shadow-xl:  0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10);
  --shadow-2xl: 0 25px 50px -12px rgba(15, 23, 42, 0.18);
  --ring-focus: 0 0 0 3px rgba(94, 106, 210, 0.20);
  --ring-error: 0 0 0 3px rgba(220, 38, 38, 0.16);

  /* ── Motion ── */
  --ease-out:        cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out-back:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-in-out:     cubic-bezier(0.4, 0, 0.2, 1);
  --duration-75:  75ms;
  --duration-100: 100ms;
  --duration-150: 150ms;
  --duration-200: 200ms;
  --duration-250: 250ms;
  --duration-300: 300ms;

  /* ── Layout ── */
  --container-sm:  640px;
  --container-md:  768px;
  --container-lg: 1024px;
  --container-xl: 1280px;
  --container-2xl: 1536px;
  --sidebar-w:        256px;
  --sidebar-w-collapsed: 56px;
  --topbar-h:         48px;
}

.dark {
  --bg-canvas:   #0A0E1A;
  --bg-surface:  #111827;
  --bg-elevated: #1F2937;
  --bg-hover:    rgba(255, 255, 255, 0.04);

  --text-primary: #F1F5F9;
  --text-muted:   #94A3B8;
  --text-subtle:  #64748B;

  --border-default: #1F2937;
  --border-strong:  #374151;

  --brand-fg:         #A5B4FC;
  --brand-bg-subtle:  rgba(94, 106, 210, 0.16);

  --success-fg: #4ADE80;
  --success-bg: rgba(22, 163, 74, 0.16);
  --warning-fg: #FACC15;
  --warning-bg: rgba(202, 138, 4, 0.16);
  --error-fg:   #F87171;
  --error-bg:   rgba(220, 38, 38, 0.16);
  --info-fg:    #60A5FA;
  --info-bg:    rgba(37, 99, 235, 0.16);

  --shadow-xs:  0 1px 2px 0 rgba(0, 0, 0, 0.30);
  --shadow-sm:  0 1px 3px 0 rgba(0, 0, 0, 0.40);
  --shadow-md:  0 4px 8px -2px rgba(0, 0, 0, 0.50);
  --shadow-lg:  0 12px 20px -6px rgba(0, 0, 0, 0.55);
  --shadow-xl:  0 20px 32px -10px rgba(0, 0, 0, 0.60);
  --shadow-2xl: 0 30px 60px -15px rgba(0, 0, 0, 0.70);
}

/* ── Base ── */
html {
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  font-feature-settings: "kern" 1, "liga" 1, "calt" 1, "cv11" 1, "ss01" 1;
}

body {
  margin: 0;
  background: var(--bg-canvas);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 14px;
  line-height: 1.43;
}

.numeric, table, [data-numeric="true"] {
  font-variant-numeric: tabular-nums;
}

*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--brand-fg);
  outline-offset: 2px;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}
```

---

## 15. Forensic Visual Analysis

> **Design Language DNA & Implementation Physics**
> Reverse-engineered patterns from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale.

### 15.1 Visual Hierarchy & Spatial Logic

#### Density Tier

| Tier | Row Height | Button Height | Default Text | When |
|---|---|---|---|---|
| **Compact** (Linear, dev tools) | 32px | 28px | 13px | Power-user surfaces, data-heavy tables |
| **Default** (Stripe, Vercel) | 40px | 32px | 14px | The default SaaS product UI |
| **Comfortable** (Notion, Clerk) | 48px | 36px | 15px | Content-heavy, settings, billing |
| **Spacious** (Auth, onboarding) | 56px+ | 40px+ | 16px | Single-purpose conversion pages |

**Pick one tier per product, not per page.** Mixing density breaks the rhythm.

#### Layout Constants

```
Content width:
├── Tables / lists:       full bleed within --container-2xl (1536px)
├── Dashboards:           --container-xl (1280px) centered
├── Settings forms:       --container-md (768px) — readable column
└── Auth / onboarding:    --container-sm (640px) centered

Page padding (X):
├── Mobile:    16px (px-4)
├── Tablet:    24px (px-6)
└── Desktop:   32px (px-8)

Page padding (Y):
├── Page header → content:     16-24px
├── Section → section:         32-40px
└── Last section → page end:   64px (so footer doesn't crowd)

Grid gaps:
├── KPI cards:           12px
├── Card grids:          16px (sm) → 20px (md) → 24px (lg)
├── Form fields:         16px (tight) or 20px (default)
└── Settings rows:       0 — separated by 1px bottom border
```

#### The "Three Heights" Rule

In a SaaS UI, three vertical rhythms coexist:

1. **Page rhythm**: 24px / 32px / 48px between page sections.
2. **Component rhythm**: 12px / 16px / 20px inside cards and panels.
3. **Inline rhythm**: 4px / 6px / 8px between adjacent elements (icon + label, badge + text).

Each rhythm is a multiple of 4 but **does not reuse** the same numbers from another rhythm. Inline gaps stay ≤8px, component gaps stay 12–20px, page gaps stay ≥24px. This is what makes a SaaS UI feel "organized" without conscious effort.

### 15.2 Color Science & Elevation

#### How elevation is conveyed (without heavy shadows)

| Layer | Light Mode | Dark Mode |
|---|---|---|
| Canvas | `#F8FAFC` (1 step down from white) | `#0A0E1A` |
| Surface (default card) | `#FFFFFF` + 1px border | `#111827` + 1px `rgba(255,255,255,0.04)` border |
| Elevated (popover) | `#FFFFFF` + `--shadow-md` | `#1F2937` + low shadow |
| Modal | `#FFFFFF` + `--shadow-xl` + backdrop blur | `#1F2937` + `--shadow-xl` + backdrop blur |

In light mode, elevation = subtle shadow + 1px border.
In dark mode, elevation = LIGHTER background (the higher the layer, the lighter the bg) + nearly-invisible shadow.

#### Brand color is "interaction language"

- Brand color = "this is interactive, this is selected, this is focused."
- Anything else uses neutrals.
- Status colors (success/error/etc.) are ONLY for status — never for buttons that don't represent that status.

A useful test: **squint at the UI**. You should see 90% gray-on-white, with brand pinpricks marking exactly the interactive surface you'd click.

#### Selected-state convention

| State | Convention |
|---|---|
| Hover | Background shifts by 1 step (`--bg-surface` → `--bg-hover`) |
| Active (pressed) | Background shifts by 2 steps OR scale(0.98) |
| Selected (persistent) | `--brand-bg-subtle` background + `--brand-fg` text. **No border change.** |
| Focused (keyboard) | `--ring-focus` box-shadow ring. Independent of hover/selected. |
| Disabled | `opacity: 0.5` + `cursor: not-allowed`. Color unchanged. |

### 15.3 Typography & Micro-Copy

#### The "13/14 default" rule

The default body/UI size is **13px or 14px**. Headings step up modestly (no 48px headlines in a dashboard). The biggest text in a typical product page is the H1 at 24–30px.

| Element | Size | Weight |
|---|---|---|
| H1 (page title) | 24px | 600 |
| H2 (section) | 18px | 600 |
| H3 (card heading) | 15-16px | 600 |
| Body / table cell | 13-14px | 400 |
| Label / button | 13-14px | 500 |
| Microlabel / caption | 11-12px | 500 (uppercase optional) |
| Mono (id, code, money) | 12-13px | 400 |

#### Micro-copy patterns

**Buttons** — verb + object, never just verb:
- ✅ "Create customer", "Save changes", "Delete invoice"
- ❌ "Submit", "OK", "Click here"

**Empty state headings** — declarative, not interrogative:
- ✅ "No active subscriptions"
- ❌ "You don't have any subscriptions yet, would you like to create one?"

**Inline help / hints** — single sentence, no period (because it's not a sentence in form context):
- ✅ "Used for receipts and customer emails"
- ❌ "This email will be used for receipts and customer emails."

**Destructive confirmation** — type-to-confirm for irreversible:
- "Type `acme-corp` to confirm deletion" — see GitHub's repo deletion modal.

**Toast text** — past tense, with optional action:
- ✅ "Invoice sent. [Undo]"
- ❌ "Sending invoice…" (use a loading state for in-flight)

### 15.4 Component Anatomy

#### Button (the most-touched primitive)

```
┌────────────────────────────────────┐
│  [icon] [label]            [⌘K]    │   ← gap 8px between icon/label, 12px to shortcut
└────────────────────────────────────┘
   ↑ padding 12-14px              ↑ padding 12-14px
   ─ height: 32px (default)
   ─ radius: 6px
   ─ font: 13px / 500
```

Variants:
- `primary` — solid brand bg, white text. ONE per surface.
- `secondary` — surface bg, neutral border, primary text. Most actions.
- `ghost` — no bg, no border. Toolbar / row actions.
- `destructive` — solid red bg. Reserved for irreversible.

States:
- default → hover (bg shifts) → active (bg shifts more / scale 0.98) → focus (ring) → disabled (opacity 0.5)

Forbidden:
- Pill radius (`9999px`) — that's marketing
- Gradient background — that's marketing
- Drop shadow — buttons are flat against the surface

#### Input

```
┌────────────────────────────────────┐
│  [icon] placeholder text      [×]  │   ← optional leading icon, trailing clear
└────────────────────────────────────┘
   ─ height: 32px (default)
   ─ radius: 6px
   ─ border: 1px --border-default
   ─ padding: 0 12px (or 0 36px when icon present)
   ─ font: 14px / 400
```

Focus state: border → `--brand-fg`, add `--ring-focus` box-shadow. **Never use solid background change for focus** — borders + ring are the convention.

Error state: border → `#DC2626`, ring → `--ring-error`. Show error message below input in `#B91C1C`, 12px, with `aria-describedby` linking.

#### Card

```
┌──────────────────────────────────┐
│  Heading            [actions]    │   ← header row, 16-20px from edges
│  Optional description            │
├──────────────────────────────────┤   ← 1px divider OR padding gap
│                                  │
│  Card body                       │
│                                  │
└──────────────────────────────────┘
   ─ radius: 8px
   ─ border: 1px --border-default
   ─ background: --bg-surface
   ─ padding: 20-24px
   ─ shadow: none (default)
```

The card has a 1px border by default. NO shadow. NO inner glow. The border is what carries the elevation — it implies a layer.

Interactive cards (e.g., clickable list items): hover lightens border to `--border-strong` AND adds `--shadow-sm`. Don't change the background.

### 15.5 Animation Physics

#### Speed map

```
Color/border transitions:     100-150ms
Hover state:                  100ms
Focus ring appearance:        150ms
Dropdown / popover enter:     150ms
Modal / dialog enter:         200-250ms
Slide-over (drawer) enter:    300ms
Toast slide-in:               200ms
Tab switch:                   instant (no animation)
Page mount:                   instant
Skeleton shimmer cycle:       1500ms
Spinner rotation:             750ms per turn
```

#### Easing map

```
Linear → only for indeterminate progress / spinners
ease-out (expo-out, --ease-out) → DEFAULT for everything else
ease-out-back → modal/popover entrance (subtle overshoot)
ease-in-out → reversible transitions (accordion height)
ease-in → almost never; exit animations only
```

#### The "no entrance animation" rule

Page mount animations are bad UX in SaaS:
- The user already navigated; the destination is the priority.
- An entrance animation delays perceived load time.
- Staggered grids look "designed" on first view and tedious on the second.

The ONLY place entrance animation belongs in a product UI is **overlay layers**: modals, popovers, toasts. These are appearing on top of an already-visible page, so the motion is meaningful (where did this come from?).

### 15.6 Technical Deliverables

#### Tailwind Configuration

```typescript
// tailwind.config.ts
import type { Config } from 'tailwindcss';

const config: Config = {
  darkMode: 'class',
  content: ['./src/**/*.{ts,tsx,js,jsx,html}'],
  theme: {
    extend: {
      colors: {
        brand: {
          50:  '#EEF0FB', 100: '#DDE0F7', 200: '#B9C0EF',
          500: '#7B85DC', 600: '#5E6AD2', 700: '#4A56C0',
          800: '#3A45A8', 900: '#2A3284',
        },
        neutral: {
          0:   '#FFFFFF', 50:  '#F8FAFC', 100: '#F1F5F9',
          200: '#E2E8F0', 300: '#CBD5E1', 400: '#94A3B8',
          500: '#64748B', 600: '#475569', 700: '#334155',
          800: '#1E293B', 900: '#0F172A', 950: '#020617',
        },
        canvas:   'var(--bg-canvas)',
        surface:  'var(--bg-surface)',
        elevated: 'var(--bg-elevated)',
      },
      fontFamily: {
        sans: ['Inter', 'Geist', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'system-ui', 'sans-serif'],
        mono: ['JetBrains Mono', 'Geist Mono', 'SF Mono', 'Menlo', 'Consolas', 'ui-monospace', 'monospace'],
      },
      fontSize: {
        '2xs':  ['11px', { lineHeight: '15px', letterSpacing: '0.04em' }],
        xs:     ['12px', { lineHeight: '16px' }],
        sm:     ['13px', { lineHeight: '18px' }],
        base:   ['14px', { lineHeight: '20px' }],
        md:     ['15px', { lineHeight: '22px' }],
        lg:     ['16px', { lineHeight: '24px', letterSpacing: '-0.005em' }],
        xl:     ['18px', { lineHeight: '26px', letterSpacing: '-0.01em' }],
        '2xl':  ['20px', { lineHeight: '28px', letterSpacing: '-0.015em' }],
        '3xl':  ['24px', { lineHeight: '32px', letterSpacing: '-0.02em' }],
        '4xl':  ['30px', { lineHeight: '36px', letterSpacing: '-0.025em' }],
      },
      borderRadius: {
        none: '0',
        sm:   '4px',
        DEFAULT: '6px',
        md:   '6px',
        lg:   '8px',
        xl:   '12px',
        '2xl': '16px',
        full: '9999px',
      },
      boxShadow: {
        xs:  '0 1px 2px 0 rgba(15, 23, 42, 0.04)',
        sm:  '0 1px 2px 0 rgba(15, 23, 42, 0.05), 0 1px 3px 0 rgba(15, 23, 42, 0.06)',
        md:  '0 2px 4px -1px rgba(15, 23, 42, 0.06), 0 4px 6px -2px rgba(15, 23, 42, 0.04)',
        lg:  '0 4px 6px -2px rgba(15, 23, 42, 0.05), 0 10px 15px -3px rgba(15, 23, 42, 0.08)',
        xl:  '0 8px 10px -4px rgba(15, 23, 42, 0.06), 0 20px 25px -5px rgba(15, 23, 42, 0.10)',
        '2xl': '0 25px 50px -12px rgba(15, 23, 42, 0.18)',
        focus: 'var(--ring-focus)',
        'focus-error': 'var(--ring-error)',
      },
      transitionTimingFunction: {
        out:      'cubic-bezier(0.16, 1, 0.3, 1)',
        'out-back': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
      },
      transitionDuration: {
        75: '75ms', 100: '100ms', 150: '150ms',
        200: '200ms', 250: '250ms', 300: '300ms',
      },
      maxWidth: {
        '8xl': '1536px',
      },
      animation: {
        'dialog-enter':  'dialog-enter 250ms cubic-bezier(0.34, 1.56, 0.64, 1)',
        'popover-enter': 'popover-enter 150ms cubic-bezier(0.16, 1, 0.3, 1)',
        'toast-enter':   'toast-enter 200ms cubic-bezier(0.16, 1, 0.3, 1)',
        shimmer:         'shimmer 1.5s ease-in-out infinite',
      },
      keyframes: {
        'dialog-enter': {
          from: { opacity: '0', transform: 'translateY(8px) scale(0.98)' },
          to:   { opacity: '1', transform: 'translateY(0) scale(1)' },
        },
        'popover-enter': {
          from: { opacity: '0', transform: 'translateY(-4px)' },
          to:   { opacity: '1', transform: 'translateY(0)' },
        },
        'toast-enter': {
          from: { opacity: '0', transform: 'translateX(8px)' },
          to:   { opacity: '1', transform: 'translateX(0)' },
        },
        shimmer: {
          from: { backgroundPosition: '200% 0' },
          to:   { backgroundPosition: '-200% 0' },
        },
      },
    },
  },
  plugins: [],
};

export default config;
```

#### Framer Motion Variants

```typescript
// src/lib/motion.ts
import type { Variants, Transition } from 'framer-motion';

const easeOut: Transition['ease'] = [0.16, 1, 0.3, 1];
const easeOutBack: Transition['ease'] = [0.34, 1.56, 0.64, 1];

/** Modals, dialogs */
export const dialogVariants: Variants = {
  hidden: { opacity: 0, y: 8, scale: 0.98 },
  visible: {
    opacity: 1, y: 0, scale: 1,
    transition: { duration: 0.25, ease: easeOutBack },
  },
  exit: {
    opacity: 0, y: 4, scale: 0.99,
    transition: { duration: 0.15, ease: easeOut },
  },
};

/** Dropdowns, popovers, menus */
export const popoverVariants: Variants = {
  hidden: { opacity: 0, y: -4 },
  visible: { opacity: 1, y: 0, transition: { duration: 0.15, ease: easeOut } },
  exit: { opacity: 0, y: -2, transition: { duration: 0.10, ease: easeOut } },
};

/** Toasts */
export const toastVariants: Variants = {
  hidden: { opacity: 0, x: 16 },
  visible: { opacity: 1, x: 0, transition: { duration: 0.20, ease: easeOut } },
  exit: { opacity: 0, x: 16, transition: { duration: 0.15, ease: easeOut } },
};

/** Slide-over / drawer */
export const sheetVariants: Variants = {
  hidden: { x: '100%' },
  visible: { x: 0, transition: { duration: 0.30, ease: easeOut } },
  exit: { x: '100%', transition: { duration: 0.25, ease: easeOut } },
};

/** Backdrop fade */
export const backdropVariants: Variants = {
  hidden: { opacity: 0 },
  visible: { opacity: 1, transition: { duration: 0.20, ease: 'linear' } },
  exit: { opacity: 0, transition: { duration: 0.15, ease: 'linear' } },
};

/** Optimistic checkmark (action confirmation) */
export const checkmarkVariants: Variants = {
  hidden: { scale: 0, opacity: 0 },
  visible: {
    scale: 1, opacity: 1,
    transition: { duration: 0.20, ease: easeOutBack },
  },
};

/** Accordion expand/collapse */
export const accordionVariants: Variants = {
  collapsed: { height: 0, opacity: 0, transition: { duration: 0.20, ease: easeOut } },
  expanded: { height: 'auto', opacity: 1, transition: { duration: 0.25, ease: easeOut } },
};
```

#### Global Keyboard Handler

```typescript
// src/lib/shortcuts.ts
type Handler = (e: KeyboardEvent) => void;

const handlers = new Map<string, Set<Handler>>();

export function register(combo: string, handler: Handler) {
  if (!handlers.has(combo)) handlers.set(combo, new Set());
  handlers.get(combo)!.add(handler);
  return () => handlers.get(combo)?.delete(handler);
}

function keyOf(e: KeyboardEvent): string {
  const parts: string[] = [];
  if (e.metaKey || e.ctrlKey) parts.push('mod');
  if (e.shiftKey) parts.push('shift');
  if (e.altKey) parts.push('alt');
  parts.push(e.key.toLowerCase());
  return parts.join('+');
}

if (typeof window !== 'undefined') {
  window.addEventListener('keydown', (e) => {
    // Don't trigger global shortcuts inside text fields (except Esc and Mod+K)
    const target = e.target as HTMLElement;
    const inField = target.matches('input, textarea, [contenteditable]');
    const combo = keyOf(e);
    if (inField && combo !== 'escape' && combo !== 'mod+k') return;
    const set = handlers.get(combo);
    if (set?.size) {
      e.preventDefault();
      set.forEach((h) => h(e));
    }
  });
}
```

Usage:
```typescript
useEffect(() => register('mod+k', () => setCommandOpen(true)), []);
useEffect(() => register('/', () => searchRef.current?.focus()), []);
```

---

## 16. SaaS Module Patterns

### 16.1 Application Shell

The canonical layout. Implement once, share across all authenticated pages.

```tsx
export function AppShell({ children }: { children: React.ReactNode }) {
  return (
    <div className="h-screen flex flex-col bg-canvas text-neutral-900 dark:text-neutral-50">
      <Topbar />
      <div className="flex-1 flex overflow-hidden">
        <Sidebar />
        <main className="flex-1 overflow-y-auto">
          <div className="mx-auto max-w-7xl px-6 lg:px-8 py-6">
            {children}
          </div>
        </main>
      </div>
    </div>
  );
}
```

Key invariants:
- The whole viewport is `h-screen` with `overflow-hidden`. Only the main content area scrolls.
- The topbar and sidebar are **fixed in place**. They stay visible during scroll.
- The content area has its own scroll context (`overflow-y-auto`), so sticky table headers work inside.
- Max content width caps at `--container-2xl` (1280–1536px) for readability; centered with `mx-auto`.

### 16.2 Page Header

Every page in the app starts with a header. Standard shape:

```tsx
<div className="flex items-start justify-between gap-4 mb-6">
  <div className="min-w-0">
    <Breadcrumb items={[{ label: 'Customers' }, { label: 'Acme Corp' }]} />
    <h1 className="mt-1 text-2xl font-semibold tracking-tight">
      {title}
    </h1>
    {description && (
      <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
        {description}
      </p>
    )}
  </div>
  <div className="flex items-center gap-2 shrink-0">
    <Button variant="secondary" size="sm">Export</Button>
    <Button variant="primary" size="sm">New customer</Button>
  </div>
</div>
```

Rules:
- **One H1 per page.** Sub-sections use H2/H3.
- **Max one primary button** in the actions area. Secondary actions can be a chain of `secondary`/`ghost`. Overflow → `…` dropdown.
- **Breadcrumb only when 2+ levels deep.** A bare page doesn't need a breadcrumb that just says "Customers".

### 16.3 Data Tables

The most-used surface in SaaS. Specifications:

```
Row height:        40px (default) / 48px (with avatar)
Cell padding:      0 16px horizontal, vertical centered
Header padding:    same horizontal, 12px vertical
Header background: --bg-canvas (1 step darker than body) — sticks during scroll
Border:            1px between header and body, NO row separators by default
Hover:             row bg → --bg-hover
Selection:         row bg → --brand-bg-subtle, leftmost cell shows brand color stripe (3px wide, only on selected)
Column header:     13px, 500 weight, --text-muted, uppercase optional
Cell text:         13-14px, 400 weight
Numeric columns:   right-aligned, tabular-nums
```

#### Column types

| Type | Alignment | Wrapping | Special |
|---|---|---|---|
| ID / handle | left | nowrap | `font-mono`, `text-neutral-500` |
| Primary name | left | nowrap → truncate | `font-medium` |
| Description | left | clamp 1-2 lines | secondary |
| Date / time | left | nowrap | `data-numeric="true"`, relative on hover absolute |
| Amount / money | right | nowrap | `font-mono`, `tabular-nums` |
| Status | left | nowrap | `<Badge>` |
| Avatar + name | left | nowrap | 24px avatar + 8px gap + name |
| Row actions | right | none | `<DropdownMenu>` triggered by `⋯` icon |

#### Empty / loading / error

- **Empty**: Show empty state component inside the table body row spanning all columns.
- **Loading (initial)**: Render 5–10 skeleton rows matching the column structure.
- **Loading (subsequent)**: Overlay the existing data at 50% opacity with a thin top progress bar.
- **Error**: Replace tbody with an error state row (icon, message, retry button).

#### Pagination

For server-side: bottom-aligned, "Showing 1–25 of 1,247" left, prev/next buttons right. Avoid numbered pagination unless explicitly requested — `Prev | 1 2 3 … 50 | Next` clutters more than it helps.

For client-side small datasets (<200 rows): no pagination; let it scroll within a max-height container.

### 16.4 Settings Pages

Three layouts to choose from:

**A. Single-column settings** (small projects, profile pages):
```
Page header
─────
Form section heading
  Field row (label left @ 240px, input right)
  Field row
  ─ 1px divider ─
  Field row
Form section heading
  Field row
─────
[Save] (sticky bottom OR inline after fields)
```

**B. Sub-nav settings** (most products):
```
Page header
─────
[Profile] [Team] [Billing] [Integrations] [API keys]   ← tabs OR secondary sidebar
─────
Selected tab's content (using layout A)
```

**C. Full app-within-app** (large products):
```
Sidebar → "Settings"
  Sub-sidebar (256px) ────┬──── Content (single-column form)
   Profile                │
   Account                │
   Notifications          │
   Team                   │
   Billing                │
```

Each pattern uses the same form row primitive:

```tsx
<div className="grid grid-cols-[240px_1fr] gap-6 py-5 border-b border-default last:border-b-0">
  <div>
    <label className="text-sm font-medium">Display name</label>
    <p className="mt-1 text-xs text-neutral-500">
      Shown to teammates in mentions and on activity feeds.
    </p>
  </div>
  <div>
    <Input value={name} onChange={setName} maxLength={64} />
    <p className="mt-1.5 text-xs text-neutral-500">{name.length}/64</p>
  </div>
</div>
```

Save patterns:
- **Per-field autosave** (Notion-style): each field debounces and saves on blur. Inline "Saved" toast or checkmark next to the field.
- **Per-section save** (Stripe-style): "Save changes" button activates when the form is dirty, lives at section footer.
- **Page-level sticky save bar** (Vercel-style): when dirty, a bar slides up from bottom: `[Reset]                    [Save changes]`.

Pick one per product and use it everywhere. Mixing is jarring.

### 16.5 Empty States

The empty state is a first-class surface in SaaS. Stripe and Linear use them as activation moments.

Anatomy:
```
[Single line-art icon, 48px, --neutral-300]
[Heading: declarative — "No customers yet"]
[1-line description: what they'd see when populated + why it's empty]
[Primary action: the obvious next step — "Create customer"]
[Secondary action (optional): docs link, sample data]
```

Implementation:
```tsx
<div className="flex flex-col items-center justify-center py-16 px-6 text-center">
  <div className="mb-4 text-neutral-300">
    <UsersIcon size={48} strokeWidth={1.5} />
  </div>
  <h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-50">
    No customers yet
  </h3>
  <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 max-w-sm">
    Customers appear here after their first successful payment. You can also
    create one manually.
  </p>
  <div className="mt-6 flex items-center gap-2">
    <Button variant="primary" size="sm">Create customer</Button>
    <Button variant="ghost" size="sm" as="a" href="/docs/customers">
      Read the docs →
    </Button>
  </div>
</div>
```

Variants:
- **First-run empty**: full activation messaging with primary action.
- **Filtered empty** ("No customers match this search"): just heading + suggestion to clear filters. No primary CTA.
- **Permissioned empty** ("You don't have access"): icon + reason + contact-admin link.
- **Error-as-empty**: don't use empty state for errors. Use a dedicated error state with retry.

### 16.6 Authentication

Auth pages are the **only place** in a SaaS where you allow yourself spacious design — they're conversion-focused, single-task surfaces.

Layout:
```
┌────────────────────────────────────────────────┐
│                                                │
│            [logo, 32px]                        │   ← Top centered
│                                                │
│            Sign in to Acme                     │   ← 24px / 600
│            Welcome back                        │   ← 14px / muted
│                                                │
│       ┌────────────────────────────┐           │
│       │ [continue with google]     │           │
│       │ [continue with github]     │           │
│       ├────────────────────────────┤           │
│       │ ─── or ───                 │           │
│       ├────────────────────────────┤           │
│       │ Email                      │           │
│       │ [_____________________]    │           │
│       │ Password           [Forgot]│           │
│       │ [_____________________]    │           │
│       │ [        Sign in       ]   │           │
│       └────────────────────────────┘           │
│                                                │
│       Don't have an account? Sign up →         │
│                                                │
└────────────────────────────────────────────────┘
```

Specs:
- Card max-width: 400px
- Card padding: 32px
- Field gap: 16px
- Primary button: full-width
- Social/SSO buttons: above the email/password divider
- No marketing copy, no "value props" — that's what `/pricing` is for

### 16.7 Modals & Dialogs

Use modals **sparingly**. They block the entire page; only acceptable for:
1. Destructive confirmation (delete, revoke)
2. Critical inline forms (≤5 fields, no nested complexity)
3. Quick previews (image, log line expansion)

For anything bigger, use a **slide-over (sheet)** that takes the right 40-50% of the viewport.

Anatomy:
```
┌──────────────────────────────────┐
│  Delete invoice INV-1024     [×] │   ← Header: title + close button
├──────────────────────────────────┤
│                                  │
│  This action cannot be undone.   │
│  The invoice and its payment     │   ← Body: 14px text
│  history will be permanently     │
│  removed.                        │
│                                  │
│  Type INV-1024 to confirm:       │
│  [_________________]             │
│                                  │
├──────────────────────────────────┤
│              [Cancel] [Delete]   │   ← Footer: secondary left, primary right
└──────────────────────────────────┘
```

Specs:
- Width: 480px (sm), 560px (default), 720px (lg)
- Radius: 12px
- Shadow: `--shadow-xl`
- Backdrop: `rgba(15, 23, 42, 0.40)` with `backdrop-filter: blur(2px)` (optional)
- Header padding: 20px 24px
- Body padding: 20px 24px (no top divider; spacing alone separates)
- Footer padding: 16px 24px, right-aligned button group, top border
- Close button: top-right `×` icon, 32px hit area
- ESC closes; focus trapped; `aria-modal="true"`; focus restored to trigger on close

### 16.8 Slide-Overs / Sheets

For larger inline forms ("New customer", "Edit project"). Slides in from the right.

Specs:
- Width: 480px (sm), 640px (default), 800px (lg)
- Full-height
- Same header/body/footer structure as modal
- Backdrop optional (Linear: yes; Stripe: no — sheet floats over content)
- Slide animation: 300ms ease-out
- ESC closes; click outside dismisses (unless dirty form — confirm first)

### 16.9 Toasts

Bottom-right corner (default) or top-right. Max 4 stacked; older ones fade.

Anatomy:
```
┌─────────────────────────────────────┐
│ [icon] Invoice INV-1024 sent. [Undo]│
└─────────────────────────────────────┘
```

Specs:
- Width: 320–400px
- Padding: 12px 16px
- Radius: 8px
- Background: `--bg-elevated` (or status-tinted for error)
- Shadow: `--shadow-lg`
- Border: 1px `--border-default`
- Leading icon: 16px status color
- Auto-dismiss: 5s (success) / 8s (error) / never (with action button)
- Hover pauses the dismiss timer
- Action button: link-style, brand color

Variants:
- `success` — green check icon, body text only
- `error` — red alert icon, expand-on-hover for full message
- `info` — blue info icon
- `loading` — spinner icon, persistent until completed (replace with success/error)

### 16.10 Command Palette (⌘K)

The defining "S-tier SaaS" feature. Linear, Vercel, Raycast, Stripe all have one.

Anatomy:
```
┌─────────────────────────────────────────────────────────────┐
│ 🔍 Type a command or search…                          esc   │
├─────────────────────────────────────────────────────────────┤
│ RECENTLY USED                                                │
│   [→]  Go to customers                          ⌘ G C       │
│   [+]  Create invoice                           ⌘ N I       │
│                                                              │
│ ACTIONS                                                      │
│   [+]  Create customer                          ⌘ N C       │
│   [+]  Create invoice                           ⌘ N I       │
│   [↗]  Export data                                          │
│                                                              │
│ NAVIGATION                                                   │
│   [→]  Dashboard                                ⌘ G D       │
│   [→]  Customers                                ⌘ G C       │
│   [→]  Invoices                                 ⌘ G I       │
│                                                              │
│ HELP                                                         │
│   [?]  Keyboard shortcuts                                   │
│   [↗]  Open documentation                                   │
└─────────────────────────────────────────────────────────────┘
   ↑↓ navigate    ↵ select    esc close
```

Specs:
- Width: 640px, max-height: 480px
- Position: vertically centered, slightly above center (top: 20vh)
- Backdrop: `rgba(15, 23, 42, 0.40)` blur 4px
- Input: 14px, no border, padding 16px, autofocus
- Result row: 36px tall, 13px text, hover bg + leading icon
- Selected (keyboard nav): `--brand-bg-subtle` + `--brand-fg` text
- Section headers: 11px uppercase, `--text-muted`, 12px y-padding
- Trailing shortcut hints: `<kbd>` chips, mono 11px
- Footer (sticky): keyboard legend in `--text-subtle`

Implementation libs to consider: `cmdk` (the canonical lib, what Vercel and Linear use).

### 16.11 Onboarding Checklist

Top of dashboard for first 7-14 days OR until all items complete:

```
┌──────────────────────────────────────────────────────────┐
│  Get started                                  [×] dismiss │
│  3 of 5 complete                                          │
│  ▓▓▓▓▓▓▓▓▓▓░░░░░░░░  60%                                 │
│                                                           │
│  ✓ Create your workspace                                  │
│  ✓ Invite a teammate                                      │
│  ✓ Connect your first source                              │
│  ○ Create your first chart    →  [Open guide]            │
│  ○ Set up alerts              →  [Open guide]            │
└──────────────────────────────────────────────────────────┘
```

Specs:
- Card with `--shadow-sm`, dismissable
- Progress bar 4px tall, brand color fill
- Completed items: subtle (60% opacity), strikethrough text optional
- Pending items: hover reveals primary action button
- Dismissable but persist completion state on backend

### 16.12 Billing / Usage Surfaces

```
Current plan                    [Manage]
─────
[ Pro plan                                ]
[ $99/mo · renews March 1                 ]
[ Includes: 10 seats, 100k events, 50GB   ]

Usage this period
─────
Events processed     ████████████░░░░  62,341 / 100,000
Storage used         ███░░░░░░░░░░░░░  12.4 GB / 50 GB
Active users         ██████░░░░░░░░░░  6 / 10

Recent invoices                 [Download all]
─────
Mar 1, 2025          $99.00          [Paid]      [PDF]
Feb 1, 2025          $99.00          [Paid]      [PDF]
Jan 1, 2025          $99.00          [Paid]      [PDF]
```

Specs:
- Plan card uses `--bg-elevated` background
- Usage bars: full-width, 8px tall, `--brand-bg-subtle` track + `--brand-fg` fill (or warning color when ≥80%)
- Invoice list: table with right-aligned amount, status badge, download link

---

## 17. Component Snippets

> Copy-paste-ready. React + Tailwind by default; vanilla HTML provided where the React version would obscure the structural CSS. All examples assume the `tokens.css` and tailwind config from Sections 14 / 15.6 are loaded.

### 17.1 Buttons

#### BTN-PRIMARY

```tsx
<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-brand-600 text-white
             hover:bg-brand-700
             active:bg-brand-800
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Save changes
</button>
```

#### BTN-SECONDARY

```tsx
<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-surface text-neutral-900 dark:text-neutral-50
             border border-neutral-200 dark:border-neutral-800
             hover:bg-neutral-50 dark:hover:bg-neutral-800/50
             hover:border-neutral-300 dark:hover:border-neutral-700
             active:bg-neutral-100 dark:active:bg-neutral-800
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Cancel
</button>
```

#### BTN-GHOST

```tsx
<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-2.5 rounded-md text-sm font-medium
             bg-transparent text-neutral-700 dark:text-neutral-300
             hover:bg-neutral-100 dark:hover:bg-neutral-800/50
             hover:text-neutral-900 dark:hover:text-neutral-50
             focus-visible:outline-none focus-visible:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Edit
</button>
```

#### BTN-DESTRUCTIVE

```tsx
<button
  className="inline-flex items-center justify-center gap-1.5
             h-8 px-3 rounded-md text-sm font-medium
             bg-red-600 text-white
             hover:bg-red-700
             active:bg-red-800
             focus-visible:outline-none focus-visible:shadow-focus-error
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
>
  Delete project
</button>
```

#### BTN-ICON (square)

```tsx
<button
  aria-label="Settings"
  className="inline-flex items-center justify-center
             w-8 h-8 rounded-md
             bg-transparent text-neutral-600 dark:text-neutral-400
             hover:bg-neutral-100 dark:hover:bg-neutral-800/50
             hover:text-neutral-900 dark:hover:text-neutral-50
             focus-visible:outline-none focus-visible:shadow-focus
             transition-colors duration-150"
>
  <SettingsIcon className="w-4 h-4" strokeWidth={1.75} />
</button>
```

#### BTN-LOADING

```tsx
<button
  disabled
  className="inline-flex items-center justify-center gap-2
             h-8 px-3 rounded-md text-sm font-medium
             bg-brand-600 text-white opacity-80 cursor-progress"
>
  <svg className="w-3.5 h-3.5 animate-spin" viewBox="0 0 16 16" fill="none">
    <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2" />
    <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
  </svg>
  Saving…
</button>
```

#### BTN-LINK

```tsx
<a
  href="/docs"
  className="inline-flex items-center gap-1
             text-sm font-medium text-brand-600 dark:text-brand-fg
             hover:text-brand-700 hover:underline underline-offset-4
             focus-visible:outline-none focus-visible:shadow-focus
             rounded-sm transition-colors duration-150"
>
  Read the docs
  <ArrowRightIcon className="w-3.5 h-3.5" />
</a>
```

#### BTN-SEGMENTED (radio group)

```tsx
<div className="inline-flex p-0.5 bg-neutral-100 dark:bg-neutral-800/50 rounded-md gap-0.5">
  {options.map((opt) => (
    <button
      key={opt.value}
      onClick={() => setValue(opt.value)}
      className={cn(
        'px-2.5 h-7 rounded text-sm font-medium transition-colors duration-100',
        value === opt.value
          ? 'bg-surface text-neutral-900 dark:text-neutral-50 shadow-xs'
          : 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-900'
      )}
    >
      {opt.label}
    </button>
  ))}
</div>
```

### 17.2 Inputs

#### INPUT-TEXT

```tsx
<input
  type="text"
  placeholder="customer@example.com"
  className="block w-full h-8 px-3 rounded-md text-sm
             bg-surface text-neutral-900 dark:text-neutral-50
             placeholder:text-neutral-400 dark:placeholder:text-neutral-500
             border border-neutral-200 dark:border-neutral-800
             hover:border-neutral-300 dark:hover:border-neutral-700
             focus:border-brand-600 focus:outline-none focus:shadow-focus
             disabled:opacity-50 disabled:cursor-not-allowed
             transition-colors duration-150"
/>
```

#### INPUT-WITH-LABEL-HELP

```tsx
<div className="space-y-1.5">
  <label htmlFor="email" className="block text-sm font-medium">
    Work email
  </label>
  <input
    id="email"
    type="email"
    aria-describedby="email-help"
    className="block w-full h-8 px-3 rounded-md text-sm
               bg-surface border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus"
  />
  <p id="email-help" className="text-xs text-neutral-500">
    We'll use this for receipts and password resets.
  </p>
</div>
```

#### INPUT-ERROR-STATE

```tsx
<div className="space-y-1.5">
  <label htmlFor="email-err" className="block text-sm font-medium">
    Work email
  </label>
  <input
    id="email-err"
    aria-invalid="true"
    aria-describedby="email-err-msg"
    className="block w-full h-8 px-3 rounded-md text-sm
               bg-surface border border-red-500
               focus:outline-none focus:shadow-focus-error"
  />
  <p id="email-err-msg" role="alert" className="text-xs text-red-600 dark:text-red-400">
    Enter a valid email address.
  </p>
</div>
```

#### INPUT-WITH-LEADING-ICON

```tsx
<div className="relative">
  <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
  <input
    type="search"
    placeholder="Search customers…"
    className="block w-full h-8 pl-8 pr-3 rounded-md text-sm
               bg-surface border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus"
  />
  <kbd className="absolute right-2 top-1/2 -translate-y-1/2 hidden sm:inline-flex
                   items-center gap-0.5 px-1.5 h-5 rounded text-[11px] font-mono
                   text-neutral-500 bg-neutral-100 dark:bg-neutral-800
                   border border-neutral-200 dark:border-neutral-700">
    ⌘K
  </kbd>
</div>
```

#### TEXTAREA

```tsx
<textarea
  rows={4}
  placeholder="What's this for?"
  className="block w-full px-3 py-2 rounded-md text-sm
             bg-surface border border-neutral-200 dark:border-neutral-800
             focus:border-brand-600 focus:outline-none focus:shadow-focus
             resize-y min-h-[80px]
             transition-colors duration-150"
/>
```

#### SELECT (native)

```tsx
<div className="relative">
  <select
    className="appearance-none block w-full h-8 pl-3 pr-9 rounded-md text-sm
               bg-surface text-neutral-900 dark:text-neutral-50
               border border-neutral-200 dark:border-neutral-800
               focus:border-brand-600 focus:outline-none focus:shadow-focus
               transition-colors duration-150"
  >
    <option>USD</option>
    <option>EUR</option>
    <option>GBP</option>
  </select>
  <ChevronDownIcon className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
</div>
```

#### CHECKBOX

```tsx
<label className="inline-flex items-center gap-2 cursor-pointer select-none">
  <input
    type="checkbox"
    className="peer sr-only"
    checked={checked}
    onChange={(e) => setChecked(e.target.checked)}
  />
  <span
    className="grid place-items-center w-4 h-4 rounded-[4px]
               bg-surface border border-neutral-300 dark:border-neutral-700
               peer-checked:bg-brand-600 peer-checked:border-brand-600
               peer-focus-visible:shadow-focus
               transition-colors duration-100"
  >
    {checked && (
      <svg className="w-3 h-3 text-white" viewBox="0 0 12 12" fill="none">
        <path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
      </svg>
    )}
  </span>
  <span className="text-sm">Send email receipts</span>
</label>
```

#### SWITCH (toggle)

```tsx
<button
  role="switch"
  aria-checked={on}
  onClick={() => setOn(!on)}
  className={cn(
    'relative inline-flex items-center w-9 h-5 rounded-full',
    'transition-colors duration-150',
    'focus-visible:outline-none focus-visible:shadow-focus',
    on ? 'bg-brand-600' : 'bg-neutral-300 dark:bg-neutral-700'
  )}
>
  <span
    className={cn(
      'inline-block w-4 h-4 rounded-full bg-white shadow-sm',
      'transition-transform duration-150',
      on ? 'translate-x-[18px]' : 'translate-x-0.5'
    )}
  />
</button>
```

#### RADIO GROUP

```tsx
<div role="radiogroup" aria-label="Plan" className="space-y-2">
  {plans.map((plan) => (
    <label
      key={plan.id}
      className={cn(
        'flex items-start gap-3 p-3 rounded-md border cursor-pointer',
        'transition-colors duration-100',
        value === plan.id
          ? 'border-brand-600 bg-brand-50/50 dark:bg-brand-fg/10'
          : 'border-neutral-200 dark:border-neutral-800 hover:border-neutral-300'
      )}
    >
      <input
        type="radio"
        name="plan"
        value={plan.id}
        checked={value === plan.id}
        onChange={() => setValue(plan.id)}
        className="peer sr-only"
      />
      <span className={cn(
        'mt-0.5 grid place-items-center w-4 h-4 rounded-full border-2',
        value === plan.id ? 'border-brand-600' : 'border-neutral-300 dark:border-neutral-600'
      )}>
        {value === plan.id && (
          <span className="w-1.5 h-1.5 rounded-full bg-brand-600" />
        )}
      </span>
      <span className="flex-1">
        <span className="block text-sm font-medium">{plan.name}</span>
        <span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
          {plan.description}
        </span>
      </span>
      <span className="text-sm font-medium tabular-nums">{plan.price}</span>
    </label>
  ))}
</div>
```

### 17.3 Form Row (label-left, input-right)

```tsx
function FormRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
  return (
    <div className="grid grid-cols-1 md:grid-cols-[240px_1fr] gap-2 md:gap-6 py-5
                    border-b border-neutral-200 dark:border-neutral-800 last:border-b-0">
      <div>
        <label className="text-sm font-medium text-neutral-900 dark:text-neutral-50">
          {label}
        </label>
        {hint && (
          <p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
            {hint}
          </p>
        )}
      </div>
      <div className="min-w-0">{children}</div>
    </div>
  );
}

// Usage
<form>
  <FormRow label="Display name" hint="Shown to teammates in mentions.">
    <Input value={name} onChange={setName} maxLength={64} />
  </FormRow>
  <FormRow label="Time zone">
    <Select value={tz} onChange={setTz}>{tzOptions}</Select>
  </FormRow>
</form>
```

### 17.4 Cards

#### CARD-DEFAULT

```tsx
<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-5">
  <div className="flex items-start justify-between mb-3">
    <div>
      <h3 className="text-base font-semibold">API keys</h3>
      <p className="text-xs text-neutral-500 mt-0.5">
        Used to authenticate requests to your account.
      </p>
    </div>
    <Button variant="secondary" size="sm">Create key</Button>
  </div>
  <div className="text-sm">{/* card body */}</div>
</div>
```

#### CARD-KPI (stat tile)

```tsx
<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4">
  <div className="flex items-center justify-between">
    <p className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
      Active subscriptions
    </p>
    <UsersIcon className="w-4 h-4 text-neutral-400" />
  </div>
  <div className="mt-2 flex items-baseline gap-2">
    <span className="text-3xl font-semibold tabular-nums tracking-tight">
      1,247
    </span>
    <span className="text-xs font-medium text-green-600 dark:text-green-400">
      +12.4%
    </span>
  </div>
  <p className="mt-0.5 text-xs text-neutral-500">vs. previous 30 days</p>
</div>
```

#### CARD-INTERACTIVE (clickable list item)

```tsx
<button
  className="group w-full text-left
             bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4
             hover:border-neutral-300 dark:hover:border-neutral-700 hover:shadow-sm
             focus-visible:outline-none focus-visible:shadow-focus
             transition-all duration-150"
>
  <div className="flex items-center gap-3">
    <Avatar src={project.icon} size={32} />
    <div className="flex-1 min-w-0">
      <div className="text-sm font-medium truncate">{project.name}</div>
      <div className="text-xs text-neutral-500 truncate">{project.url}</div>
    </div>
    <ChevronRightIcon className="w-4 h-4 text-neutral-400 group-hover:text-neutral-600 group-hover:translate-x-0.5 transition-all" />
  </div>
</button>
```

### 17.5 Data Table

```tsx
function DataTable({ rows, columns }: Props) {
  return (
    <div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg overflow-hidden">
      {/* Toolbar */}
      <div className="flex items-center gap-2 p-3 border-b border-neutral-200 dark:border-neutral-800">
        <div className="relative flex-1 max-w-xs">
          <SearchIcon className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-400" />
          <input
            type="search"
            placeholder="Search…"
            className="block w-full h-8 pl-8 pr-3 rounded-md text-sm
                       bg-surface border border-neutral-200 dark:border-neutral-800
                       focus:border-brand-600 focus:outline-none focus:shadow-focus"
          />
        </div>
        <Button variant="secondary" size="sm">
          <FilterIcon className="w-3.5 h-3.5" /> Filter
        </Button>
        <div className="ml-auto flex items-center gap-2">
          <Button variant="ghost" size="sm">Export</Button>
        </div>
      </div>

      {/* Table */}
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead className="bg-neutral-50 dark:bg-neutral-900/50">
            <tr>
              {columns.map((col) => (
                <th
                  key={col.key}
                  scope="col"
                  className={cn(
                    'px-4 py-2.5 font-medium text-neutral-600 dark:text-neutral-400',
                    col.numeric ? 'text-right' : 'text-left',
                    col.sortable && 'cursor-pointer hover:text-neutral-900'
                  )}
                  onClick={() => col.sortable && toggleSort(col.key)}
                >
                  <span className="inline-flex items-center gap-1">
                    {col.label}
                    {col.sortable && sortKey === col.key && (
                      <ChevronDownIcon className={cn('w-3 h-3', sortDir === 'asc' && 'rotate-180')} />
                    )}
                  </span>
                </th>
              ))}
              <th className="w-8 px-4 py-2.5" /> {/* row action column */}
            </tr>
          </thead>
          <tbody>
            {rows.map((row) => (
              <tr
                key={row.id}
                className="group border-t border-neutral-200 dark:border-neutral-800
                           hover:bg-neutral-50 dark:hover:bg-neutral-900/30
                           transition-colors duration-100"
              >
                {columns.map((col) => (
                  <td
                    key={col.key}
                    className={cn(
                      'px-4 py-2.5',
                      col.numeric && 'text-right tabular-nums font-mono text-xs',
                      col.muted && 'text-neutral-500'
                    )}
                  >
                    {col.render ? col.render(row) : row[col.key]}
                  </td>
                ))}
                <td className="px-2 py-2.5">
                  <button
                    aria-label="Row actions"
                    className="opacity-0 group-hover:opacity-100 focus-visible:opacity-100
                               w-7 h-7 rounded grid place-items-center
                               hover:bg-neutral-200 dark:hover:bg-neutral-800
                               transition-opacity duration-100"
                  >
                    <MoreHorizontalIcon className="w-4 h-4" />
                  </button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Pagination */}
      <div className="flex items-center justify-between px-4 py-3 border-t border-neutral-200 dark:border-neutral-800 text-xs text-neutral-500">
        <span>Showing 1–25 of <span className="tabular-nums">1,247</span></span>
        <div className="flex items-center gap-1">
          <Button variant="ghost" size="sm" disabled>Previous</Button>
          <Button variant="ghost" size="sm">Next</Button>
        </div>
      </div>
    </div>
  );
}
```

#### TABLE-SKELETON-ROW

```tsx
function TableSkeleton({ rows = 8, cols = 5 }: { rows?: number; cols?: number }) {
  return (
    <tbody>
      {Array.from({ length: rows }).map((_, i) => (
        <tr key={i} className="border-t border-neutral-200 dark:border-neutral-800">
          {Array.from({ length: cols }).map((__, j) => (
            <td key={j} className="px-4 py-3">
              <div
                className="h-4 rounded skeleton"
                style={{ width: `${40 + Math.random() * 50}%` }}
              />
            </td>
          ))}
        </tr>
      ))}
    </tbody>
  );
}
```

### 17.6 Navigation

#### SIDEBAR

```tsx
function Sidebar() {
  return (
    <aside className="hidden lg:flex flex-col w-64 shrink-0 border-r border-neutral-200 dark:border-neutral-800 bg-canvas">
      {/* Workspace switcher */}
      <button className="flex items-center gap-2 m-2 p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors">
        <div className="w-6 h-6 rounded bg-brand-600 grid place-items-center text-white text-xs font-semibold">
          A
        </div>
        <span className="flex-1 text-left text-sm font-medium truncate">Acme Corp</span>
        <ChevronsUpDownIcon className="w-3.5 h-3.5 text-neutral-400" />
      </button>

      {/* Nav */}
      <nav className="flex-1 overflow-y-auto px-2 pb-2">
        <SidebarSection label="Workspace">
          <SidebarItem icon={<HomeIcon />} href="/" active>Dashboard</SidebarItem>
          <SidebarItem icon={<UsersIcon />} href="/customers" badge="247">Customers</SidebarItem>
          <SidebarItem icon={<FileTextIcon />} href="/invoices">Invoices</SidebarItem>
        </SidebarSection>

        <SidebarSection label="Settings">
          <SidebarItem icon={<SettingsIcon />} href="/settings">General</SidebarItem>
          <SidebarItem icon={<KeyIcon />} href="/settings/api-keys">API keys</SidebarItem>
        </SidebarSection>
      </nav>

      {/* User menu */}
      <button className="flex items-center gap-2 m-2 p-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-colors">
        <Avatar size={24} src={user.avatar} />
        <div className="flex-1 text-left min-w-0">
          <div className="text-sm font-medium truncate">{user.name}</div>
          <div className="text-xs text-neutral-500 truncate">{user.email}</div>
        </div>
      </button>
    </aside>
  );
}

function SidebarSection({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <div className="mt-4 first:mt-2">
      <div className="px-2 py-1 text-[11px] font-medium uppercase tracking-wider text-neutral-500">
        {label}
      </div>
      <div className="space-y-0.5">{children}</div>
    </div>
  );
}

function SidebarItem({ icon, href, active, badge, children }: SidebarItemProps) {
  return (
    <a
      href={href}
      className={cn(
        'group flex items-center gap-2 px-2 h-8 rounded-md text-sm font-medium',
        'transition-colors duration-100',
        active
          ? 'bg-brand-50 dark:bg-brand-fg/10 text-brand-700 dark:text-brand-fg'
          : 'text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-neutral-50'
      )}
    >
      <span className={cn('shrink-0', active ? 'text-brand-600 dark:text-brand-fg' : 'text-neutral-500')}>
        {React.cloneElement(icon as React.ReactElement, { className: 'w-4 h-4', strokeWidth: 1.75 })}
      </span>
      <span className="flex-1 truncate">{children}</span>
      {badge && (
        <span className="text-xs text-neutral-500 tabular-nums">{badge}</span>
      )}
    </a>
  );
}
```

#### TOPBAR

```tsx
function Topbar() {
  return (
    <header className="h-12 shrink-0 flex items-center gap-2 px-4 border-b border-neutral-200 dark:border-neutral-800 bg-surface">
      <button aria-label="Toggle sidebar" className="lg:hidden p-1.5 rounded hover:bg-neutral-100">
        <MenuIcon className="w-4 h-4" />
      </button>

      <button
        onClick={openCommand}
        className="hidden md:inline-flex items-center gap-2 flex-1 max-w-md
                   h-8 px-3 rounded-md text-sm
                   bg-neutral-50 dark:bg-neutral-900 text-neutral-500
                   border border-neutral-200 dark:border-neutral-800
                   hover:border-neutral-300 transition-colors"
      >
        <SearchIcon className="w-4 h-4" />
        <span>Search or jump to…</span>
        <kbd className="ml-auto px-1.5 h-5 rounded text-[11px] font-mono
                        bg-surface border border-neutral-200 dark:border-neutral-700">⌘K</kbd>
      </button>

      <div className="ml-auto flex items-center gap-1">
        <IconButton aria-label="Notifications"><BellIcon /></IconButton>
        <IconButton aria-label="Help"><HelpCircleIcon /></IconButton>
        <Avatar size={28} src={user.avatar} />
      </div>
    </header>
  );
}
```

#### BREADCRUMB

```tsx
function Breadcrumb({ items }: { items: Array<{ label: string; href?: string }> }) {
  return (
    <nav aria-label="Breadcrumb" className="flex items-center text-xs text-neutral-500">
      {items.map((item, i) => (
        <React.Fragment key={i}>
          {i > 0 && <ChevronRightIcon className="w-3 h-3 mx-1 text-neutral-300" />}
          {item.href && i < items.length - 1 ? (
            <a href={item.href} className="hover:text-neutral-900 dark:hover:text-neutral-50 transition-colors">
              {item.label}
            </a>
          ) : (
            <span className={i === items.length - 1 ? 'text-neutral-900 dark:text-neutral-50 font-medium' : ''}>
              {item.label}
            </span>
          )}
        </React.Fragment>
      ))}
    </nav>
  );
}
```

#### TABS

```tsx
<div role="tablist" className="flex items-center gap-1 border-b border-neutral-200 dark:border-neutral-800">
  {tabs.map((tab) => (
    <button
      key={tab.id}
      role="tab"
      aria-selected={active === tab.id}
      onClick={() => setActive(tab.id)}
      className={cn(
        'relative px-3 h-9 text-sm font-medium',
        'focus-visible:outline-none focus-visible:shadow-focus rounded-t-md',
        'transition-colors duration-150',
        active === tab.id
          ? 'text-neutral-900 dark:text-neutral-50'
          : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
      )}
    >
      {tab.label}
      {tab.count !== undefined && (
        <span className="ml-1.5 text-xs text-neutral-400 tabular-nums">{tab.count}</span>
      )}
      {active === tab.id && (
        <span className="absolute -bottom-px left-0 right-0 h-0.5 bg-brand-600 rounded-t" />
      )}
    </button>
  ))}
</div>
```

#### DROPDOWN MENU

```tsx
// Using Radix UI primitives (recommended) or headlessui
<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <IconButton aria-label="More"><MoreHorizontalIcon /></IconButton>
  </DropdownMenuTrigger>
  <DropdownMenuContent
    align="end"
    className="min-w-[180px] bg-elevated rounded-lg shadow-md
               border border-neutral-200 dark:border-neutral-800 p-1
               animate-popover-enter"
  >
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-800/50
                                  focus:bg-neutral-100 focus:outline-none">
      <EditIcon className="w-3.5 h-3.5 text-neutral-500" />
      Edit
    </DropdownMenuItem>
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  cursor-pointer hover:bg-neutral-100">
      <CopyIcon className="w-3.5 h-3.5 text-neutral-500" />
      Duplicate
      <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘D</kbd>
    </DropdownMenuItem>
    <DropdownMenuSeparator className="h-px bg-neutral-200 dark:bg-neutral-800 my-1" />
    <DropdownMenuItem className="flex items-center gap-2 px-2 h-8 rounded text-sm
                                  text-red-600 dark:text-red-400
                                  cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/30">
      <Trash2Icon className="w-3.5 h-3.5" />
      Delete
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>
```

### 17.7 Modal / Dialog

```tsx
// Using Radix Dialog or headlessui
<Dialog open={open} onOpenChange={setOpen}>
  <DialogOverlay
    className="fixed inset-0 bg-neutral-900/40 backdrop-blur-[2px]
               data-[state=open]:animate-fade-in"
  />
  <DialogContent
    className="fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
               w-full max-w-md bg-elevated rounded-xl shadow-xl
               border border-neutral-200 dark:border-neutral-800
               data-[state=open]:animate-dialog-enter"
  >
    <div className="flex items-start justify-between p-5 pb-3">
      <div>
        <DialogTitle className="text-base font-semibold">
          Delete invoice INV-1024?
        </DialogTitle>
        <DialogDescription className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
          This action cannot be undone. The invoice and its payment history will be permanently removed.
        </DialogDescription>
      </div>
      <DialogClose className="-mr-1 -mt-1 p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800">
        <XIcon className="w-4 h-4" />
      </DialogClose>
    </div>

    <div className="px-5 pb-3">
      <label className="block text-sm font-medium mb-1.5">
        Type <code className="font-mono text-xs bg-neutral-100 dark:bg-neutral-800 px-1 py-0.5 rounded">INV-1024</code> to confirm
      </label>
      <Input value={confirm} onChange={setConfirm} autoFocus />
    </div>

    <div className="flex items-center justify-end gap-2 p-4 border-t border-neutral-200 dark:border-neutral-800">
      <Button variant="secondary" size="sm" onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="destructive" size="sm" disabled={confirm !== 'INV-1024'}>Delete invoice</Button>
    </div>
  </DialogContent>
</Dialog>
```

#### SHEET (slide-over)

```tsx
<Sheet open={open} onOpenChange={setOpen}>
  <SheetOverlay className="fixed inset-0 bg-neutral-900/30 backdrop-blur-[2px]" />
  <SheetContent
    side="right"
    className="fixed right-0 top-0 bottom-0 w-full max-w-xl
               bg-elevated border-l border-neutral-200 dark:border-neutral-800
               shadow-xl flex flex-col
               data-[state=open]:animate-slide-in-right"
  >
    <div className="flex items-center justify-between p-5 border-b border-neutral-200 dark:border-neutral-800">
      <SheetTitle className="text-base font-semibold">New customer</SheetTitle>
      <SheetClose className="p-1.5 rounded hover:bg-neutral-100">
        <XIcon className="w-4 h-4" />
      </SheetClose>
    </div>

    <div className="flex-1 overflow-y-auto p-5 space-y-4">
      {/* form body */}
    </div>

    <div className="flex items-center justify-end gap-2 p-4 border-t border-neutral-200 dark:border-neutral-800">
      <Button variant="secondary" size="sm" onClick={() => setOpen(false)}>Cancel</Button>
      <Button variant="primary" size="sm">Create customer</Button>
    </div>
  </SheetContent>
</Sheet>
```

### 17.8 Toast

```tsx
function Toast({ variant, title, description, action, onDismiss }: ToastProps) {
  const tone = {
    success: { icon: CheckCircleIcon, color: 'text-green-600 dark:text-green-400' },
    error:   { icon: AlertCircleIcon, color: 'text-red-600 dark:text-red-400' },
    info:    { icon: InfoIcon,        color: 'text-blue-600 dark:text-blue-400' },
    loading: { icon: LoaderIcon,      color: 'text-neutral-500 animate-spin' },
  }[variant];

  return (
    <div
      role={variant === 'error' ? 'alert' : 'status'}
      className="flex items-start gap-3 w-[360px] p-3 pr-2
                 bg-elevated rounded-lg shadow-lg
                 border border-neutral-200 dark:border-neutral-800
                 animate-toast-enter"
    >
      <tone.icon className={cn('w-4 h-4 mt-0.5 shrink-0', tone.color)} strokeWidth={1.75} />
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium">{title}</p>
        {description && (
          <p className="text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
            {description}
          </p>
        )}
        {action && (
          <button
            onClick={action.onClick}
            className="mt-1.5 text-xs font-medium text-brand-600 dark:text-brand-fg hover:underline"
          >
            {action.label}
          </button>
        )}
      </div>
      <button
        onClick={onDismiss}
        aria-label="Dismiss"
        className="shrink-0 p-1 -m-1 rounded text-neutral-400 hover:text-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800"
      >
        <XIcon className="w-3.5 h-3.5" />
      </button>
    </div>
  );
}
```

Toaster container (fixed position):
```tsx
<div className="fixed bottom-4 right-4 z-50 flex flex-col-reverse gap-2 pointer-events-none">
  {toasts.map((t) => (
    <div key={t.id} className="pointer-events-auto">
      <Toast {...t} />
    </div>
  ))}
</div>
```

### 17.9 Empty State

```tsx
function EmptyState({
  icon: Icon,
  title,
  description,
  primaryAction,
  secondaryAction,
}: EmptyStateProps) {
  return (
    <div className="flex flex-col items-center justify-center py-16 px-6 text-center">
      <div className="mb-4 text-neutral-300 dark:text-neutral-600">
        <Icon size={48} strokeWidth={1.5} />
      </div>
      <h3 className="text-base font-semibold text-neutral-900 dark:text-neutral-50">
        {title}
      </h3>
      {description && (
        <p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400 max-w-sm">
          {description}
        </p>
      )}
      {(primaryAction || secondaryAction) && (
        <div className="mt-6 flex items-center gap-2">
          {primaryAction && (
            <Button variant="primary" size="sm" onClick={primaryAction.onClick}>
              {primaryAction.label}
            </Button>
          )}
          {secondaryAction && (
            <Button variant="ghost" size="sm" onClick={secondaryAction.onClick}>
              {secondaryAction.label}
            </Button>
          )}
        </div>
      )}
    </div>
  );
}
```

### 17.10 Loading States

#### SKELETON

```css
.skeleton {
  display: block;
  background: linear-gradient(
    90deg,
    var(--neutral-100) 25%,
    var(--neutral-200) 50%,
    var(--neutral-100) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.5s ease-in-out infinite;
  border-radius: 4px;
}
.dark .skeleton {
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0.04) 25%,
    rgba(255, 255, 255, 0.08) 50%,
    rgba(255, 255, 255, 0.04) 75%
  );
  background-size: 200% 100%;
}
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}
```

Skeleton card:
```tsx
<div className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-5">
  <div className="h-4 w-1/3 skeleton mb-3" />
  <div className="h-3 w-2/3 skeleton mb-1.5" />
  <div className="h-3 w-1/2 skeleton" />
</div>
```

#### SPINNER

```tsx
function Spinner({ size = 16 }: { size?: number }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 16 16"
      fill="none"
      className="animate-spin"
      style={{ animationDuration: '750ms' }}
      aria-hidden="true"
    >
      <circle cx="8" cy="8" r="6" stroke="currentColor" strokeOpacity="0.25" strokeWidth="2" />
      <path d="M14 8a6 6 0 0 0-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
    </svg>
  );
}
```

#### PROGRESS BAR (determinate)

```tsx
function Progress({ value, max = 100, tone = 'brand' }: ProgressProps) {
  const pct = Math.min(100, Math.max(0, (value / max) * 100));
  const fill =
    tone === 'warning' ? 'bg-yellow-500' :
    tone === 'error'   ? 'bg-red-500' :
    'bg-brand-600';
  return (
    <div
      role="progressbar"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      className="h-1.5 rounded-full bg-neutral-100 dark:bg-neutral-800 overflow-hidden"
    >
      <div
        className={cn('h-full transition-[width] duration-500 ease-out', fill)}
        style={{ width: `${pct}%` }}
      />
    </div>
  );
}
```

### 17.11 Badges

```tsx
function Badge({ tone = 'neutral', dot, children }: BadgeProps) {
  const tones = {
    neutral: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
    brand:   'bg-brand-50 text-brand-700 dark:bg-brand-fg/15 dark:text-brand-fg',
    success: 'bg-green-50 text-green-700 dark:bg-green-950/40 dark:text-green-400',
    warning: 'bg-yellow-50 text-yellow-700 dark:bg-yellow-950/40 dark:text-yellow-400',
    error:   'bg-red-50 text-red-700 dark:bg-red-950/40 dark:text-red-400',
    info:    'bg-blue-50 text-blue-700 dark:bg-blue-950/40 dark:text-blue-400',
  };
  const dotTones = {
    neutral: 'bg-neutral-400',
    brand:   'bg-brand-600',
    success: 'bg-green-500',
    warning: 'bg-yellow-500',
    error:   'bg-red-500',
    info:    'bg-blue-500',
  };
  return (
    <span className={cn(
      'inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs font-medium',
      tones[tone]
    )}>
      {dot && <span className={cn('w-1.5 h-1.5 rounded-full', dotTones[tone])} />}
      {children}
    </span>
  );
}

// Usage
<Badge tone="success" dot>Active</Badge>
<Badge tone="warning" dot>Trial ending</Badge>
<Badge tone="error" dot>Failed</Badge>
```

### 17.12 Tooltip

```tsx
// Using Radix Tooltip — apply these styles to the content
<TooltipContent
  side="top"
  sideOffset={4}
  className="z-50 px-2 py-1 rounded text-xs font-medium
             bg-neutral-900 text-neutral-50 dark:bg-neutral-50 dark:text-neutral-900
             shadow-md
             data-[state=delayed-open]:animate-popover-enter"
>
  {label}
  {shortcut && (
    <span className="ml-2 text-neutral-400 dark:text-neutral-500 font-mono">{shortcut}</span>
  )}
</TooltipContent>
```

Delay: 300ms hover before showing. Tooltips fire on focus too.

### 17.13 Avatar

```tsx
function Avatar({ src, name, size = 32 }: AvatarProps) {
  const initials = name?.split(' ').map((s) => s[0]).slice(0, 2).join('').toUpperCase();
  return (
    <span
      className="inline-grid place-items-center rounded-full overflow-hidden
                 bg-neutral-200 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300
                 font-medium shrink-0"
      style={{ width: size, height: size, fontSize: Math.round(size * 0.4) }}
    >
      {src ? (
        <img src={src} alt="" className="w-full h-full object-cover" />
      ) : (
        <span>{initials || '?'}</span>
      )}
    </span>
  );
}

// Avatar group with overlap
<div className="flex -space-x-1.5">
  {users.slice(0, 3).map((u) => (
    <Avatar key={u.id} {...u} size={24} className="ring-2 ring-surface" />
  ))}
  {users.length > 3 && (
    <span className="grid place-items-center w-6 h-6 rounded-full
                     bg-neutral-100 dark:bg-neutral-800 ring-2 ring-surface
                     text-[10px] font-medium text-neutral-600">
      +{users.length - 3}
    </span>
  )}
</div>
```

### 17.14 KPI Strip

```tsx
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
  {kpis.map((kpi) => (
    <div
      key={kpi.label}
      className="bg-surface border border-neutral-200 dark:border-neutral-800 rounded-lg p-4"
    >
      <p className="text-xs font-medium text-neutral-500 uppercase tracking-wider">
        {kpi.label}
      </p>
      <div className="mt-2 flex items-baseline gap-2">
        <span className="text-2xl font-semibold tabular-nums tracking-tight">
          {kpi.value}
        </span>
        {kpi.delta && (
          <span className={cn(
            'text-xs font-medium',
            kpi.delta > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
          )}>
            {kpi.delta > 0 ? '+' : ''}{kpi.delta}%
          </span>
        )}
      </div>
      {kpi.subtitle && (
        <p className="mt-0.5 text-xs text-neutral-500">{kpi.subtitle}</p>
      )}
    </div>
  ))}
</div>
```

### 17.15 Pagination

```tsx
function Pagination({ page, total, perPage, onChange }: PaginationProps) {
  const from = (page - 1) * perPage + 1;
  const to = Math.min(page * perPage, total);
  const lastPage = Math.ceil(total / perPage);
  return (
    <div className="flex items-center justify-between px-4 py-3 text-xs text-neutral-500">
      <span>
        Showing <span className="tabular-nums">{from}–{to}</span> of{' '}
        <span className="tabular-nums">{total.toLocaleString()}</span>
      </span>
      <div className="flex items-center gap-1">
        <Button variant="ghost" size="sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
          <ChevronLeftIcon className="w-3.5 h-3.5" /> Previous
        </Button>
        <Button variant="ghost" size="sm" disabled={page >= lastPage} onClick={() => onChange(page + 1)}>
          Next <ChevronRightIcon className="w-3.5 h-3.5" />
        </Button>
      </div>
    </div>
  );
}
```

### 17.16 Command Palette (using `cmdk`)

```tsx
import { Command } from 'cmdk';

function CommandPalette({ open, onOpenChange }: Props) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogOverlay className="fixed inset-0 bg-neutral-900/40 backdrop-blur-[4px]" />
      <DialogContent
        className="fixed left-1/2 top-[20vh] -translate-x-1/2 w-full max-w-xl
                   bg-elevated rounded-xl shadow-xl
                   border border-neutral-200 dark:border-neutral-800
                   data-[state=open]:animate-dialog-enter overflow-hidden"
      >
        <Command className="flex flex-col">
          <div className="flex items-center gap-2 px-4 border-b border-neutral-200 dark:border-neutral-800">
            <SearchIcon className="w-4 h-4 text-neutral-400" />
            <Command.Input
              placeholder="Type a command or search…"
              className="flex-1 h-12 bg-transparent text-sm
                         placeholder:text-neutral-400 focus:outline-none"
            />
            <kbd className="text-[10px] text-neutral-400 px-1.5 py-0.5 rounded
                            bg-neutral-100 dark:bg-neutral-800 font-mono">esc</kbd>
          </div>

          <Command.List className="max-h-[400px] overflow-y-auto p-2">
            <Command.Empty className="py-8 text-center text-sm text-neutral-500">
              No results.
            </Command.Empty>

            <Command.Group heading="Actions" className="cmdk-group">
              <Command.Item className="cmdk-item">
                <PlusIcon className="w-4 h-4 text-neutral-500" />
                <span>Create customer</span>
                <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘ N C</kbd>
              </Command.Item>
              {/* … */}
            </Command.Group>

            <Command.Group heading="Navigation" className="cmdk-group">
              <Command.Item className="cmdk-item">
                <HomeIcon className="w-4 h-4 text-neutral-500" />
                <span>Go to dashboard</span>
                <kbd className="ml-auto text-[10px] text-neutral-400 font-mono">⌘ G D</kbd>
              </Command.Item>
              {/* … */}
            </Command.Group>
          </Command.List>

          <div className="flex items-center justify-between gap-2 px-3 py-2
                          border-t border-neutral-200 dark:border-neutral-800
                          text-[11px] text-neutral-500">
            <div className="flex items-center gap-3">
              <span className="flex items-center gap-1">
                <kbd className="font-mono">↑↓</kbd> navigate
              </span>
              <span className="flex items-center gap-1">
                <kbd className="font-mono">↵</kbd> select
              </span>
            </div>
          </div>
        </Command>
      </DialogContent>
    </Dialog>
  );
}
```

Required CSS for `cmdk` items:
```css
.cmdk-group [cmdk-group-heading] {
  padding: 8px;
  font-size: 11px;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-subtle);
}
.cmdk-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 8px;
  height: 36px;
  border-radius: 6px;
  font-size: 13px;
  cursor: pointer;
  color: var(--text-primary);
}
.cmdk-item[data-selected="true"] {
  background: var(--brand-bg-subtle);
  color: var(--brand-fg);
}
.cmdk-item[data-selected="true"] svg { color: currentColor; }
```

### 17.17 Sticky Save Bar (Vercel-style)

```tsx
{isDirty && (
  <div className="fixed bottom-4 left-1/2 -translate-x-1/2 z-40
                  animate-popover-enter">
    <div className="flex items-center gap-3 pl-4 pr-2 py-2
                    bg-neutral-900 dark:bg-neutral-50 text-neutral-50 dark:text-neutral-900
                    rounded-full shadow-xl">
      <span className="text-sm">You have unsaved changes</span>
      <button
        onClick={onReset}
        className="text-sm text-neutral-400 hover:text-neutral-50 dark:hover:text-neutral-900 transition-colors"
      >
        Reset
      </button>
      <Button variant="primary" size="sm" onClick={onSave}>Save changes</Button>
    </div>
  </div>
)}
```

---

## 18. Pre-flight Checklist

Run through this before shipping any SaaS surface. Each "no" is a regression.

### Visual
- [ ] Background is `--bg-canvas` (not pure white).
- [ ] Text is `--text-primary` (not pure black).
- [ ] Brand color fills <10% of any viewport (squint test).
- [ ] No pill buttons (`border-radius: 9999px`) outside avatars and status dots.
- [ ] All borders 1px solid, color `--border-default`.
- [ ] All shadows are downward, low-spread, neutral (not colored, not glow).
- [ ] One font family for sans, one for mono. No display fonts mixed in.

### Density
- [ ] Default UI text is 13px or 14px (not 16px).
- [ ] Default button height is 32px.
- [ ] Default input height matches button height (32px).
- [ ] Table rows are 40px (default) or 32px (compact).
- [ ] Numeric columns use `font-variant-numeric: tabular-nums`.

### Interaction
- [ ] Every interactive element has hover + focus-visible + active states.
- [ ] Focus rings: `2px solid --brand-fg` outline OR `--ring-focus` box-shadow.
- [ ] Hover transitions ≤150ms.
- [ ] Modal/dialog enter ≤250ms.
- [ ] No animation on page mount (no entrance stagger).
- [ ] `prefers-reduced-motion` honored.

### Accessibility
- [ ] Body text contrast ≥4.5:1.
- [ ] UI element contrast (borders, focus rings) ≥3:1.
- [ ] Every form field has a `<label>` (or `aria-label`).
- [ ] Errors linked via `aria-describedby`, role `alert`.
- [ ] Modals trap focus, restore on close, `aria-modal="true"`.
- [ ] `⌘K`, `Esc`, `Tab`, `↑↓` work on every surface they apply to.
- [ ] Icon-only buttons have `aria-label`.

### Content
- [ ] One H1 per page.
- [ ] One primary button per surface (the rest are secondary/ghost).
- [ ] Button text is verb + object ("Save changes", not "Submit").
- [ ] Empty states have icon + declarative heading + 1-line description + primary action.
- [ ] Status badges use semantic color + dot, never solid backgrounds.

### Performance
- [ ] No CSS-in-JS in hot paths; tokens via CSS variables.
- [ ] Tables virtualize at >100 rows.
- [ ] Skeleton states for any data fetch taking >250ms.
- [ ] No layout shift on initial render (reserve space for async content).

### Dark Mode
- [ ] `.dark` class on `<html>`, set BEFORE first paint (no FOUC).
- [ ] Three-way theme toggle: system / light / dark.
- [ ] Borders are translucent or lighter in dark mode (not the same as light).
- [ ] Brand color shifts to lighter ramp step in dark mode (`--brand-600` → `--brand-fg: #A5B4FC`).
- [ ] Shadows nearly invisible in dark mode; elevation conveyed by lifted background.

---

*Last updated: synthesized from Stripe Dashboard, Linear, Vercel, Notion, Clerk, PlanetScale design patterns. Always pin to your product's brand color via the `--brand-*` scale; everything else stays intact.*

More from cloudflare/vibesdk