Skip to content

Design tokens

The token architecture, from raw OKLCH values to semantic roles, read live from the stylesheet.

Four layers#

A token travels from a raw OKLCH value to a semantic variable, then to a Tailwind theme key, then to the utility a component writes. Components only ever touch the last layer.

  1. 1Raw valuesOKLCH colors, shadow stacks and lengths, written once per theme.:root, .light and .dark
  2. 2Semantic variablesEach value is named for its role, never for its hue or lightness.--card, --primary, --elevation-border
  3. 3Tailwind mappingRoles become Tailwind theme keys that resolve at the element.@theme inline
  4. 4UtilitiesComponents only ever write the utility.bg-card, shadow-border, bg-ink-65
Four tokens traced through every layer
Raw value (light)Semantic variableTailwind mappingUtility
oklch(0.52 0.19 272)--primary--color-primary: var(--primary)bg-primary text-primary bg-primary/6
oklch(1 0 0)--card--color-card: var(--card)bg-card
0 0 0 1px oklch(0.2 0.02 265 / 0.07), plus two drops--elevation-border--shadow-border: var(--elevation-border)shadow-border
oklch(0.21 0.006 265)--foreground--color-ink-65: color-mix(in oklch, var(--foreground) 65%, transparent)bg-ink-65
One token, end to end
/* 1 and 2. A raw value, named for its role, once per theme */:root,.light {  --primary: oklch(0.52 0.19 272);  --elevation-border:    0 0 0 1px oklch(0.2 0.02 265 / 0.07),    0 1px 2px -1px oklch(0.2 0.02 265 / 0.07),    0 2px 4px -2px oklch(0.2 0.02 265 / 0.04);  --radius: 0.625rem;}.dark {  --primary: oklch(0.585 0.18 272);  --elevation-border:    0 0 0 1px oklch(1 0 0 / 0.07),    0 1px 2px -1px oklch(0 0 0 / 0.4);}/* 3. The Tailwind mapping, resolved where the class is used */@theme inline {  --color-primary: var(--primary);  --color-ink-65: color-mix(in oklch, var(--foreground) 65%, transparent);  --shadow-border: var(--elevation-border);  --radius-xl: calc(var(--radius) * 1.2);}
What a component writes
// 4. Components write utilities, nothing else<section className="rounded-xl bg-card p-4 shadow-border">  <div className="h-1.5 rounded-full bg-muted">    <div className="h-full w-[77%] rounded-full bg-ink-65" />  </div>  <Button>Schedule run</Button> {/* bg-primary text-primary-foreground */}</section>

Raw values are written straight into the role that uses them. There is no primitive scale such as --gray-100 or --indigo-600 underneath. When two roles share a value, as --primary and --ring do in light, each states it, so either can move without dragging the other along.

The static Tailwind theme, @theme without inline, holds what never changes with the theme: the four easing curves, --duration-80, the text-13 and text-2xs sizes and the four keyframe animations.

Only utilities, in both themes

Every class in this card is a token utility. Switch the preview theme below it: the markup stays the same and every surface, ink, hairline and shadow follows.

Payment run, Friday

Oct 2, 2:00 PM CT
Northwind Freight$48,210.00
Halcyon$12,940.50
Invoices approved164 of 212
ACH
import { Button } from "@oration/canon/components/button";import { toast } from "@oration/canon/components/toast";import { ArrowUpRightIcon } from "lucide-react";export function TokensInUse() {    return (        <div className="flex w-full max-w-sm flex-col gap-3 rounded-xl bg-card p-4 shadow-border transition-shadow duration-150 ease-out hover:shadow-border-hover">            <div className="flex items-baseline justify-between gap-3">                <h3 className="text-sm font-semibold text-foreground">                    Payment run, Friday                </h3>                <span className="text-xs text-muted-foreground tabular-nums">                    Oct 2, 2:00 PM CT                </span>            </div>            <div className="flex flex-col gap-2 rounded-[10px] bg-muted/70 px-3 py-2.5">                <div className="flex items-center justify-between text-13">                    <span className="text-foreground">Northwind Freight</span>                    <span className="font-medium text-foreground tabular-nums">                        $48,210.00                    </span>                </div>                <div className="flex items-center justify-between text-13">                    <span className="text-foreground">Halcyon</span>                    <span className="font-medium text-foreground tabular-nums">                        $12,940.50                    </span>                </div>            </div>            <div className="flex flex-col gap-1.5">                <div className="flex items-center justify-between text-xs text-muted-foreground">                    <span>Invoices approved</span>                    <span className="tabular-nums">164 of 212</span>                </div>                <div className="h-1.5 overflow-hidden rounded-full bg-muted">                    <div className="h-full w-[77%] rounded-full bg-ink-65" />                </div>            </div>            <div className="flex items-center justify-between gap-2">                <span className="inline-flex h-5 items-center rounded-md bg-(--tag-green) px-1.5 text-xs font-medium text-(--tag-green-fg)">                    ACH                </span>                <Button                    type="button"                    size="sm"                    onClick={() =>                        toast.add({                            title: "Payment run scheduled",                            description:                                "212 invoices to 48 suppliers on Friday, Oct 2.",                        })                    }                >                    Schedule run                    <ArrowUpRightIcon                        data-icon="inline-end"                        aria-hidden="true"                    />                </Button>            </div>        </div>    );}

Why the mapping is inline#

@theme inline makes each utility read the semantic variable at the element. That is what lets .dark and .light swap the whole set by redefining roles on a subtree.

theme versus theme inline
/* Without inline, Tailwind would emit a reference to its own variable: */@theme { --color-primary: var(--primary); }.bg-primary { background-color: var(--color-primary); }/* --color-primary is computed once on :root, so a .dark or .light   subtree that redefines --primary never reaches it. *//* With inline, the utility reads the role directly: */@theme inline { --color-primary: var(--primary); }.bg-primary { background-color: var(--primary); }/* --primary resolves at the element, so scopes and themes just work. */

The ink steps depend on it most. --color-ink-65 is color-mix() over var(--foreground), so it has to be evaluated where it's used to pick up the dark foreground. Defined anywhere else it would stay graphite on a dark plane.

The scope classes and the dark: variant are covered in Theming.

Naming#

Tokens are named for the job they do. A fill and the ink that sits on it share a stem, and DESIGN.md gives each role a display name people can say out loud.

AaLight
AaDark
--background--foregroundWhite Plane
AaLight
AaDark
--card--card-foregroundCard White
AaLight
AaDark
--popover--popover-foregroundPopover White
AaLight
AaDark
--primary--primary-foregroundQuiet Indigo
AaLight
AaDark
--secondary--secondary-foregroundQuiet Fill
AaLight
AaDark
--accent--accent-foregroundMenu Hover
AaLight
AaDark
--warning--warning-foregroundCaution Amber
AaLight
AaDark
--sidebar--sidebar-foregroundCool Rail
AaLight
AaDark
--sidebar-accent--sidebar-accent-foregroundRail Highlight
AaLight
AaDark
--tag-blue--tag-blue-fg
Naming patterns
PatternMeansExamples
--{role}A surface or fill.--background, --card, --muted, --primary
--{role}-foregroundThe text and icons that sit on that fill.--card-foreground, --primary-foreground, --warning-foreground
--{name}-foreground aloneA step of ink that works on any surface, with no fill of its own.--muted-foreground (Slate Meta), --subtle-foreground (Faint Slate)
--tag-{hue} and --tag-{hue}-fgA categorical fill and its same-hue ink. Tags use the short suffix.--tag-blue, --tag-blue-fg
--elevation-{name}A raw shadow stack, exposed as --shadow-{name}.--elevation-border to shadow-border
--radius, --radius-{size}One base and the steps derived from it.--radius-xl: calc(var(--radius) * 1.2) to rounded-xl
--color-ink-{alpha}Graphite Ink at a fixed alpha, Tailwind-only.bg-ink-80, bg-ink-65, bg-ink-15
--sidebar-*, --app-*, --chart-*A role set scoped to one region.--sidebar-accent, --app-crm, --chart-1

Display names such as Quiet Indigo, White Plane, Well Gray and Hairline lift come from DESIGN.md. Use them in reviews, specs and these docs, where a name reads better than a variable. Code always uses the role: bg-primary, never a class named after its color. The reference below shows both.

Token reference#

All 107 tokens, parsed from packages/canon/src/styles/globals.css on every build. Each swatch is drawn inside a light or dark scope, so it shows the value that theme ships.

Surfaces#

The planes and fills things sit on. In light the plane, card and popover are all white and separate only by their lift; in dark each steps lighter.

Surfaces tokens
TokenLightDarkUtility
--backgroundWhite Planeoklch(1 0 0)oklch(0.178 0.005 265)bg-background
--surfaceRow Mistoklch(0.985 0.0015 265)oklch(0.198 0.006 265)bg-surface
--cardCard Whiteoklch(1 0 0)oklch(0.205 0.006 265)bg-card
--popoverPopover Whiteoklch(1 0 0)oklch(0.225 0.007 265)bg-popover
--secondaryQuiet Filloklch(0.962 0.004 265)oklch(0.25 0.007 265)bg-secondary
--mutedWell Grayoklch(0.965 0.0035 265)oklch(0.235 0.006 265)bg-mutedbg-muted/70
--accentMenu Hoveroklch(0.955 0.005 265)oklch(0.255 0.007 265)bg-accent

Text#

Three steps of ink for everything a person reads, plus the foreground half of each surface pair.

Text tokens
TokenLightDarkUtility
--foregroundGraphite Inkoklch(0.21 0.006 265)oklch(0.955 0.003 265)bg-foreground
--card-foregroundoklch(0.21 0.006 265)oklch(0.955 0.003 265)text-card-foreground
--popover-foregroundoklch(0.21 0.006 265)oklch(0.955 0.003 265)text-popover-foreground
--secondary-foregroundQuiet Fill Inkoklch(0.26 0.008 265)oklch(0.93 0.004 265)text-secondary-foreground
--muted-foregroundSlate Metaoklch(0.5 0.014 265)oklch(0.7 0.012 265)text-muted-foreground
--subtle-foregroundFaint Slateoklch(0.62 0.012 265)oklch(0.58 0.012 265)text-subtle-foreground
--accent-foregroundoklch(0.21 0.006 265)oklch(0.955 0.003 265)text-accent-foreground

Lines#

Structural dividers and field strokes. Raised surfaces never use these; they take their edge from shadow-border.

Lines tokens
TokenLightDarkUtility
--borderHairlineoklch(0.918 0.005 265)oklch(1 0 0 / 0.075)border-border
--border-strongFirm Hairlineoklch(0.86 0.007 265)oklch(1 0 0 / 0.13)border-border-strong
--inputField Strokeoklch(0.885 0.006 265)oklch(1 0 0 / 0.12)border-input

Brand#

Quiet Indigo, the text that sits on it and the focus ring. See where indigo is allowed.

Brand tokens
TokenLightDarkUtility
--primaryQuiet Indigooklch(0.52 0.19 272)oklch(0.585 0.18 272)bg-primarybg-primary/6
--primary-foregroundIndigo Paperoklch(0.99 0.004 272)oklch(0.99 0.004 272)text-primary-foreground
--ringFocus Indigooklch(0.52 0.19 272)oklch(0.65 0.16 272)border-ringring-ring/50

Semantic#

Verdicts: errors, success, warnings and notices. Each one always travels with a text label.

Semantic tokens
TokenLightDarkUtility
--destructiveSignal Redoklch(0.56 0.2 25)oklch(0.68 0.18 22)text-destructivebg-destructive/10
--destructive-foregroundoklch(0.99 0 0)oklch(0.99 0 0)text-destructive-foreground
--successLedger Greenoklch(0.56 0.13 155)oklch(0.7 0.14 155)text-successbg-success
--success-foregroundoklch(0.99 0 0)oklch(0.18 0.03 155)text-success-foreground
--warningCaution Amberoklch(0.7 0.15 68)oklch(0.8 0.14 75)bg-warning
--warning-foregroundAmber Inkoklch(0.27 0.06 60)oklch(0.22 0.05 70)text-warning-foreground
--infoNote Blueoklch(0.58 0.13 240)oklch(0.7 0.12 240)text-info
--info-foregroundoklch(0.99 0 0)oklch(0.18 0.03 240)text-info-foreground

Ink steps#

Graphite Ink at fixed alphas, defined only in @theme inline so they resolve at the element and invert with the theme.

Ink steps tokens
TokenLightDarkUtility
--color-ink-80Ink 80color-mix(in oklch, var(--foreground) 80%, transparent)Inverts with --foregroundbg-ink-80
--color-ink-65Ink 65color-mix(in oklch, var(--foreground) 65%, transparent)Inverts with --foregroundbg-ink-65
--color-ink-45Ink 45color-mix(in oklch, var(--foreground) 45%, transparent)Inverts with --foregroundbg-ink-45
--color-ink-25Ink 25color-mix(in oklch, var(--foreground) 25%, transparent)Inverts with --foregroundbg-ink-25
--color-ink-15Ink 15color-mix(in oklch, var(--foreground) 15%, transparent)Inverts with --foregroundbg-ink-15

Chart#

Series colors for charts inside the chart container, in this order.

Chart tokens
TokenLightDarkUtility
--chart-1Chart Inkoklch(0.4 0.02 265)oklch(0.82 0.015 265)bg-chart-1
--chart-2Chart Tealoklch(0.66 0.11 190)oklch(0.7 0.11 190)bg-chart-2
--chart-3Chart Amberoklch(0.76 0.14 75)oklch(0.8 0.13 75)bg-chart-3
--chart-4Chart Magentaoklch(0.6 0.19 330)oklch(0.68 0.17 330)bg-chart-4
--chart-5Chart Soft Inkoklch(0.72 0.05 265)oklch(0.55 0.03 265)bg-chart-5

Tags#

Ten pale fills with same-hue text, for select-option values and identity tints. They have no Tailwind mapping; read them with bg-(--tag-*).

Tags tokens
TokenLightDarkUtility
--tag-grayoklch(0.955 0.004 265)oklch(0.27 0.006 265)bg-(--tag-gray)
--tag-gray-fgoklch(0.42 0.012 265)oklch(0.8 0.01 265)text-(--tag-gray-fg)
--tag-blueoklch(0.95 0.03 245)oklch(0.29 0.05 250)bg-(--tag-blue)
--tag-blue-fgoklch(0.45 0.13 250)oklch(0.8 0.1 245)text-(--tag-blue-fg)
--tag-indigooklch(0.945 0.035 275)oklch(0.29 0.06 275)bg-(--tag-indigo)
--tag-indigo-fgoklch(0.46 0.17 275)oklch(0.8 0.11 275)text-(--tag-indigo-fg)
--tag-violetoklch(0.95 0.035 300)oklch(0.29 0.06 300)bg-(--tag-violet)
--tag-violet-fgoklch(0.47 0.16 300)oklch(0.81 0.1 300)text-(--tag-violet-fg)
--tag-pinkoklch(0.955 0.03 350)oklch(0.29 0.06 350)bg-(--tag-pink)
--tag-pink-fgoklch(0.5 0.16 355)oklch(0.82 0.1 350)text-(--tag-pink-fg)
--tag-redoklch(0.955 0.03 25)oklch(0.29 0.06 25)bg-(--tag-red)
--tag-red-fgoklch(0.5 0.17 25)oklch(0.8 0.11 25)text-(--tag-red-fg)
--tag-orangeoklch(0.96 0.035 60)oklch(0.3 0.05 55)bg-(--tag-orange)
--tag-orange-fgoklch(0.5 0.13 50)oklch(0.82 0.1 60)text-(--tag-orange-fg)
--tag-amberoklch(0.965 0.045 85)oklch(0.31 0.05 80)bg-(--tag-amber)
--tag-amber-fgoklch(0.48 0.1 75)oklch(0.85 0.1 85)text-(--tag-amber-fg)
--tag-greenoklch(0.955 0.035 155)oklch(0.29 0.05 155)bg-(--tag-green)
--tag-green-fgoklch(0.46 0.11 155)oklch(0.8 0.11 155)text-(--tag-green-fg)
--tag-tealoklch(0.955 0.03 190)oklch(0.29 0.04 190)bg-(--tag-teal)
--tag-teal-fgoklch(0.46 0.08 195)oklch(0.8 0.08 190)text-(--tag-teal-fg)

Sidebar#

The rail's own set, mirroring the main roles so the sidebar can sit a step darker than the plane.

Sidebar tokens
TokenLightDarkUtility
--sidebarCool Railoklch(0.978 0.0025 265)oklch(0.158 0.005 265)bg-sidebar
--sidebar-foregroundRail Inkoklch(0.34 0.01 265)oklch(0.78 0.008 265)text-sidebar-foreground
--sidebar-primaryoklch(0.52 0.19 272)oklch(0.585 0.18 272)bg-sidebar-primary
--sidebar-primary-foregroundoklch(0.99 0 0)oklch(0.99 0 0)text-sidebar-primary-foreground
--sidebar-accentRail Highlightoklch(0.936 0.005 265)oklch(0.23 0.006 265)bg-sidebar-accent
--sidebar-accent-foregroundoklch(0.2 0.006 265)oklch(0.96 0.003 265)text-sidebar-accent-foreground
--sidebar-borderoklch(0.915 0.005 265)oklch(1 0 0 / 0.065)border-sidebar-border
--sidebar-ringoklch(0.52 0.19 272)oklch(0.65 0.16 272)ring-sidebar-ring

App marks#

The hue of each app's active mark in the sidebar. They have no Tailwind mapping, and in dark they alias the matching tag ink. See app marks.

App marks tokens
TokenLightDarkUtility
--app-crmoklch(0.62 0.16 250)var(--tag-blue-fg)text-(--app-crm)
--app-agentsoklch(0.6 0.18 300)var(--tag-violet-fg)text-(--app-agents)
--app-contactoklch(0.62 0.15 155)var(--tag-green-fg)text-(--app-contact)
--app-ticketsoklch(0.72 0.15 75)var(--tag-amber-fg)text-(--app-tickets)

Elevation#

Raw shadow stacks. Tailwind exposes them as shadow-* through @theme inline. See Elevation.

Elevation tokens
TokenLightDarkUtility
--elevation-xsControl0 1px 2px -1px oklch(0.2 0.02 265 / 0.08)0 1px 2px -1px oklch(0 0 0 / 0.4)shadow-xs
--elevation-sm0 1px 2px -1px oklch(0.2 0.02 265 / 0.08),0 2px 4px -2px oklch(0.2 0.02 265 / 0.04)0 1px 2px -1px oklch(0 0 0 / 0.4),0 2px 4px -2px oklch(0 0 0 / 0.3)shadow-sm
--elevation-mdOverlay0 2px 4px -2px oklch(0.2 0.02 265 / 0.06),0 6px 16px -4px oklch(0.2 0.02 265 / 0.08)0 2px 4px -2px oklch(0 0 0 / 0.4),0 8px 18px -4px oklch(0 0 0 / 0.4)shadow-md
--elevation-lgDialog0 4px 8px -4px oklch(0.2 0.02 265 / 0.06),0 16px 40px -8px oklch(0.2 0.02 265 / 0.16)0 4px 8px -4px oklch(0 0 0 / 0.4),0 18px 44px -8px oklch(0 0 0 / 0.55)shadow-lg
--elevation-borderHairline lift0 0 0 1px oklch(0.2 0.02 265 / 0.07),0 1px 2px -1px oklch(0.2 0.02 265 / 0.07),0 2px 4px -2px oklch(0.2 0.02 265 / 0.04)0 0 0 1px oklch(1 0 0 / 0.07),0 1px 2px -1px oklch(0 0 0 / 0.4)shadow-bordershadow-ring
--elevation-border-hoverHairline lift, hover0 0 0 1px oklch(0.2 0.02 265 / 0.11),0 1px 2px -1px oklch(0.2 0.02 265 / 0.08),0 4px 10px -4px oklch(0.2 0.02 265 / 0.08)0 0 0 1px oklch(1 0 0 / 0.11),0 2px 6px -2px oklch(0 0 0 / 0.5)shadow-border-hover
--elevation-popoverFloating bar0 0 0 1px oklch(0.2 0.02 265 / 0.08),0 4px 8px -4px oklch(0.2 0.02 265 / 0.08),0 12px 32px -6px oklch(0.2 0.02 265 / 0.14)0 0 0 1px oklch(1 0 0 / 0.09),0 4px 8px -4px oklch(0 0 0 / 0.4),0 14px 36px -6px oklch(0 0 0 / 0.55)shadow-popover

Radius#

One base radius and the steps derived from it. Canon uses sm, md, lg and xl; 2xl and up come from the shadcn base.

Radius tokens
TokenLightDarkUtility
--radiusBase radius0.625rem10pxSame in bothrounded-lg
--radius-smTight cornerscalc(var(--radius) * 0.6)6pxSame in bothrounded-sm
--radius-mdSmall cornerscalc(var(--radius) * 0.8)8pxSame in bothrounded-md
--radius-lgStandard cornersvar(--radius)10pxSame in bothrounded-lg
--radius-xlContainer cornerscalc(var(--radius) * 1.2)12pxSame in bothrounded-xl
--radius-2xlcalc(var(--radius) * 1.6)16pxSame in bothrounded-2xl
--radius-3xlcalc(var(--radius) * 2.2)22pxSame in bothrounded-3xl
--radius-4xlcalc(var(--radius) * 2.6)26pxSame in bothrounded-4xl

Type#

Font families and the two sizes Canon adds to Tailwind's scale.

Type tokens
TokenLightDarkUtility
--font-sansvar(--font-geist-sans),ui-sans-serif,system-ui,sans-serifSame in bothfont-sans
--font-monovar(--font-geist-mono),ui-monospace,"SF Mono",monospaceSame in bothfont-mono
--text-2xs0.6875rem,line height 1rem11px on 16pxSame in bothtext-2xs
--text-130.8125rem,line height 1.25rem13px on 20pxSame in bothtext-13

Motion#

Durations, easing curves and the four keyframe animations. Springs live in @oration/canon/lib/springs, not in CSS. The three --duration-* variables in :root aren't read anywhere yet; components write duration-150 and friends directly.

Motion tokens
TokenLightDarkUtility
--duration-fast80msSame in bothduration-(--duration-fast)
--duration-moderate160msSame in bothduration-(--duration-moderate)
--duration-slow240msSame in bothduration-(--duration-slow)
--duration-8080msSame in bothduration-80
--ease-outcubic-bezier(0.23, 1, 0.32, 1)Same in bothease-out
--ease-in-outcubic-bezier(0.77, 0, 0.175, 1)Same in bothease-in-out
--ease-drawercubic-bezier(0.32, 0.72, 0, 1)Same in bothease-drawer
--ease-standardcubic-bezier(0.2, 0, 0, 1)Same in bothease-standard
--animate-shimmershimmer 1.6s linear infiniteSame in bothanimate-shimmer
--animate-pulse-softpulse-soft 1.8s var(--ease-in-out) infiniteSame in bothanimate-pulse-soft
--animate-dashdash 0.9s linear infiniteSame in bothanimate-dash
--animate-caret-blinkcaret-blink 1.1s ease-out infiniteSame in bothanimate-caret-blink

Legacy#

Older variables still defined in the stylesheet. Don't use them in new code.

Legacy tokens
TokenLightDarkUtility
--hoverrgb(0 0 0 / 0.04)rgb(255 255 255 / 0.06)bg-hover
--activergb(0 0 0 / 0.07)rgb(255 255 255 / 0.1)bg-active
--selected#d4d4d4#525252bg-selected
--destructive-light#fef2f2#450a0abg-destructive-light
--overlay0 0 0255 255 255rgb(var(--overlay)/0.1)
--focus-ring#6b97ffSame as lightring-focus-ring

Reading tokens in code#

A utility for every common need, an arbitrary-value escape hatch for the variables Tailwind doesn't map, and nothing hard-coded.

How to read a token
NeedWrite
A role as a fill, text or linebg-card, text-muted-foreground, border-border
A role at an alphabg-muted/70 for a well, bg-primary/6 for a selected row, bg-destructive/10 for a destructive tint
A variable with no Tailwind keybg-(--tag-green) text-(--tag-green-fg), text-(--app-crm)
A shadowshadow-border, hover:shadow-border-hover, shadow-popover
SVG strokes and fillsstroke-chart-1, fill-card, stroke-border, or currentColor with a text utility
A mix the scale doesn't havebg-[color-mix(in_oklch,var(--primary),black_9%)], as the filled button's hover does. Mix variables, never literals.

Tokens inside an SVG

The line takes stroke-chart-1, the baseline stroke-border and the end point fill-card, so the chart reads in both themes with no extra code.

Invoices processed per dayLast 10 days
export function TokensInSvg() {    const points = [42, 51, 47, 58, 63, 60, 71, 76, 74, 82];    const max = 90;    const path = points        .map((value, index) => {            const x = (index / (points.length - 1)) * 280;            const y = 80 - (value / max) * 80;            return `${index === 0 ? "M" : "L"}${x.toFixed(1)} ${y.toFixed(1)}`;        })        .join(" ");    return (        <figure className="flex w-full max-w-sm flex-col gap-3 rounded-xl bg-card p-4 shadow-border">            <figcaption className="flex items-baseline justify-between gap-3">                <span className="text-sm font-semibold text-foreground">                    Invoices processed per day                </span>                <span className="text-xs text-muted-foreground tabular-nums">                    Last 10 days                </span>            </figcaption>            <svg                viewBox="0 0 280 84"                role="img"                aria-label="Invoices processed per day, rising from 42 to 82 over ten days"                className="h-24 w-full overflow-visible"            >                <line                    x1="0"                    x2="280"                    y1="80"                    y2="80"                    className="stroke-border"                    strokeWidth={1}                />                <path                    d={path}                    fill="none"                    className="stroke-chart-1"                    strokeWidth={1.75}                    strokeLinejoin="round"                    strokeLinecap="round"                />                <circle                    cx="280"                    cy={80 - (82 / max) * 80}                    r="3"                    className="fill-card stroke-chart-1"                    strokeWidth={1.75}                />            </svg>        </figure>    );}

Adding a token#

Rare, and done in four places at once. Most new colors turn out to be an existing role at an alpha.

  1. Check whether an existing role or an opacity modifier already does the job. A paler indigo is bg-primary/6, a softer well is bg-muted/60, a quieter ink is an ink step.
  2. Name it for its role. Add the value to :root, .light and to .dark in packages/canon/src/styles/globals.css, in OKLCH, with neutrals on hue 265. A token without a dark value is a bug.
  3. Map it in @theme inline as --color-name: var(--name) (or --shadow-*, --radius-*) so a utility exists.
  4. Record it in DESIGN.md: the light value in the frontmatter, and a display name with its use in the Colors section.
  5. Check contrast in both themes against every surface it sits on. See contrast.
  6. Reload this page. The reference reads the stylesheet on every build; a token no group claims shows up under Other until content/foundations/tokens/catalog.ts sorts it.
Faint Slate, wired through every place a token lives
/* packages/canon/src/styles/globals.css */:root,.light {  --subtle-foreground: oklch(0.62 0.012 265);}.dark {  --subtle-foreground: oklch(0.58 0.012 265);}@theme inline {  --color-subtle-foreground: var(--subtle-foreground);}/* DESIGN.md frontmatter */colors:  subtle-foreground: "oklch(0.62 0.012 265)"/* DESIGN.md, Colors, Neutral */- **Faint Slate** (subtle-foreground): empty-cell dashes,  column-header icons, the neutral status dot.

Don'ts#

Hard-coded colors are the most common way a screen breaks in dark and drifts from the system in light.

Halcyon$12,940.50
Halcyon$12,940.50
Do. Build from role utilities. The same markup is right in both themes.
Halcyon$12,940.50
Halcyon$12,940.50
Don't. Write raw hex. bg-white and text-[#1f2937] look fine in light and stay a white slab in dark.
Northwind FreightDue Oct 2
Orchard StreetDue Oct 2
Northwind FreightDue Oct 2
Orchard StreetDue Oct 2
Do. Take meta text from Slate Meta and a selected row from Quiet Indigo at 6%.
Northwind FreightDue Oct 2
Orchard StreetDue Oct 2
Northwind FreightDue Oct 2
Orchard StreetDue Oct 2
Don't. Reach for a one-off text-gray-500 or bg-indigo-50 because it looks close. It is close in one theme and wrong in the other, and nobody can find it later.
  • No hex, rgb() or literal oklch() in components, and no Tailwind palette colors (bg-white, text-black, bg-gray-100, bg-indigo-600).
  • No one-off colors. If one screen needs a color, it is an existing role; if two do, it becomes a token through the steps above.
  • Don't fix a color with dark: in product code. Fix the token. dark: overrides belong inside packages/canon components, where a role needs a different alpha in dark: the 30% input fill, the 55% scrim, the 20% destructive tint.
  • Don't name classes or variables after colors. --primary, not --indigo; the display name is for people.
  • Shadows come from shadow-*. Never write a box-shadow with its own color, and never pair border with shadow-* on a raised surface.

Legacy variables

--hover, --active, --selected, --destructive-light, --overlay and --focus-ring are older variables still defined in globals.css, in hex and rgb() rather than OKLCH. --focus-ring has no dark value, which DESIGN.md rules out. Nothing in apps/web or packages/canon reads them. Use bg-accent, bg-muted, bg-destructive/10 and ring-ring instead.