tokens — the system becomes one file
Stage: Phase 3 — Foundation (final step, after color/typography/motion-language/shape-language decide) - Reads: design/SYSTEM.md, design/DIRECTION.md - Writes: app/globals.css
Standard
- app/globals.css is the single source of design truth. Components consume generated utilities; a hex code, a bare
oklch(, ortext-[17px]inside a component is a defect, not a shortcut. - Every SYSTEM.md decision becomes a token; every token traces to a SYSTEM.md line. No orphans in either direction.
- Full semantic color set in BOTH
:rootand.darkas complete oklch values — never bare HSL triplets, neverhsl(var(--x))(legacy shadcn v3 pattern, dead). - Tailwind 4.3 is CSS-first: no tailwind.config.js, no
@tailwinddirectives, notheme.extend. Ever. - Type scale is fluid; the hero token resolves to ≥3.5×
--text-baseat 1440px (taste floor). - Three tiers, never skipped: primitive (raw scale —
--color-neutral-*, never named by a component), semantic (intent —--background,--primary,--ring; what color/typography/depth already emit), component (one-off —--pricing-featured-ring, declared in the consuming component's own CSS, aliasing a semantic/primitive token). A component that invents a semantic token or hardcodes a value is skipping its tier — that is how the system rots. - The cascade layer order is the specificity contract. Tailwind v4 ships
@layer theme, base, components, utilities; treat it as law. Restyling shadcn's default look (taste demands it) is won by putting hand-written component CSS in@layer components— never by!important, never by an ever-deeper selector. - Animatable tokens are
@property-registered; static tokens are not. A plain custom property is untyped, so a color or gradient token inside atransition/keyframe snaps at the midpoint instead of interpolating. Register exactly the tokens that move — no more, and no exemption for a second engine: a token a DIRECTION-commissioned ultraweb:animejs timeline interpolates registers here like any other. lib/motion.tsis the same contract for time, and it is ONE mirror serving BOTH engines: motion-language's durations and curves live there once, read bymotion/reactin seconds and bezier arrays, and — only when DIRECTION.md commissions the SVG engine — by an appendedanimeEase/animeDurblock (ms andcubicBezier(), per motion-language) for anime.js. Two engines, one set of numbers; a duration typed into a component is the same defect either way.- globals.css is the single authoring source;
lib/tokens.tsis its generated projection, never a second source. Everything inside the CSS cascade reads globals.css; everything outside it (react-email,next/ogImageResponse) reads the resolved-hex mirror — one generated file, never a hand-retyped second palette.
Process
- Read SYSTEM.md §color/§type/§layout/§depth/§shape/§motion. A missing decision means the owning skill hasn't run — run it. Never invent values here.
- Write
:rootand.darkas plain CSS variables — the color skill's value table, verbatim. These are the primitive and semantic tiers; the component tier is declared later, inside each component's own CSS, aliasing back here — never a new value born in a component. - Bridge every var-referencing token through
@theme inline(colors, fonts, radius math, shadows); literals (text scale, spacing, easings, keyframes) go in plain@theme. Rule:inlinewhenever a token references another CSS variable. - Define the entrance
@keyframesinside@theme; durations and curves come from motion-language's--dur-*/--ease-*tokens, never hardcoded. Land its reduced-motion policy the way motion-language mandates: author every entrance utility inside@media (prefers-reduced-motion: no-preference)(worked example below) so motion is opt-in — reduce users receive the static end state and noboth-fill entrance leaves content hidden. No globalreducekill-switch. - Register the tokens that animate with
@property— a gradient<angle>, an interpolating<color>— each with asyntax,inherits, andinitial-value. An untyped custom property is a string to the engine, so it snaps at the transition midpoint instead of tweening; register exactly those that move and no static token. - Make the cascade order explicit: Tailwind v4 already declares
@layer theme, base, components, utilities, so any hand-written component override goes in@layer componentsand wins over shadcn's base styling by layer, not force.!importantin component CSS means the layer order is wrong. - Base layer: body colors, default border-color,
::selectionin palette — craft in the last 2% (taste). - Dark mode is class-strategy:
@custom-variant darkin CSS + next-themes —<html suppressHydrationWarning>and a client<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>wrapper. - Export
lib/tokens.ts— a plain object of resolved sRGB-hex values for every semantic token, generated from this file (a ~20-line converter, never hand-typed). react-email templates andnext/ogImageResponserender outside the cascade and read neither CSS variables noroklch(); this is their single color source, so the brand can't silently fork. - Verify:
npm run buildclean, then Playwrightbrowser_evaluate→getComputedStyle(document.body).backgroundColorresolves to the token value in BOTH themes.
Worked example — app/globals.css (Tailwind 4.3)
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Anti-patterns
tailwind.config,@tailwind base,theme.extend,darkMode:— v3 relics; the config file must not exist.hsl(var(--and bare HSL triplets in:root— the dead shadcn bridge; full oklch only.#-hex,rgb(,oklch(insideapp/orcomponents/— grep for all three; only globals.css defines color. The lone sanctioned exception is a named component-tier token in that component's own CSS (--pricing-featured-ring: var(--color-primary)), never a literal in aclassName.- A component inventing a semantic token, or reaching for a raw value, to cover a one-off — that is the component tier's job: a
--component-*custom property declared in the component, aliasing a semantic/primitive token. !importantin component CSS — the layer order is wrong, not the override too weak; move the rule into@layer componentsinstead of forcing it.- Animating a color/gradient token that was never
@property-registered (it snaps at the midpoint), or registering static tokens that never move (noise) — type exactly the ones that interpolate. - Hand-typed hex/
rgb()inemail/templates orapp/**/opengraph-image.tsx— both must importlib/tokens.ts; a second palette drifts and no visual gate renders email or OG to catch it. text-[,p-[,rounded-[,shadow-[arbitrary values — a value you need is a token you're missing.dark:bg-[— per-component dark hacks instead of the.darkblock re-decision.- Tokens defined but SYSTEM.md silent on them (invented here), or SYSTEM.md decisions with no token (system leaks into components).
- Entrance utilities authored outside
@media (prefers-reduced-motion: no-preference), or a globalreducekill-switch standing in for opt-in authoring — reduce users must never receive motion they didn't opt into; taste requires the preference honored by construction, not overridden after the fact.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.