Theming
How tokens resolve, and where to change them.
The token layer has three stages, and the order they load in is not arbitrary.
@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.
| Prefer | Over |
|---|---|
bg-surface, bg-surface-raised, bg-surface-sunken | bg-white, bg-gray-50 |
text-text-primary, text-text-secondary, text-text-muted | text-black, text-gray-500 |
border-border-subtle, border-border-strong | border-gray-200 |
text-text-accent | text-[#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.