` and keep typography in sync with Cards and Dialogs.
```tsx
Dimensions
Set the dimensions for the layer.
…
```
Don't hand-roll `` + ` `.
## Styling
- Surface: `--popover` (translucent white in light, translucent zinc-800 in dark)
- Shadow: `shadow-popover` + `backdrop-blur-popover`
- Border: `--border`, radius `var(--radius)` (10px)
- Enter/exit animation from `tailwindcss-animate`
Source: `packages/react/src/components/popover.tsx`.
---
# MenuItem
The building block for every menu surface — dropdown menus, popovers, command menus, context menus. Use `MenuItem` for the row, `MenuGroup` + `MenuLabel` for sections, `MenuSeparator` for dividers.
```tsx
import { MenuItem, MenuGroup, MenuLabel, MenuSeparator } from "@tidal-ds/react";
File
} shortcut="⌘C">Copy
} shortcut="⌘V">Paste
Show line numbers
Light theme
} hasSubmenu>Open in…
```
## MenuItem props
| Prop | Type | Default |
|---|---|---|
| `icon` | `ReactNode` | — |
| `iconWrapped` | `boolean` — wraps the icon in a rounded muted surface (Figma "Wrapped Icons") | `false` |
| `indicator` | `"none" | "check" | "radio"` | `"none"` |
| `checked` | `boolean` — for check/radio indicators | `false` |
| `shortcut` | `ReactNode` — keyboard hint, rendered right-aligned | — |
| `hasSubmenu` | `boolean` — shows a chevron on the right | `false` |
| `disabled` | `boolean` | `false` |
| `asChild` | `boolean` — for composing with Radix primitives (e.g. ` `) | `false` |
| …rest | `HTMLAttributes` | — |
## Role + a11y
- `indicator="none"` → `role="menuitem"`
- `indicator="check"` → `role="menuitemcheckbox"` with `aria-checked`
- `indicator="radio"` → `role="menuitemradio"` with `aria-checked`
- `disabled` sets `aria-disabled`, 50% opacity, and `tabIndex=-1`.
When mounted inside a Radix primitive, use `asChild`:
```tsx
} shortcut="⌘C">Copy
```
Radix's `data-highlighted` / `data-disabled` attributes will drive the hover/disabled styling automatically.
## Exports
`MenuItem`, `MenuGroup`, `MenuLabel`, `MenuSeparator`, `MenuShortcut`.
Source: `packages/react/src/components/menu-item.tsx`. Figma node `2819:22795`.
---
# Separator
Thin divider. Built on `@radix-ui/react-separator`.
```tsx
import { Separator } from "@tidal-ds/react";
```
| Prop | Type | Default |
|---|---|---|
| `orientation` | `"horizontal" | "vertical"` | `"horizontal"` |
| `decorative` | `boolean` — if true, hidden from AT | `true` |
Source: `packages/react/src/components/separator.tsx`.
---
# Breadcrumb
Displays the path to the current resource using a hierarchy of links. Compositional API modeled on shadcn.
```tsx
import {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
} from "@tidal-ds/react";
Home
Components
Breadcrumb
```
## Anatomy
- **`Breadcrumb`** — ``.
- **`BreadcrumbList`** — `` with `flex flex-wrap items-center gap-1.5`. Typography: `text-base leading-6 text-muted-foreground`.
- **`BreadcrumbItem`** — `` wrapping a `Link`, `Page`, or `Ellipsis`.
- **`BreadcrumbLink`** — past crumbs. `hover:text-foreground` with `transition-colors`; focus ring via `focus-visible:shadow-ring-primary` on `rounded-sm`. Supports `asChild` to wrap router links.
- **`BreadcrumbPage`** — current crumb. `` with `font-medium text-foreground`.
- **`BreadcrumbSeparator`** — ``. Defaults to a chevron-right icon (18px). Override via children: `/ `.
- **`BreadcrumbEllipsis`** — horizontal ellipsis for collapsed middles. Pair with `` to surface the hidden crumbs.
Source: `packages/react/src/components/breadcrumb.tsx`. Figma node `2814:11672`.
---
# Tabs
Layered sections shown one at a time. Two visual variants and an optional stretched layout.
```tsx
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@tidal-ds/react";
Account
Password
Account settings…
Password settings…
```
## Variants
- **`default`** (pill) — segmented control look. Inactive tabs blend into a muted groove; active tab uses the **Default Button chrome** (`bg-button-default` gradient + `border-border` + `shadow-sm`) so it pops like a raised button.
- **`line`** — underline. Active tab carries a 2px lilac underline (`border-primary`); no container chrome.
**Typography**: all tabs use `text-base font-normal`. Interactive components already signal themselves visually; no need to double down with a heavier weight. Save `font-medium`/`font-semibold` for labels and headings.
```tsx
…
```
## Stretch
Pass `stretch` to distribute the triggers across the full width of their list:
```tsx
Account
Billing
Team
```
## Props
| Prop | Type | Default |
|---|---|---|
| `variant` | `"default" | "line"` | `"default"` |
| `size` | `"sm" | "md"` | `"md"` |
| `stretch` | `boolean` | `false` |
| …rest | Radix Tabs.Root props (`value`, `onValueChange`, `defaultValue`, …) | — |
## When to use Tabs vs PageTabBar
| Scenario | Use |
|----------|-----|
| Fixed view-switching sections (Settings / Account / Billing, Terminal / Problems / Output) | **Tabs** |
| Closeable document/file tabs with icons (IDE tabs, browser tabs, query tabs) | **PageTabBar + PageTab** |
Use `Tabs` when the set of sections is fixed and not user-closeable. Use `PageTabBar` when tabs represent open documents that can be closed, reordered, or have left-aligned icons.
When combining both on one screen (e.g., PageTabBar for file tabs + Tabs for a terminal panel), the Tabs `TabsList` can be visually hidden (`className="sr-only"`) while PageTabBar handles the visible tab UI and syncs `activeTab` state to the Tabs component.
Source: `packages/react/src/components/tabs.tsx`. Figma node `2819:31096`.
---
# NavigationMenu
Horizontal top-of-page nav with popover panels under each section. Built on `@radix-ui/react-navigation-menu`.
```tsx
import {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuTrigger,
NavigationMenuContent,
NavigationMenuLink,
navigationMenuTriggerStyle,
} from "@tidal-ds/react";
Getting started
Documentation
```
## Anatomy
- `NavigationMenu` — root. Ships a `NavigationMenuViewport` by default so the common case just works.
- `NavigationMenuList` — horizontal list of items.
- `NavigationMenuItem` — wraps a Trigger/Content pair or a plain Link.
- `NavigationMenuTrigger` — opens a Content panel. Chevron rotates on open via `[&[data-state=open]>svg]:rotate-180`.
- `NavigationMenuContent` — panel body; slotted into the shared Viewport.
- `NavigationMenuLink` — navigational link. Apply `navigationMenuTriggerStyle()` for top-level links that don't open a panel.
- `NavigationMenuIndicator` — optional caret that follows the active trigger.
- `NavigationMenuViewport` — animated surface. All popover chrome (bg, blur, border, shadow, radius) lives here so panels share one visual container.
## Styling
- **Trigger** — `h-8 rounded-md px-2.5 text-base font-medium`, matches menuItemBase hover/focus semantics (`hover:bg-accent`, `focus-visible:shadow-ring-primary`). The trigger gets `data-[state=open]:bg-accent` while its panel is open.
- **Viewport** — popover surface (`bg-popover`, `backdrop-blur-popover`, `backdrop-saturate-150`, `shadow-overlay`, `border-[var(--border-subtle)]`, `rounded-[var(--radius-popover)]`). Width and height follow `--radix-navigation-menu-viewport-{width,height}`, so panels self-size and the container animates between them.
- **Content** — per-panel motion only (fade + slide between siblings). No chrome; chrome lives on the Viewport.
## When to use
- Top-of-page marketing / product-site navigation with grouped link panels under each section (e.g. "Products ▾" opens a grid of product links).
- Prefer over `Menubar` when: items are navigational **links** (not actions); panels may include images, descriptions, or multi-column layouts.
- Prefer over `Tabs` when: the surfaces are separate pages (not a single layered view on the same page).
- **Don't use for**: app sidebars (use `Sidebar`); dropdown action menus (use `DropdownMenu`); simple breadcrumb lists (use `Breadcrumb`).
Source: `packages/react/src/components/navigation-menu.tsx`. Figma node `2819:28229`.
---
# Chart
Thin wrapper over [recharts](https://recharts.org) styled against Tidal tokens. Compose with the raw recharts primitives inside ``.
```tsx
import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@tidal-ds/react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
const config = {
revenue: { label: "Revenue", color: "var(--chart-1)" },
} satisfies ChartConfig;
} />
```
## Color tokens
Five series colors, available as CSS vars. Lilac is the implicit default.
| Var | Color | Hex (light / dark) |
|---|---|---|
| `--chart-1` | **Lilac** (brand) | `#cd82f0` / `#cd82f0` |
| `--chart-2` | **Viola** | `#5505af` / `#7c3aed` |
| `--chart-3` | **Mandarin** | `#ff5f1e` / `#ff8052` |
| `--chart-4` | **Lime** | `#84cc16` / `#a3e635` |
| `--chart-5` | **Cerulean** | `#0891b2` / `#22d3ee` |
Viola/Mandarin/Lime/Cerulean lighten slightly in dark mode for legibility on the dark canvas.
## Per-series lookup
The `config` prop maps series keys → label + color. Inside the container, use `var(--color-)`:
```tsx
const config = {
revenue: { label: "Revenue", color: "var(--chart-1)" },
expenses: { label: "Expenses", color: "var(--chart-3)" },
} satisfies ChartConfig;
```
Tooltip and Legend read labels from the same config — keys match series `dataKey`.
## Exports
| Export | Role |
|---|---|
| `ChartContainer` | Responsive wrapper. Provides `--color-` vars + theme styling. |
| `ChartTooltip` | Alias of `recharts.Tooltip`. Pair with `content={ }`. |
| `ChartTooltipContent` | Styled tooltip panel (Popover surface). |
| `ChartLegend` | Alias of `recharts.Legend`. |
| `ChartLegendContent` | Styled legend row. |
| `ChartConfig` | Type for the `config` prop. |
## Composing with Card
```tsx
Overview
Last 12 months of revenue.
…
```
Source: `packages/react/src/components/chart.tsx`.
---
# Command
Searchable command palette. Built on [cmdk](https://cmdk.paco.me) — it handles filtering, keyboard nav, and selection.
```tsx
import {
Command, CommandInput, CommandList, CommandEmpty,
CommandGroup, CommandItem, CommandSeparator, CommandShortcut,
} from "@tidal-ds/react";
No results found.
Calendar
Search Emoji
Profile
⌘P
Billing
⌘B
```
## Exports
| Export | Role |
|---|---|
| `Command` | Root. |
| `CommandInput` | Search input with a leading icon. |
| `CommandList` | Scroll container, `max-h: 300px`. |
| `CommandEmpty` | Fallback when filter yields nothing. |
| `CommandGroup` | Titled section. Accepts `heading`. |
| `CommandSeparator` | Divider between groups. |
| `CommandItem` | A row. `data-selected` drives the highlight. |
| `CommandShortcut` | Keyboard-hint text, right-aligned. |
## Pairing with Popover / Dialog
Drop a `Command` inside a `PopoverContent` or `DialogContent` for a command-palette UX. Use `border` and `shadow-overlay` for a crisp floating appearance; the Popover's backdrop blur already provides the frosted surface.
Source: `packages/react/src/components/command.tsx`.
---
# DropdownMenu
A Radix dropdown menu. Same item typography as Command (`text-base`); the dropdown distinguishes itself by being a compact, action-focused menu without search. Matches Figma 2819:24800.
```tsx
import {
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent,
DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator,
DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem,
DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent,
DropdownMenuShortcut,
Button,
} from "@tidal-ds/react";
Open
My Account
Profile ⌘P
Billing ⌘B
Settings ⌘S
Show line numbers
Word wrap
Invite users
Email
Link
Log out ⇧⌘Q
```
## Exports
| Export | Role |
|---|---|
| `DropdownMenu` | Root state controller. |
| `DropdownMenuTrigger` | Activator. Use `asChild` to compose with `Button`. |
| `DropdownMenuContent` | Floating panel. |
| `DropdownMenuItem` | Standard row. `inset` prop aligns with check/radio items. |
| `DropdownMenuCheckboxItem` | Row with a check indicator. Controlled via `checked`/`onCheckedChange`. |
| `DropdownMenuRadioItem` | Row inside a `RadioGroup`. |
| `DropdownMenuRadioGroup` | Holds `DropdownMenuRadioItem`s. Controlled via `value`/`onValueChange`. |
| `DropdownMenuLabel` | Muted group heading. |
| `DropdownMenuSeparator` | Divider. |
| `DropdownMenuShortcut` | Right-aligned keyboard hint. |
| `DropdownMenuSub`, `DropdownMenuSubTrigger`, `DropdownMenuSubContent` | Nested menus. |
| `DropdownMenuGroup`, `DropdownMenuPortal` | Pass-throughs to Radix. |
## Dropdown vs Command
- **Dropdown**: action menu. No search. Use for trigger-opened menus (account, settings, filters with few options).
- **Command**: includes a search input + cmdk filtering. Use for command palettes and larger option sets.
Both use the same row typography (`text-base`), same floating surface (`bg-popover` + backdrop blur + `shadow-overlay`). Pick based on whether users need to search.
Source: `packages/react/src/components/dropdown-menu.tsx`. Figma node `2819:24800`.
---
# Card
Composable surface. Each card has three optional slots — include only what you need.
```tsx
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from "@tidal-ds/react";
Create project
Deploy your new project in one click.
…form fields…
Cancel
Save
```
## Slots
| Component | Role |
|---|---|
| `Card` | Outer surface. Props: `elevation: 0 | 1 | 2` (default `0`), `padding: "sm" | "lg"` (default `"lg"` = 24px; `"sm"` = 16px), `align: "left" | "center"` (default `"left"` — cascades to Header/Content/Footer). |
| `CardMedia` | Full-width image/video/chart that breaks out of the Card's padding. Inherits the Card's top corners when it's the first child, and bottom corners when last. Any direct ` `/`` fills the slot with `object-cover`. |
| `CardHeader` | Title + description block. `p-6 pb-3`. |
| `CardTitle` | ``, `font-medium tracking-tight`. `size` prop: `sm` (text-base) / `md` default (text-lg) / `lg` (text-2xl) / `xl` (text-4xl). CardHeader padding auto-scales when title is `lg` or `xl`. |
| `CardDescription` | ` `, `text-sm text-muted-foreground`. |
| `CardContent` | Main body. `p-6 pt-3`. |
| `CardFooter` | Action row. Accepts `variant: "minimal" | "outlined"` — `outlined` adds a top border + secondary fill and inherits the Card's bottom corners; use for prominent CTAs like "View Event". |
## Elevations
**Use `elevation=0` by default.** Reach for higher elevations only when there's a clear information hierarchy that calls for visual separation (e.g., a floating callout above dense content, a modal-like surface, a hovered preview).
## Reusing slots outside a Card
`CardTitle` and `CardDescription` are standalone — they render without needing a `` parent. Use them inside **Popover**, **Dialog**, or any other surface that has a title + description pattern. Doing so keeps typography consistent automatically:
- `` → `text-base font-medium` (right for Popover headers)
- `` (default) → `text-lg` (for Card headers)
- `` → `text-base text-muted-foreground` (never `text-sm`; see §2.5 text-size rule)
Avoid hand-rolling `` + ` ` inside a popover — use the real components so a future type-scale change cascades everywhere.
## Elevations
**Use `elevation=0` by default.** Reach for higher elevations only when there's a clear information hierarchy that calls for visual separation (e.g., a floating callout above dense content, a modal-like surface, a hovered preview).
- `elevation=0` (default) — flat, border only. Almost all cards on a page should use this.
- `elevation=1` — `shadow`. Use sparingly to lift one card above its peers when it genuinely matters more.
- `elevation=2` — `shadow-lg`. Reserve for truly floating surfaces.
If multiple cards on a page would qualify for elevated, reconsider — probably only one should be, or none.
Radius matches `--radius` (10px). Fill: `--card` (white in light, zinc-800 in dark). Border: `--border`.
Source: `packages/react/src/components/card.tsx`. Figma node `4707:17850`.
---
# Toggle
A two-state button (pressed / not pressed) built on `@radix-ui/react-toggle`. Use it for stateful chrome — text-formatting buttons, pinned/unpinned, mute/unmute.
```tsx
import { Toggle } from "@tidal-ds/react";
```
## Variants
- **`default`** — ghost: transparent until hovered/pressed. Pressed state lights up with `bg-accent text-accent-foreground`.
- **`outline`** — bordered, with the same pressed treatment.
## Sizes
`sm` (h-8) · `md` (h-10, default) · `lg` (h-11). Icons size with the control.
## Props
| Prop | Type | Default |
|---|---|---|
| `variant` | `"default" | "outline"` | `"default"` |
| `size` | `"sm" | "md" | "lg"` | `"md"` |
| `pressed` / `defaultPressed` | `boolean` | — |
| `onPressedChange` | `(pressed: boolean) => void` | — |
| `disabled` | `boolean` | `false` |
| …rest | Radix `Toggle.Root` props | — |
Disabled state dims 50% via the canonical class-based rule (DESIGN.md §2.4) — no inline style.
Source: `packages/react/src/components/toggle.tsx`. Figma node `2819:31491`.
---
# ToggleGroup
A set of two-state buttons sharing a single value. Built on `@radix-ui/react-toggle-group`. Use `type="single"` for mutually-exclusive selection (alignment toolbar) or `type="multiple"` for independent toggles (text formatting).
```tsx
import { ToggleGroup, ToggleGroupItem } from "@tidal-ds/react";
// Single — radio-like
// Multiple — independent
```
`variant` and `size` set on `` cascade to every `` via context — items can override per-item if needed.
## Props
| Prop | Type | Default |
|---|---|---|
| `type` | `"single" | "multiple"` | — |
| `variant` | `"default" | "outline"` | `"default"` |
| `size` | `"sm" | "md" | "lg"` | `"md"` |
| `value` / `defaultValue` | `string | string[]` | — |
| `onValueChange` | `(value: string | string[]) => void` | — |
| `disabled` | `boolean` | `false` |
| …rest | Radix `ToggleGroup.Root` props | — |
Source: `packages/react/src/components/toggle.tsx`. Figma node `2819:31631`.
---
# Toast
Transient notification with a title, description, optional action button, and a close (×) that slides in on hover. Built on `@radix-ui/react-toast`.
```tsx
import {
Toast, ToastProvider, ToastViewport,
ToastTitle, ToastDescription, ToastAction, ToastClose,
} from "@tidal-ds/react";
function App() {
const [open, setOpen] = React.useState(false);
return (
setOpen(true)}>Notify
Scheduled: Catch up
Friday, Feb 10 at 5:57 PM
Undo
);
}
```
## Setup
Render `` once near the root and a single ` ` somewhere it can stack fixed-position. Then control `open` on each `` from anywhere.
## Behavior notes
- **Action button** defaults to outlined chrome (border + transparent fill). Don't reach for a Primary inside a toast.
- **Close (×)** is hidden by default and slides in from the right on hover (`group-hover`) or focus-visible. Designed not to compete with the toast content while also being one motion away.
- Animations are gated globally by the `prefers-reduced-motion` rule in `tokens.css`.
## Variants
- `default` — neutral card surface.
- `destructive` — `bg-destructive` with a translucent-bordered action.
## Props
| Component | Notable props |
|---|---|
| `Toast` | `variant: "default" | "destructive"`, `open`, `onOpenChange`, `duration`, …Radix `Toast.Root` |
| `ToastAction` | `altText` (required by Radix for screen readers) |
| `ToastClose` | hidden until `group-hover` / focus-visible |
| `ToastTitle` / `ToastDescription` | Radix slots; title = `font-medium`, description = `opacity-90` |
| `ToastProvider` | `swipeDirection`, `duration`, `label` |
| `ToastViewport` | render once; top-right on mobile, bottom-right on desktop |
Source: `packages/react/src/components/toast.tsx`. Figma node `164:1459`.
---
# Sonner
Stackable, swipeable toast notifications. Built on the `sonner` library and skinned with Tidal tokens (`bg-popover`, `shadow-overlay`, `--radius-popover`, outline-Button action chrome). Render one ` ` near the root and fire toasts imperatively from anywhere.
```tsx
import { Toaster, toast } from "@tidal-ds/react";
// Root layout / app shell — render once.
// Anywhere in a client component.
toast("Event has been scheduled", {
description: "Sunday, December 03, 2023 at 9:00 AM",
action: { label: "Undo", onClick: () => revert() },
});
toast.success("Saved");
toast.error("Network error");
toast.promise(fetch(url), { loading: "Saving…", success: "Saved", error: "Failed" });
```
## Setup
- Render exactly one ` ` near the root of the app — typically in the root layout (client component).
- Pass `theme` from `next-themes`'s `resolvedTheme` so Sonner tracks the page's dark mode. Default `theme="system"` also works if the page only uses the `prefers-color-scheme` media query.
- `toast` is re-exported from `@tidal-ds/react` — don't import it from `sonner` directly, so the whole app goes through one skinned entry point.
## When to use
- **Use Sonner for:** transient, stackable notifications — success, error, info, loading. Anywhere you want an imperative `toast(...)` API, rich interactions (action + cancel buttons, promise integration), swipe-to-dismiss, and auto-stacking with no per-toast state plumbing.
- **Prefer Sonner over `Toast`** when: you want auto-stacking, swipe-to-dismiss, promise / loading toasts, or Sonner's programmatic `toast.*` API. Sonner is the "modern toast" choice and should be the default for new code.
- **Prefer `Toast` (Radix-based)** when: you want first-party Radix control over each notification's open state, a compound `` / `` / `` / `` API for custom layouts, or you need to colocate the toast element with the component that owns its data.
- **Don't use Sonner for:** persistent alerts on the page (use `Alert`); destructive confirmations (use `AlertDialog`); modal dialogs (use `Dialog`).
## Tokens & chrome
- Surface: `bg-popover text-popover-foreground shadow-overlay backdrop-blur-popover backdrop-saturate-150 border border-[var(--border-subtle)] rounded-[var(--radius-popover)] p-3` — matches Popover / Dialog / DropdownMenu.
- Description: `text-base leading-6 text-muted-foreground`.
- Action / cancel buttons: mapped onto the Tidal outline Button via `buttonVariants({ variant: "outline", size: "sm" })`.
## API
| API | Notable props |
|---|---|
| `Toaster` | `position`, `theme` (`"light" | "dark" | "system"`), `richColors`, `closeButton`, `expand`, `visibleToasts`, `duration`, `offset` |
| `toast(title, options)` | `options: { description, action, cancel, duration, id, icon, … }` |
| `toast.success / error / info / warning / loading` | Same signature as `toast()`; varies icon / accent. |
| `toast.promise(p, { loading, success, error })` | Wire a toast to a Promise. |
| `toast.dismiss(id?)` | Dismiss one or all toasts. |
Source: `packages/react/src/components/sonner.tsx`. Figma node `2819:30654`.
---
# Alert
Inline callout for user attention. Optional leading icon (direct `` child of ``), a title, and a description. Layout is a two-column grid so title/description align under the icon; omit the svg and the text spans the full width.
```tsx
import { Alert, AlertTitle, AlertDescription } from "@tidal-ds/react";
Heads up!
You can add components to your app using the cli.
Error
Your session has expired. Please log in again.
```
## Variants
- `default` — neutral card surface: `bg-card` + `border`.
- `destructive` — faded destructive fill (`--background-destructive-fill-10`) + `--border-destructive-50` + `text-destructive`. Icon inherits the destructive color.
## Props
| Component | Notable props |
|---|---|
| `Alert` | `variant: "default" | "destructive"` (default `"default"`); `role="alert"` applied |
| `AlertTitle` | `` styled `text-base font-medium leading-5` |
| `AlertDescription` | Block container for body text (`text-base leading-6`) |
Source: `packages/react/src/components/alert.tsx`. Figma node `2813:9374`.
---
# Select
Enum picker — no typing. Built on `@radix-ui/react-select`.
The trigger uses **Default-button chrome** (border + card fill + subtle shadow): Input chrome would imply typing, a promise this component can't keep. For the searchable / filterable case, use **Combobox**. Items reuse `menuItemBase` / `menuLabelBase` so rows stay aligned with MenuItem and CommandItem.
```tsx
import {
Select, SelectGroup, SelectValue,
SelectTrigger, SelectContent,
SelectItem, SelectLabel, SelectSeparator,
} from "@tidal-ds/react";
Fruits
Apple
Banana
```
## Behavior
- Selected item shows a leading check mark (left-aligned to keep the label baseline stable).
- Trigger uses `data-[placeholder]:text-muted-foreground` so the placeholder reads like an Input's placeholder.
- `disabled` on the root cascades to the trigger; per-item `disabled` works on `SelectItem`.
## Props
| Component | Notable props |
|---|---|
| `Select` | Radix `Select.Root`: `value`, `defaultValue`, `onValueChange`, `disabled`, `name` |
| `SelectTrigger` | reuses `inputBase`; supports `aria-invalid` |
| `SelectValue` | `placeholder?: ReactNode` |
| `SelectContent` | `position="popper"` default; `side`, `sideOffset` |
| `SelectItem` | `value` (required), `disabled` |
| `SelectLabel` / `SelectSeparator` | share styling with MenuItem |
Source: `packages/react/src/components/select.tsx`. Figma node `2819:30005`.
---
# Combobox
Searchable single-value picker. Composition of Popover + Command.
The closed trigger uses **Input chrome** (`inputBase`) — a typeable field needs an Input affordance. The actual filter input lives inside the popover. For the enum-only counterpart, use **Select** (Default-button chrome).
```tsx
import { Combobox } from "@tidal-ds/react";
const [value, setValue] = React.useState();
```
## Combobox vs Select
| Use | When |
|---|---|
| **Combobox** | List is long enough that typing helps; tag-style fields; `> ~10` options |
| **Select** | Short fixed enum; typing adds nothing |
## Composing manually
For groups, separators, async loading, or custom item rendering, drop to `` + `` directly — Combobox is a thin wrapper on top.
## Props
| Prop | Type | Default |
|---|---|---|
| `options` | `{ value: string; label: string; disabled?: boolean }[]` | — |
| `value` / `defaultValue` | `string` | — |
| `onValueChange` | `(value: string) => void` | — |
| `placeholder` | `ReactNode` | `"Select…"` |
| `searchPlaceholder` | `string` | `"Search…"` |
| `emptyMessage` | `ReactNode` | `"No results."` |
| `disabled` | `boolean` | `false` |
| `triggerClassName` | `string` | — |
Source: `packages/react/src/components/combobox.tsx`.
---
# ScrollFade
Wraps a scrollable region and overlays a gradient fade at the edges where content is clipped. Top fade appears only once you've scrolled down; bottom fade disappears when you reach the end. Toggle with `enabled={false}` to drop the visual cue without removing the wrapper.
```tsx
import { ScrollFade } from "@tidal-ds/react";
```
The wrapper owns the overflow container — pass a static child, not another scrollable. `surface` should match the parent surface color so the fade reads as the surface "swallowing" content.
## Props
| Prop | Type | Default |
|---|---|---|
| `direction` | `"vertical" | "horizontal"` | `"vertical"` |
| `surface` | `string` (CSS color) | `"var(--background)"` |
| `size` | `number` (px) | `24` |
| `enabled` | `boolean` | `true` |
| `className` | `string` — passed to the overflow container | — |
Source: `packages/react/src/components/scroll-fade.tsx`.
## When to use
- Use `ScrollFade` when you want **only a soft gradient cue** at the edges of a scrollable region — no visible scrollbar chrome.
- Prefer `ScrollArea` when the region is tall enough that users need **visible feedback about position** (styled scrollbar track + thumb).
- **Use ScrollFade to communicate "there's more content."** When a bounded region clips its children (code blocks, command palettes, long lists), ScrollFade is the first-reach tool for conveying overflow. The gradient at the edge says "keep scrolling" without adding any interactive chrome. This is why the docs site's expanded code blocks use ScrollFade — the fade at the top/bottom of the code tells the reader there's more above or below before they even try to scroll.
- The two are complementary: `ScrollFade` is the subtler cue; `ScrollArea` is the explicit one.
- Don't use either for the page body — let the browser handle full-page scrolling.
---
# ScrollArea
Augments native scroll with custom, cross-browser scrollbar styling. Built on `@radix-ui/react-scroll-area`. Use when you need a scrollable region with a visible scrollbar that matches the Tidal surface (scrollbar track + thumb styled for both light and dark).
```tsx
import { ScrollArea, ScrollBar } from "@tidal-ds/react";
…tall content…
…wide content…
```
## When to use
- **Use for:** a bounded scrolling region that needs custom-styled scrollbars (to avoid OS-default bars inside an app surface).
- **Prefer over `ScrollFade`** when: you want visible-on-hover styled scrollbars; when the area is tall enough that users need visible feedback about position.
- **Prefer `ScrollFade`** when: you want only a soft gradient cue without any visible scrollbar chrome.
- **Don't use for:** the page body (let the browser handle it).
## Props
| Component | Notable props |
|---|---|
| `ScrollArea` | Forwards all Radix `ScrollArea.Root` props. Owns the viewport + vertical `ScrollBar`. |
| `ScrollBar` | `orientation: "vertical" | "horizontal"` (default `"vertical"`). Render a horizontal bar as a child of `ScrollArea` to opt in. |
Source: `packages/react/src/components/scroll-area.tsx`. Figma node `2819:29663`.
---
# Sidebar
Anchored, collapsible panel. One shell for navigation AND inspector-style tool panels. Built on `@radix-ui/react-dialog` (for mobile drawer) + our primitives.
```tsx
import {
SidebarProvider, Sidebar, SidebarTrigger,
SidebarHeader, SidebarContent, SidebarFooter,
SidebarGroup, SidebarGroupLabel, SidebarGroupContent,
SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarMenuLabel,
} from "@tidal-ds/react";
…
Workspace
Home
…
…
```
## Collapse modes (`collapsible` prop)
- **`offcanvas`** — slides fully out of view (width → 0).
- **`icon`** — shrinks to a narrow rail (default 48px); labels hide, icons remain; `tooltip` prop on `SidebarMenuButton` renders as the native `title` attribute for icon identification.
- **`none`** — fixed width, no collapse.
## Mobile auto-offcanvas
Below the `lg` breakpoint (1024px), the sidebar automatically renders as a Radix Dialog drawer (portal'd, with backdrop), regardless of the desktop `collapsible` mode. Enforced in the component — no consumer setup. See DESIGN.md §2.8.
## State
Controlled via `SidebarProvider` (`open` / `onOpenChange` / `defaultOpen`) or via the `useSidebar()` hook. Separate mobile state (`openMobile` / `setOpenMobile`) — `toggleSidebar()` routes to the right one based on viewport. ⌘B / Ctrl+B toggles from anywhere (disable with `keyboardShortcut={false}`).
## Composition patterns
- **Identity chips** — drop in `SidebarIdentity` with `icon`, `title`, `subtitle`, and a `trailing` chevron. Pair with `DropdownMenu` via `asChild` when the row should open a menu. Auto-collapses to icon-only in icon-rail mode.
- **Collapsible groups** — `` turns the `SidebarGroupLabel` into a chevron-toggle trigger; content animates open/close. Add `bordered` on the group or label for visible dividers (auto-symmetric padding around the borders).
- **Nested menus** — wrap a `SidebarMenuButton` in ``, render `` under ``. Sub-rows are smaller (`h-7` / `text-sm`) and indented with a 1px tree-guide line. Auto-hides in icon-rail mode.
- **Resizable** — set `resizable` to add a drag handle at the inner edge. Uses `width` as initial, fires `onWidthChange` for persistence (e.g. localStorage). `minWidth` / `maxWidth` clamp the range. Arrow keys + `Home` / `End` work when handle is focused.
## Props
| Component | Notable props |
|---|---|
| `SidebarProvider` | `open`, `onOpenChange`, `defaultOpen`, `keyboardShortcut` |
| `Sidebar` | `side` (`"left" | "right"`); `collapsible` (`"icon" | "offcanvas" | "none"`); `width`, `collapsedWidth`; `resizable`, `minWidth`, `maxWidth`, `onWidthChange` |
| `SidebarTrigger` | `asChild`; toggles appropriate state (desktop / mobile) |
| `SidebarGroup` | `collapsible`, `open`, `defaultOpen`, `onOpenChange`, `disabled`, `bordered` |
| `SidebarGroupLabel` | `bordered` |
| `SidebarMenuButton` | `isActive`, `tooltip`, `size` (`"sm" | "md"`), `asChild` |
| `SidebarMenuSub` / `SidebarMenuSubItem` | composition primitives |
| `SidebarMenuSubButton` | `isActive`, `size` (`"sm" | "md"`), `href`, `asChild` |
| `SidebarIdentity` | `icon`, `title`, `subtitle`, `trailing`, `asChild` |
| `useSidebar()` | `{ state, open, setOpen, openMobile, setOpenMobile, isMobile, toggleSidebar }` |
Source: `packages/react/src/components/sidebar.tsx`. Figma node `2846:12053`.
---
# SidePanel
Persistent right-side inspector panel. Sibling to `Sidebar` (left-anchored navigation), explicitly NOT a `Sheet` (transient overlay) or `Dialog`. Header aligns to the page header (`--header-height`); body holds a stack of titled sections — some static, some collapsible.
```tsx
import {
SidePanel,
SidePanelHeader, SidePanelTitle, SidePanelHeaderActions,
SidePanelBody,
SidePanelSection, SidePanelSectionHeader, SidePanelSectionTitle,
SidePanelSectionActions, SidePanelSectionContent,
SidePanelSubsectionLabel, SidePanelSeparator,
} from "@tidal-ds/react";
Sample 10 of 3.86k
Annotations
...
```
## When to use
Use `SidePanel` for: inspector panels (Figma-style position/appearance/etc.), sample-by-sample review UIs, panel-style filters with multiple grouped sections, anywhere you need a persistent right-side surface with stacked sections that aligns its header to the page header.
Prefer over `Sidebar` when: panel sits on the right as an inspector (Sidebar is left-anchored navigation); panel needs section-level density (header / body / footer) rather than flat menu rows; you want auto-divider behavior between sections.
Prefer over `Sheet` when: panel is persistent in layout, not a transient overlay drawer; users return to it repeatedly without dismissing.
Don't use for: app navigation (use `Sidebar`); transient confirmation surfaces (use `Dialog` / `Sheet`); inline disclosure (use `Collapsible` or `Accordion`).
## Density
`density="compact"` (default) and `density="comfortable"` cascade to all sub-components via context — same pattern as Card / Dialog / Sheet / Popover.
## Section borders
`` (default true) auto-borders adjacent sections edge-to-edge. `` and `` allow per-section overrides. Body has no horizontal padding so borders span the full panel width; sections have symmetric padding so borders bisect equal whitespace.
## Collapsible sections
`` wraps the section in Radix Collapsible. The section header becomes the trigger; chevron rotates 180° on open. `` (right-side controls) stop click propagation so embedded buttons don't toggle the parent section.
## Info tooltips
`title ` renders an inline (i) icon next to the title with a Tooltip on hover.
## Props
| Component | Notable props |
|---|---|
| `SidePanel` | `side` ("left" | "right"); `density` ("compact" | "comfortable"); `width`; `divided` |
| `SidePanelSection` | `collapsible`, `open`, `defaultOpen`, `onOpenChange`, `disabled`, `bordered` |
| `SidePanelSectionHeader` | `bordered` |
| `SidePanelSectionTitle` | `info` (renders (i) icon + Tooltip) |
Source: `packages/react/src/components/sidepanel.tsx`.
---
# Tooltip
Classic inverted hover/focus tip — dark on light, light on dark. Built on `@radix-ui/react-tooltip`, adapted from shadcn.
## Setup
Wrap your app (or the region using tooltips) in `` once. `` wires one internally, so sidebar icon-rail tooltips work with no extra setup.
```tsx
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@tidal-ds/react";
Hover me
Copied to clipboard
```
## Differences from shadcn
- **Surface:** `bg-foreground text-background` (auto-invert) instead of shadcn's `bg-primary` — Lilac primary is too loud for ambient tips.
- **Size:** `rounded-md px-2.5 py-1 text-xs font-medium` matches Tidal's compact-row scale.
- **Delay:** default `300ms` (Radix defaults to 700ms) — snappier for nav affordances.
## Props
| Component | Notable props |
|---|---|
| `TooltipProvider` | `delayDuration` (default 300), `skipDelayDuration`, `disableHoverableContent` |
| `Tooltip` | `open`, `defaultOpen`, `onOpenChange`, per-tooltip `delayDuration` override |
| `TooltipTrigger` | `asChild` (wrap your own Button / IconButton / anchor) |
| `TooltipContent` | `side` (`"top" | "right" | "bottom" | "left"`), `align`, `sideOffset` (default 6), `collisionPadding` |
Source: `packages/react/src/components/tooltip.tsx`.
---
# Dialog
Centered modal surface for focused tasks. Uses the Tidal floating-surface family
(`bg-popover` + `shadow-overlay` + `backdrop-blur-popover` + `rounded-[var(--radius-popover)]`).
Overlay is a light `bg-black/30 backdrop-blur-sm` scrim. Title/description render at
`text-base leading-6` so the pair reads as a single unit (DESIGN.md §2.5).
## When to use
- **Use for:** focused tasks that need modal attention — sign-in, create form, confirmation with detail.
- **Prefer over `AlertDialog`** when the action isn't destructive and the user can dismiss by clicking outside.
- **Prefer over `Sheet`** when the content is short and doesn't need vertical scrolling.
- **Prefer over `Popover`** when the content deserves modal focus (not anchored to a specific trigger element).
```tsx
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@tidal-ds/react";
Open
Title
Description
Confirm
```
| Component | Notable props |
|---|---|
| Dialog | open, defaultOpen, onOpenChange, modal |
| DialogContent | onEscapeKeyDown, onPointerDownOutside |
| DialogTitle | Required for a11y |
Source: `packages/react/src/components/dialog.tsx`. Figma node `2819:24561`.
---
# AlertDialog
Destructive-confirmation modal. Same floating-surface family as Dialog
(`bg-popover` + `shadow-overlay` + `--radius-popover`) but with AlertDialog
semantics: no ✕ close, no outside-click dismissal — Radix enforces an explicit
Action or Cancel. Title is `text-base font-medium` so the header reads as a
single unit with the description (DESIGN.md §2.5).
```tsx
Delete
Are you absolutely sure?
This cannot be undone.
Cancel
Delete
```
AlertDialogAction renders `Button variant="primary"` via `buttonVariants`;
AlertDialogCancel renders `Button variant="outline"`. Both track every Button
change (focus ring, disabled, hover). Footer stacks Action-first on mobile and
right-aligns on ≥sm — matches Figma Mobile=True / Mobile=False.
Source: `packages/react/src/components/alert-dialog.tsx` · Figma 2814:11168.
---
# Sheet
Side drawer built on Radix Dialog. Docks to any of the four edges and renders
on the Tidal floating-surface family (`bg-popover` + `shadow-overlay` +
`backdrop-blur-popover` + `--border-subtle`). Title is
`text-base font-medium leading-5` — same body-baseline rule as AlertDialog
(DESIGN.md §2.5).
```tsx
Open
Edit profile
Make changes to your profile here.
Save
```
## When to use
- Use for **side-drawer surfaces that overlay main content temporarily** —
filter panels, detail views, "create new" forms, mobile nav drawers.
- Prefer over `Dialog` when the content benefits from a **tall vertical
scroll** (long form, long list) — the full-height edge dock gives the
surface room to breathe where a centered dialog would feel cramped.
- Prefer over `Sidebar` when the drawer is **modal** (must dismiss before
continuing) rather than a persistent navigation pane.
- Prefer over `Popover` when the content **doesn't anchor to a specific
trigger element** on the page — Sheet is rooted to the viewport edge,
Popover is rooted to its trigger.
## Props
| Component | Notable props |
|---|---|
| Sheet | open, defaultOpen, onOpenChange, modal |
| SheetTrigger | asChild |
| SheetContent | side: "top" \| "right" \| "bottom" \| "left" (default "right") |
| SheetClose | asChild — renders a close affordance |
Left/right sheets are `w-3/4 sm:max-w-sm`; top/bottom sheets span full width.
The inside-edge corners round to `--radius-popover` (e.g. right-anchored
sheet rounds its left corners); the dock edge stays flush with the viewport.
Source: `packages/react/src/components/sheet.tsx` · Figma node `2819:30307`.
---
# Accordion
A vertically stacked set of interactive headings that each reveal a section
of content. Built on Radix Accordion. Trigger uses `py-3` (10px) padding,
`gap-3.5` (12px) between label and chevron; chevron is `size-5` (18px)
and rotates 180° when open. Hover underlines the trigger label only. Height
is animated via `accordion-up`/`accordion-down` keyframes declared in
`apps/docs/tailwind.config.ts`.
```tsx
Is it accessible?
Yes. It adheres to the WAI-ARIA design pattern.
```
| Component | Props |
|---|---|
| Accordion | type ("single" \| "multiple"), collapsible, value, defaultValue, onValueChange |
| AccordionItem | value (required), disabled, border (boolean, default true) |
| AccordionTrigger | Standard button props. Chevron rotates on open. |
| AccordionContent | Animates height via data-state + accordion-up/down keyframes. |
Source: `packages/react/src/components/accordion.tsx` · Figma node `2811:14800`.
---
# Collapsible
A single expand/collapse panel. Built on Radix Collapsible. Animates height via
the shared `accordion-up`/`accordion-down` keyframes declared in
`apps/docs/tailwind.config.ts` — the component aliases
`--radix-collapsible-content-height` into `--radix-accordion-content-height`
so the keyframes are reused verbatim.
```tsx
@peduarte starred 3 repositories
@radix-ui/colors
@stitches/react
```
## Header always visible pattern
When building collapsible panels (e.g., a terminal panel, output panel), the trigger/header bar must stay visible at all times — only the content body collapses. Place the trigger **outside** `CollapsibleContent`:
```tsx
{/* Header — always visible */}
Panel Title
{/* Body — collapses with animation */}
Expandable content here…
```
## Custom triggers with asChild
The default `CollapsibleTrigger` renders a label wrapper + chevrons-up-down icon. When you need a fully custom trigger row (e.g., tabs + status badges + a toggle button), pass `asChild` to render any element as the trigger:
```tsx
```
With `asChild`, no default chrome (label wrapper, chevron icon) is rendered — you control the entire trigger element.
## Props
| Component | Props |
|---|---|
| Collapsible | open, defaultOpen, onOpenChange, disabled |
| CollapsibleTrigger | Standard button props. Default: renders label + chevrons-up-down icon. With `asChild`: renders child element directly as trigger (no default chrome). |
| CollapsibleContent | Animates height via `data-state`. Inner wrapper adds `flex flex-col gap-2 pt-2` — override with `className` if needed (e.g., `className="gap-0 pt-0"` for flush content). |
Source: `packages/react/src/components/collapsible.tsx` · Figma node `2819:22061`.
---
# Switch
Two-state toggle pill for boolean settings that take effect immediately. Track
is `bg-muted` unchecked, flat `bg-primary` (Lilac) checked; thumb is a white
pill that slides between the two ends.
```tsx
Airplane mode
```
## When to use
- Use for **boolean settings that take effect immediately** — "Enable
notifications", "Dark mode", "Airplane mode". The visual metaphor is a
physical switch: on/off, now.
- **Prefer `Switch` over `Checkbox`** when the state change takes effect the
moment the user flips it (not when they later submit a form).
- **Prefer `Checkbox`** when the control is part of a form the user submits
later, or when multiple options belong to a shared list (e.g. "select all
that apply").
- **Don't use** for one-of-N selection — reach for `RadioGroup`.
- **Don't use** for toolbar-style selected state — reach for `Toggle`.
## Sizes
| Size | Track | Thumb |
|---|---|---|
| sm | 36 × 20 | 16 |
| md (default) | 44 × 24 | 20 |
## Props
| Prop | Type |
|---|---|
| size | `"sm" \| "md"` (default `"md"`) |
| checked / defaultChecked | boolean |
| onCheckedChange | (checked: boolean) => void |
| disabled | boolean |
Source: `packages/react/src/components/switch.tsx`. Figma node
`2819:30733`.
---
# Progress
Horizontal progress bar. Track is a `bg-muted` pill; indicator is a flat
`bg-primary` (Lilac) fill translated via `translateX` to animate value changes.
```tsx
```
## When to use
- Use for **determinate** progress of an operation with a known total — file
upload, multi-step form completion, batch job percentage.
- For **indeterminate / loading** states, reach for `Skeleton` (shimmering
placeholders for content shape) or a spinner inside a Button via the
`loading` prop.
- **Don't** use for range selection — use `Slider`.
## Sizes
| Size | Track height |
|---|---|
| xs | 4px |
| sm | 8px |
| md (default) | 12px |
| lg | 16px |
## Props
| Prop | Type |
|---|---|
| size | `"xs" \| "sm" \| "md" \| "lg"` (default `"md"`) |
| value | number \| null (null = indeterminate) |
| max | number (default 100) |
| getValueLabel | (value, max) => string |
Source: `packages/react/src/components/progress.tsx`.
---
# Slider
An input where the user selects a value (or range) from within a given range. Built on Radix Slider.
```tsx
// Range (two thumbs)
```
## When to use
- Use for **selecting a numeric value from a continuous or stepped range** — volume, opacity, price range, brightness.
- **Range mode**: pass a `value` / `defaultValue` array with two entries for filtering / "between X and Y" selections.
- Prefer Slider over `Input type="number"` when the **visual position communicates the scale** (bigger = more; mid = middle) and exact numeric precision isn't required.
- **Don't** use for precise numeric entry — use `Input`. Don't use for progress display — use `Progress`.
## Anatomy
- **Track** — `h-1.5` (6px) pill, `bg-muted`.
- **Range** — filled portion, flat `bg-primary` (Lilac).
- **Thumb** — `size-4` circle with 1px Lilac border and `bg-background` fill. Focus-visible applies `shadow-ring-primary` (compound focus ring). One thumb per entry in `value`.
## Props
| Prop | Type |
|---|---|
| value | number[] — controlled |
| defaultValue | number[] — uncontrolled initial value |
| onValueChange | (value: number[]) => void |
| onValueCommit | (value: number[]) => void |
| min | number (default 0) |
| max | number (default 100) |
| step | number (default 1) |
| minStepsBetweenThumbs | number |
| orientation | `"horizontal" \| "vertical"` |
| disabled | boolean |
| inverted | boolean |
Source: `packages/react/src/components/slider.tsx`.
---
# Avatar
Circular (or rounded) user image with a text fallback. Six sizes
(`xs` 24 / `sm` 32 / `md` 40 / `lg` 48 / `xl` 56 / `2xl` 64) and two shapes
(`circle` default / `rounded`). Fallback uses `bg-muted text-muted-foreground`
with text size scaled to the avatar.
```tsx
DZ
```
## When to use
- **Use for**: representing a user, team, or identity with a circular image and
fallback initials. The fallback appears when the image fails to load or no
`src` is provided.
- **Prefer over a plain ` `** when the image could be missing (Avatar
always renders something), or when the subject is always a person / team /
organization. Use `shape="rounded"` for team / brand / product identities
where a soft square reads better than a circle.
- **Don't use for**: app icons or logomarks with no "identity" meaning — reach
for a plain ` ` instead. For decorative imagery, use a `Card` with
content, not an Avatar.
| Component | Props |
|---|---|
| Avatar | size: xs \| sm \| md \| lg \| xl \| 2xl (default md). shape: circle \| rounded. |
| AvatarImage | src, alt, onLoadingStatusChange, shape |
| AvatarFallback | delayMs, size, shape — text size scales with avatar size |
Source: `packages/react/src/components/avatar.tsx` · Figma node `2814:11241`.
---
# Skeleton
Pulsing placeholder block. Just a `bg-muted` div with `animate-pulse`. Set
dimensions via className.
```tsx
```
Source: `packages/react/src/components/skeleton.tsx`.
---
# Calendar
A date field that allows users to enter and edit dates. Built on [`react-day-picker`](https://react-day-picker-v8.netlify.app) v8 and themed with Tidal tokens.
```tsx
import { Calendar } from "@tidal-ds/react";
const [date, setDate] = React.useState();
```
## Modes
- **`single`** — one date.
- **`multiple`** — array of dates.
- **`range`** — `{ from, to }`. Pair with `numberOfMonths={2}` for a two-pane picker.
```tsx
```
## Styling
Day cells are `size-8` (32px), `rounded-md`, `text-base`. Selected days get `bg-primary text-primary-foreground` (flat lilac). The current day (unselected) uses `bg-accent text-accent-foreground font-medium`. Range endpoints get the primary fill; days between get `bg-accent`. Outside-month and disabled days fade to `text-muted-foreground` at 50% opacity.
Caption is `text-sm font-medium`. Weekday header row is `text-sm font-normal text-muted-foreground`. Prev/next nav buttons reuse `buttonVariants({ variant: "outline", size: "sm" })`.
## Props
Forwards every prop to `DayPicker`. See [react-day-picker docs](https://react-day-picker-v8.netlify.app/api/interfaces/DayPickerBase) for the full API.
Source: `packages/react/src/components/calendar.tsx`. Figma node `2819:19887`.
---
# Carousel
A horizontally (or vertically) scrolling set of slides. Thin Tidal wrapper
around `embla-carousel-react`, following the shadcn composition. Previous/Next
use `` absolutely positioned at
`-left-12` / `-right-12` (horizontal) or `-top-12` / `-bottom-12`
(vertical, arrows rotated 90°). CarouselContent uses a negative margin +
per-item padding to create a consistent 12px (`gap-3`) gutter on both axes.
Keyboard navigation: arrow keys scroll prev/next on the active axis.
```tsx
{items.map((i) => (
{i}
))}
```
| Component | Notable props |
|---|---|
| Carousel | orientation ("horizontal" \| "vertical"), opts (embla options), plugins, setApi |
| CarouselContent | Standard div props. Holds the viewport. |
| CarouselItem | Standard div props. basis-* controls per-view slide count. |
| CarouselPrevious / CarouselNext | IconButton props. Default variant="outline", size="md". Disables when scroll bound is reached. |
Source: `packages/react/src/components/carousel.tsx` · Figma node `2819:21463`.
---
# Pagination
Compositional nav for paginated result lists. Built around `buttonVariants` — active link swaps to `variant="outline"`, inactive links use `variant="ghost"`. No external dependency.
```tsx
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from "@tidal-ds/react";
1
2
3
```
## When to use
- **Use for** paginated result lists — search results, table pages, archives — where the user needs to see the count or jump to a specific page.
- **Prefer over infinite scroll** when page position is meaningful: shareable URLs, bookmarkable state, "return to page 4" flows.
- **Don't use for** linear wizards (use a Steps / breadcrumb-like UI) or swipeable media decks (use `Carousel`).
## Anatomy
- **`Pagination`** — ``, centered flex wrapper.
- **`PaginationContent`** — `` with `flex flex-row items-center gap-1 text-base leading-6`.
- **`PaginationItem`** — ``.
- **`PaginationLink`** — `` styled via `buttonVariants({ variant: isActive ? "outline" : "ghost", size: "sm" })`. `isActive` sets `aria-current="page"`.
- **`PaginationPrevious` / `PaginationNext`** — labeled links with a chevron. Labels are hidden below `sm` via `hidden sm:inline`.
- **`PaginationEllipsis`** — `size-8` centered horizontal dots at `text-muted-foreground`. Decorative; `aria-hidden` with an `sr-only` "More pages" label.
Source: `packages/react/src/components/pagination.tsx` · Figma node `2819:28904`.
---
# Data Table
Styled HTML table primitives (`Table`, `TableHeader`, `TableBody`, `TableFooter`, `TableRow`, `TableHead`, `TableCell`, `TableCaption`) — pure visual, no logic, no external deps. Compose with `@tanstack/react-table` for the full DataTable pattern (sorting, filtering, pagination, row selection).
```tsx
import {
Table, TableBody, TableCaption, TableCell,
TableFooter, TableHead, TableHeader, TableRow,
} from "@tidal-ds/react";
A list of your recent invoices.
Invoice
Status
Amount
INV001
Paid
$250.00
```
## When to use
- **Use Table for** displaying structured tabular data (rows x columns).
- **Use DataTable pattern** (Table + `@tanstack/react-table`) when you need sorting, filtering, pagination, row selection, or column visibility controls.
- **Prefer over Card list** when data has >3 comparable columns.
- **Prefer Card list over Table** when each item is a rich object best read as a card (project, user profile, media).
- **Don't use for** key-value pairs (use a definition list or Field), single-column lists (just a ``).
## Anatomy
- **`Table`** — `` inside an `overflow-auto` wrapper. `w-full caption-bottom text-sm`.
- **`TableHeader`** — `` with bottom-bordered rows, `bg-muted/40`.
- **`TableBody`** — ` `, last row has no bottom border.
- **`TableFooter`** — ` ` with top border, `bg-secondary`, `font-medium`.
- **`TableRow`** — `` with bottom border, `hover:bg-muted/50`, `data-[state=selected]:bg-muted`.
- **`TableHead`** — `` at `h-10 px-3.5`, `text-muted-foreground font-normal`. Checkbox columns auto-collapse `pr-0`.
- **`TableCell`** — ` ` at `h-12 px-2.5`. Checkbox columns auto-collapse `pr-0`.
- **`TableCaption`** — `` at bottom, `px-3.5 py-3.5 text-sm text-muted-foreground`.
Source: `packages/react/src/components/table.tsx` · Figma node `2819:30904`.
---
# IDE Screen Mock — Design Decisions
A full-viewport IDE/code editor mock built entirely with Tidal components. Demonstrates how to compose design system primitives into a dense, realistic developer tool interface.
**Route:** `/ide` (opens in new tab, full viewport, forced dark mode)
## Components used
| Component | Role in the IDE mock | Why this component |
|-----------|---------------------|-------------------|
| `PageTabBar` + `PageTab` | Editor file tabs with close buttons and file type icons | Document tabs are closeable and have icons — PageTab use case, not Tabs (DESIGN.md §8.6) |
| `Tabs` + `TabsTrigger` | Terminal panel view switching (Terminal / Problems / Output) | Fixed view switching within a panel — the Tabs use case |
| `Sidebar` + `SidebarMenu` | File explorer with collapsible folder tree | Full slot system (header, content, groups, menu items) |
| `Collapsible` + `CollapsibleTrigger asChild` | Terminal panel expand/collapse | Header stays visible, only content collapses (§8.4). `asChild` for custom trigger row |
| `Breadcrumb` | File path (src > components > sidebar.tsx) | Bare defaults, zero overrides |
| `ScrollArea` | Code editor and terminal scroll regions | Styled scrollbars for bounded regions |
| `Tooltip` | Activity bar icon labels | Hover labels for icon-only buttons |
| `Badge` | Problem count in terminal tabs | Inline count indicator |
| `Separator` | Status bar dividers | Thin vertical dividers between status items |
| `Skeleton` | Inactive tab loading placeholders | Loading state for unloaded editor tabs |
## Design decisions from iteration
### 1. Use real components, not hand-rolled divs
Initial agent pass hand-built everything from raw divs. Refactored to use Sidebar, PageTabBar, Collapsible, Breadcrumb. **Rule: check @tidal-ds/react first** (§8.1).
### 2. Zero className overrides
Multiple bugs came from overrides fighting component defaults. Fix: strip overrides, use bare defaults. **Rule: no className first** (§8.2).
### 3. PageTab icon prop, not icon-as-children
Passing icons as children caused vertical stacking. Added `icon` prop for left-aligned inline icons. **Rule: use icon prop** (PageTabs docs).
### 4. Tabs replace the header — no redundant info
"Terminal" label above Terminal/Problems/Output tabs was redundant. Merged into one row. **Rule: no redundant information** (§8.3).
### 5. Collapsible header always visible
First impl hid entire panel on collapse. Correct: tab bar stays, only content collapses. Trigger outside CollapsibleContent. **Rule: header always visible** (§8.4).
### 6. Amber for warnings, not green
Used `--button-success` (green) for warnings initially. Corrected to `text-status-warning` (amber). **Rule: red=error, amber=warning, green=success** (§8.5).
### 7. Dark mode via next-themes, not CSS class
`class="dark"` on a div didn't trigger CSS variable dark mode. Fixed with ForceDark + `setTheme("dark")`. **Rule: force at html level** (§8.8).
### 8. text-sm for content, text-xs for metadata only
Terminal at `text-xs` felt too compact. Switched to `text-sm`. Status bar stays `text-xs`. **Rule: text-sm base for tool content** (§8.7).
### 9. Sidebar toggle via activity bar
PanelLeftClose icon at bottom of activity bar. Clicking hides/shows file explorer. Already-active icon re-click also toggles.
## Source
`apps/docs/app/(screens)/ide/page.tsx`
Reference this example when composing file tabs, collapsible panels, status indicators, or sidebar navigation into a screen.
---
# DESIGN.md
# DESIGN.md — Tidal Design System
> **Audience:** AI coding agents and engineers contributing to this repo or consuming it.
> **Purpose:** the rules that keep the system coherent. Read this before writing UI code.
---
## 0. Source of truth
1. **Figma** is the design source of truth. File: `N7zMXtdBdI71vQcBW3retD`.
2. **`/design-tokens/`** at the repo root is the canonical token export (DTCG format) — re-exported from Figma via the Tokens plugin. Versioned in git so every clone is reproducible.
3. **`packages/tokens/scripts/build.ts`** ingests `/design-tokens/` and emits everything in `packages/tokens/dist/` (CSS vars, JSON, Tailwind preset). Never edit `dist/` or `src/_generated.ts` by hand.
4. If Figma changes, re-export to `/design-tokens/` and run `pnpm tokens:build`. See `FIGMA_SYNC.md`.
---
## 0.5. Design principles
Short list of invariants. When a design-in-Figma or a judgment call conflicts with one of these, push back before you implement.
1. **A heading is never smaller than its content.** Any label, title, or heading that introduces a block must be **≥ the size of the text it introduces** — equal size at minimum, usually one or more steps larger. If you feel tempted to set a heading to `text-sm` above a body of `text-base`, the heading is wrong. Applies to card titles, popover/section headings, form-field labels, legend-style group titles, and every other "title → content" pair.
2. **Hierarchy is read top-down.** The eye lands on the largest thing first. Don't invert that with tiny titles above big bodies — it reads as a label/caption, not a heading.
3. **Default to the calmest variant.** Reach for Primary (button), elevation > 0 (card), or destructive/success tones only when the content genuinely warrants them. See rules §1 and §1a.
4. **Token before hex.** If Figma shows a value, there's a token. If there isn't, add one — never inline a hex in component code (§1, §7).
5. **Compose before extending.** If `Card`, `Input`, `InputGroup`, `Popover` exist, use them. Don't reinvent a surface with a `` (§5).
6. **Accessibility is part of "done".** Keyboard nav, focus-visible rings, `aria-*` wiring. See §5, §6.
## 1. Token taxonomy
There are **three layers**. Always reach for the highest layer that fits.
| Layer | Where | When to use |
|---|---|---|
| **1. Primitives** | `Tailwind Colors`, `Liquid colors` (Lilac/Viola/Mandarin), `Tailwind Primitives` — exposed via `liquid` export and Tailwind palette | Almost never in components. Reference these only when defining a semantic token in Figma. |
| **2. Semantic** | `--background`, `--foreground`, `--card`, `--popover`, `--border-default`, `--border-focus`, `--input`, `--accent`, `--ring`, `--button-{primary,secondary,destructive,success,default}` (+ `-foreground`, `-hover`), `--sidebar-*`, plus gradients (`--gradient-button-*`) and rings (`--ring-{primary,destructive}`). | **Default choice for all component code.** |
| **3. Component** | Add a CSS var scoped to the component when a value doesn't generalize. | When a component has a tweak that does not generalize. |
**Specials**:
- `--background-destructive-fill-10`, `--background-success-fill-10` (and `-hover-20`) — faded backgrounds for callouts, secondary destructive surfaces.
- `--neon-green` (`#adfa1d`) — accent for special highlights only.
### Hard rules
- ❌ **Never** write a raw hex (`#cd82f0`) in component code or MDX.
- ❌ **Never** reference Tailwind palette ramps (`bg-zinc-100`) in components — go through a semantic token.
- ❌ **Never** inline `style={{ color: '...' }}` for design values.
- ✅ Use `bg-primary text-primary-foreground` (Tailwind preset) **or** `var(--primary)` in raw CSS.
- ✅ When a value is missing, **add a semantic token** rather than reaching down a layer.
---
## 2. Light & dark mode contract
- Every semantic token has a `light` and `dark` value in `packages/tokens/src/semantic.ts`.
- The `dark` mode is activated by `class="dark"` on `` (Tailwind convention).
- Components must work in both modes **without conditionals**. If you find yourself writing `dark:` overrides on a token-driven class, the token is wrong — fix the token.
- ⚠️ **Current state**: dark values in `semantic.ts` are seeded from shadcn defaults. They have NOT yet been pulled from Figma. Sync them when the dark frame is ready (see `FIGMA_SYNC.md`).
---
## 2.4. Disabled state
**One strategy: 50% opacity via class.** Every disabled control (and any descendants) dims uniformly. Applied via the twin classes `disabled:opacity-50 data-[disabled]:opacity-50` so it fires for both native `
` / ` ` and Radix Slot / `asChild` paths (which only receive the `data-disabled` attribute). For paired labels: `peer-disabled:opacity-50 peer-data-[disabled]:opacity-50`. **No inline `style`** — class-based means consumers can override via `className` (e.g. `data-[disabled]:opacity-75`).
Pair with `disabled:cursor-not-allowed` (text fields) or `disabled:pointer-events-none` (interactive surfaces). Loading states (Button/IconButton) reuse this path by setting both `disabled` and `data-disabled` when `loading` is true.
## 2.5. Text size rule
**Body, emphasis, and descriptions all share `text-base leading-6`.** The difference between them is **weight and color**, not size — so they sit on the same baseline grid and compose without size-mismatch awkwardness.
- **Body (default)** — `text-base leading-6 font-normal`. Paragraphs, prose, list items, PageHeader descriptions, article body.
- **Emphasis paragraph** — `text-base leading-6 font-medium`. Bump a single paragraph or sentence within prose. Don't use this for titles — that's what the heading scale is for.
- **Description / secondary** — `text-base leading-6 font-normal text-muted-foreground`. Same size as body, tonally quieter. Use for field descriptions, captions under content, muted metadata, source-file paths, prop-table hints.
- **Caption / metadata** — `text-xs leading-4 font-normal`. Tiny labels, timestamps, badge text. The only place smaller than body is allowed.
- **Code** — `text-sm leading-5 font-mono`. Inline code references, prop names, file paths.
If prose feels too small to read comfortably, you're probably reaching for `text-sm` where Description / secondary (`text-base` muted) is the right call — same readability as body, lower visual weight via color.
**Headings are never smaller than the content they introduce** (see §0.5 principle 1). A `text-base` body means the label above it is `text-base` or larger.
For the full canonical pairings (sizes × leading × weight × font, including headings), see [Typography → Text styles](/tokens/typography).
## 2.6. Size scale (canonical)
All interactive / control components use a **single size vocabulary**: `xs | sm | md | lg`.
- A component that needs fewer steps (e.g. Checkbox has only `md | lg`, Tabs has only `sm | md`) just implements the steps it needs — **don't invent new names** (`xss`, `xxs`, `xl-button`, etc.).
- `CardTitle` is a **type-scale primitive** with `sm | md | lg` (no `xl`). Defaults to `sm` (text-base + medium) so a card title never outranks a section's `` (text-lg). Bump to `md` / `lg` only for cards that *are* a top-level page region.
- Canonical pixel heights: **`sm` = 28px**, **`md` = 32px (default)**, **`lg` = 36px**. `md` is the workhorse; it's the size of the docs search trigger and every form control. `lg` is the presence-size for hero actions and inspector-density triggers.
- Text + icon per size: `sm` → `text-sm` + `size-3` (12px icons), `md`/`lg` → `text-base` + `size-4` (16px icons). **lg keeps text-base** — "same type, bigger box," not bigger type.
- Menu rows use `min-h-8` + `py-2` rather than fixed `h-8` so multi-line items grow naturally; single-line rows still land at 32px.
- File and number inputs render via custom layout (`` + hidden native ` `); the native rendering can't be vertically centered reliably.
## 2.8. Side panels collapse at narrow widths
Sidebars, inspectors, and any fixed-edge panel **must** collapse to an offcanvas drawer (or condense into a dropdown menu) below the `lg` breakpoint (1024px). Two side panels competing with main content on a 640px viewport is always wrong — the content area becomes unreadable and the panels become un-tappable.
- `` enforces this automatically: below `lg` it renders as a Radix Dialog drawer with backdrop, regardless of the desktop `collapsible` mode.
- For bespoke panels (not using ``), the same rule applies: hide the panel below `lg` and surface its controls via a trigger that opens an offcanvas drawer or a `` collapse.
- Never try to fit a sidebar into a mobile viewport by shrinking width — icon rails don't have room for text, offcanvas keeps both controls and content legible.
This is about behavior, not aesthetics. If you need a side panel on mobile, it slides over.
## 2.7. Reduced motion
Handled **once**, at the token layer. `packages/tokens/dist/tokens.css` ships a global
```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;
}
}
```
…which zeroes every transition and animation duration system-wide when the user has set `prefers-reduced-motion`. **Components must never gate animations on `motion-safe:` / `motion-reduce:`** — the global rule covers it. The only exception is when a component has an animation that is *structural* (e.g. collapsible content height) and must explicitly be preserved; document the exception in the component.
## 3. Typography
- **Families** (mode-aware):
- `font-sans` — **Inter** (Internal) / **Söhne** (External). Resolved via `var(--font-sans)`.
- `font-serif` — **Georgia** (system font, no download needed).
- `font-mono` — **JetBrains Mono**.
- `font-heading` — page headings and display text. Resolves to `var(--font-sans)` in Internal (Inter), `Georgia` in External. Use `font-heading` class on any heading that should switch to serif in External mode.
- **Heading weight** is mode-aware: `--font-heading-weight` is `600` (semibold) in Internal, `400` (normal) in External. Georgia reads heavier than sans at the same weight, so External dials it back. Apply via inline style `fontWeight: "var(--font-heading-weight)"`.
- **Allowed weights (only)**: `font-normal` (400), `font-medium` (500), `font-semibold` (600). Bold / extrabold / black / light / thin are deliberately **not exposed** through the Tailwind preset. If Figma ships a design that uses them, push back or translate to semibold.
- **Söhne weight files shipped**: Leicht (300, unused), Buch (400), Kräftig (500). `font-semibold` (600) has no `.otf` yet — falls back to Kräftig until you license/add the file.
- **Two size scales** (selected via CSS class on a parent):
- **Internal** (default, `:root` / `.typography-internal`) — compact app scale, Inter sans: `text-xs=11, text-sm=12, text-base=14, text-2xl=20`. Use for internal apps and ML tools.
- **External** (`.typography-external`) — larger scale, Söhne sans, Georgia headings: `text-xs=12, text-sm=14, text-base=16, text-2xl=24`. Use for marketing sites, landing pages, and public-facing docs.
- The Tailwind utility `text-base` resolves to `var(--text-base)`, which the active mode controls. Components don't need to change classes between modes.
---
## 3.5. Gradients & focus rings
Buttons in Tidal use **gradients**, not flat fills:
- **Primary** (Lilac): horizontal lilac base (`#dc92ff → #9d82f0` at 306°) + subtle vertical darken overlay. Hover swaps the lilac stops for deeper viola tones (`#964aba → #6666bf`) — sourced from Figma "Gradient styles/primary/hover".
- **Default / Secondary / Destructive / Success**: vertical fill→hover, where `fill` and `hover` come from the corresponding `--button-{variant}` and `--button-{variant}-hover` tokens.
Use the Tailwind utilities `bg-button-{primary,primary-hover,default,secondary,destructive,success}` or the underlying `--gradient-button-*` CSS vars.
**Focus rings** are compound `box-shadow` tokens, not outlines:
- `shadow-ring-primary` — 1px solid `--border-focus` + 1px halo (lilac @ 70%)
- `shadow-ring-destructive` — 2px background spacer + 2px destructive ring (shadcn-style "gap" focus ring)
Apply via `focus-visible:shadow-ring-primary` (and `focus-visible:outline-none`).
## 4. Spacing, radius, shadows
- **Spacing — Tidal Compact scale.** Class numbers do **not** map to standard Tailwind pixels. Use this lookup or just trust the rules below:
| Class | px | | Class | px |
|---|---|---|---|---|
| `gap-1` | 2 | | `gap-3` | 10 |
| `gap-1.5` | 4 | | `gap-3.5` | 12 |
| `gap-2` | 6 | | `gap-4` | 14 |
| `gap-2.5` | 8 | | `gap-5` | 18 |
Heights (`h-*`) use the standard scale (`h-8 = 32px`).
- **Radius**: `--radius` (10px) is the system base. `rounded-sm` = 6px, `rounded-md` = 8px, `rounded-lg` = 12px, `rounded-full` = pills.
- **`--radius-popover` (12px)** is the canonical radius for **floating surfaces** (Popover, DropdownMenu, Command, future Tooltip). Apply via `rounded-[var(--radius-popover)]`. Do **not** use `rounded-lg` or `rounded-[var(--radius)]` on a floating surface — target the role, not the number, so the slot can drift independently.
- **Shadows**: `shadow-sm` (subtle), `shadow` (default elevation), `shadow-popover` (with backdrop blur). Never craft a one-off shadow inline.
### Canonical spacing rules
| Rule | Value | Class | Used by |
|---|---|---|---|
| Control horizontal padding | sm 8 / md 10 / lg 12 px | `px-2` / `px-2.5` / `px-3` | Button, Select, Toggle, Input |
| Internal gap (icon ↔ text within a control) | 6px | `gap-1.5` | Button, Badge, SelectTrigger |
| Group gap (sibling controls in a bar) | 4px | `gap-1` | TabsList, ToggleGroup, Pagination |
| List-item gap (menu rows) | 2px | `gap-0.5` | MenuItem, DropdownMenu |
| Form stack (Field) | 6px | `gap-2` | Field default — Label → control → description/error |
### Container density
`Card`, `Dialog`, `AlertDialog`, `Sheet`, `Popover`, `SidePanel` all accept a `density` prop (compact / comfortable). Card uses `padding="sm" | "lg"` for the same idea. Density cascades to sub-components via React context.
| Slot | Compact | Comfortable |
|---|---|---|
| Shell | 8–12px | 20–24px |
| Section gap (header / body / footer) | 8px | 16px |
| Header stack (title ↔ description) | 4px | 8px |
| Body stack | 8px | 16px |
| Anchor offset (floating surfaces) | 6px | n/a |
### Notification surfaces aligned
`Alert`, `Toast`, and `Sonner` share padding/gap math: shell `p-4` (14px), icon→body `gap-3` / `gap-x-[10px]` (10px), title↔description gap (2px) via `gap-y-1` (Alert grid) / `gap-1` (Toast wrapper) / `mt-1` (Sonner description).
### New semantic tokens (use these, not hex)
| Token | What | When |
|---|---|---|
| `--border-subtle` | Hairline border — `rgba(0,0,0,0.12)` light / `rgba(255,255,255,0.12)` dark | Floating surfaces (Popover/Dropdown/Command) where `--border-default` is too faint against the translucent fill. |
| `--surface-hover-overlay` | Subtle fill overlay — `rgba(0,0,0,0.03)` light / `rgba(255,255,255,0.04)` dark | Hover overlays on tokenized surfaces (Input, Textarea, Command input). Eliminates the `dark:hover:…` duplicate rule. |
| `--radius-popover` | `12px` | Radius of floating surfaces (see above). |
| `--header-height` | `56px` | Page header AND `Sidebar`/`SidePanel` header heights. Override on a parent to scale chrome together. |
| `--button-primary-border` | `#964aba` | Deeper viola for accent-tone button borders. The light lilac `--button-primary` loses saturation at 1px. |
**`dark:` is forbidden in components.** Every case that previously required a `dark:` override on a tokenized element must go through a token whose light/dark values are defined in `packages/tokens`. If you encounter a new case that isn't covered — add a token, don't patch with `dark:`.
---
## 4.5. Textarea action bar pattern
When a Textarea has an action bar below it (chat composer, command prompt, etc.), the bar splits into **two button groups**:
- **Left** — context: model picker, mode toggles, scope selectors. Anything that changes *where the input goes* or *what it means*.
- **Right** — actions: file upload, mic, then the **Send** button. Send is the only Primary button in the bar and sits at the far right.
Both groups use Ghost buttons (`variant="ghost"`, `size="sm"`) except for Send, which is Primary IconButton. Upload/mic on the right are IconButtons too. On the left, use labeled Ghost buttons (`"Claude Opus 4.6 ▾"`, `"+ Add"`).
**The hover / focus / invalid ring wraps the whole composer** (textarea + action bar) — not just the textarea. Strip the Textarea's own border/shadow/focus and move them to the outer wrapper via `focus-within:` and `has-[[aria-invalid=true]]:`. The action bar is part of the field, visually.
## 5. Component composition rules
1. **Reserve Accent for one CTA per page.** `` (high-contrast inverse default) is the workhorse CTA. `` (lilac) is the brand moment — reserved for the single most important action or CTA on a given view. If a design calls for multiple Accent buttons, push back: either keep them all on `tone="default"`, or identify the one that genuinely outranks the others and keep only that one on `tone="accent"`. Same rule for ``.
1a. **Card elevation: default to 0.** Most cards should render flat (`elevation={0}`). Only promote to `1` or `2` when a clear information hierarchy calls for it (a floating callout, a single "featured" card among peers). If multiple cards on a page would qualify for elevated, reconsider — probably only one should be.
1b. **CardHeader padding scales with CardTitle size.** CardTitle exposes a `size` prop (`sm | md | lg | xl`). When you bump the title to `lg` or `xl`, CardHeader automatically steps up both vertical and horizontal padding (via a `has-[[data-title-size]]:` selector) so the header gets proportional breathing room. You don't need to touch Card's `padding` prop to make a hero card feel right — just set `… `.
1d. **`variant × tone` is the canonical component API shape.** Any component with both a visual style axis and an intent axis uses two props: `variant` (style) and `tone` (intent). This matches `Button`, `IconButton`, and `Badge`:
- `Button` / `IconButton`: `variant: primary | default | outline | ghost | link` × `tone: default | destructive | success | accent`.
- `Badge`: `variant: solid | secondary | outline` × `tone: default | destructive | success`.
Don't conflate the two into a single flat `variant` list (e.g. `"primary" | "destructive"`) — that muddles the mental model and makes compound cases like "outline + success" unreachable. Use `cva` compound variants to express the cell-by-cell styling.
1c. **Reuse `CardTitle` + `CardDescription` anywhere you need a title + description pair.** They work standalone — no `` parent required. Use them inside ``, ``, toolbar headers, whatever. For Popover/Dialog, pass `` (text-base). For Card, the default (`size="md"` → text-lg) is right. Never hand-roll `` + ` ` — the type scale will drift.
2. **Compose, don't reinvent.** If `Card`, `Button`, `Input` exists, use it. Don't wrap a `
` to imitate a card.
3. **Polymorphic via `asChild`** (Radix pattern) is preferred over `as` props.
4. **Variants live in the component** via `cva` (class-variance-authority). Don't pass `className` to override variant styles — add a variant.
5. **Forward refs** on every interactive component.
6. **Accept `className`** and merge with `cn()` (`packages/react/src/lib/cn.ts`).
7. **Accessibility is non-negotiable**: keyboard navigation, focus states (`focus-visible:ring-2 ring-primary`), proper roles. Use Radix Primitives for any interactive widget.
---
## 6. New-component checklist
When adding a component (P2+), every PR must include:
- [ ] `packages/react/src/components/
.tsx` — implementation, with `cva` variants, `forwardRef`, `displayName`
- [ ] `apps/docs/content/docs/components/.mdx` — usage, props table, all variants rendered, do/don't, accessibility notes
- [ ] A live preview (` `) in the MDX
- [ ] Source-of-truth note linking back to the Figma node ID
- [ ] Updated `CHANGELOG.md` entry
---
## 7. Forbidden patterns
| Don't | Why | Do instead |
|---|---|---|
| `font-bold` / `font-extrabold` / `font-black` / `font-light` | not in the allowed weight set | `font-semibold` (heaviest we use) |
| `bg-[#cd82f0]` | bypasses the system | `bg-primary` |
| `style={{ padding: 13 }}` | off-scale | `p-3` (12) or `p-3.5` (14) |
| `dark:…` on a tokenized component | duplicates dark logic | fix the token (or add a new semantic token — see §4) |
| `style={{ opacity: 0.5 }}` for disabled state | inline style can't be overridden | use `disabled:opacity-50 data-[disabled]:opacity-50` classes (see §2.4) |
| `disabled:text-foreground-disabled` / `disabled:bg-muted` chrome dimming | drifts from canonical 50% opacity rule | use `disabled:opacity-50` (see §2.4) |
| `motion-safe:` / `motion-reduce:` in a component | reduced motion is global (see §2.7) | do nothing — the global rule handles it |
| `rounded-lg` on a Popover/Dropdown surface | wrong semantic | `rounded-[var(--radius-popover)]` |
| New component without MDX docs | invisible to consumers | follow §6 |
| Editing `dist/` files | regenerated on every build | edit the source in `src/` |
| Importing from `tailwindcss/colors` in a component | leaks primitives | go through a semantic token |
---
## 8. Screen-building patterns
Patterns codified from iterative design reviews of production screen mocks.
### 8.1 Components over custom markup
Always check `@tidal-ds/react` for an existing component before hand-building UI. Use design system primitives even if the result deviates from a reference design (e.g., an IDE mock). Consistency with the system matters more than mimicking an external product.
### 8.2 Defaults over overrides
Use components with zero `className` overrides first. Only add styling after confirming the default doesn't work. Overrides fighting component defaults are the #1 source of visual bugs in screen mocks.
### 8.3 No redundant information
If tabs already label a panel's content, don't add a separate header above them. Every element in a dense tool UI must earn its space.
### 8.4 Collapsible panels: header always visible
The trigger/header bar stays visible at all times — only the content body collapses. Hiding the header removes the user's ability to re-expand.
- Place the trigger row **outside** `CollapsibleContent`.
- `CollapsibleContent` wraps only the expandable body.
- Use `CollapsibleTrigger asChild` when the trigger needs to be a custom element (e.g., a row with tabs + badges + a toggle button).
### 8.5 Status color system
| Meaning | Color | Token | Tailwind utility |
|---------|-------|-------|-----------------|
| Error / failure | Red | `--status-error` | `text-status-error` |
| Warning / caution | Amber / gold | `--status-warning` | `text-status-warning` |
| Success / healthy | Green | `--status-success` | `text-status-success` |
| Informational | Cerulean | `--status-info` | `text-status-info` |
Never use green for warnings. Always pair color with an icon shape or text label — never rely on color alone. Prefer `text-status-*` utilities over raw `text-[var(--button-*)]` or Tailwind color classes (`text-amber-500`).
### 8.6 PageTabBar vs Tabs — when to use which
| Scenario | Component |
|----------|-----------|
| File tabs, editor tabs, query tabs, open documents — closeable, with icons | `PageTabBar` + `PageTab` |
| View switching within a panel (Terminal / Problems / Output, Settings sections) | `Tabs` + `TabsTrigger` |
`PageTab` has an `icon` prop for left-aligned decorations. Pass icons via `icon`, not as children — children become the text label. This prevents vertical stacking in small tab spaces.
### 8.7 Text sizing in tool UIs
| Size | Use for |
|------|---------|
| `text-sm` (14px) | Tool content: code, terminal output, diagnostics, form fields, panel body text |
| `text-xs` (12px) | Metadata only: status bars, column headers, timestamps, breadcrumbs |
Don't default to `text-xs` for primary content — it reads as uncomfortably compact.
### 8.8 Dark mode for screen mocks
Force dark mode via `next-themes` `setTheme("dark")` (see `apps/docs/app/(screens)/force-dark.tsx`). Do **not** add `class="dark"` to a wrapper div — CSS custom properties for dark mode are scoped to `html.dark`, so a dark class on a descendant div won't trigger them. Components like `PageTab` (`bg-button-default`) will resolve to light-mode values if the `html` element isn't in dark mode.
### 8.9 Dev workflow: component library changes
After editing files in `packages/react/src/components/`, the Next.js dev server HMR cache won't reliably update. Run:
```bash
rm -rf apps/docs/.next && npx next dev -p
```
---
## 9. File map (where things live)
```
design-tokens/ ← canonical Figma export (DTCG, do not edit by hand)
manifest.json
Semantics.{light,dark}/liquid.tokens.json
Typography.Liquid {Internal,External}.tokens.json
Tailwind Colors.Mode 1.tokens.json
Tailwind Primitives.{Liquid,Mode 2}.tokens.json
Liquid colors.Mode 1.tokens.json
Primitives.Mode 1.tokens.json
*.styles.tokens.json ← gradients/effects/text styles (TODO: importer)
packages/tokens/
scripts/build.ts ← importer: design-tokens/ → src/_generated.ts + dist/
src/
_generated.ts ← generated, do not edit
gradients.ts ← hand-curated supplements (until styles importer lands)
shadows.ts ← hand-curated supplements
index.ts ← public API
dist/ ← generated outputs (tokens.css, tokens.json, tailwind-preset.js)
apps/docs/
app/ ← Next.js app router pages
public/fonts/ ← Söhne .otf files
```
---
## 10. Before you build — clarify and propose
### 10.1 Ask for clarity when the brief is underspecified
If a PRD, prompt, or design brief doesn't answer these questions, **stop and ask the prompter** before writing any code:
1. **What is the primary user task on this screen?** (If unclear, you can't identify Level 1.)
2. **What data is displayed, and how much of it?** (Determines table vs list vs cards, and whether pagination/filtering is needed.)
3. **What states need to exist?** (Empty, loading, error, partial, no-access — if the brief only describes the happy path, ask about the others.)
4. **What actions can the user take?** (Create, edit, delete, bulk operations — determines button placement, modals, confirmation flows.)
5. **What existing components should be used?** (If the brief doesn't reference the design system, ask which components from `@tidal-ds/react` are expected.)
Don't guess on ambiguous requirements. A 30-second clarification question saves hours of rework.
### 10.2 Propose the build plan before implementing
Before writing code for a new screen or major feature, present a short build plan to the prompter for approval:
```
## Build Plan: [Screen/Feature Name]
**Purpose:** What is this screen for?
**Layout:** Which page structure pattern? (List View, Detail View, Dashboard, Settings, etc.)
Reference: [cite which products/patterns from ux_patterns_researched.md inform this choice]
**Key components:**
- [Component] → [purpose] → [why this component vs alternatives]
**States:** Loading / Empty / Error / No-access — one line each describing what the user sees.
**Open questions:** Anything still ambiguous after reading the brief.
```
The prompter may approve, adjust, or redirect. **Do not write code until the plan is approved.** This applies to: new pages, new major components, layout changes, and navigation changes. It does not apply to trivial changes (adding a field, fixing a color, updating copy).
---
## 11. When in doubt
- Check Figma first (`mcp__figma__get_design_context` with the node ID).
- If it's not in Figma, **ask before inventing**. Don't extrapolate the system on your own.
- If you're patching a bug in code that drifts from Figma, fix the code to match Figma (or open a sync request).
---
# AGENTS.md
# AGENTS.md
Agent-readable reference for Tidal Design System. Two purposes:
1. **When should I reach for which component?** (decision tables §1–§6)
2. **What principles govern how I compose UI?** (UX rules §7–§8, forwarded from DESIGN.md)
For API details, see per-component docs pages and `apps/docs/lib/llms.ts`.
## 0. Read first — and read ALL of it
**`DESIGN.md` is mandatory reading**, not just for token values and component APIs — for the **principles** that govern every design decision. Specifically:
- **§0.5 Design principles** — heading hierarchy ("heading ≥ content"), default-to-calmest-variant, token-before-hex, compose-before-extending, accessibility-is-done. These are invariants. When a Figma design or a judgment call conflicts with one of these, push back before implementing.
- **§1 Token taxonomy** — the three-layer model (Primitives → Semantic → Component). Always reach for the highest layer that fits. Never inline hex.
- **§2 Light & dark mode** — no `dark:` in components. If a color needs a dark variant, fix the token.
- **§2.4 Disabled** — class-based 50% opacity, no inline style.
- **§2.5 Text size rule** — body/emphasis/description all share `text-base leading-6`, differentiated by weight and color, not size. The full typography table is the source of truth.
- **§2.6 Size scale** — `sm` = 24px, `md` = 32px, `lg` = 40px. Don't invent new size names.
- **§2.8 Side panels** — collapse at narrow widths. Always.
- **§3 Typography** — Mode-aware fonts: Internal = Inter (sans) + sans headings; External = Söhne (sans) + Georgia (serif headings via `font-heading`). `font-mono` = JetBrains Mono. Only normal/medium/semibold weights. Two size scales (Internal default, External opt-in via `.typography-external`).
- **§4 Spacing, radius, shadows** — 4px base, `--radius-popover` for floating surfaces, never craft one-off shadows.
- **§5 Composition rules** — one Primary per page, card elevation defaults to 0, `variant × tone` API shape, `asChild` over `as`, compose before reinvent.
- **§7 Forbidden patterns** — the full "Don't / Why / Do instead" table. Memorize it.
- **§8 Screen-building patterns** — components over custom markup, defaults over overrides, collapsible panel rules, status colors, PageTabBar vs Tabs, text sizing, dark mode.
- **§10 Before you build** — if the brief is underspecified, ask for clarity. Before building a new screen, propose a build plan and get approval. **Do not jump to code.**
**Treat DESIGN.md as a design rubric, not a reference appendix.** Before writing any UI code, ask: *does this comply with every principle in §0.5 and every rule in §1–§7?* If you can't answer yes, stop and fix.
Also read:
- `ux_patterns_researched.md` — UX layout patterns and product evidence reference. Consult when choosing page structures, data surfaces, or panel patterns for new screens.
- `.claude/commands/figma-sync.md` — workflow for Figma → code syncs.
- `.claude/commands/token-audit.md` — 5-layer alignment audit.
- `.claude/commands/quality-check.md` — system-wide QA.
- `.claude/commands/ui-audit.md` — 14-principle UI compliance check.
- `.claude/commands/doc-maintenance.md` — mechanical docs sweep.
## 0.5 Component inventory
Every component exported from `@tidal-ds/react`. Scan this before hand-building any UI.
| Component | One-liner |
|-----------|-----------|
| Accordion | Vertically stacked expand/collapse sections |
| Alert | Inline banner for status messages (info, warning, error, success) |
| AlertDialog | Blocking modal for destructive confirmations with explicit OK / Cancel actions |
| Avatar | User avatar with image + initials fallback |
| Badge | Small label chip for counts, statuses, and color-coded tags (supports tinted variant) |
| Breadcrumb | Path navigation trail showing the current resource's location |
| Button | Primary call-to-action; supports variant × tone API and loading state |
| Calendar | Date/date-range picker grid, usually placed inside a Popover |
| Card | Grouped container with optional media, header, title, description, and footer slots |
| Carousel | Horizontal scroll slideshow with previous/next navigation |
| Chart | Recharts wrapper (bar, line, pie, etc.) styled with Tidal tokens and CSS variable color series |
| Checkbox | Square two-state boolean control; use in forms (Switch for settings) |
| Collapsible | Single expand/collapse panel |
| Combobox | Searchable single-select dropdown (filterable list, >~10 options) |
| Command | Keyboard-first command palette with search, groups, and shortcuts |
| Dialog | Modal dialog for focused tasks and confirmations |
| DropdownMenu | Anchored menu of contextual actions triggered by a button |
| Field | Slot-based form row container wiring label, control, description, and error with correct a11y |
| IconButton | Icon-only button with the same variant × tone API as Button |
| Input | Single-line free-form text input |
| InputOTP | Fixed-length one-time-password / PIN entry with slot and separator layout |
| Label | Accessible form label with peer-based styling hooks |
| Menubar | Application-style top menu bar (File / Edit / View) with nested submenus |
| MenuItem | Primitive menu row with icon, shortcut, check/radio indicator, and submenu chevron |
| MultiCombobox | Multi-select tag picker with inline search and optional create-new-option |
| NavigationMenu | Top-of-page marketing nav with hover-triggered popover panels |
| PageTabBar / PageTab | Browser-style horizontal tab bar with close buttons and optional add-tab action |
| Pagination | Numbered page navigation with previous/next and ellipsis |
| Popover | Anchored floating panel that dismisses on click-away |
| Progress | Determinate horizontal progress bar |
| RadioGroup | Mutually exclusive option set with radio button indicators |
| ScrollArea | Bounded scroll region with styled visible scrollbars |
| ScrollFade | Bounded scroll region that overlays a gradient fade at clipped edges |
| Select | Native-style single-select dropdown for short fixed-option lists |
| Separator | Thin horizontal or vertical visual divider |
| Sheet | Side-anchored modal drawer (top / right / bottom / left) |
| Sidebar | Persistent or collapsible app navigation panel with full header/content/footer/group/menu slot system. Supports `resizable` (drag handle), `collapsible` groups, nested `SidebarMenuSub`, and `SidebarIdentity` for workspace/user chips |
| SidePanel | Right-side persistent inspector panel (sibling to Sidebar). Header aligned to page header (`--header-height`), stack of static or collapsible sections with `bordered` prop and `info` tooltip on titles |
| Skeleton | Loading placeholder that mimics content shape |
| Slider | Single or range numeric value picker |
| Switch | Pill-shaped toggle for binary settings (use Checkbox for form fields) |
| Table | Semantic data table with header, body, footer, caption, and row/cell slots |
| Tabs | Layered content panels — one section visible at a time |
| Textarea | Multi-line free-form text input |
| Toast | Radix-controlled transient notification with explicit per-instance lifecycle |
| Toaster / toast | Sonner-based stackable toast API; fire toasts imperatively from anywhere in the app |
| Toggle / ToggleGroup | Pressable button that stays active; group variant for single or multi-select toolbar controls |
| Tooltip | Hover/focus tooltip for labels and help text |
## 1. Size conventions
Every interactive control uses the same scale. **`md` is the default and the workhorse.**
| Size | Height | Text | Icon | Use for |
|---|---|---|---|---|
| `sm` | **28px** | `text-sm` | `size-3` (12px) | Dense / inline (table filter rows, command bars, inline chips, compact forms) |
| `md` | **32px** | `text-base` | `size-4` (16px) | Standalone controls; form rows that own a full horizontal row |
| `lg` | **36px** | `text-base` | `size-4` (16px) | Presence-size for hero actions and inspector-density triggers |
**Pair heights.** An `sm` Input next to an `md` Button is wrong — match sizes.
**lg keeps text-base, not text-lg.** lg is "same type, bigger box" — the height bump alone provides presence. Bumping the text size makes lg feel like a different component family.
**Input has a refinement** — Input/Textarea/Combobox/CommandInput/SelectTrigger default to `min-h-*` (not fixed `h-*`) so multi-line content doesn't clip. Heights are minimums.
**File and number inputs are custom-rendered.** ` ` renders a `` with a "Choose File" prepend and the filename body — the native button is hidden. ` ` (when added) follows the same pattern. The native rendering can't be vertically centered reliably.
## 2. Form surfaces — which one?
| Need | Use |
|---|---|
| Free-form text, single-line | `Input` |
| Free-form text, multi-line | `Textarea` |
| Short atomic token with known length (OTP, 2FA code) | `InputOTP` |
| Pick one from a short fixed enum, no typing | `Select` |
| Pick one from a list, typing helps filter (>~10 options) | `Combobox` |
| Pick multiple from a list | `ToggleGroup` (small, visible) or `Combobox` with multi-value state (when longer) |
| Two-state boolean | `Switch` (pill) or `Checkbox` (square) — Switch for settings, Checkbox for form fields |
| One of a few mutually-exclusive options | `RadioGroup` or `ToggleGroup type="single"` |
| Numeric range selection | `Slider` (single or two-thumb range) |
| Date / date range | `Calendar` (often inside `Popover`) |
| Label above/beside a control | `Label` + `peer-*` / `Field` for complex layouts |
## 3. Overlays and surfaces — which one?
| Need | Use |
|---|---|
| Anchored floating panel, click-away dismissal | `Popover` |
| Anchored menu of actions | `DropdownMenu` |
| Searchable command palette | `Command` (standalone) or `CommandDialog` (centered modal) |
| Centered modal for confirmations or focused tasks | `Dialog` |
| Centered modal for destructive confirmations (OK / Cancel) | `AlertDialog` |
| Side drawer (modal, overlays main content) | `Sheet` |
| Persistent left nav | `Sidebar` |
| Persistent right inspector with stacked sections | `SidePanel` |
| Hover/focus tip | `Tooltip` |
### Sidebar vs SidePanel — decision rule
These both sit at the edge of the page and stay in layout, so it's easy to reach for the wrong one. The line:
| | `Sidebar` | `SidePanel` |
|---|---|---|
| Built around | A **flat list of menu rows** (icon + label, click navigates) | **Stacked sections**, each with its own header + arbitrary body content |
| Use it for | Workspace nav · file tree · channel list · layer list — *menus* | Property inspector · annotation editor · per-record review panel — *rich content per section* |
| Collapse | The panel itself collapses (icon-rail or offcanvas), ⌘B toggle, mobile Dialog drawer | Sections inside collapse; the panel itself stays |
| Section content | Almost always a `` | Anything: forms, charts, annotation cards, thumbnails |
| Mental model | Cursor's left file tree, Linear's left workspace nav | Figma's right inspector, Linear's right "issue details" panel |
**Quick test:** if every "row" inside the panel is "click to navigate or toggle," use `Sidebar`. If sections contain forms, sliders, charts, annotation editors, or any UI richer than a clickable label, use `SidePanel`.
Note: a right-anchored `` is valid for *menus* on the right (a chat thread list, a notifications drawer). It's not the right tool for an inspector.
| Info popover with rich content on hover | (future `HoverCard`) |
## 4. Feedback — which one?
| Need | Use |
|---|---|
| Transient notification, rich interactions (promise, action, swipe-dismiss) | `Sonner` (Toaster) |
| Transient notification, explicit Radix control per instance | `Toast` |
| Persistent inline callout (info/warning/error on the page) | `Alert` |
| Determinate progress of a known operation | `Progress` |
| Loading placeholder for content that's coming | `Skeleton` |
| Spinner inside a submit button | `` |
| Destructive confirmation ("are you sure?") | `AlertDialog` |
## 5. Navigation — which one?
| Need | Use |
|---|---|
| Primary app navigation (left sidebar, docs nav, admin nav) | `Sidebar` |
| Top-of-page marketing site nav with popover panels | `NavigationMenu` |
| App menu bar (File / Edit / View / Help) | `Menubar` |
| Paginated list navigation | `Pagination` |
| Path to current resource | `Breadcrumb` |
| Layered sections, one visible at a time | `Tabs` |
| Multi-step carousel / slideshow | `Carousel` |
## 6. Layout / grouping
| Need | Use |
|---|---|
| Grouped container with optional header/footer | `Card` |
| Group of related items with a heading label | `SidebarGroup` (in sidebar) or `Accordion` (expandable) |
| Single expand/collapse panel | `Collapsible` |
| Multiple expand/collapse sections | `Accordion` |
| Thin visual divider | `Separator` |
| Bounded scroll region with visible scrollbars | `ScrollArea` |
| Bounded scroll region with only edge-fade cue | `ScrollFade` |
## 7. Spacing convention
Tidal uses a **Compact spacing scale** (defined in `packages/tokens/dist/tailwind-preset.js`). Class numbers don't map to standard Tailwind pixels:
| Class | px | | Class | px |
|---|---|---|---|---|
| `gap-1` / `p-1` | 2 | | `gap-3` / `p-3` | 10 |
| `gap-1.5` / `p-1.5` | 4 | | `gap-3.5` / `p-3.5` | 12 |
| `gap-2` / `p-2` | 6 | | `gap-4` / `p-4` | 14 |
| `gap-2.5` / `p-2.5` | 8 | | `gap-5` / `p-5` | 18 |
Heights (`h-8`, `h-10` etc.) use the standard Tailwind scale (`h-8 = 32px`).
**Five canonical spacing rules:**
| Rule | Value | Class | Used by |
|---|---|---|---|
| Control horizontal padding | sm 8 / md 10 / lg 12 px | `px-2` / `px-2.5` / `px-3` | Button, Select, Toggle, Input |
| Internal gap (icon ↔ text within a control) | 6px | `gap-1.5` | Button, Badge, SelectTrigger |
| Group gap (sibling controls in a bar) | 4px | `gap-1` | TabsList, ToggleGroup, Pagination |
| List-item gap (menu/dropdown rows) | 2px | `gap-0.5` | MenuItem, DropdownMenu |
| Form stack (Field) | 6px | `gap-2` | Field default — Label → control → description |
**Container density (compact / comfortable):**
| Slot | Compact | Comfortable | Used by |
|---|---|---|---|
| Shell | 8–12px | 20–24px | dropdown/popover/sidebar (compact) · dialog/sheet/card-lg (comfortable) |
| Section gap | 8px | 16px | Header → body → footer |
| Header stack (title ↔ description) | 4px | 8px | DialogHeader, CardHeader |
| Body stack (sibling blocks) | 8px | 16px | DialogBody |
| Row gap (list rows) | 2–4px | 4px | Menu rows |
| Anchor offset | 6px | n/a | Popover/Dropdown sideOffset |
`Card`, `Dialog`, `AlertDialog`, `Sheet`, `Popover`, `SidePanel` accept a `density` prop (or in Card's case, `padding="sm" | "lg"`) and cascade values to their sub-components via context.
**Notification surfaces** (Alert, Toast, Sonner) are aligned: shell `p-4` (14px), icon→body gap `gap-3`/`gap-x-[10px]` (10px), title↔description `gap-y-1`/`gap-1`/`mt-1` (2px).
When in doubt for grouped-element layout: **6px**.
## 8. UX rules (product-level — beyond tokens)
These are rules about **using** the system, not building it. They govern how pages and flows are composed.
1. **Never lock the user in.** Any toggleable / hideable / collapsible surface *must* always expose a way to restore it. If the sidebar can collapse, a trigger to re-expand it has to be visible on every screen where it's collapsed. If you reach for `display: none`, you owe the user a re-entry point — a button, a keyboard shortcut, a rail affordance. Same principle applies to dismissable Toasts carrying undoable actions (show an Undo action before it auto-dismisses), hidden nav items on mobile (always route through a menu trigger), and anything else a user might want back.
2. **Don't duplicate identity or information.** If the workspace name, logo, or a status badge is already visible in the sidebar, the page header doesn't need to repeat it. Mirror only when the *other* surface will be hidden (e.g. page-header logo shows only when the sidebar is collapsed — `HeaderIdentity` in the docs app is the reference pattern). Two visible "Tidal" wordmarks at once is a smell.
3. **Be intentional about component choice.** Before reaching for a component, ask: *what is the user's mental model here, and which component matches it?* A single Button that toggles light/dark only communicates "press me"; a two-cell ToggleGroup with sun/moon icons communicates "these are the two choices, the highlighted one is your current state." Pick for the semantic, not the familiar pattern. A checkbox in a form ≠ a switch in settings ≠ a toggle in a toolbar — they feel different because they *are* different.
4. **Position communicates intent.** When a UI region's purpose is "click this to see more," the action belongs in the middle of that region (vertically + horizontally), not hugging a corner. A collapsed code block's whole job is to say "here's code, click to reveal" — so its View-code button is dead-center, the code fades behind it as texture. A card's primary action anchors at the content's center of gravity. Corner placement is for affordances that *live alongside* the content (close × top-right, copy top-right of a code block).
5. **Position consistency across states.** Chrome that survives a state change must not move with it. When the sidebar toggles open/closed, the search and theme-toggle should stay anchored to the right; don't rely on `justify-between` when one side can disappear. Use `ml-auto` on the side that's always present. Users shouldn't have to chase the same control across a UI.
6. **Align header-row baselines across columns.** If the page has a sidebar header and a main header (e.g. the panel trigger in the sidebar + the search bar in the top bar), set them to the same height so a control in one column shares the visual axis with a control in the other. When the sidebar collapses and the trigger reappears in the page header, the icon should land at the same vertical position — not jump 4px up or down. In practice: pick one `h-*` (e.g. `h-14`) and hardcode it on both header containers.
7. **Variant choice must account for what's behind the element, not just what the element is.** An `outline` or `ghost` button has a transparent background — legible over empty space, unreadable when placed on top of text/code/another surface. Over content, use a variant with a solid fill (`default` / `primary`). The "View code" button in CollapsibleCode sits on top of faded code, so it ships as `variant="default"` — a switch to `outline` would regress legibility.
8. **Icons enable density.** A long list of text-only nav rows forces linear reading. Add an icon per row and the eye pattern-matches before it reads — scanning goes from O(n) to O(1). Pair this with `collapsible="icon"` on Sidebar and the collapsed rail stays navigable (rail without icons is a dead zone — users can't click anything they recognize). Anything with more than ~10 items in a list deserves icons.
9. **Categorize long lists.** Once a list grows past ~15 rows, replace one bucket with ~5 sub-buckets. In docs: the 47-component Components section split into Forms / Overlays / Feedback / Data / Navigation / Utility. Fewer-but-labeled buckets shrink cognitive load more than alphabetizing a longer list.
10. **Alignment & grid discipline.** Treat every element as if it's snapping to an invisible grid. Left edges should align with other left edges. Right edges with right edges. Text baselines, icon centers, and control heights should line up horizontally across columns. Spacing between elements should be consistent and repetitive — not eyeballed.
Think of it like this: if you drew a vertical line anywhere in the layout, it should land on something — the start of a label, the edge of a button, the left edge of an input. Random offsets and incidental misalignments should not exist. If two things feel like they belong to the same group or column, their edges should be flush.
The same applies vertically — elements in a row should share a center line or a baseline, not float at slightly different heights. When stacking sections, the vertical rhythm should feel metronomic: same gap, every time.
A useful test: if you squint at the layout, the whitespace should form clean rectangular regions — not irregular patches.
## 8.5 Layout tokens (shared across chrome)
These CSS variables are emitted in `dist/tokens.css` and consumed by both the design system and the docs site.
| Token | Default | Purpose |
|---|---|---|
| `--header-height` | `56px` | Page header AND `SidePanel`/`Sidebar` header heights — keeps cross-column header strokes aligned. Override on any parent to scale chrome together. |
| `--button-primary-border` | `#964aba` | Deeper viola for accent-tone button borders. The brand `--button-primary` (light lilac) loses saturation at 1px, so this token gives accent borders chromatic mass. |
| `--font-weight-normal/medium/semibold` | `400/500/600` | Internal scale matches External (was `300/400/500`). Bumped so `font-medium` reads with weight in compact UIs. |
Heading hierarchy on docs/screens: `h1` → `text-2xl`, `h2` → `text-lg`, `h3`/`h4` → `text-base font-medium`. **``, ``, and `` all default to `text-base font-medium`** so a nested card/dialog title never outranks the section's own h2. Bump `` (text-lg) or `size="lg"` (text-xl) only for cards that *are* a top-level page region.
## 9. Principles agents must follow
Every component in this system follows these. Don't invent local variants.
1. **Token-only colors.** No raw hex / rgba / oklch / Tailwind color ramps in component code. `pnpm lint:tokens` enforces.
2. **Dark mode is automatic via tokens.** No `dark:` prefixes in components — if a color needs a dark-mode value, fix the token.
3. **Class-based disabled.** `disabled:opacity-50 data-[disabled]:opacity-50 disabled:pointer-events-none`. No inline `style={{ opacity: … }}`. Covers both native `disabled` and Radix Slot / `asChild` paths.
4. **Reduced motion at the token layer.** Global `@media (prefers-reduced-motion: reduce)` rule in `tokens.css` zeroes all transition/animation durations. Never gate effects with `motion-safe:` / `motion-reduce:` in a component.
5. **Focus-visible:** bordered chrome → `focus-visible:border-focus` (border color swap, no shadow ring). Borderless controls (Ghost button, Switch, Toast close, Tabs triggers) → `focus-visible:shadow-ring-primary`.
6. **Side panels collapse at narrow widths.** Below `lg` (1024px), `` auto-switches to an offcanvas drawer. Bespoke side panels must follow the same rule — never fight the main content for horizontal real estate on mobile.
7. **Reuse shared bases.** `inputBase` for input-shaped chrome; `menuItemBase` for menu rows. Don't hand-roll fills/borders that repeat across components.
8. **Canonical `variant × tone` API shape** for tint-able components (Button, IconButton, Badge): `variant` = visual style (`primary | default | outline | ghost | link`), `tone` = intent (`default | destructive | success`). Compound variants apply tone color inside each variant.
9. **Primary/destructive/success buttons are flat.** No gradient on primary surfaces — use `bg-primary hover:bg-[var(--button-primary-hover)]` (flat color with `transition-colors`). `default` button keeps its tonal gradient (neutral zinc → slightly darker zinc).
## 10. How to add a component
1. `/figma-sync ` — does the whole thing (reads Figma, diffs against current code, applies, lints, builds).
2. Manual path: copy a similar component (shadcn if we don't have it), swap shadcn tokens for Tidal tokens, follow §7 above.
3. Register in:
- `packages/react/src/index.ts`
- `apps/docs/lib/llms.ts` (add `*Md` const with a **"## When to use"** section, register in `MD` and `PAGES`)
- `apps/docs/lib/page-index.ts`
- `apps/docs/app/layout.tsx` sidebar nav
4. Create `apps/docs/app/components//page.tsx` (+ `examples.tsx` with `"use client"` for stateful examples).
5. Run `pnpm lint:tokens` + `pnpm --filter @tidal-ds/docs build` before committing.
## 11. How to audit for drift
`/token-audit` — 5-layer check: lint → token↔CSS parity → component var usage → Figma spot-check (via MCP) → canonical-pattern adherence. Pass `--fix` to apply unambiguous corrections automatically.
---
# UX Patterns & Usage Intelligence
# UX Patterns & Usage Intelligence — Enterprise Developer Tools
A prescriptive doctrine for AI agents and designers building enterprise developer tool interfaces. Grounded in observed patterns from 11 products: Linear, Vercel, Resend, Supabase, Retool, Datadog, GitHub, Neon, Clerk, Raycast, and Cursor.
Every rule is actionable. Where products diverge, a decision has been made. This document is the single source of truth for UX decisions.
---
## 0. Core Philosophy
### Principle 1: The interface is a tool, not a destination
**Rationale:** Enterprise users open this product to accomplish a task and leave. Every screen should minimize time-to-task-completion.
- **Do:** Show the user's most likely next action within 1 second of page load (e.g., a pre-focused search field, the most recent deployment at the top).
- **Don't:** Show a welcome dashboard with metrics the user didn't ask for before they can reach their task.
### Principle 2: Hierarchy before aesthetics
**Rationale:** In data-heavy interfaces, the user must instantly distinguish what matters from what doesn't. Visual hierarchy — achieved through weight, size, contrast, and position — is the primary design tool. If everything looks the same, nothing communicates.
- **Do:** Make the page title, primary data column, or key status indicator visually dominant. Push metadata, timestamps, and secondary labels to lower contrast and smaller size.
- **Don't:** Give equal visual weight to all elements on a row (same font size, same color, same weight).
### Principle 3: Reduce decisions, not options
**Rationale:** Enterprise tools are complex by necessity. The solution is not to remove features but to present the right feature at the right time. Progressive disclosure, contextual actions, and smart defaults reduce cognitive load without reducing capability.
- **Do:** Show advanced settings behind a toggle or expansion, with sensible defaults pre-selected.
- **Don't:** Present 15 form fields simultaneously when the user only needs to fill 3 to complete the common case.
### Principle 4: Every state is a designed state
**Rationale:** Users encounter loading, empty, error, partial, and success states as often as they encounter the "happy path." An undesigned state (a blank screen, a raw error code, a spinner with no context) erodes trust.
- **Do:** Design loading, empty, error, no-access, and partial-data states for every data-dependent surface.
- **Don't:** Ship a screen where any state renders as a blank white area or an unformatted error string.
### Principle 5: Spatial consistency builds muscle memory
**Rationale:** Users operate fastest when they can predict where things are. Navigation, actions, and status indicators must appear in the same position across every screen. Inconsistency forces the user to re-orient on every page.
- **Do:** Place primary actions in the same position (top-right of content area) on every page. Place navigation in the same sidebar position on every page.
- **Don't:** Move the "Create" button from top-right on one page to bottom-left on another, or inline on a third.
---
## 1. Information Hierarchy
### The Three-Level Priority Model
Every screen has exactly three levels of visual priority. Before designing any screen, identify:
**Level 1 — What the user came here to see or do.**
This is the single most important element. It receives: the largest font size, the highest contrast (900-weight text on the lightest background or white text on dark), and the most prominent position (top-left of the content area or center of the viewport). There is only ONE Level 1 per screen.
Examples: Issue title on an issue detail page. The data table on a list page. The code editor on an editor page. The deployment status on a deployment detail page.
**Level 2 — Context that supports Level 1.**
These elements help the user interpret or act on Level 1. They receive: medium font size, medium contrast (600-weight or muted text color), and supporting positions (beside, below, or in a sidebar relative to Level 1). There are 2–5 Level 2 elements per screen.
Examples: Status badges, assignee, labels on an issue detail page. Filter bar above a table. Metadata panel beside an editor.
**Level 3 — Everything else.**
Timestamps, secondary metadata, helper text, navigation breadcrumbs, tertiary labels. They receive: small font size, low contrast (400-weight or muted/secondary text color), and peripheral positions (right-aligned in rows, bottom of sections, collapsed by default).
**MANDATORY: No screen may have all text at the same size and weight.** If every text element is 14px/regular, the design has failed. Before rendering any screen, explicitly assign: one element at 18–24px/600–700w (Level 1), 2–5 elements at 13–14px/500w (Level 2), and everything else at 12–13px/400w (Level 3). If you cannot identify a Level 1, the page has no purpose — redesign it.
### How Hierarchy is Signaled
| Signal | Level 1 | Level 2 | Level 3 |
|--------|---------|---------|---------|
| **Font size** | 18–24px (page titles) or 14–16px bold (in-row titles) | 13–14px medium/regular | 12–13px regular |
| **Font weight** | 600–700 | 500 | 400 |
| **Text color** | Foreground primary (highest contrast) | Foreground secondary (muted) | Foreground tertiary (lowest contrast) |
| **Position** | Top-left of content area or first element in row | Adjacent to Level 1 or in a supporting panel | Right-aligned, bottom, or collapsed |
| **Whitespace** | Most whitespace around it (padding, margin) | Moderate whitespace | Least whitespace (tighter spacing) |
### Rules for Prominent Placement
- The page title is always Level 1. It appears at the top-left of the content area, never centered, never below other content.
- In a data row, the entity name/title is always the first text element after any status icon. It is the only bold text in the row.
- Status indicators (icons, dots, badges) appear at the far left of a row or immediately before the title — never at the far right where they compete with metadata.
- Timestamps are always Level 3. They appear right-aligned in rows and are never bold.
- Action buttons (Create, Save, Deploy) appear at the top-right of the content area — Level 2 prominence. They are never Level 1.
### Common Hierarchy Failures
| Failure | What Goes Wrong | Fix |
|---------|----------------|-----|
| **Equal-weight rows** | Every text element in a row is the same size and weight. User cannot scan. | Make title 14–16px/600w, everything else 12–13px/400w. |
| **Competing Level 1s** | Two elements both demand attention (e.g., a large KPI card AND a large table on the same screen). | Pick one. The other becomes Level 2. |
| **Metadata as Level 1** | Timestamps, IDs, or counts rendered at the same size/weight as titles. | Metadata is always Level 3: smaller, muted, right-aligned. |
| **Invisible status** | Status is communicated only through text color or a tiny dot with no label. | Status uses icon + text label OR icon + distinct shape. Minimum touch target 24×24px. |
---
## 2. Page Structure Patterns
### Pattern: List View
```
┌─────────────────────────────────────────────┐
│ Page Title [Filter] [+New]│
├─────────────────────────────────────────────┤
│ [Search bar] [Sort ▾] [View ▾] │
├─────────────────────────────────────────────┤
│ ● Title of item metadata 12m ago │
│ ● Title of item metadata 3h ago │
│ ● Title of item metadata 1d ago │
│ ... │
└─────────────────────────────────────────────┘
```
**When to use:** Displaying a collection of similar entities where the primary task is scanning, filtering, and selecting one to view in detail. Examples: issues list, deployments list, emails list, users list.
**When NOT to use:** When items have 5+ comparable numeric columns (use Table View instead). When items are primarily visual (use Card Grid).
**Key rules:**
- The page title and primary action button share the top row. Title left-aligned, action button right-aligned.
- Search/filter bar is a separate row below the title row.
- Each row is a single horizontal line. No multi-line rows unless the entity genuinely needs a secondary line.
- Status icon is the first element in each row (left-most).
- Entity title is the second element, and the only bold text.
- Metadata (assignee, labels, counts) are right-aligned and muted.
- Timestamp is always the rightmost element.
- Three-dot action menu appears at the far right of each row — always visible, not hover-only.
---
### Pattern: List + Inspector (Master-Detail)
```
┌──────────────────────────────┬──────────────┐
│ Page Title [Filter] │ │
├──────────────────────────────┤ Detail of │
│ [Search bar] │ selected │
├──────────────────────────────┤ item │
│ ● Selected item 12m ▸ │ │
│ ○ Item two 3h │ Title │
│ ○ Item three 1d │ Status │
│ ... │ Metadata │
│ │ Actions │
└──────────────────────────────┴──────────────┘
```
**When to use:** When users need to scan a list AND view details of individual items without losing their place in the list. Examples: email inbox, issue triage, log review, PR review.
**When NOT to use:** When the detail view is complex enough to need the full screen width (use full-page navigation instead). When items are independent and not compared to each other.
**Key rules:**
- List occupies 35–45% of width. Inspector occupies 55–65%.
- The split is resizable by the user. Minimum list width: 280px. Minimum inspector width: 400px.
- Selecting a row highlights it with a background color change (surface-accent or border-left indicator) and populates the inspector.
- The inspector has its own scroll context — scrolling the inspector does not scroll the list.
- Keyboard navigation: arrow keys move selection through the list; the inspector updates in real time.
---
### Pattern: Detail View
```
┌─────────────────────────────────────────────┐
│ ← Back / Breadcrumb │
├───────────────────────────┬─────────────────┤
│ │ Status: Active │
│ Entity Title │ Assignee: Name │
│ │ Labels: A, B │
│ ───────────────────── │ Created: date │
│ │ Project: name │
│ Main content area │ │
│ (description, activity, │ [Actions] │
│ timeline, tabs) │ │
│ │ │
└───────────────────────────┴─────────────────┘
```
**When to use:** Displaying the full detail of a single entity. Examples: issue detail, user profile, deployment detail, monitor status page.
**When NOT to use:** When the entity has minimal detail (use a drawer or inline expand instead).
**Key rules:**
- Back navigation (← or breadcrumb) at the top-left. This is the escape hatch.
- Entity title is the Level 1 element — largest text on the page.
- Main content area (left, 65–70% width) holds the primary content: description, activity feed, code, graphs.
- Metadata sidebar (right, 30–35% width) holds structured attribute pairs: Status, Assignee, Labels, Dates, Tags, Related entities.
- Metadata sidebar uses label: value pairs, vertically stacked, with labels in muted text and values in primary text.
- Destructive actions (Delete, Archive) go at the bottom of the metadata sidebar or in a "Danger zone" section, never at the top.
---
### Pattern: Dashboard
```
┌─────────────────────────────────────────────┐
│ Dashboard Title [Time range ▾] [Edit]│
├─────────────┬─────────────┬─────────────────┤
│ KPI Card │ KPI Card │ KPI Card │
├─────────────┴─────────────┴─────────────────┤
│ │
│ Chart / Graph (full width or 2-column) │
│ │
├─────────────────────────────────────────────┤
│ │
│ Data table or activity feed │
│ │
└─────────────────────────────────────────────┘
```
**When to use:** Overview/summary pages where the user needs to assess the state of a system at a glance. Examples: project overview, monitoring dashboard, analytics overview.
**When NOT to use:** As a landing page before the user can reach their actual task. Dashboards should be opt-in destinations, not mandatory pass-throughs.
**Key rules:**
- Time range selector at the top-right. All data on the page respects this filter.
- KPI cards in the top row: 3–4 maximum. Each card shows one number, one label, and optionally one trend indicator. KPI cards are Level 2.
- Charts occupy the middle band. Limit to 2–4 charts per dashboard. Each chart has a clear title.
- Data table or feed at the bottom for drill-down detail.
- Vertical layout order: summary (KPI) → trend (charts) → detail (table). Matches investigation flow.
- **No decorative element may appear above the KPI cards.** The first visible element below the page title must be functional content.
---
### Pattern: Settings / Configuration
```
┌────────────┬────────────────────────────────┐
│ Settings │ Section Title │
│ │ Description of this section │
│ ▸ General │ ┌────────────────────────────┐ │
│ Account │ │ Field label [input] │ │
│ Billing │ │ Field label [dropdown] │ │
│ Security │ │ Field label [toggle] │ │
│ ▸ Advanced │ │ [Save] │ │
│ API Keys │ └────────────────────────────┘ │
│ Webhooks │ │
│ │ ─────────────────────────────── │
│ ⚠ Danger │ Danger Zone │
│ │ [Delete project] │
└────────────┴────────────────────────────────┘
```
**When to use:** Any page where the user configures settings, preferences, or administrative controls.
**Key rules:**
- Left sidebar navigation lists all settings sections. Page-per-section (not single scroll page) for URL addressability.
- Each section has a title and a short description (1 sentence).
- Fields grouped in a card container. Label left, input right (horizontal) on wide screens. Label above input (vertical) on narrow.
- Save button at the bottom of each card, scoped to that section. Disabled until a field changes.
- Destructive settings in a visually distinct "Danger zone" section at the bottom.
- Toggle switches for binary. Dropdowns for enumerated. Text inputs for freeform.
- For auto-save patterns (user preferences only, never production config): apply on change, show "Saved" confirmation text that fades after 2 seconds.
---
### Pattern: Empty State
```
┌─────────────────────────────────────────────┐
│ │
│ [Illustration/Icon] │
│ │
│ No [entities] yet │
│ [1–2 sentence explanation of what this │
│ feature does and why they'd use it] │
│ │
│ [Create first entity] │
│ │
│ Learn more → (optional) │
│ │
└─────────────────────────────────────────────┘
```
**When to use:** Any view that can have zero items: first-time feature entry, search with no results, filtered view with no matches.
**Key rules:**
- Centered vertically and horizontally in the content area.
- Icon or illustration at top — functional, not decorative.
- Title uses active voice: "No deployments yet" not "This area is empty."
- Description explains the value: "Deploy your first project to see deployment history here."
- Primary action button: "Create [entity]" or "Get started."
- For search/filter zero results: keep the table/list chrome visible. Show inline message: "No results match your search." Do NOT show the feature-introduction empty state.
- For no-access states: "You don't have access to [resource]. Contact your admin to request access." No create CTA.
---
### Pattern: Multi-Step Wizard
```
┌─────────────────────────────────────────────┐
│ Step 1 ──●── Step 2 ──○── Step 3 ──○── Done│
├─────────────────────────────────────────────┤
│ │
│ Step Title │
│ Step description │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Field label [input] │ │
│ │ Field label [dropdown] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [← Back] [Continue →] │
└─────────────────────────────────────────────┘
```
**When to use:** Creation or setup flows with 3–5 distinct stages where each stage's inputs depend on or are contextualized by the previous stage. Examples: new project setup, onboarding, complex resource provisioning.
**When NOT to use:** Forms that can be filled in any order (use Settings pattern). Flows with only 1–2 steps (use a single form with sections).
**Key rules:**
- Horizontal stepper at the top: step labels, current step highlighted, completed steps with checkmark. Maximum 5 steps.
- Each step is a distinct screen (not a single long page).
- "Back" button (left) and "Continue" button (right). Allow clicking completed step labels to go back.
- The final step shows a summary of all entered values before "Create" / "Deploy" / "Submit."
- Validate on "Continue" click, not on field blur. Prevent advancing until the current step is valid.
---
### Pattern: Comparison View
```
┌─────────────────────────────────────────────┐
│ Compare: [Entity A ▾] vs [Entity B ▾] │
├──────────────────────┬──────────────────────┤
│ Entity A │ Entity B │
│ │ │
│ Attribute: value │ Attribute: value* │
│ Attribute: value │ Attribute: value │
│ Attribute: value* │ Attribute: — │
│ │ │
│ [Show differences only] │
└──────────────────────┴──────────────────────┘
```
**When to use:** Comparing two entities, two configurations, two time periods, or two versions side-by-side.
**Key rules:**
- Entity selectors at the top of each pane.
- Differences highlighted: changed values in amber background, added in green, removed in red.
- "Show differences only" toggle hides identical rows.
- For time-period comparisons on charts: overlay on same chart (solid = current, dashed = comparison) rather than side-by-side.
---
## 3. Data Density Rules
### Choosing the Right Data Surface
| Surface | Use When | Don't Use When |
|---------|----------|---------------|
| **Table** | Items have 4+ comparable attributes. Users sort, filter, compare across rows. Data is quantitative. | Items are title-primary (use List). Items are visual (use Card Grid). |
| **List** | Items are title-primary with 1–3 secondary attributes. Users scan to select one. | Users compare values across rows. Data has many numeric columns. |
| **Card Grid** | Items are primarily visual (screenshots, images). Items need more than one line. | Items are text-only. Cross-item comparison is important. |
| **Feed / Timeline** | Chronological events. Time is the organizing principle. Variable content length. | Users filter/sort by non-time attributes. |
### Table Design Rules
**Columns:**
- Display 4–7 columns by default. Column visibility toggle for additional columns.
- First column: entity identifier (name, title, ID).
- Second column: status (if applicable).
- Numeric columns: right-aligned. Text columns: left-aligned.
- Column headers: sentence case, 12–13px, font-weight 500, muted color.
- Every column sortable unless non-sortable content (action buttons).
**Rows:**
- Standard row height: 44–48px. Compact: 32–36px. Never below 32px.
- No zebra striping. Use subtle border-bottom (1px, border-secondary) between rows.
- Selected row: background-accent at 8–12% opacity. Do not change text color.
- Hover row: background-muted. Show three-dot action menu if not always visible.
**Bulk selection:**
- Checkbox as the first column.
- "Select all" selects visible rows on current page.
- After selecting all visible: show banner "All 25 items on this page selected. [Select all 1,243 items matching this filter]."
- Bulk action bar appears fixed above the table: selected count + action buttons + "Deselect all."
- Destructive bulk actions require confirmation modal with exact count.
**Truncation and formatting:**
- Truncate text with ellipsis. Full text in tooltip on hover.
- Numbers: locale formatting (1,234). Tabular/monospaced figures for vertical alignment.
- Dates: relative for <7 days ("3m ago"), absolute for >7 days ("Jan 15, 2026"). Absolute in tooltip always.
- UUIDs/hashes: truncate to 8 chars. Copy-on-click. Full in tooltip.
**Pagination:**
- Default 25–50 rows per page.
- Show: "Showing 1–25 of 1,243."
- "Load more" or infinite scroll for lists. Page numbers for server-side paginated tables.
### Density Modes
| Mode | Row Height | Font Size | Use Case |
|------|-----------|-----------|----------|
| **Comfortable** | 48px | 14px | Default. General use. |
| **Standard** | 40px | 13px | Power users in tables daily. |
| **Compact** | 32px | 12px | Log viewers, monitoring, event streams. |
Global preference, persists across sessions.
---
## 4. Panel & Section Usage
### When Something Earns Its Own Panel
A card/panel is appropriate when:
- Content inside it is independently actionable (own Save button, expand/collapse, actions).
- Content represents a distinct data entity or grouping.
- Content needs visual separation from adjacent content of a different type.
A card is NOT appropriate when:
- You're just adding visual texture. Whitespace and headers suffice.
- Every list item gets its own card ("card soup"). Use table/list instead.
- Content is a single label-value pair. Use inline text.
**RULE: A card is earned, not default.** If you find yourself putting 5+ cards on a single page, you are over-carding. Lists and tables do NOT need per-item cards.
### Modal vs Drawer vs Inline Expand vs New Page
| Criteria | Modal | Drawer (Right Panel) | Inline Expand | New Page |
|----------|-------|---------------------|---------------|----------|
| **Content size** | Small (1–5 fields, confirmation) | Medium (form, detail summary) | Tiny (1–2 fields, snippet) | Large (full detail, complex) |
| **User needs list context** | No | Sometimes | Yes | No |
| **URL addressable** | No | No | No | Yes |
| **Can interact with background?** | No | Configurable | Yes | N/A |
**PROHIBITION: A modal may ONLY be used for: (a) creating a new resource with ≤5 fields, (b) confirming a destructive action, or (c) displaying one-shot information (API key, share link).** Any other use of a modal is wrong. If tempted to open a modal for a detail view, edit form with >5 fields, or settings panel — use a page, drawer, or split pane instead. Count the fields. If >5, it's not a modal.
**Decision flow:**
1. Does this content need its own URL? → **New Page.**
2. Is this a simple creation form (≤5 fields) or destructive confirmation? → **Modal.**
3. Does the user need to reference the list while viewing this? → **Drawer** (medium) or **Inline Expand** (tiny).
4. Is this a full entity detail with multiple sections? → **New Page.**
### Form Grouping Rules
- Group related fields under a section heading. 3–7 fields per group.
- Each group separated by whitespace (24–32px) or contained in a card.
- Labels: left of field on ≥768px, above field on <768px.
- Labels always visible — never placeholder-only.
- Required fields are default. Mark optional with "(optional)" after label. No asterisks.
- Save actions scoped to nearest form group, bottom-right. Disabled until dirty.
- Destructive form actions visually distinct and separated from Save.
### Long Form Rules (20+ Fields)
- Use multi-section page with sticky left sidebar showing section anchors.
- Progress indicator: "Section 3 of 7" or visual stepper.
- Auto-save drafts. Show "Draft saved" timestamp.
- Validate per-section on navigation (not just final submit). Mark error sections in sidebar with red indicator.
- For frequently-filled forms: offer templates or "Fill from previous."
### Progressive Disclosure
Use when:
- Form has ≥10 fields but only 3–5 needed for common case. "Advanced options" toggle.
- Settings section has low-frequency options. "Show advanced" expansion.
- Data row has additional detail. Expand on click (accordion).
Don't use when:
- All fields equally important.
- Hiding fields makes the form misleading.
- User needs all options visible simultaneously.
---
## 5. Communication Patterns
### Status Communication Spectrum
| Level | Meaning | Visual Treatment | Example |
|-------|---------|-----------------|---------|
| **Neutral** | Informational, no action | Grey icon + grey text | "Draft", "Queued", "Pending" |
| **Active** | In progress, working | Blue icon (animated if live) + text | "Building", "Processing", "Running" |
| **Success** | Completed successfully | Green icon + text | "Deployed", "Delivered", "Passed" |
| **Warning** | Attention needed, not failing | Yellow/amber icon + text | "Degraded", "Expiring", "Slow" |
| **Error** | Failed, action required | Red icon + text | "Failed", "Bounced", "Error" |
| **Disabled** | Intentionally off | Grey icon + muted/strikethrough text | "Disabled", "Archived", "Muted" |
**Rules:**
- ALWAYS pair color with another indicator: icon shape, text label, or both. Never color alone.
- Status text: sentence case. "In progress" not "IN PROGRESS."
- Status in table row: second column (after entity name) or first visual element (icon before title).
- Actionable status: "Failed — [Retry]" or "Expiring — [Renew]" — surface the action inline.
### Loading State Patterns
| Pattern | Use When | Implementation |
|---------|----------|----------------|
| **Skeleton screen** | Page/section load. Layout known. | Grey placeholder blocks matching final layout. Shimmer animation. Show after 150ms delay; minimum 300ms display. |
| **Inline spinner** | Specific element loading (button, field, save). | Small spinner (16–20px) inline, or button label: "Saving…" |
| **Progress bar** | Known progress (upload, build). | Horizontal bar, percentage, estimated time if calculable. |
| **Table skeleton** | Table data loading. | Table header + 5–8 skeleton rows matching expected content widths. |
**Rules:**
- Never full-screen spinner with no context. Always say what is loading.
- >5 seconds: add text label ("Loading deployments…").
- >15 seconds: add cancel/retry option ("This is taking longer than usual. [Cancel] [Retry]").
- For frequent views: cache previous data, show immediately with subtle "Updating…" indicator.
### Error State Rules
Every error surface communicates three things:
1. **What happened** — plain language, not error codes. "The deployment failed" not "Error 502."
2. **Why** (if known) — "The build script exited with a non-zero code."
3. **What to do** — clear next action with link/button. If nothing can be done: "This issue has been reported."
**Error placement:**
- Form field: inline below field. Red text + red border. Clears on correction.
- Page-level: banner at top of content area. Destructive background at low opacity, error icon, message, dismiss.
- Toast: for async failures. Bottom-right. Persists until dismissed if action required.
- Table row: status indicator with inline action link.
### Empty vs Zero vs No-Access
| State | Meaning | UI Pattern |
|-------|---------|------------|
| **Empty (first time)** | Feature never used | Centered illustration + value prop + CTA |
| **Zero results** | Data exists, filter matches nothing | Keep table chrome. "No results match your filters." |
| **No access** | User lacks permission | "You don't have access. Contact your admin." No create CTA. |
| **Deleted/archived** | Entity was removed | "This [entity] has been deleted." Recovery action if available. "[Go back]". |
### Toast / Notification Rules
**Use toast when:** Async operation succeeds/fails. Info is transient. No immediate action required.
**Don't use toast when:** Action is critical (use banner). Multiple toasts stack (batch the info). Form submission (use inline states).
**Specs:**
- Position: bottom-right.
- Width: 320–400px.
- Duration: success 5s auto-dismiss. Error persists until dismissed. Info 5s auto-dismiss.
- Max simultaneous: 3. 4th displaces oldest.
- Contents: icon + message (≤80 chars) + dismiss (×).
### Real-Time Data Rules
- **Auto-updating:** Show "Live" indicator (green dot + "Live" text) in section header.
- **Scroll pause:** If user scrolls up in live feed, pause auto-scroll. Show "New events below ↓" pill.
- **Data freshness:** For interval-refresh data: "Last updated: 30s ago" + manual refresh button.
- **Edit conflict:** "This value was updated by another user. [Use their value] [Keep my edit]."
- **New row insertion:** Fade-in animation (300ms). Do not re-sort while user is reading — defer to next manual action.
### Notification Center
- Bell icon, top-right header. Unread count as number badge (not just a dot).
- Click opens dropdown panel (not full page) with 10 most recent notifications.
- Each notification: type icon, summary (≤100 chars), relative timestamp, unread indicator.
- "Mark all as read" at top. "View all" link at bottom → full page with filtering.
- Click notification → navigate to entity + mark read.
---
## 6. Navigation & Wayfinding
### What Earns a Sidebar Entry
Top-level when:
- Distinct data type or workflow (Issues, Deployments, Users, Logs).
- Accessed in ≥30% of sessions.
- Cannot logically nest under another item.
Nested (second level) when:
- Sub-category of a top-level feature.
- Accessed only in context of parent.
Not in sidebar when:
- It's a setting (put in Settings).
- Accessed only through entity context (put on entity's detail page).
- One-time setup (put in onboarding).
### Sidebar Depth Rules
- **Maximum visible depth: 2 levels.** Top-level items and one level of nested items.
- **Never 3 levels in sidebar.** Third level becomes tabs or secondary nav within content area.
- **Collapsed by default.** Only active section's children expanded.
- **Maximum items: 12–18 top-level.** Beyond 18, use collapsible section headers or flyout patterns.
### Breadcrumb Rules
**Show when:** Page is ≥2 levels deep. Entity detail page. NOT on top-level sidebar pages.
**Format:** Separator ` / ` or ` › `. Each segment clickable except current (plain text). Truncate long segments with ellipsis. Position: top-left, above page title. 12–13px, muted.
### Tab Rules
**Use when:** Different views of same entity. Dividing large settings page. Dashboard sections.
**Don't use when:** Navigating between different entities (use sidebar). >7 tabs (use sidebar/dropdown). Tabs containing sub-tabs (no nesting).
**Conventions:**
- Horizontal row below page title, above content.
- Active: bold + bottom border (2–3px, accent color). Inactive: regular weight, no border.
- Labels: 1–2 words, sentence case. No icons in nav tabs.
- No close buttons on navigation tabs (only on user-created editor tabs).
### Multi-Tenant & Impersonation
- Tenant context visible on every row when admin views aggregate data (column, group header, or badge).
- Impersonation mode: persistent banner at viewport top. "Viewing as [Tenant Name]" + "Exit" button. Amber background. Persists across all navigation, cannot scroll away.
---
## 7. Grid & Alignment Discipline
### The Core Alignment Rule
**Every element aligns to shared vertical axes.** Left edge of page title → left edge of section headings, form labels, list item titles, table first column — all touch the same line. Right edge of primary action → right edge of Save buttons, timestamps, metadata values — all right-align to the same line.
Misalignment is detectable at 1–2px. Alignment must be exact.
### Specific Alignment Axioms
1. **Left edges form a column.** Page title, section headings, form labels, list item titles — same left margin (24–32px from content area edge).
2. **Right edges form a column.** Action buttons, timestamps, metadata values, save buttons — same right margin.
3. **Baseline alignment within rows.** All text in a row shares the same baseline. Icons vertically center with accompanying text baseline.
4. **Consistent inner padding.** Cards and panels use same inner padding everywhere (16px or 24px). Do not vary.
5. **Spacing communicates grouping.** Related items: 8–12px. Sections: 24–32px. Section gap ≥ 2× item gap.
### Spacing Scale
**CONSTRAINT: All spacing values must come from this scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64px. No other values permitted.**
| Relationship | Gap | Example |
|-------------|-----|---------|
| **Tightly related** | 4–8px | Label + input, icon + text |
| **Related siblings** | 8–12px | List rows, form fields in a group |
| **Section header to content** | 16px | Heading and its card |
| **Between sections** | 24–32px | "General" and "Security" groups |
| **Major page divisions** | 32–48px | Title area and content area |
### Column Alignment in Mixed Layouts
- Form labels and table headers in different layout contexts don't need to align to each other.
- But within each context, all elements align to their own internal grid.
- Borders between contexts (sidebar | main) are clean vertical lines with consistent gutter (16–24px).
---
## 8. Agent Decision Guide
### MANDATORY: Propose Before Building
**Before writing any code for a new screen or feature, the agent MUST present a build plan for approval.** Do not jump to implementation. The canonical build plan format is defined in **DESIGN.md §10.2** — use that format. In addition to the standard fields, cite specific products from the Product Evidence Reference (Section 9 of this document) as precedent for layout and component choices.
**Rules for proposals:**
- Every component choice must cite at least one product from the research as precedent. "Vercel uses this for deployments" or "Linear and GitHub both use headerless list rows for title-primary entities."
- If the agent cannot find a precedent in the research, it must flag the choice as novel and explain why no standard pattern fits.
- The user may approve, request changes, or reject the proposal. The agent does not write code until the plan is approved.
- For trivial changes (adding a field, changing a label), a proposal is not required. Proposals are required for: new pages, new major components, layout changes, and navigation changes.
- If the brief is underspecified, ask the clarification questions listed in DESIGN.md §10.1 before proposing.
---
### Step 1: Identify Page Purpose → Determine Level 1
| If the purpose is... | Level 1 is... | Layout pattern is... |
|----------------------|---------------|---------------------|
| Browse a list of entities | The list/table | **List View** or **Table View** |
| View details of one entity | Entity title + primary content | **Detail View** |
| Scan a list and inspect items | List + inspector split | **List + Inspector** |
| Assess system state | KPI/chart area | **Dashboard** |
| Configure settings | Form fields | **Settings / Config** |
| Start from scratch | CTA to create | **Empty State** |
| Multi-stage creation | Current step's form | **Wizard** |
| Compare two things | Side-by-side panes | **Comparison View** |
### Step 2: Choose Layout Pattern
Use the table above. If uncertain, choose the pattern requiring fewer clicks for the most common task.
### Step 3: Choose Data Surface
| Question | If yes → | If no → next |
|----------|---------|------|
| 5+ comparable data columns? | **Table** | ↓ |
| Title-primary + 1–3 attributes? | **List** | ↓ |
| Primarily visual (images)? | **Card Grid** | ↓ |
| Chronological events? | **Feed / Timeline** | **List** (default) |
### Step 4: Define All States (BLOCKING REQUIREMENT)
**Before a screen design is considered complete, you must explicitly specify the UI for ALL of these states. If any would show a blank screen or unhandled error, the design is incomplete.**
- [ ] **Loading** — skeleton, spinner, or cached data?
- [ ] **Empty (first time)** — illustration + CTA
- [ ] **Empty (filtered)** — inline message, keep chrome
- [ ] **Error** — banner with what happened + what to do
- [ ] **Partial data** — show loaded parts, error indicator on failed
- [ ] **No access** — message + contact admin
- [ ] **Success** — toast, inline confirmation, or redirect
### Step 5: Verify Hierarchy Test
1. Squint test: can you identify Level 1 at 50% zoom? If not → Level 1 not prominent enough.
2. Are two elements competing? → demote one to Level 2.
3. Is any metadata same size/weight as title? → shrink it.
4. Do all rows have same visual weight? → differentiate primary element.
### Step 6: Check Alignment
1. Vertical line from left edge of page title — do all headings/labels/titles touch it?
2. Vertical line from right edge of action button — do all timestamps/metadata right-align to it?
3. Between-section spacing: consistent 24–32px? Within-section: 8–12px?
4. No element offset by 1–2px from neighbors. Pixel-exact.
### Step 7: Permission & Access Check
1. Are there fields/sections some users can't edit? → Show read-only (greyed, tooltip).
2. Are there sections some users can't see? → Omit entirely. Don't show locked placeholders.
3. Are there actions some users can't perform? → Hide button (preferred) or disable with tooltip.
---
## 9. Product Evidence Reference
Every pattern in this doctrine is grounded in observed behavior from real products. When proposing or justifying a design decision, cite the relevant products below.
### Layout & Navigation
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **Left sidebar as primary nav** | Linear, Vercel, Supabase, Retool, Datadog, Neon, Clerk, Cursor, Resend | 9/11 — strong convergence |
| **Top horizontal tab bar (no sidebar)** | GitHub (repo-level tabs) | 1/11 — context-specific (repo as unit) |
| **Search-first / no persistent nav** | Raycast (floating overlay) | 1/11 — context-specific (launcher tool) |
| **Sidebar with hover flyouts for 2nd level** | Datadog | Appropriate only when product has 20+ distinct features |
| **Sidebar with in-place expand for 2nd level** | Linear, Supabase, Vercel, Clerk | Default choice for ≤15 sidebar items |
| **Command palette (⌘K / quick-jump)** | Linear, Vercel, Supabase, GitHub, Retool, Raycast, Cursor | 7/11 — expected in developer tools |
| **Persistent env/context switcher in header** | Clerk (dev/prod toggle), Retool (staging/prod), Vercel (preview/production) | Required for multi-environment products |
### Detail Views & Overlays
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **Full page for entity detail** | All 11 products | 11/11 — universal. No product uses modal for primary detail. |
| **Split pane / master-detail** | Linear (split view), Retool (Split Pane frame), GitHub (PR Files Changed) | Use when list context needed during inspection |
| **Right-side drawer for supplementary detail** | Resend (bounce/delivery details) | Use for transient, supplementary info only |
| **Peek / quicklook overlay** | Linear (Space key) | Unique to Linear. Consider for high-volume triage workflows. |
| **Modals only for creation + confirmation** | All 11 products | 11/11 — universal |
### Data Display
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **Table with column headers** | Supabase, Retool, Datadog, Neon, Clerk | For structured, multi-column, comparable data |
| **Headerless list rows (title-primary)** | Linear, GitHub, Resend, Vercel | For entity lists where title is dominant |
| **Three-dot menu always visible** | Linear, GitHub, Resend, Clerk, Neon | 5/11 — preferred for accessibility and discoverability |
| **Action buttons on hover only** | Retool, Datadog | 2/11 — only in builder/canvas tools where resting density matters |
| **Bulk selection with checkbox column** | Supabase, Datadog, GitHub | Required for enterprise data tables |
| **Faceted filtering (panel + search)** | Datadog, GitHub, Supabase | Required when datasets exceed hundreds of items |
| **Tiled multi-panel view** | Cursor (Agents Window) | Novel pattern for parallel AI workstream coordination |
### Status Communication
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **Icon shape + color (distinct shape per state)** | Linear, GitHub | Strongest accessibility pattern — each state has unique shape |
| **Color dot + text label** | Vercel (Status Dot), Resend (API keys) | Acceptable when paired with text |
| **Text labels as primary status** | Datadog, Supabase, Neon | Works for dashboards where text scanning is primary |
| **Status in browser favicon** | Vercel | Unique. Consider for deploy/build monitoring tools. |
| **14-state granular status model** | Resend | Most granular observed. Model for products with complex delivery pipelines. |
### Settings Patterns
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **Page-per-section with left sidebar** | Vercel, GitHub, Clerk | Recommended for 8+ settings sections |
| **Single scrollable page with anchors** | Neon | Acceptable for ≤5 sections |
| **Auto-save (no save button)** | Linear, Cursor, Raycast | For personal preferences only, never production config |
| **Scoped save per section** | Supabase, Neon, GitHub | Default for settings that affect shared state |
| **Danger zone at bottom** | GitHub (originated it), Neon, Clerk, Supabase, Vercel | 5/11 — industry standard for destructive settings |
### Empty & Loading States
| Pattern | Products Using It | Evidence Strength |
|---|---|---|
| **5 distinct empty state types** | Supabase | Most thorough system observed. Model for completeness. |
| **Blankslate component system** | GitHub (Primer) | Well-documented reusable pattern |
| **Instructional empty state (pre-filled content)** | Neon (SQL editor with sample query) | Effective for developer tools where first action is non-obvious |
| **Skeleton + timing rules (150ms delay, 300ms min)** | Vercel | Most specific loading implementation observed |
| **AI-assisted error recovery** | Supabase ("Fix with Assistant") | Emerging pattern — contextual AI repair actions |
### Standout Unique Decisions (Worth Studying)
| Product | Unique Pattern | When to Reference |
|---|---|---|
| **Linear** | Peek (Space key quicklook) — preview without nav, dismissed on release | High-volume triage workflows |
| **Vercel** | Favicon as status indicator — scan state from tab bar | Build/deploy monitoring |
| **Resend** | 48-hour shareable preview links for non-account stakeholders | Collaborative review flows |
| **Supabase** | Git-style queued table ops with diff view before commit (⌘S) | Any editable data grid |
| **Retool** | Split Pane as first-class layout frame | Master-detail without overlay |
| **Datadog** | Monitor Status page with deployment correlation + cross-product context preservation | Investigation/triage pages |
| **GitHub** | PR Files Changed: resizable file tree + per-file indicators + pending review state | Code review and diff workflows |
| **Neon** | Branch creation dialog with point-in-time options + auto-deletion schedule | Resource provisioning with lifecycle |
| **Clerk** | Always-visible dev/prod toggle that re-scopes entire dashboard | Multi-environment dashboards |
| **Raycast** | Context-scoped Action Panel (⌘K for selected item, not global) | Context-sensitive command surfaces |
| **Cursor** | Tiled agent management — multiple AI conversations in resizable grid | Parallel workstream coordination |
---
## 10. Anti-Pattern Table
| # | Anti-Pattern | What Goes Wrong | Correct Alternative |
|---|-------------|----------------|-------------------|
| 1 | **Card soup** — every list item in its own card | Wall of boxes, no entry point, impossible comparison | Flat list or table. Cards only for independent, actionable entities. |
| 2 | **Modal for detail view** | Blocks list, not URL-addressable, content overflows | Full page navigation. Split pane if list context needed. |
| 3 | **Equal-weight everything** | Cannot scan, nothing communicates priority | Three-level hierarchy. Vary size, weight, contrast. |
| 4 | **Color-only status** | Fails colorblind users, ambiguous without label | Icon + text label + color. Distinct shape per state. |
| 5 | **Giant hero above data** | Forces scroll past decoration to reach task | Put functional content (table, chart) immediately below title. Illustrations only in empty states. |
| 6 | **Nested modals** | Disorienting, accessibility nightmare | Single modal. Complex flows get full page. |
| 7 | **Placeholder-only labels** | Label disappears on focus, fails accessibility | Always visible label. Placeholder is supplementary hint. |
| 8 | **Invisible empty state** | User thinks page is broken, no path forward | Icon + explanation + CTA. Tell user what page is for. |
| 9 | **Hamburger on desktop** | Features undiscoverable, navigation drops | Persistent sidebar on desktop. Hamburger only <768px. |
| 10 | **Over-collapsed accordions** | Critical info hidden, whack-a-mole clicking | Primary content visible by default. Collapse only secondary. |
| 11 | **Inconsistent action placement** | User can't predict where to look | Primary: top-right header. Save: bottom-right form. Destructive: bottom danger zone. |
| 12 | **Raw error codes** | Meaningless, no resolution path | Human-readable: what happened, why, what to do. |
| 13 | **Full-page spinner** | No context, feels broken after 3s | Skeleton screens. Text label after 5s. Cancel/retry after 15s. |
| 14 | **Tabs as navigation** | Semantically wrong for unrelated sections | Tabs for views of same entity. Sidebar for product sections. |
| 15 | **Floating action button (FAB)** | Covers content, overlaps toasts, desktop-inappropriate | Action button in page header, top-right. |
| 16 | **Unpaginated 1000+ row table** | Sluggish, scroll jank, memory issues | Paginate at 25–50 rows or virtual scroll. Show total count. |
| 17 | **Confirmation for non-destructive actions** | Friction on routine tasks, devalues real confirmations | Confirm only destructive/irreversible. Routine saves without confirmation. |
| 18 | **Sidebar with 25+ ungrouped items** | Wall of text, nothing findable | Collapsible section headers. 12–18 visible. User-customizable. |
| 19 | **Mixed alignment in a column** | Breaks invisible grid, can't scan vertically | One alignment per column: left for text, right for numbers. |
| 20 | **Toast for form validation** | User forgets which fields wrong, toast disappears | Inline errors: red border + message below field. Scroll to first error. |
---
## 11. Enterprise Patterns Addendum
### Permission-Gated UI
- Read-only fields: visible but disabled (greyed, no focus). Tooltip: "You don't have permission to edit."
- Hidden sections: omit entirely. Don't show locked icons for every gated section.
- Gated actions: hide button (preferred) or disable with tooltip. Never allow click → permission error.
### Audit Log Views
- Use table (not feed): Timestamp (absolute), Actor (name + email), Action (verb), Resource (type + ID), Details (expandable).
- Filter by: time range, actor, action type, resource type — independently.
- Read-only. No edit/delete on rows.
- CSV/JSON export of filtered view.
### Activity Log / Investigation Pages
Structure in investigation order — each section answers the next question:
1. **Header:** What is the current state? (status indicator, entity name, key metadata)
2. **Graphs/Charts:** What does the data look like over time?
3. **Timeline:** When did things change? (event markers, deployment correlations)
4. **Details:** What specifically happened? (logs, diffs, error messages)
5. **Actions:** What do I do now? (runbook links, "Create incident", "Retry", escalation)
---
## 12. Common AI Agent Mistakes
These are the most frequent and damaging errors AI agents make when generating enterprise UIs. Each is an explicit prohibition.
1. **Do not give equal visual weight to all elements.** Every screen must have exactly one Level 1 element that is visually dominant. If you cannot point to it, the design is wrong.
2. **Do not use modals for anything other than creation (≤5 fields) or destructive confirmation.** Detail views, edit forms, settings panels, and help content are never modals. Count the fields. If >5, it's not a modal.
3. **Do not skip empty, loading, and error states.** A screen that only handles the happy path is incomplete. Before the design is done, you must specify what appears during loading, when data is empty, and when an error occurs.
4. **Do not use arbitrary spacing values.** All spacing must come from the scale: 4, 8, 12, 16, 20, 24, 32, 40, 48, 64px. If a value is not on this list, it's wrong.
5. **Do not wrap every item in a card.** Cards are for independently actionable content. Lists and tables render items as rows, not cards. If a page has 5+ cards, you are over-carding.
6. **Do not place decorative elements above functional content.** No hero images, gradient banners, or large illustrations above the data table, form, or primary content area. Illustrations belong in empty states only.
7. **Do not invent novel UI patterns when standard ones exist.** If shadcn has a component, use it. If this doctrine defines a pattern, use it. Novel patterns require explicit justification for why standard patterns are insufficient.
8. **Do not forget focus states and accessibility.** Every interactive element needs a visible focus state. Every icon needs alt text or aria-label. Color contrast must meet WCAG AA (4.5:1 normal text, 3:1 large text/UI). These are not negotiable.
9. **Do not use color alone to communicate status.** Every status indicator uses at minimum two channels: color AND (icon shape OR text label). Eight percent of males are colorblind.
10. **Do not ignore keyboard navigation.** Tab order must be logical. Interactive elements must be keyboard-reachable. Modals must trap focus. Escape must close overlays. Enterprise users work fast — keyboard access is not optional.
11. **Do not place the primary action button in inconsistent positions.** It is always top-right of the content header. Save/Submit is always bottom-right of the form group. Destructive actions are always at the bottom in a danger zone. Every page. No exceptions.
12. **Do not show a full-page spinner without context.** Use skeleton screens. After 5 seconds, add a text label. After 15 seconds, add cancel/retry. A spinner on a blank white screen communicates nothing.
13. **Do not use placeholder text as the only label for form inputs.** Labels must be visible at all times — above or beside the field. Placeholder text is optional supplementary hint, never the label.
14. **Do not generate forms with 15+ visible fields and no grouping.** Group into sections of 3–7 fields. Use progressive disclosure for advanced options. Long forms need section anchors in a sidebar.
15. **Do not build tables without pagination or virtual scrolling.** Any table that can exceed 50 rows must paginate or virtualize. Show total count. Default page size: 25–50 rows.
---
## Known Gaps & Caveats
This document is grounded in observed patterns from 11 products as of early 2026. Known limitations:
1. **Offline/degraded state behavior** is not covered. None of the researched products handle this well. If your product needs offline capability, this requires separate research.
2. **Mobile-responsive patterns** are minimally addressed. Most enterprise developer tools are desktop-primary. If your product needs full mobile support, extend the layout patterns with responsive breakpoints.
3. **Internationalization / RTL** patterns are not covered. Enterprise tools serving global audiences need bidirectional layout support, variable text length handling, and locale-aware formatting.
4. **Complex data visualization** (charts, graphs, dashboards with 10+ widgets) needs its own sub-document. This doctrine covers layout structure for dashboards but not the internals of chart design.
5. **Accessibility beyond WCAG AA** — this document specifies AA compliance as baseline. Products targeting government or enterprise contracts may need AAA compliance, which requires additional contrast and interaction work.
6. **The shadcn component library** provides excellent primitives but some doctrine rules (e.g., specific row heights, density modes) require custom configuration of shadcn defaults. Component-level override guidance is not provided here.
7. **Cross-entity comparison** is identified as an industry-wide gap. The Comparison View pattern provided here is a starting point but has not been validated against products that implement it at scale.
---
## Start Here
The five highest-leverage actions for implementing this system:
1. **Establish the spacing scale and hierarchy tokens first.** Define your 4–64px spacing scale as design tokens. Define your three text hierarchy levels (L1/L2/L3) as token sets (size + weight + color). These two decisions propagate to every component and screen.
2. **Build the List View and Detail View patterns as reusable page templates.** These two patterns cover ~60% of screens in an enterprise developer tool. Build them once with correct hierarchy, spacing, and state handling (loading, empty, error).
3. **Design all five empty state variants before building any feature.** First-time empty, filtered-to-zero, error, no-access, and deleted/archived. Create reusable components for each. This prevents blank-screen shipping.
4. **Implement the Settings pattern as a shared layout.** Left sidebar + page-per-section + scoped save buttons. Most enterprise tools have 10+ settings pages — a shared layout prevents inconsistency.
5. **Create an agent-facing checklist component or linting rule.** Encode the Agent Decision Guide (Section 8) as something that can be checked programmatically: does the page have a Level 1? Are all states defined? Does spacing use the allowed scale? This converts doctrine into enforceable standards.
---
# FIGMA_SYNC.md
# FIGMA_SYNC.md — Updating from Figma
The repo is downstream of Figma. There are **two flavors** of sync: the **bulk token sync** (preferred — uses the DTCG export) and the **node-level sync** (for ad-hoc inspection / individual component pulls via the Figma MCP).
---
## A. Bulk token sync (preferred)
The repo's `/design-tokens/` directory is the canonical token export. Replace it whenever the design system's variables/styles change in Figma.
### Steps
1. In Figma, open the design system file → run the **Tokens plugin** (the one that exports DTCG-format with a `manifest.json` + per-mode files).
2. Export to a folder. **Replace** `/design-tokens/` in this repo with that folder's contents.
3. Run:
```bash
pnpm tokens:build
```
4. Inspect the diff:
```bash
git diff packages/tokens/dist packages/tokens/src/_generated.ts
```
5. If everything looks right, commit. If a token was renamed and breaks usage, search/replace across `apps/` and `packages/react/`.
### Prompt template (for AI agent)
> Sync tokens. I just re-exported `/design-tokens/`. Run `pnpm tokens:build`, summarize the diff, flag any breaking renames in component code, and propose fixes.
The agent will then:
1. Run the build and show what changed in `dist/tokens.css`
2. Grep for any token names that disappeared (likely renamed)
3. Update component code if needed
4. Append a `CHANGELOG.md` entry
---
## B. Node-level sync (Figma MCP)
For pulling a single component or inspecting a specific frame, use the Figma MCP tools directly.
### Prompt template
> Sync from Figma node: ``
> What changed: ``
> Update: ``
### Recipes
**Update a component implementation** (`packages/react/src/components/.tsx`):
1. `mcp__figma__get_design_context(nodeId, fileKey)` — read structure + styles
2. Compare to existing component
3. Patch the `cva` variants — never inline a hex; if a new color is needed, **add it to Figma + re-export** (don't shortcut)
4. Update the MDX page
5. Changelog entry
**Add a new component**:
1. `mcp__figma__get_metadata` on the parent frame to inventory
2. `mcp__figma__get_design_context` on the component node
3. Check `packages/react/src/components/` for any primitive that fits — don't duplicate
4. Generate following `DESIGN.md` §6
5. MDX page + sidebar entry
6. Changelog entry
### Prerequisites
- Figma desktop app open with the relevant frame **selected** (MCP can't read variables/context without a selection)
- URL must include `node-id`
---
## C. Important — do not shortcut
The `/design-tokens/` export is the only legitimate way to add/rename a semantic token. If you encounter an unmet need:
- **Right way**: add it to Figma → re-export → re-run `pnpm tokens:build`
- **Wrong way**: edit `packages/tokens/src/_generated.ts` (gets overwritten)
- **Wrong way**: hardcode a hex in a component (violates `DESIGN.md` §1 forbidden patterns)
The only files in `packages/tokens/src/` you should hand-edit are:
- `gradients.ts` — until the styles-file importer lands
- `shadows.ts` — same
- `index.ts` — to expose new categories
---
## D. Field reference: DTCG path → CSS var
| Figma path | CSS var | Tailwind |
|---|---|---|
| `Semantics.background.base` | `--background` | `bg-background` |
| `Semantics.foreground.base` | `--foreground` | `text-foreground` |
| `Semantics.foreground.muted` | `--foreground-muted` | `text-muted-foreground` |
| `Semantics.button.primary.fill` | `--button-primary` | `bg-primary` |
| `Semantics.button.primary.foreground` | `--button-primary-foreground` | `text-primary-foreground` |
| `Semantics.card.fill` | `--card` | `bg-card` |
| `Semantics.popover.fill` | `--popover` | `bg-popover` |
| `Semantics.border.default` | `--border-default` | `border-border` |
| `Semantics.border.focus` | `--border-focus` | `border-focus` |
| `Semantics.sidebar.*` | `--sidebar-*` | `bg-sidebar`, etc. |
| `Liquid colors.Lilac.Full` | (not a CSS var; access via `liquid` export) | n/a |