Getting started

Theming

How tokens resolve, and where to change them.

The token layer has three stages, and the order they load in is not arbitrary.

styles/index.css
@import "./tokens/color.css"; /* 1. primitives register in Tailwind's namespaces */
@import "./themes/base.css"; /* 2. the semantic layer resolves them to roles   */
@import "./tokens/themes.css"; /* 3. the bridge maps roles back to utilities     */

Never use a primitive directly

A component asks for a role, not a value. bg-surface-sunken, not bg-neutral-100; text-text-accent, not text-[#F4521B]. The role is what survives a theme change — the value is what a theme is allowed to move.

PreferOver
bg-surface, bg-surface-raised, bg-surface-sunkenbg-white, bg-gray-50
text-text-primary, text-text-secondary, text-text-mutedtext-black, text-gray-500
border-border-subtle, border-border-strongborder-gray-200
text-text-accenttext-[#F4521B]

Products differ by theme, not by component

A product that needs to look different changes themes/<product>.css and nothing else. If you find yourself editing a component to make one app look different, the theme layer is missing something — that gap is the bug, not the component.

<html data-theme="admin">        {/* light */}
<html data-theme="admin" class="dark">  {/* dark */}

Dark mode is a block inside each theme file, guarded so source order can't make the light block outrank it.

Strict mode

Once an app has finished migrating, you can delete Tailwind's default scales entirely, so bg-blue-500 and text-sm stop resolving:

/* src/app/globals.css (or ./tokens/strict.css & ./index.css in src/styles/globals.css) */
@import "tailwindcss";
@import "../styles/tokens/strict.css"; /* between the two */
@import "../styles/index.css";

The build then fails on anything left behind, which is the point. If the failure list is long, that list is the ad hoc styling the migration was for — work through it rather than disabling the guard.

Adding a token

Two files, always: the CSS variable in tokens/, and its description in lib/tokens.ts. Every swatch and table in these docs is generated from the second one, so a token without a description shows up here as a blank.

Product accent rationale

Why each signed-in product uses a unique accent color:

  • Buyer keeps Woohl Orange (#F4521B) as the brand
  • Seller uses Forest Green (#15803d) — operational dashboard
  • Admin uses Operations Gold (#ca8a04) — internal, ink-led UI with gold as high-priority signal only
  • Creator uses Iris/Purple (#7e22ce) — outward-facing publishing, distinct from seller's operational tone
  • The reason: when someone has 3 tabs open, different steps of the same orange don't survive the 'which one am I looking at' test

Color ramp contract

The standardized step assignments:

  • Light mode: 600 fills, 700 hovers, 800 accent text (must pass AA 4.5:1)
  • Dark mode: 500 fills, 400 hovers/accent text, 300 strong step
  • Dark inverts direction because deep colors disappear into dark surfaces
  • White text on 600 steps is ~3:1 (large text/buttons only), step 800 required for small text

Dark mode contrast math

Why --text-on-accent flips to Ink in seller/admin dark:

  • White on green/gold fill measures 2.2:1 — fails AA
  • Ink text on same fill gives 8.3:1 — passes
  • Light mode keeps white because 600 sits in a band where white works
  • Creator purple is darker at same step, so white clears the bar in both (5.38:1 light, 3.96:1 dark large text)
  • Orange lifted to #FF7A45 in dark mode to clear 4.5:1 on warm dark surface

Surface hierarchy

Max 2 surfaces + 1 accent per composition (70/20/10 ratio). Orange at ~10% coverage. Full-bleed CTA panel is the one exception.

Non-inverting fixed tokens

--text-on-fill, --surface-glass, --surface-media do NOT flip with appearance. A photograph is dark in both themes; text on it must be white in both.

@theme inline mechanics

Why inline is required: emits var(--surface) directly into utilities without :root indirection, enabling runtime [data-theme] subtree switches. Plain @theme would alias at :root and scoped themes silently stop working.

Naming guardrails

Ramp names avoid Tailwind core scale collisions: iris not purple/violet, forest not green, gold not amber.

Shadow & elevation rules

Four levels, warm-tinted (never neutral black). E1 rests, E2 hovers/dropdowns, E3 modals/drawers only. Heavy shadow on resting card means nothing floats.

Radius containment

One radius per component stack. 16px (--radius-lg) for photographs in product, 32px on marketing artboards, 0 when image bleeds. Never drop shadow on photographs.

On this page