Skip to content
garn

theme.css

Token architecture

Why each token holds the value it holds — the tier rules, the mode contract, and the contrast math. The Foundations pages show you the tokens; this one is the reasoning behind them.

garn design tokens — 3-tier, mode-based architecture

  • Tier 1 primitives — the raw locked palette (mode-agnostic ramps), stored as OKLCH so the lightness/chroma design intent is readable and editable in place
  • Tier 2 semantic — roles, grouped into "collections" with modes
  • @theme exposes Tier 2 to Tailwind utilities (bg-brand-solid, etc.)

Modes are PER-COLLECTION and orthogonal, never a global cross-product:

  • Color → Light / Dark, AUTHORED once per token as light-dark(light, dark) and switched by `color-scheme` (:root is light; `.dark` flips it). The `.dark` block's per-token overrides are DERIVED from those pairs — edit the :root pair, never the derived block.
  • Elevation → Light / Dark — whole box-shadow recipes, re-declared by hand in `.dark` (no light-dark() for non-colors).
  • Sizing & Spacing → modes: Compact / Default / Spacious ([data-density])
  • Identity geometry (box, radius, border) → NO modes (single value)
  • Motion (duration, easing, enter/exit) → NO modes — "Glide" vibe, globally softened by prefers-reduced-motion (guard at end of file)

"Density scales air, never identity." Color never gets a density mode; sizing never gets a light/dark mode. Components consume ONLY Tier 2, never a raw ramp. Re-theme = remap Tier 2; re-palette = edit Tier 1. A consumer override wins by scope: re-declare the token on :root — with or without light-dark(). To re-theme only part of a page (a tenant, an embedded widget), put the overrides on a [data-garn-theme] element: the derived tiers re-resolve there (see "theme scope" below).

The mode switch

every color token below is AUTHORED once — mode pairs as light-dark(light, dark), resolved against color-scheme at the element that uses the token. The .dark block re-declares each pair's dark arm, derived mechanically from these declarations (see the generated section there) — so this block is the only place mode values are edited.

Tier 1 — primitives

neutral

one continuous cool-gray family (NGTC: neutral-gray → cool). The hue drifts gently 286 → 252 (a whisper of violet at the light end easing to blue-violet in the darks) while chroma climbs smoothly from ~0 to 0.017, so the light surfaces read near-neutral and the cool builds in restrained toward the #181E25 base pinned at 900, with 1000 a near-black floor beneath it. Lightness is distributed so no two adjacent stops collapse into one color and none of them cliff: the light end is deliberately dense (0…150 are the surface steps), the middle carries the widest jumps (200…400 is the one span with few stops), and 600…1000 opens back out so dark mode has four separable ink steps rather than three near-identical ones.

--garn-neutral-250A
hairline border step — between the ·06 fill-hint and the ·14 divider

darkneutral

the same continuous curve as the light ramp, sampled from the other end: cool sits in the LOW (surface) steps and the hue drifts 252 → 283 back toward neutral at the pale ink end, chroma ≤ 0.017 throughout. Its 700…1000 band is the dark-mode ink ramp, so it is spaced wider than the surface band below it (0…200): text steps need to separate, surface steps need to stay close enough to read as one sheet. The anchors reappear as shared cross-mode roles: the dark base (0) sits on the light ramp's #181E25 family, and the brightest ink (1000) on its near-white surface end. 150 is the half- step above the popover (100), as in the light ramp: dark surfaces climb 0 → 25 → 50 → 100, so a filled control that has to read on ANY of them (secondary) needs a step none of them uses.

--garn-darkneutral-250A
hairline border step — between the ·06 fill-hint and the ·14 divider

Chromatic ramps

Each keeps its own hue and chroma curve; the lightness of every stop is tuned against the SAME per-stop target across all nine, so a semantic rule like "600 is the tonal fill, 400 is the dark-mode fill" lands with comparable weight whichever tone it is applied to. Yellow, orange and lime are held deliberately looser: those hues cannot reach the shared lightness at their chroma without going muddy, which is also why warning is the one status solid that takes dark text. 150 and 950 are half-steps at the tint ends (between 100/200 and 900/1000): the tint end is where soft fills live, and a single ramp hop there is too big for a hover — these give a soft interaction state one gentle step to move to (light softs step 100→150; dark softs step 1000→950) while staying on-hue, instead of mixing toward the page ink.

black / white alphas (overlays, scrims)

#000000 lives ONLY here: pure black is reserved for scrims, never a surface or ink stop

space ramp

Tier-1 primitive sizing scale (px-named, mode-agnostic). The semantic sizing/air tokens below ALIAS these; no semantic token holds a raw length. 2px grid up to 24, then 4px grid to 48.

--garn-space-3
3 — off the even ramp on purpose: a hairline gap for framed surfaces (the glass frame strip)

duration ramp

Tier-1 primitive timing scale (ms-named, mode-agnostic), the time analogue of the space ramp. The semantic --garn-motion-* roles below ALIAS these; no semantic motion token holds a raw ms value.

sizing / shape

Control heights snap to a locked 5-rung ladder: 24·28·32·40·44px at default density. Density modes (compact / spacious) re-declare these same tokens — see the [data-density] blocks below — so any subtree carrying data-density re-sizes every control inside it, exactly the way .dark re-colors a subtree. The :root values here ARE the default density.

--garn-radius
8px — md control radius

type family

IDENTITY, NO modes. garn keeps Tailwind's default size / weight / leading scale; only the FACE is tokenised. Exposed to Tailwind as --font-sans / --font-mono in @theme inline (drives both the font-sans / font-mono utilities and the default document font). These name the family; they do NOT load it — the app must load the webfont itself (docs app does via next/font). Registry consumers copying this file must load IBM Plex (Sans + Mono) or remap these two tokens to a face they already ship.

fixed control-glyph geometry

IDENTITY, NOT air. A checkbox box, radio dot, switch track and slider thumb are drawn shapes / hit targets, so they are sized by the component's `size` prop and are deliberately NOT re-declared in the [data-density] blocks: density never resizes them.

box corner radius

IDENTITY, and CAPPED. A checkbox box tracks the small control radius but is clamped so a large global --garn-radius can never round a ~14-16px box into a circle. 0.375rem is the ceiling (below half of the 14px sm box); under it the box follows the theme.

icon ladder

IDENTITY, NOT air. A glyph is drawn shape, like the box / control-glyph geometry above, so density never resizes it (a compact menu keeps full-weight icons; only the air around them tightens). One ladder for the whole library: the sm rung is deliberately 14px — fields and buttons historically disagreed at this rung (14 vs 16), and 14 wins because a 16px glyph inside a 24px xs control leaves too little air around the cap height.

chip ladder

IDENTITY, NOT air. The height of a small label pill (badge, tag, counter, kbd, board count) — a drawn label, not a control, so density never resizes it (a compact table keeps full-size tags; only the air around them tightens). One four-rung ladder so the five chip families stop each inventing their own height: 16·20·24·28. A component maps its own size axis onto these rungs by VALUE (e.g. counter sm=16→xs, tag md=28→lg), so the numbers are preserved while the source of truth is shared.

optical inset

IDENTITY. The amount a control trims its padding on the side that holds an icon/slot, to compensate for the glyph's own internal whitespace (a leading icon otherwise reads as over-padded next to text). Fixed, not air: the glyph's optical inset is ~constant px regardless of density, so the trim is subtracted from the (density-scaled) control-px.

--garn-control-icon-inset
2px

scheduler (event-grid) geometry

IDENTITY, not air. The hour-band height is the ruler the whole time canvas measures against (event top/height are fractions of it via calc), and the axis column is a fixed gutter for the hour labels. Density never rescales the timeline, so these are NOT re-declared per density.

--garn-scheduler-hour
height of one 60-min band (48px)
--garn-scheduler-axis
hour-axis column width (56px)

gantt (roadmap / time-bars) geometry

IDENTITY, not air. The per-day column width is the ruler every bar + header tick measures against (bar left/width are integer-day multiples of it via calc); the `scale` axis scales the EFFECTIVE day width by a unitless factor set inline, so a day/week/month/quarter/year view all ride this one base. Row height, rail width and the header-tier height are fixed structure. Density never rescales a timeline, so these are NOT re-declared per density.

--garn-gantt-day-base
per-day column width at "day" scale (40px)
--garn-gantt-day
EFFECTIVE per-day width; the root overrides this inline to (scale-factor × base) so every bar + tick calc()s off one var.
--garn-gantt-row
gantt row height (40px)
--garn-gantt-rail
left row-rail width (240px) — the name column
--garn-gantt-col
default width of one extra rail column (88px)
--garn-gantt-tier
one header-tier height (24px); header = 2 tiers
--garn-gantt-viewport
default max-height of the scroll region

air — density-scaled container padding + layout gaps

"Density scales air, never identity." These ARE re-declared per density below. Each family has its own step (gentler on big surfaces than on gaps) — density is not one global delta.

--garn-pad-surface
card / dialog / sheet / drawer body
--garn-pad-panel
popover / hover-card / drawer header
--garn-pad-inset
menu / listbox frame — the thin inset between a floating panel's edge and its rows
--garn-gap-inline
adjacent controls: toolbar/menubar/nav/pagination/breadcrumb
--garn-gap-field
label ↔ control
--garn-gap-stack
control ↔ control / header ↔ body
--garn-gap-section
section ↔ section
--garn-row-h
dense selectable row: menu / option / list item (density-scaled below)
--garn-row-px
inline padding of that same row — air, so it breathes with density like --garn-row-h

row ladder

the sized selectable row (list / tree / table). A four-rung ladder so those three families stop hand-coding heights and instead share one scale, which heals the fracture where tree-md (24) silently equalled list-xs (24). Rows currently ride the control ladder exactly, so each rung ALIASES --garn-control-h-* — the alias, not a raw space value, is the point: rebase the whole row family in one place if rows ever want to diverge from controls. An alias resolves where it is DECLARED, so the [data-density] blocks re-declare these four too — otherwise a density subtree would shrink its controls but keep default-height rows. The bare --garn-row-h above stays the tighter menu-row rung and is unaffected.

theme scope — where the derived tiers resolve

Everything below is DERIVED from the primitives above, and a custom property holding var() resolves at the element that declares it — then inherits as a finished value. Declared on :root alone, a re-palette set on a container (a tenant region, an embedded widget, a side-by-side theme preview) would change the ramps but leave every role still computed from :root's. So the derived tiers are declared on :root AND on every [data-garn-theme] element, which re-derives them from whatever primitives are in scope there:

  • [data-garn-theme="acme"] { --garn-brand-500: …; --garn-brand-700: …; }

The attribute value is free-form — it only marks the scope. Primitives, sizing and density are NOT re-declared here, so a scope inherits them from its ancestors (a theme scope inside a compact region stays compact). A role overridden on an ancestor is re-derived inside a scope; override it on the scope itself if it must hold there.

Tier 2 — semantic (light-dark per token)

surface roles

named elevation intents (sunken → canvas/background → raised → overlay), each step paired with the shadow that carries its depth. Light rides to the white end of the ramp — raised (card) and overlay (popover) both land on pure white over a near-white canvas, so the paired shadows do the separating, and each one opens with a hairline ring that draws the edge. Dark climbs lighter surfaces. card and popover ALIAS raised / overlay, so no component bindings change. sunken is a large area, so it stays one quiet step under the canvas in light (neutral-50) and lets its inset hairline draw the rim; a darker fill turns a page-sized well into a grey slab. Dark drops it below the canvas.

One recipe for every tier

a hairline ring, then the lift. A higher tier scales the lift layers up rather than adding new ones; the sunken well is the inward mirror (inset ring + inset contact). The lift layers are declared once per mode in the elevation block below.

A gentle filled surface

the lightest-touch tint off the base surface, for a "soft" card fill that reads without the heavier `surface-muted` (neutral-100). Light: one step off white (neutral-25); dark: the raised step (darkneutral-50), ABOVE the `surface-muted` well (darkneutral-25) — a muted well nested in a soft card has to stay visible. Shares its dark fill with `card`/`raised`; the elevated card is told apart by its shadow, not its fill.

surface-muted

the ground under a track or well (tab list, progress / slider track, skeleton, empty grid cells) and the tinted band of a table header or footer. It is always something's ground, never the object: a filled chip or soft button is tone-neutral-soft. Tracks are thin, and a thin shape needs a bigger lightness step than a large surface to be seen, so light takes neutral-100 (darker than sunken): it reads on the canvas and on a white card alike. Dark sits one step under the card, so a track recesses into the surface it lies on.

surface-thumb

the raised "thumb" that lifts OUT of a tinted track (the segmented control's `surface-muted` well): white in light, well ABOVE `surface-muted` in dark. `card` now sits just one step above `surface-muted` (a faint lift), so a card thumb would read only barely raised on the well; surface-thumb sits a further step up for an unambiguous lift. Its hairline edge sits ~2 ramp steps off the thumb, away from the track, so the two can never merge — in dark. Light has no step above white, so there the edge lands on the track's own step and the white body plus its shadow carry the lift. (An ambient [data-elevated] track with no well of its own — a toggle-group on the canvas — keeps the `card` thumb; this pair is for the muted well.)

Framed-surface signature

opacity of the frosted strip fill (surface-overlay mixed toward transparent). Default suits an in-flow surface over content (Card); surfaces over a dim backdrop (Dialog / overlays) raise it locally so the strip stays light, not muddy. Mode-independent.

brand

Tier-2 semantic. Shares the `brand` prefix with the Tier-1 ramp (--garn-brand-50…1000) by decision: the tone vocabulary is brand/danger everywhere (tone="brand", --garn-badge-brand-*), so the semantic tier follows it. Suffixed names (-solid/-soft/-strong) never collide with the numeric ramp steps.

--garn-brand-strong
one step darker than the solid — solid-fill edge/border
--garn-muted-foreground
600, not 500 — 500 lands under 4.5:1 on the canvas, so it is icon/large-text only

interaction state layers

translucent slices of the surface's OWN foreground (Material 3 on-surface state-layer model). Because each is a fraction of --garn-foreground, it darkens light surfaces and lightens dark ones — so a hover / active / selection reads on ANY surface shade (card, muted bar, popover) and can never collapse to the surface colour the way an absolute neutral fill (bg-surface-muted) does on a tinted base. rest < hover < active is the Radix A3/A4/A5 escalation; exposed as bg-state-* below. Declared once: the nested --garn-foreground carries its own light-dark(), which resolves at the element USING the layer — so the mix flips with the surrounding color-scheme, subtree .dark included.

interaction EDGE layers

the stroke twin of the state layers above. Same on-surface model (a fraction of the surface's own foreground, so an edge reads on any surface shade), but the edge ramp sits higher than the fill ramp because a 1px stroke needs more alpha than a filled area to carry the same weight: 15 / 25 / 40 vs the fills' 5 / 8 / 12.

  • rest — a resting stroke on a tinted surface
  • hover — the edge darkening under the cursor
  • strong — the pressed / active edge

These are the adaptive alternative to `--garn-border`, for a stroke that has to survive on a surface whose shade is not known up front — and the named home of the field family's border dialect (inputs hand-roll these exact foreground fractions for rest / hover / focused-companion strokes today); a control that sits on a known surface should keep using `border` / `input`. Neither is the focus indicator — that is `--garn-ring`. Exposed in @theme as border-edge-* / ring-edge-* below. Mode-adaptive the same way the state layers are: the nested foreground's light-dark() resolves at point of use.

alpha ladder

the blessed translucency steps. Five sanctioned fractions of the surface's OWN foreground, same on-surface model as the state/edge layers above (ink-relative, so a step darkens light surfaces and lightens dark ones and can never collapse into the surface it sits on); the number IS the percent of foreground. This is the generic vocabulary for any translucent ink — where a `/NN` opacity modifier seems needed, snap to a ladder step instead; a modifier on a contrast-floor token (`muted-foreground/70`) defeats the floor and is never sanctioned. Rungs, by role: 4 = faint wash (a barely-there fill hint) · 10 / 15 = tint fills (a readable wash / a resting scrim over content) · 30 = a visible stroke or divider · 50 = a strong mark (an emphatic stroke, a dimmed glyph). Named --garn-alpha-<step> rather than folded into state-* / edge-*: those are ROLE-tuned interaction rungs (fill 5/8/12, stroke 15/25/40) whose values move with the interaction model; the ladder is role-free and its numbers are the contract. Also distinct from the Tier-1 --garn-alpha-black/white ramps: those are ABSOLUTE scrims for over-media chrome and deliberately never adapt to mode, while this ladder is mode-adaptive by construction (the nested foreground's light-dark() resolves at point of use, subtree .dark included). Exposed as bg/text/border/ring-alpha-<step> utilities below.

elevated-selected contract

a selected/on control reads its fill + lift from these two vars, so ONE recipe covers both contexts (WCAG 1.4.11). The default is the adaptive translucent layer (visible on any surface); a tinted TRACK (a soft toolbar bar, a segmented control) sets [data-elevated] below to lift the selected control to an opaque card "thumb" + shadow, the way tabs' active trigger cuts out of its muted list.

Destructive keeps white text in BOTH modes

the alarm reads from a saturated red, not an inverted-text lozenge — so the fill stays red-700 everywhere: dark's brighter 600 would leave white at only 4.3:1.

The focus ring is a non-text UI indicator, so it owes ≥3:1 against BOTH surfaces it can land on (WCAG 1.4.11) — and the canvas, not the white card, is the harder of the two. brand-500 clears the card but not the canvas, so light takes brand-600; dark keeps the brighter 400 against its near-black.

named roles previously improvised per-component

  • disabled — fill + text pair for disabled controls; the tokened replacement for ad-hoc opacity-50 dimming
  • link — inline link color + its hover (prose links, file-upload's browse affordance), decoupled from the button's brand fill
  • inverse — the opposite-mode surface pair (tooltip, sonner's dark toast, app-shell's floating selection bar)
  • skeleton — skeleton.tsx's fill; alias of muted today, named so the shimmer can tint independently of muted

disabled is a faint veil of the surface's own ink, not a solid step: a solid lands at a different distance on the canvas, a card and a popover (and equals one of them in dark), so the control loses its box there. A 4% veil keeps the same small step on every surface, stays below the 5% rest layer and the soft fill so it never reads as enabled, and because controls reuse it for their border, the border paints veil-over-veil — a rim twice as dark that keeps the silhouette without a token of its own.

button bodies + solid fills

The neutral default is the DARKEST neutral (inverts per mode); status solids are contrast-tuned (success needs white text → darker green; warning is amber → dark text). brand-soft is the low-emphasis "soft" brand. The filled-button edge is mixed from each fill at the component (button's --btn-edge), so no per-variant border tokens.

--garn-neutral-solid
near-black neutral default

the 1px rim on the neutral solid fill (button's --btn-edge, the split-button seam). The neutral fill INVERTS between modes (near-black in light, near-white in dark), so this is a ramp PAIR, not one step: a mid-neutral that sits a few steps off the fill in each mode — lighter than the dark light-mode button, darker than the near-white dark-mode one. A designed step, not a mix toward the foreground; colored solids keep their fill-derived --btn-edge.

--garn-brand-soft
soft brand (low emphasis)
--garn-success-solid
darker → AA with white text
--garn-warning-solid
amber → dark text
--garn-info-solid
darker → AA with white text

interaction companions — hover / active per fill

SOLID fills follow one recipe: the fill mixed toward the PAGE ink (--garn-foreground), in oklab. In light that deepens the fill (the classic darkened hover); in dark the ink is near-white, so the fill brightens — the state moves AWAY from the page in both modes, where a fixed darken (`black N%`) fades dark-mode fills INTO the page. Hover 8%, active 16%. Solid hover/active pairs keep their text ≥ AA (contrast-checked). SOFT fills do NOT mix — they step the tone's own ramp (see the soft block below), which stays on-hue where a mix on a pale tint greys it. Exposed as bg-*-hover / bg-*-active utilities below.

the inverse fill

page ink ≈ the fill itself, a no-op — so neutral's states mix toward its OWN foreground (the page-background direction).

danger active runs 10%, not 16

white text on red-700 has the least slack of the solids, and dark's near-white ink at 16% pulls it under AA.

warning active runs 12%

amber carries DARK text, so light's near-black ink at 16% eats the pair's AA margin from the text side.

Soft interaction states step the tone's OWN ramp rather than mixing toward the page ink: a soft fill is a pale tint (100 in light, 1000 in dark), and a mix toward ink there travels far and greys the hue. Stepping keeps the hue — hover takes the tint-end half-step (150 / 950), active the next full step (200 / 900) — so the chip deepens in its own colour. Solid states still mix (they sit deep in the ramp, where a mix reads clean); see the companions note above.

warning's tonal role takes 600 like every other tone, NOT the 500 its solid fill uses. This token is a graphic — a Progress bar, a Tracker cell, a Gantt bar, the Gauge arc, an Alert icon — so it owes 3:1 against the surface it draws on (WCAG 1.4.11), and amber at 500 only reaches 2.5:1 on a white card. The cost is that warning reads more burnt-amber than the 500; the ramp's hue turns toward orange as it darkens, which is why `warning-solid` stays at 500 and inverts to dark text instead of following the 700 the other solids take.

brand AS A DRAWN GRAPHIC

the 600/400 chart step, mirroring the status tones' bare graphic roles above. NOT `brand-solid` (700), whose extra depth is tuned for a text-bearing button/fill; a thin Gauge arc or marker owes only 3:1 as non-text (WCAG 1.4.11), which 600 clears on the light card. Fills the one gap in the bare-graphic family (status tones have it; the primary did not).

orange and violet stop here

no bare Tier-2 role. Reach them through the tone matrix below (`tone-orange-*`, `tone-discovery-*` for violet's hue); the two hue-named roles this comment used to declare were grandfathered drift that duplicated the matrix under a Tier-1-shaped name.

Chart / data-viz — categorical + structural

The series palette for charts & sparklines. Eight categorical hues that ALIAS the Tier-1 ramps (no raw values), ORDERED for maximum perceptual separation + CVD-safety (Okabe-Ito style): the first 3–4 are maximally distinct, similar hues never sit adjacent (blue↔orange lead the ramp; green and red are kept far apart). Light uses the 600 step — each clears ≥3:1 against the light card surface (WCAG 1.4.11 non-text contrast); dark takes the brighter 400 step for ≥3:1 on dark, the same light/dark convention the status tones use. Structural (grid / axis) + signed (positive / negative) tokens ALIAS semantic roles and flip with them. Exposed in @theme as fill-chart-1 / stroke-chart-1 / text-chart-1 utilities AND consumable as var(--color-chart-1) inside a Recharts ChartConfig. Color is never the only channel — the Chart a11y contract also requires a marker / dash / direct-label cue.

--garn-chart-grid
gridlines — subtle, flips w/ border
--garn-chart-axis
axis lines + tick labels
--garn-chart-positive
signed series — gain / over
--garn-chart-negative
signed series — loss / under

MAGNITUDE scales

categorical ≠ signed ≠ sequential. A heatmap or activity grid encodes "how much", which the categorical palette above cannot: it is ordered for hue separation, not lightness order. The sequential ramp is a single-hue lightness walk on the brand (chart-1) hue — seq-1 sits near the surface, seq-5 is the saturated anchor — and each mode walks AWAY from its own surface (light darkens, dark lightens), so the ramp reads in both without inverting data. The diverging scale runs danger ↔ neutral ↔ success for signed magnitude (delta heatmaps, over/under tracks): its extremes ALIAS the signed chart-negative / chart-positive tokens so "the chart red/green" has one source, and its zero is a near-surface neutral. Only the saturated extremes owe 3:1 non-text contrast (contrast-checked); interior steps encode magnitude precisely by approaching the surface.

Tone matrix — the semantic tone-accent tier

One complete palette per tone: `soft` (tinted fill) + `soft-fg` (its text), `solid` (bold fill) + `solid-fg`, and `edge` (the tone's stroke). This is THE map for any component with a `tone` axis — chips, bars, markers, headers — so a tone always renders the same pair everywhere, neutral included (no per-component improvisation). Light rides the semantic ramps; dark deepens soft to the 1000 tint carrying bright 400-level text — a deep tint with luminous ink, not a lightened copy — and keeps solid a brighter 700. Every fg/bg pair is contrast-checked (pnpm -F @garn/tokens contrast). Exposed in @theme as bg-tone-<tone>-soft / text-tone-<tone>-soft-fg / bg-tone-<tone>-solid / border-tone-<tone>-edge utilities.

The neutral soft fill sits on every surface

canvas, muted, card, popover — so in dark it takes the 150 half-step none of them use, a lift above all four; its hover/active then climb further away from the host. Light takes the 100 step every chromatic soft uses, so a neutral chip carries the same weight as a brand or danger one. That is muted's light step too: in light a soft fill doesn't belong on a muted well — use the outline appearance.

hover companions, per tone

SOFT hover steps the tone's own ramp to the tint-end half-step (150 in light, 950 in dark) — on-hue, matching the flat soft-hover family above. SOLID hover keeps the mix toward the page ink (it sits deep in the ramp, where a mix reads clean). Neutral is the exception: its soft has no clean ramp twin (neutral-50 / darkneutral-150) and its solid is the inverse fill, so both keep the mix. Neutral soft also carries a press rung (-active, the 12% mix), because it is the soft Button's fill.

tone-neutral-solid IS the inverse fill

own-foreground direction (see the interaction-companions note).

Badge — component tokens

The Badge consumes ONLY these --garn-badge-* tokens; each aliases the tone matrix above 1:1 (bg→soft, solid→solid, edge→edge), kept as a stable component namespace so existing Badge copies keep working while the matrix is the single place tone values live.

Avatar — presence / status component tokens

The Avatar binds ONLY these (exposed as bg-avatar-* / text-avatar-* / border-avatar-* / ring-avatar-* utilities in @theme inline below), never a raw ramp. Each ALIASES a semantic role and flips with it.

--garn-avatar-online
presence online · status approved
--garn-avatar-busy
presence busy · status declined
--garn-avatar-focus
presence focus
--garn-avatar-offline
presence offline (hollow ring)
--garn-avatar-locked
status locked

Tabs — the active-tab chip surfaces

The segmented thumb rides the shared `surface-thumb` role (a raised thumb over the `surface-muted` well); only the `chip` variant keeps its own surfaces here. The soft chip is a gentle pair: a fill barely off the canvas, delineated by its own hairline 2 ramp steps down rather than by fill contrast. Its hover fill deliberately sits a step BELOW the chip — a fill that light leaves no room for a tint beneath it, and a hover nobody can see is not a hover. Selection still reads, because the chip is marked by its hairline and the bar, not by being the heaviest fill on the strip. Dark HAS room beneath the chip, so there the order is the natural one: the chip lifts to the raised step (darkneutral-50) and the hover sits between it and the canvas (darkneutral-25) — off the `surface-muted` well's step for the chip, off the card's step for the hover.

The vertical `folder` menu's row fills

alpha ramp steps, so they tint whatever surface the menu sits on. Selection is one step above hover and both stay light — the selected label's ink, not a dark slab, carries it.

The brand tone's chip is the SAME design stepped off the brand ramp — fill lightest, hover one step down, edge two — rather than a second hand-tuned look, so the two tones stay in sync when either ramp moves. Its icon sits lighter than the label (brand-solid) exactly as muted-foreground sits under foreground, and the count pill one step above the chip so it still separates inside it. Dark walks the ramp's dark end for the same relationships.

Unlike the neutral chip, the brand one is already legible against the canvas, so its hover does not need to step down to be seen — it takes the chip's own fill and lets the hairline, the bar and the label weight carry selection.

Board (Kanban) geometry

Column width is a layout dimension (not density spacing), so it lives in the token layer as a raw length; `size` picks the sm/md width via --garn-board-col. Card + column gaps ride the shared --garn-space-* scale (density), not here.

--garn-board-col-w
288px — default column width; the middle of the 270–300 band mainstream kanban tools settle on
--garn-board-col-w-sm
240px — dense board
--garn-board-col
EFFECTIVE column width; the `size` variant reassigns this to -w / -w-sm, and columns read it for their width
--garn-board-rail-w
48px — collapsed-column rail width

Calendar geometry

The day/weekday cell edge is a layout dimension (a square grid unit), not density spacing — so, like --garn-board-col-w, it lives in the token layer as a raw length; the `size` axis picks sm/md/lg via --garn-calendar-cell. md (32px) matches the previous hard-wired --garn-control-h-md cell so the embedded pickers keep their footprint. Row/caption gaps ride --garn-space-*.

--garn-calendar-cell-sm
28px — dense popover calendar
--garn-calendar-cell-md
32px — default
--garn-calendar-cell-lg
40px — airy inline calendar
--garn-calendar-cell
EFFECTIVE cell edge; the `size` variant reassigns this to -sm / -md / -lg, and cells read it

sticky-surface

the translucent fill for a sticky header / footer that content scrolls under (table header, list group label, toolbar pin). 80% of the surrounding canvas over transparent: enough body to keep the pinned row legible, enough translucency that scrolled content still reads as passing UNDER it rather than vanishing. Mode-adaptive by construction (background carries its own light-dark(), resolved at the element using the fill). The frosted look needs a backdrop-blur with it — blur is a filter, not a color, so it stays a component-side utility.

Elevation — semantic shadows

A Light/Dark collection (NOT density). Mapped onto Tailwind's --shadow-* below so `shadow-sm|md|lg` are tokenized + mode-aware.

The one ink every shadow recipe derives from

remap it (e.g. toward the brand hue) and the whole elevation system tints together. Light casts the page ink #17181b (the same rgb(23,24,27) the recipes always used, so the rendered output is unchanged); dark casts pure black — shadows on unlit surfaces need weight, not tint. Each recipe mixes it to its own alpha via color-mix, which premultiplies against transparent — identical output to the old per-recipe rgba literals. Hand-declared per mode like the recipes themselves (shadow tokens sit outside the generated dark block).

The edge every raised surface draws as a 0

5px ring instead of leaning on its fill: a whisper of the shadow ink on light surfaces. Dark flips it to a faint LIGHT line (below), since a dark ring vanishes on a dark page.

sm is the card lift

a soft ambient layer that spreads the light plus a tight contact layer that grounds the edge.

Directional seam shadows for a pinned table column / sticky header / sticky footer — a tight one-edge cast (negative spread keeps it off the perpendicular edges), shown only while content is scrolled under the frozen edge.

lift

the DRAG elevation role, distinct from overlay elevation: a card mid-drag (board, sortable list, gallery reorder) floats above its peers, not above the whole page the way a popover does, but today both borrow shadow-lg. Naming the role lets drag elevation diverge from overlay elevation without touching components; for now it aliases shadow-lg. Exposed as the shadow-lift utility.

filled-button shadow recipe lives in @theme inline (NOT here): it derives from --btn-fill, which is set per-variant on the button — a custom property declared in :root would pre-resolve var(--btn-fill) to its fallback and inherit that down, so the recipe must be inlined into the utility instead.

Motion — semantic ("Glide" vibe)

Mode-agnostic, like identity geometry: no light/dark or density modes. "Glide" = a confident cubic-out (easeOutCubic) at a 300ms default. Durations ALIAS the ramp above; easings + enter geometry are the Glide curve set. Wired into Tailwind below as the DEFAULT transition timing, so every existing `transition-*` utility adopts Glide with zero component edits.

--garn-motion-instant
color / opacity hovers, focus rings
--garn-motion-fast
controls, toggles, switches, accordions
--garn-motion-normal
DEFAULT — content, menus, overlays
--garn-motion-slow
large overlays: dialog / sheet / drawer
--garn-motion-slower
page-level, route transitions
--garn-motion-spin
continuous loop — spinner rotation
--garn-ease-standard
easeOutCubic — default + entrances
--garn-ease-exit
easeInCubic — exits accelerate away
--garn-ease-in-out
easeInOutCubic — moves that return
--garn-ease-out-back
gentle overshoot — a settle that "lands" (drop-to-place)
--garn-ease-linear
progress fills, spinners, carets
--garn-enter-slide
12px — panel / menu / content rise
--garn-enter-slide-sm
8px — popover / tooltip nudge
--garn-enter-zoom
scale origin for zoom-in entrances

density modes (re-declare the size tokens)

Apply on <html> for a global default, or on any container to scope density to a region (e.g. a dense data table inside a roomy page). Each block re-declares the control tokens directly so descendants inherit the new heights — default density needs no attribute.

Dark mode — the switch + per-mode shadows

Also applied to a theme scope inside a dark subtree: the scope re-declares the derived tiers with their light recipes (shadows carry no light-dark()), so the dark ones must be re-applied there. :where() keeps this at the same specificity as .dark, so a scope's own overrides still win by source order.

light-dark() reads this

flipping color-scheme flips the color tokens declared above at the element that uses them. (Native form controls and scrollbars also pick up the dark UA styling.)

Shadows are whole box-shadow recipes, not single colors, and dark's lg geometry differs — so elevation stays a per-mode block (deeper casts: elevation must read on surfaces that get no ambient light), and the shadow ink is re-declared with them so the recipes below mix against dark's black rather than the inherited light pair.

Dark drops the wide ambient layer (it is invisible on a near-black page) and lets surface lightness carry the depth: the hairline turns LIGHT to catch the edge, and only one tight, dark contact layer grounds it.

The shadow aliases (the surface recipes + lift) are re-declared, unlike every color alias: a var() nested in a custom property still substitutes at the DECLARING scope, and only light-dark() defers to the point of use — a shadow value's geometry and alphas carry no light-dark(), so without this re-declare a subtree .dark would inherit the light recipes.

The lit edge

a faint 1px highlight along the top inside edge of raised and overlay surfaces, so they read lit from above. Dark only — on a light surface a light line is invisible.

The

root light-dark() pairs are the single authored mode source; the re-declares below are DERIVED from them (scripts/build-dark-block.mjs; drift fails CI). They exist because Chromium resolves a light-dark() arriving through var() against the INHERITED color-scheme — so a same-element pattern like `<div class="dark bg-surface-sunken">` would otherwise paint the light arm. Re-declared custom properties apply on the .dark element itself, which every engine resolves correctly.

▼ GENERATED dark overrides

do not edit: derived from the :root light-dark() pairs by scripts/build-dark-block.mjs

app-shell canvas ↔ variant

The only tonal difference between the two shell looks is the step *under* the surfaces — surfaces (--garn-card) stay white; the canvas (--garn-background) is the variable.

  • framed (default) → canvas keeps its tint (neutral-25 / darkneutral-0); white panels float on the step.
  • flush → collapse the step: alias the canvas to the surface color, so shell + panels merge into one sheet and hairlines do all the dividing (the low-ornament "clean" look).

Written as an alias (not a literal) so it holds in light AND dark for free: card resolves to neutral-0 in light, darkneutral-50 in dark.

elevated-selected track contract

A tinted track (a soft toolbar bar, a segmented control) marks itself [data-elevated]; its selected descendants then read --garn-selected-surface as an opaque card "thumb" + a shadow (via --garn-selected-shadow) instead of the default translucent layer, so selection keeps a visible boundary on the tint (WCAG 1.4.11) exactly the way tabs' active trigger cuts out of its muted list. The reduced-transparency swap is declared FIRST so [data-elevated] (equal specificity, later in source) still wins for an elevated track.

the default selected layer isn't transparency-critical (the ring carries the boundary), but honour the OS setting — swap the translucent layer for an opaque neutral fill so selection never depends on a see-through tint. `tone-neutral-soft` carries its own light-dark(), so one declaration serves both modes.

expose Tier 2 to Tailwind utilities

--radius-xs
4px — micro chips (kbd, tags)

type family

font-sans / font-mono utilities + the default document font (Tailwind v4 derives --default-font-family from --font-sans).

surface roles

bg-surface-sunken / -raised / -overlay / -soft / -muted; the paired shadows land as shadow-surface-sunken / -raised / -overlay utilities.

the raised segmented thumb over a surface-muted well

bg-surface-thumb / border-surface-thumb-edge.

brand fill + accent (bg-brand-solid / text-brand / border-brand …). bare `brand` = the accent color (same value as the solid fill).

alpha ladder

bg/text/border/ring-alpha-<step> — the blessed ink-translucency steps (percent of the surface's own foreground).

elevated-selected contract

bg-selected-surface + shadow-selected; a [data-elevated] track flips both to the opaque thumb + lift.

named roles

bg-disabled / text-disabled-foreground / text-link / bg-inverse / text-inverse-foreground / bg-skeleton utilities

chart / data-viz

fill-chart-1 / stroke-chart-N / text-chart-N utilities (for the zero-dep sparkline SVG) and var(--color-chart-N) for a Recharts ChartConfig. Structural + signed tokens exposed the same way.

magnitude scales

bg/fill-chart-seq-N + bg/fill-chart-div-* utilities (and var(--color-chart-seq-N) in a ChartConfig) — sequential for unsigned heat, diverging for signed deltas.

tone matrix

utilities: bg-tone-<tone>-soft / text-tone-<tone>-soft-fg / bg-tone-<tone>-solid / text-tone-<tone>-solid-fg / border-tone-<tone>-edge. The canonical map for every `tone` axis.

avatar presence/status

utilities (bg-avatar-online, border-avatar-offline, …)

badge namespace

utilities (bg-badge-<tone>-bg / text-badge-<tone>-fg / bg-badge-<tone>-solid / text-badge-<tone>-solid-fg / border-badge-<tone>-edge) so Badge-shaped chips bind the namespace without arbitrary var() syntax.

tabs `chip`-variant surfaces

bg-tabs-chip / border-tabs-chip-edge / bg-tabs-chip-hover utilities. (The segmented thumb rides bg-surface-thumb.)

gallery placeholder

bg-gallery-placeholder (the LQIP / skeleton fill; the gallery's geometry tokens are lengths and stay out of the color map).

Tier-1 scrim ramps

utilities (bg-alpha-black-30, text-alpha-white-90, from-alpha-black-70, …) — the sanctioned absolute overlays for over-media chrome: lightbox scrims, caption gradients, controls drawn on images. Deliberately mode-invariant (over a photo there is no light/dark surface to adapt to), which is why this one Tier-1 family earns direct utility exposure while every other color utility here rides Tier 2.

sticky-surface

bg-sticky-surface (pair with a backdrop-blur utility at the component — blur is a filter, not a color).

elevation

Tailwind shadow utilities now resolve to garn's mode-aware tokens (shadow-sm/md/lg + bare shadow).

motion

Tailwind. Making Glide the DEFAULT transition timing means every bare `transition` / `transition-colors` / `transition-all` already in the 44 components adopts the curve (and a 200ms base) with no edits — the same zero-touch trick used for elevation. Components opt into other rungs with `duration-[var(--garn-motion-slow)]` and curves with `ease-exit` etc.

easing utilities

ease-standard / ease-exit / ease-in-out (ease-linear is built in)

named animations, all tokenized to the Glide vibe

enter/exit = composite fade + rise + zoom for overlays (data-[state=open]:animate-enter / data-[state=closed]:animate-exit); fade-* for scrims.

Progress indeterminate

a partial fill sweeps the track left→right at a steady pace (`--garn-ease-linear` is the designated progress-fill/spinner curve, so the sweep reads as continuous "busy" rather than a move that rests at each end). The library-wide prefers-reduced-motion guard (end of file) collapses it.

Draw-in

a stroke traced from its start to its end, for status glyphs that resolve in place (Button's success check / error cross). The path must set `pathLength="1"` + `stroke-dasharray="1"` so the offset ramp is unit-based and independent of the glyph's real length. `both` fill matters twice: it holds the undrawn state before the first frame, and — because the reduced-motion guard at the end of this file collapses the duration to ~0ms — it leaves the glyph fully drawn for that audience too.

Progress indeterminate sweep

a 40%-wide fill (w-2/5, set in the component) travels from just off the left edge to just off the right. The bounds are tuned to the 40% width so a *linear* loop has minimal dead time at the wrap: −100% = fully off left; 250% = 250%×40% = 100% offset = fully off right. The track's overflow-hidden clips the entering/leaving edges.

Glide enter/exit

composite fade + rise + zoom. Geometry comes from the --garn-enter-* tokens; timing from --animate-enter / --animate-exit.

Calendar month-transition (opt-in `animate`)

a slide+fade in the nav direction. `-end` = the trailing edge (forward / next month), `-start` = the leading edge (backward / previous month). Travel rides --garn-enter-slide; the global prefers-reduced-motion block below neutralizes them.

Interactive controls get a pointer cursor

Browsers only default this for links (`a:any-link`); buttons, menus, tabs, etc. otherwise show the arrow. Kept in `@layer base` so a component's own cursor utility still wins: disabled → `cursor-not-allowed`, drag handle → `cursor-grab`, gantt resizer → `cursor-ew-resize`. Text fields are omitted (they keep their I-beam); disabled controls are reset just below.

Accessibility

honour the OS "reduce motion" setting library-wide. Unlayered + !important so it overrides every token-driven transition/animation above (and any inline timing). Motion collapses to ~instant; content still arrives.