Every token is declared in app/globals.css and mapped to a utility class in its @theme inline block. Both halves are required — a variable with no mapping generates no class, silently.
The values below are the current ones. app/globals.css is the source of truth, and its comments record the measured contrast ratios and gamut ceilings behind each number.
Colour
Authored parameters
Four values. Everything else derives from them.
| Token | Value | Purpose |
|---|---|---|
--brand-h | 350 | The only hue in the product (deep raspberry). Rotate to rebrand. |
--brand-c | 0.2 | Primary chroma. |
--brand-c-subtle | 0.06 | Tints, badges, hover states. |
--neutral-c | 0.006 | Neutral tint toward the brand — cohesion, not visible colour. |
Surfaces
| Token | Light | Dark | Utility |
|---|---|---|---|
--background | 0.94 | 0.115 | bg-background |
--card | 1.0 | 0.205 | bg-card |
--muted | 0.958 | 0.265 | bg-muted |
--sidebar | var(--background) | same | bg-sidebar |
--border | 0.918 | — | border-border |
Card↔canvas measures 1.195 (light) and 1.136 (dark). That gap is what lets the design drop borders — see the overview.
Brand
| Token | Light | Dark |
|---|---|---|
--primary | oklch(0.54 var(--brand-c) var(--brand-h)) | oklch(0.72 0.19 var(--brand-h)) |
--primary-foreground | near-white | near-black |
--accent | 0.96 at subtle chroma | — |
--accent-foreground | 0.50 at full chroma | — |
Primary sits at L0.54 for a reason worth knowing: a pink only reads as pink in the upper half of the lightness range — below about L0.50 it turns to wine. 0.54 is the brightest stop that still carries a near-white label at AA (5.46:1) and works as a link on --background (5.55:1).
Dark mode is desaturated and lifted, never inverted. Inverting produces vibrating colour on dark backgrounds.
Semantic states — fixed hues
| Token | Hue | Light L | Dark L |
|---|---|---|---|
--destructive | 27 | 0.55 | 0.70 |
--success | 155 | 0.55 | 0.72 |
--warning | 75 | 0.55 | 0.80 |
--info | 240 | 0.55 | 0.70 |
These never reference --brand-h. A rebrand must not turn "success" pink. Note that --destructive sits 37° from the brand hue so a primary button and a delete button never read as the same colour — worth re-checking if you rotate the brand toward red.
The uniform 0.55 in light mode is deliberate: it is the value that satisfies text contrast, which is strictly harder than fill contrast. In dark mode the values are deliberately not uniform, because against a dark background lifting a token increases contrast — so there the risk runs the other way. The rule is not "all four must match": it is "no token drifts toward its own background."
Chart ramp
One analogous ramp anchored on the brand, hue-stable across themes:
--chart-1: oklch(0.55 0.2 var(--brand-h));
--chart-2: oklch(0.62 0.18 calc(var(--brand-h) - 45));
--chart-3: oklch(0.68 0.16 calc(var(--brand-h) - 90));
--chart-4: oklch(0.6 0.19 calc(var(--brand-h) + 45));
--chart-5: oklch(0.72 0.14 calc(var(--brand-h) + 90));
Chart stops are tuned for 3:1 against an adjacent surface, which is the bar for non-text. Small text needs 4.5:1, and the lighter stops do not reach it. Measure before using text-chart-4 or similar for a label.
Typography
--font-sans → DM Sans, --font-mono → JetBrains Mono, both loaded via next/font/google in app/layout.tsx. Mono is preload: false because nothing above the fold uses it.
Banned across the project: Inter, Roboto, Arial, Open Sans, Syne.
The scale is fluid — clamp() rather than breakpoint-specific classes, so text-4xl adapts on its own:
| Token | Value |
|---|---|
--text-2xs | 0.6875rem (11px, micro-labels) |
--text-xl | clamp(1.25rem, 1.15rem + 0.5vw, 1.5rem) |
--text-2xl | clamp(1.5rem, 1.3rem + 1vw, 2rem) |
--text-3xl | clamp(1.875rem, 1.5rem + 1.9vw, 2.75rem) |
--text-4xl | clamp(2.25rem, 1.6rem + 3.2vw, 4rem) |
--text-5xl | clamp(2.75rem, 1.7rem + 5.2vw, 5.5rem) (hero only) |
Every numeral in a table, credit balance, price, token count or timer uses tabular-nums. Proportional digits make a column jitter as values update.
Containers
| Token | Value | Use |
|---|---|---|
--container-app | 80rem | Dashboards — max-w-app |
--container-marketing | 90rem | Homepage — max-w-marketing |
--container-prose | 45rem | Legal, blog, docs — max-w-prose |
Before these existed, dashboards sprawled unbounded above 1280px.
Elevation
| Token | Use |
|---|---|
--shadow-raise | A nav item or small tile lifting off the canvas |
--shadow-card | The resting state of a card or content panel |
--shadow-float | Hover / raised state |
--shadow-modal | Dialogs, drawers |
They are brand-tinted, not neutral black — oklch(0.16 0.02 var(--brand-h) / …). A pure-black shadow on a hue-tinted surface reads as dirt.
In dark mode, express elevation through surface lightness rather than shadow: a shadow on a dark background is nearly invisible, which is why --card lifts from 0.115 to 0.205 there.
No ad-hoc box-shadow. Use shadow-card / hover:shadow-float.
Motion
| Token | Value |
|---|---|
--ease-out | cubic-bezier(0.23, 1, 0.32, 1) |
--ease-ui-in-out | cubic-bezier(0.65, 0, 0.35, 1) |
--dur-instant | 100ms |
--dur-fast | 150ms |
--dur-base | 200ms |
--dur-slow | 280ms |
--dur-reveal | 500ms |
--ease-out deliberately overrides Tailwind's built-in curve project-wide, so every existing ease-out utility picks it up. One easing across the product.
House rules: no ease-in for UI, no bounce or elastic easing, no transition: all, nothing over 300ms for UI feedback, and no animation on keyboard-initiated actions. Exit animations run at roughly 65% of enter — the user has already decided, so getting out of the way should feel faster than arriving.
Z-index
--z-base 0 · --z-raised 10 · --z-sticky 20 · --z-drawer 30 · --z-overlay 40 · --z-modal 50 · --z-popover 60 · --z-toast 70 · --z-max 100
Used through the arbitrary-property syntax: z-(--z-modal). Never a bare z-50.
Scrollbars
Styled once, globally, and inherited by every overflow-* container. A new scroll container needs no scrollbar class at all — adding one is the bug.
| Token | Value |
|---|---|
--scrollbar-size | 10px |
--scrollbar-inset | 2px |
--scrollbar-thumb | --foreground at 0.22 alpha |
--scrollbar-thumb-hover | at 0.38 alpha |
The track is transparent — the panel's own surface is the track — and carries a 6px pill. The inset is a transparent border plus background-clip: content-box, not padding: background-clip cannot exclude padding, and this keeps the hit target at the full track width while the thumb reads as a floating capsule.
Two utilities exist for deliberate exceptions:
| Utility | Use for |
|---|---|
scrollbar-none | Horizontal rails only — a snap carousel, a chip row — where the peeking next item is the affordance. Never on a vertical panel: hiding the only signal that a region scrolls is a real discoverability loss. |
scrollbar-stable | Panels whose content length changes between routes, so they do not jog sideways at the scroll threshold. |
Two hand-rolled scrollbar implementations preceded the global one, and both failed without a trace. One inlined [scrollbar-width:none]; the other reached for scrollbar-none — a class that did not exist at the time — so the rail it was meant to clean up shipped with a raw bar. A Tailwind class that matches nothing produces no error, no warning and no visual clue in review. If you write one, confirm it compiles.