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.
- 1Raw valuesOKLCH colors, shadow stacks and lengths, written once per theme.
:root, .lightand.dark - 2Semantic variablesEach value is named for its role, never for its hue or lightness.
--card,--primary,--elevation-border - 3Tailwind mappingRoles become Tailwind theme keys that resolve at the element.
@theme inline - 4UtilitiesComponents only ever write the utility.
bg-card,shadow-border,bg-ink-65
| Raw value (light) | Semantic variable | Tailwind mapping | Utility |
|---|---|---|---|
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 |
/* 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);}// 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 CTimport { 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.
/* 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.
--background--foregroundWhite Plane--card--card-foregroundCard White--popover--popover-foregroundPopover White--primary--primary-foregroundQuiet Indigo--secondary--secondary-foregroundQuiet Fill--accent--accent-foregroundMenu Hover--warning--warning-foregroundCaution Amber--sidebar--sidebar-foregroundCool Rail--sidebar-accent--sidebar-accent-foregroundRail Highlight--tag-blue--tag-blue-fg| Pattern | Means | Examples |
|---|---|---|
--{role} | A surface or fill. | --background, --card, --muted, --primary |
--{role}-foreground | The text and icons that sit on that fill. | --card-foreground, --primary-foreground, --warning-foreground |
--{name}-foreground alone | A 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}-fg | A 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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--backgroundWhite Plane | oklch(1 0 0) | oklch(0.178 0.005 265) | bg-background |
--surfaceRow Mist | oklch(0.985 0.0015 265) | oklch(0.198 0.006 265) | bg-surface |
--cardCard White | oklch(1 0 0) | oklch(0.205 0.006 265) | bg-card |
--popoverPopover White | oklch(1 0 0) | oklch(0.225 0.007 265) | bg-popover |
--secondaryQuiet Fill | oklch(0.962 0.004 265) | oklch(0.25 0.007 265) | bg-secondary |
--mutedWell Gray | oklch(0.965 0.0035 265) | oklch(0.235 0.006 265) | bg-mutedbg-muted/70 |
--accentMenu Hover | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--foregroundGraphite Ink | oklch(0.21 0.006 265) | oklch(0.955 0.003 265) | bg-foreground |
--card-foreground | oklch(0.21 0.006 265) | oklch(0.955 0.003 265) | text-card-foreground |
--popover-foreground | oklch(0.21 0.006 265) | oklch(0.955 0.003 265) | text-popover-foreground |
--secondary-foregroundQuiet Fill Ink | oklch(0.26 0.008 265) | oklch(0.93 0.004 265) | text-secondary-foreground |
--muted-foregroundSlate Meta | oklch(0.5 0.014 265) | oklch(0.7 0.012 265) | text-muted-foreground |
--subtle-foregroundFaint Slate | oklch(0.62 0.012 265) | oklch(0.58 0.012 265) | text-subtle-foreground |
--accent-foreground | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--borderHairline | oklch(0.918 0.005 265) | oklch(1 0 0 / 0.075) | border-border |
--border-strongFirm Hairline | oklch(0.86 0.007 265) | oklch(1 0 0 / 0.13) | border-border-strong |
--inputField Stroke | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--primaryQuiet Indigo | oklch(0.52 0.19 272) | oklch(0.585 0.18 272) | bg-primarybg-primary/6 |
--primary-foregroundIndigo Paper | oklch(0.99 0.004 272) | oklch(0.99 0.004 272) | text-primary-foreground |
--ringFocus Indigo | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--destructiveSignal Red | oklch(0.56 0.2 25) | oklch(0.68 0.18 22) | text-destructivebg-destructive/10 |
--destructive-foreground | oklch(0.99 0 0) | oklch(0.99 0 0) | text-destructive-foreground |
--successLedger Green | oklch(0.56 0.13 155) | oklch(0.7 0.14 155) | text-successbg-success |
--success-foreground | oklch(0.99 0 0) | oklch(0.18 0.03 155) | text-success-foreground |
--warningCaution Amber | oklch(0.7 0.15 68) | oklch(0.8 0.14 75) | bg-warning |
--warning-foregroundAmber Ink | oklch(0.27 0.06 60) | oklch(0.22 0.05 70) | text-warning-foreground |
--infoNote Blue | oklch(0.58 0.13 240) | oklch(0.7 0.12 240) | text-info |
--info-foreground | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--color-ink-80Ink 80 | color-mix(in oklch, var(--foreground) 80%, transparent) | Inverts with --foreground | bg-ink-80 |
--color-ink-65Ink 65 | color-mix(in oklch, var(--foreground) 65%, transparent) | Inverts with --foreground | bg-ink-65 |
--color-ink-45Ink 45 | color-mix(in oklch, var(--foreground) 45%, transparent) | Inverts with --foreground | bg-ink-45 |
--color-ink-25Ink 25 | color-mix(in oklch, var(--foreground) 25%, transparent) | Inverts with --foreground | bg-ink-25 |
--color-ink-15Ink 15 | color-mix(in oklch, var(--foreground) 15%, transparent) | Inverts with --foreground | bg-ink-15 |
Chart#
Series colors for charts inside the chart container, in this order.
| Token | Light | Dark | Utility |
|---|---|---|---|
--chart-1Chart Ink | oklch(0.4 0.02 265) | oklch(0.82 0.015 265) | bg-chart-1 |
--chart-2Chart Teal | oklch(0.66 0.11 190) | oklch(0.7 0.11 190) | bg-chart-2 |
--chart-3Chart Amber | oklch(0.76 0.14 75) | oklch(0.8 0.13 75) | bg-chart-3 |
--chart-4Chart Magenta | oklch(0.6 0.19 330) | oklch(0.68 0.17 330) | bg-chart-4 |
--chart-5Chart Soft Ink | oklch(0.72 0.05 265) | oklch(0.55 0.03 265) | bg-chart-5 |
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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--app-crm | oklch(0.62 0.16 250) | var(--tag-blue-fg) | text-(--app-crm) |
--app-agents | oklch(0.6 0.18 300) | var(--tag-violet-fg) | text-(--app-agents) |
--app-contact | oklch(0.62 0.15 155) | var(--tag-green-fg) | text-(--app-contact) |
--app-tickets | oklch(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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--elevation-xsControl | 0 1px 2px -1px oklch(0.2 0.02 265 / 0.08) | 0 1px 2px -1px oklch(0 0 0 / 0.4) | shadow-xs |
--elevation-sm | 0 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-mdOverlay | 0 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-lgDialog | 0 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 lift | 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) | 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, hover | 0 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 bar | 0 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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--radiusBase radius | 0.625rem10px | Same in both | rounded-lg |
--radius-smTight corners | calc(var(--radius) * 0.6)6px | Same in both | rounded-sm |
--radius-mdSmall corners | calc(var(--radius) * 0.8)8px | Same in both | rounded-md |
--radius-lgStandard corners | var(--radius)10px | Same in both | rounded-lg |
--radius-xlContainer corners | calc(var(--radius) * 1.2)12px | Same in both | rounded-xl |
--radius-2xl | calc(var(--radius) * 1.6)16px | Same in both | rounded-2xl |
--radius-3xl | calc(var(--radius) * 2.2)22px | Same in both | rounded-3xl |
--radius-4xl | calc(var(--radius) * 2.6)26px | Same in both | rounded-4xl |
Type#
Font families and the two sizes Canon adds to Tailwind's scale.
| Token | Light | Dark | Utility |
|---|---|---|---|
--font-sans | var(--font-geist-sans),ui-sans-serif,system-ui,sans-serif | Same in both | font-sans |
--font-mono | var(--font-geist-mono),ui-monospace,"SF Mono",monospace | Same in both | font-mono |
--text-2xs | 0.6875rem,line height 1rem11px on 16px | Same in both | text-2xs |
--text-13 | 0.8125rem,line height 1.25rem13px on 20px | Same in both | text-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.
| Token | Light | Dark | Utility |
|---|---|---|---|
--duration-fast | 80ms | Same in both | duration-(--duration-fast) |
--duration-moderate | 160ms | Same in both | duration-(--duration-moderate) |
--duration-slow | 240ms | Same in both | duration-(--duration-slow) |
--duration-80 | 80ms | Same in both | duration-80 |
--ease-out | cubic-bezier(0.23, 1, 0.32, 1) | Same in both | ease-out |
--ease-in-out | cubic-bezier(0.77, 0, 0.175, 1) | Same in both | ease-in-out |
--ease-drawer | cubic-bezier(0.32, 0.72, 0, 1) | Same in both | ease-drawer |
--ease-standard | cubic-bezier(0.2, 0, 0, 1) | Same in both | ease-standard |
--animate-shimmer | shimmer 1.6s linear infinite | Same in both | animate-shimmer |
--animate-pulse-soft | pulse-soft 1.8s var(--ease-in-out) infinite | Same in both | animate-pulse-soft |
--animate-dash | dash 0.9s linear infinite | Same in both | animate-dash |
--animate-caret-blink | caret-blink 1.1s ease-out infinite | Same in both | animate-caret-blink |
Legacy#
Older variables still defined in the stylesheet. Don't use them in new code.
| Token | Light | Dark | Utility |
|---|---|---|---|
--hover | rgb(0 0 0 / 0.04) | rgb(255 255 255 / 0.06) | bg-hover |
--active | rgb(0 0 0 / 0.07) | rgb(255 255 255 / 0.1) | bg-active |
--selected | #d4d4d4 | #525252 | bg-selected |
--destructive-light | #fef2f2 | #450a0a | bg-destructive-light |
--overlay | 0 0 0 | 255 255 255 | rgb(var(--overlay)/0.1) |
--focus-ring | #6b97ff | Same as light | ring-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.
| Need | Write |
|---|---|
| A role as a fill, text or line | bg-card, text-muted-foreground, border-border |
| A role at an alpha | bg-muted/70 for a well, bg-primary/6 for a selected row, bg-destructive/10 for a destructive tint |
| A variable with no Tailwind key | bg-(--tag-green) text-(--tag-green-fg), text-(--app-crm) |
| A shadow | shadow-border, hover:shadow-border-hover, shadow-popover |
| SVG strokes and fills | stroke-chart-1, fill-card, stroke-border, or currentColor with a text utility |
| A mix the scale doesn't have | bg-[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.
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.
- Check whether an existing role or an opacity modifier already does the job. A paler indigo is
bg-primary/6, a softer well isbg-muted/60, a quieter ink is an ink step. - Name it for its role. Add the value to
:root, .lightand to.darkinpackages/canon/src/styles/globals.css, in OKLCH, with neutrals on hue 265. A token without a dark value is a bug. - Map it in
@theme inlineas--color-name: var(--name)(or--shadow-*,--radius-*) so a utility exists. - Record it in DESIGN.md: the light value in the frontmatter, and a display name with its use in the Colors section.
- Check contrast in both themes against every surface it sits on. See contrast.
- 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.tssorts it.
/* 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.
bg-white and text-[#1f2937] look fine in light and stay a white slab in dark.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 literaloklch()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 insidepackages/canoncomponents, 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 abox-shadowwith its own color, and never pairborderwithshadow-*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.