This page is generated from DESIGN.md at the repo root. Fetch the raw spec (with YAML token frontmatter) at ui.saturation.io/design.md — designed for AI agents, design tools, and Tailwind/DTCG token export pipelines.
This document is the canonical specification for Saturation's visual identity. It is the source of truth that both humans and AI agents read to make consistent design decisions across the registry (ui.saturation.io), the marketing site (saturation.io), and the Saturation product app.
Saturation is production-grade tooling for film and video production. The design language reflects what production teams need from their software: calm, dense, and unobtrusive. The interface should disappear in favor of the work — schedules, budgets, call sheets, contacts. It is professional, not playful; precise, not decorative.
Voice. Direct, lower-case-friendly, technical without being clinical. Borrowed cues from production-craft tools (Avid, DaVinci, the call-sheet) more than from consumer SaaS.
Density. Dense by default. Production users live in tables and lists with hundreds of rows. Generous whitespace is reserved for marketing surfaces (saturation.io); product surfaces favor information density and predictable rhythms over breathing room.
Color space. Tokens in this file are written as #hex for sRGB compatibility with the @google/design.md tooling. The runtime canonical values live as oklch() in packages/react/src/styles/global.css for perceptually-uniform interpolation and dark-mode parity. When a token value disagrees, global.css wins. The hex values here are computed from oklch via scripts/oklch-to-hex.ts.
Two modes. Light mode tokens are the un-prefixed values above. Dark mode tokens are exposed as dark-* siblings in the same colors: map (dark-primary, dark-foreground, etc.) — the spec doesn't natively support multi-theme, so we use a prefix convention. Both light and dark are auto-synced from packages/react/src/styles/global.css by scripts/sync-design-tokens.ts. Canonical runtime values live in .dark of global.css as oklch.
State variants. Interaction states (hover, focus, active, disabled) are encoded as separate component recipes with name suffixes — button-primary-hover, input-focus, sidebar-item-active, etc. — matching the spec's idiomatic pattern from the Heritage and Totality Festival reference designs. Opacity-based hovers (e.g. bg-primary/75) are precomputed against the background surface and stored as 6-digit hex; the variants therefore look correct on a typical surface but may drift on inverted or colored surfaces.
The palette is built around a single high-saturation blue accent against a neutral zinc grayscale. There is no gratuitous color; chart palettes are the only place we reach beyond the system.
#1B4DC4) — Saturation Blue. The single interactive accent. Used for primary buttons, links, focus rings, and selection states. Never decorative; if it's blue, it's interactive or selected.#09090B) — near-black. Body text and high-contrast UI.#FCFCFC) — off-white. The page floor.#F4F4F5) / Muted-foreground (#71717B) — secondary surfaces and supporting text.#F4F4F5) — soft separators. Same value as muted for a cohesive flat aesthetic.#D4D4D8) — input borders, slightly stronger than border to communicate interactivity.#E7000B) — used only for irreversible destructive actions (delete, remove, force). Not for general errors — use form-level error states for those.#F4F4F5) / Accent-foreground (#3F3F46) — hover states and subtle emphasis.The chart palette is deliberately wide-gamut — purple, blue, orange, pink, gray — to maximize legibility across many concurrent series in budget and schedule visualizations. Never use chart colors for UI chrome.
A semantic badge palette (badge-red, badge-cyan, badge-amber, badge-green, badge-gray, badge-yellow, badge-blue, badge-purple) maps to status concepts in the product (e.g. amber = pending approval, green = confirmed, red = canceled). Badges have explicit dark-mode pairs because their saturation needs adjustment, not just inversion.
Dark mode is not an inversion — it is its own designed palette. Background drops to #09090B, primary brightens to #2158DD to maintain perceived saturation against the dark backdrop, and destructive shifts to #FF6467 for the same reason. See .dark in packages/react/src/styles/global.css.
Fonts are configurable per surface via the font-provider preset (next / gellix / marketing), which scopes --font-sans and --font-display over a subtree. The product app (next-web) renders in the system sans stack (ui-sans-serif, system-ui, sans-serif); it does not load Gellix. Marketing (saturation.io) loads Gellix and applies it to display headings via the marketing preset (system body, Gellix display); the gellix preset puts Gellix on body too. Geist Mono is used for code, numerical data, and timestamps on every surface. Georgia is a serif fallback for marketing prose only. The fontFamily values in the typography: block below name each role's default family in the registry/marketing context; the product overrides the sans roles to the system stack via the next preset.
The type scale is tight: nine primary roles. We do not multiply roles for marketing flourish.
display (3rem / 600) — marketing hero only. Not used in product.h1 (1.875rem / 600) — page title in product; section title in marketing.h2 (1.25rem / 500) — section heading.h3 (1.125rem / 500) — subsection.body (0.9375rem / 400) — default UI text and content body. Tighter than the marketing default.body-lg (1rem / 400) — lead paragraphs and the page-description line under an h1.label (0.875rem / 500) — buttons, form labels, table headers.caption (0.75rem / 500) — metadata, timestamps, status text.cell (0.8125rem / 400): grid and table cell + column header, the dense product workhorse. System sans, not used in marketing.code / code-inline — Geist Mono. Inline code uses 0.8em to read as integrated prose, not a callout.Headlines tighten (-0.025em / -0.02em) for optical density. Body and label text use the default. We never positive-letter-space ALL CAPS — uppercase is reserved for badge text and uses normal spacing.
The product is structured around a persistent left sidebar + main content split. The marketing site is more conventional — full-width sections with content centered.
--header-height: 3.5rem (56px) — top app bar.--sidebar-width: 18rem (288px) — primary navigation.--sidebar-menu-width: 14rem (224px) — secondary nav within a section.--spacing: 0.25rem (4px) — base unit. All Tailwind spacing utilities derive from this.The marketing surface uses .container-wrapper (mx-auto w-full max-w-screen-2xl, 1536px). The product surface does not constrain width — it fills the viewport, since most users dock the app full-width on a single monitor.
Standard Tailwind: sm 640, md 768, lg 1024, xl 1280, 2xl 1536. Mobile is supported for marketing and read-only product views (call sheets); core production tooling targets desktop.
Default row heights aim at ~36px (8 spacing units). Tables and list rows go to 32px (py-2) for high-density data. Avoid going below 28px — touch targets and click zones suffer.
Saturation is a flat system. We use border separation and background color shifts before we reach for shadow.
background (page) → card/popover (elevated content) → accent (hover, selected). Levels are distinguished by a 1-2% lightness step in oklch, not by drop shadow.border color border. This is the primary separation cue.shadow-md / shadow-lg defaults; do not author bespoke shadows.Radius is uniform and small. There are no circles outside of avatars and toggle dots, no ovals, no asymmetric corners.
rounded.sm (0.4rem) — small interactive elements: chips, tag-removal buttons, segmented controls.rounded.md (0.675rem) — buttons, inputs, badges, dropdown items. The default for interactive UI.rounded.lg (0.8rem) — cards, dialogs, popovers, the canonical --radius.rounded.xl (1.05rem) — code blocks and large surface tiles.rounded.full — avatars and the rare circular indicator.The base --radius is 0.8rem and all other steps derive from it (Tailwind's --radius-sm/md/lg/xl formulas in global.css). Changing the base value rescales the entire system.
Component recipes live in the YAML frontmatter under components: (hand-written, encodes design intent). The inventory below is auto-derived from lib/components.registry.ts by pnpm generate:design. Live previews and full prop tables are in the docs at /docs/components.
Component tokens compose from base tokens via {token.path} references — never hardcoded values. When adding a new component recipe, the rule is: if a property could conceivably theme, it must reference a token, not a literal.
79 components across 9 categories. Last generated by pnpm generate:design.
| Component | Description |
|---|---|
breadcrumb | Displays the path to the current page location and allows navigation back. |
command | Fast, composable command menu for React. Perfect for search, quick actions, and Cmd+K interfaces. |
menubar | A horizontal menu bar for navigation. Perfect for application-style navigation. |
navigation-menu | A navigation menu with viewport, trigger, content, and link sub-components. |
pagination | A navigation component for navigating through pages of content. |
tabs | A set of layered sections of content—known as tab panels—displayed one at a time. |
| Component | Description |
|---|---|
collapsible | An expandable content container that can be toggled open or closed. |
context-menu | Displays a menu triggered by right-click. |
dialog | A modal dialog that overlays the main content. |
dropdown-menu | Displays a menu triggered by a button. |
sheet | A slide-over dialog that overlays the main content from one side of the screen. |
| Component | Description |
|---|---|
button-group | Groups related buttons together. |
font-provider | Swappable font-token layer. Wrap a subtree to scope --font-sans, --font-mono, --font-display, and --font-feature-settings via CSS-variable cascade. Distinct from the Typography primitives (T1-T4, P, etc.) — FontProvider controls which font those primitives render in. |
wizard-split-layout | A two-column wizard layout with an interactive side and a responsive display panel. |
| Component | Description |
|---|---|
animated-group | Animates a group of elements with staggered entry/exit. |
animated-list | A dynamic list that animates items in and out with spring physics. Supports scale, slide, fade, and bounce animation styles. |
animated-number | Animates a number value with smooth transitions. |
beam | Animated traveling-glow border effect. Wraps any element with a multi-layer beam that respects border-radius, theme, and interactivity. Adapted from border-beam by Jakub Antalik (MIT). |
blur-fade | Blur + fade entrance animation for individual elements. Supports scroll-triggered reveal and configurable direction. |
border-trail | Animated border trail effect. |
glow-effect | Adds a glow effect around elements. |
liquid-metal | WebGL-powered liquid metallic effect with animated metaballs. Supports dark/light mode, configurable blob count, speed, hue cycling, and smooth fade-in. |
loading-state | Full-screen splash loader with a glowing brand logo, fake-progress bar, and shimmer text. Pair with the useLoading hook for automatic progress management. |
parallax | A scroll-driven parallax wrapper with GPU-accelerated transforms and reduced motion support. |
pixel | Interactive canvas-based pixel grid that animates on load with configurable reveal patterns. Supports dark/light mode colors and multiple animation directions. |
progressive-blur | A progressive blur effect that creates a smooth gradient blur transition using multiple backdrop-filter layers. |
ripple | Animated concentric ripple rings that pulse behind content for emphasis. Pure CSS animation, no JS dependencies. |
spotlight | Creates a spotlight effect that follows cursor. |
text-effect | Animated text reveal effects. |
text-shimmer | Shimmering text effect for loading states or emphasis. |
The spec format does not currently encode borders, opacity modifiers, hover/focus/active states, transitions, or shadows. These must be inferred from prose:
button-secondary and button-outline have a 1px border (color: {colors.input} or {colors.border}). This is implicit in the variant name; the visible "outline" look comes from the border, not the background.button-ghost has no background and no border — text-only, picks up {colors.accent} on hover.height: 2.25rem (36px) and borderRadius: rounded.md (10.8px). The rounded.md value is computed as calc(var(--radius) - 2px) from the runtime --radius: 0.8rem.{colors.ring} with a 2px offset.bg-primary, text-muted-foreground) instead of palette indices (bg-blue-600, text-zinc-500).radix-ui monorepo namespace imports (import { Slot } from "radix-ui"), not @radix-ui/react-* per-package imports.cn() to compose className conditionals and cva() for variant-driven components.packages/react/src/styles/global.css and this DESIGN.md together when changing a token. Run pnpm design:lint to catch drift.oklch() in global.css as canonical and the hex values here as a derived export.#hex or rgb() outside of global.css or DESIGN.md, you are introducing drift.gellix preset), and Geist Mono (code and numerics everywhere). Switch between them only via font-provider presets; never hardcode a font-family or a var(--font-gellix) literal in product code. Marketing motion graphics or one-off illustrations may use anything; the interface uses these families.destructive for general errors. It is reserved for irreversible actions.public/r/ or content/docs/components/ by hand — they are generated. See the registry workflow in .claude/rules/registry-workflow.md.bg-[#1B4DC4]). The arbitrary-value escape hatch should be exceedingly rare and accompanied by a comment explaining why a token won't do.