# Design tokens — single owner model (@godxjp/ui)

Every visual dimension has **one canonical owner**. Apps never patch spacing, density, typography, or color ad hoc.

## Architecture

```
src/tokens/base.css          ← token manifest; imports every base token file
src/tokens/foundation.css    ← primitive color accents, typography, raw spacing, icon size, ratio, radius, shadow
src/tokens/semantic/         ← semantic aliases by UI role
  layout.css                 ← page/section/stack/inline tokens
src/tokens/components/       ← component token files
  control.css                ← control height + control padding
  card.css                   ← Card component tokens
  table.css                  ← Table component tokens
  feedback.css               ← Dialog/Alert/EmptyState component tokens
  badge.css                  ← Badge component tokens
src/styles/
  index.css                  ← @theme bridge (Tailwind ↔ tokens) + body defaults
  density.css                ← .ui-density-* (PageContainer density prop)
  layout.css                 ← Flex / Page / EmptyState
  control.css                ← inputs, buttons height (--control-height)
  card-layout.css            ← Card* slots
  table-layout.css           ← Table + DataTable chrome
  dialog-layout.css          ← Dialog* slots
  alert-layout.css           ← Alert* slots + semantic variant colors
  badge-layout.css           ← Badge spacing
```

**Components emit `data-slot` + flags. Layout CSS applies padding/margin/gap/font-size/color.**

`base.css` is the single entry point. Primitive token files are split only for governance and audit; apps still import `@godxjp/ui/styles` once and override tokens in `theme.css`.

## By concern

### Color

| Owner                                   | Rule                                                                |
| --------------------------------------- | ------------------------------------------------------------------- |
| `base.css` `:root` / `.dark`            | HSL components (`--primary`, `--destructive`, …)                    |
| `index.css` `@theme`                    | Maps to Tailwind `bg-primary`, `text-muted-foreground`, …           |
| App `theme.css`                         | Override `:root` only — never component CSS                         |
| `alert-layout.css`, `control-styles.ts` | Semantic tones (`success`, `warning`, …) — never `text-emerald-600` |

Default brand tokens use the GodX Agent Portal palette: navy primary, 朱 orange focus/accent, warm neutral surfaces. App or customer identity colors belong in the consuming app theme, not in package tokens.

#### The three tone tiers — FILL, TEXT, MARK

A status tone can be painted three ways, and each way is judged against a different thing. Reading
the wrong tier is this palette's most expensive recurring bug, because nothing about it looks wrong
in the source.

| Tier     | Tokens                                      | What it paints                                                     | Contrast bar                                              |
| -------- | ------------------------------------------- | ------------------------------------------------------------------ | --------------------------------------------------------- |
| **FILL** | `--success`, `--warning`, `--info`, `--destructive` | A solid chip, band or bar with a label ON it                        | AA **4.5:1** against its own `*-foreground` label          |
| **TEXT** | `--text-success`, `--text-warning`, `--text-info`, `--text-error` | Small coloured type — a StatCard delta, an outline badge label, a field's error line | AA **4.5:1** against the surface BEHIND it                 |
| **MARK** | `--mark-success`, `--mark-warning`, `--mark-info`, `--mark-destructive`, `--mark-primary`, `--mark-attention` | A thin shape carrying meaning with nothing written on it — a `Card accent` rail, a `DataTable rowTone` rail | SC 1.4.11 **3:1** against the surface it sits on |

The MARK tier exists because both rails were reading FILL, and two of them were effectively
invisible. Measured in Chromium on the light card: `Card accent="warning"` drew its 6px rail at
**1.74:1**, `accent="success"` at **2.18:1**. `--warning` is a bright 山吹 tuned to carry dark text
on top of it; on a near-white card it is barely a colour at all.

`--mark-*` resolve THROUGH the TEXT tier rather than restating its numbers, so a theme retunes one
place and both follow — and since 3:1 is looser than 4.5:1, anything legible as text is legible as
a mark by construction. `--mark-attention` is the exception: there is no `--text-attention`, and
the fill already clears the floor in both themes (3.32 / 6.75).

Two guards, because one was not enough:
`src/tokens/__tests__/tone-mark-contrast.test.ts` recomputes every tone × ground × theme off the
committed tokens and reads the alias out of the CSS (so repointing a mark back at FILL fails the
ratio, not just a name check); and `check:contrast` grew a **rail pass** — its graphic pass only
ever measured elements ≤24px on both axes, and a rail is 6px by the full height of a card, so no
gate had ever looked at one.

**Progress and Legend joined the tier, and the wa-iro question is settled.** `.ui-legend-swatch`,
`.ui-progress-bar` and `.ui-progress-segment` all read FILL as standalone graphics and all failed
the same floor. The floor applies: nothing is written on a progress fill, so *where the colour
stops* is the entire datum, and a slice of a partition carries its share of the whole with no words
on it — that is precisely a "graphical object required to understand the content". The hue loses.
The two moved together because a swatch is a SAMPLE of the bar beside it; a key that is not the
colour it is a key to is not a key.

| surface | tone | ground | FILL (before) | MARK (after) |
| --- | --- | --- | --- | --- |
| `.ui-legend-swatch` | warning | light card | **1.74** | **5.90** |
| `.ui-legend-swatch` | success | light card | **2.18** | **6.84** |
| `.ui-legend-swatch` | destructive | dark card | **2.95** | **5.52** |
| `.ui-progress-segment` | warning | light track | **1.60** | **5.41** |
| `.ui-progress-segment` | success | light track | **2.00** | **6.28** |
| `.ui-progress-segment` | destructive | dark track | **2.42** | **4.52** |

Worst case anywhere on the two routes after the move: **4.52:1**. `.ui-progress-bar` (meter and
over-capacity) reads the same tokens as the slice, so a `tone="warning"` meter and a `warning`
slice on one screen stay the same colour.

**ERROR PROSE WAS THE THIRD PLACE THE SAME MISTAKE LIVED (gh#610).** `Alert`, `Text` and
`Heading` resolved their destructive ink through TEXT from the start, but the `role="alert"` line
the data-entry primitives render — `<FormField error>`, `Upload`'s rejections, the `Dialog` step-up
failure — was on `text-destructive`, the FILL utility. So the same `tone` was readable inside an
`Alert` and near-invisible one line below it, in the field that caused it. Measured in Chromium on
`/isolate/layout-auth-recovery-examples-mfa-challenge`:

| surface | ground | FILL (before) | TEXT (after) |
| --- | --- | --- | --- |
| `.ui-form-field-note[role="alert"]` | dark card | **2.95** | **5.52** |
| `.ui-form-field-note[role="alert"]` | light card | 6.16 | **7.21** |

Only the DARK branch failed, and the light one passing is why it survived: the fill is tuned for a
white label ON it, so it darkens on light grounds and lightens on dark ones — the opposite of what
ink needs. The guard is `error-text-tier.test.ts` (token ratios on `--background`/`--card`/
`--muted` in both themes, plus a source scan for the fill utility), and `check:contrast` gained the
two `ui-auth-shell` routes it had never loaded.

**A theme that repoints `--secondary` owes `--progress-track-background`.** The track defaults to
`hsl(var(--secondary))`, which is a pale neutral in the stock palette. `docs/showcase/acme-portal`
repurposes `--secondary` as a navy *button* colour, so its bars were drawn on a near-black track
and the mark fills measured 2.50 (success) / 2.90 (warning) on it. Naming the track explicitly is
the fix — 6.49 / 5.59 after — not dragging the tier back.

Three guards now, and each sees something the others cannot: the ratio tests in
`tone-mark-contrast.test.ts` (rails against card/background, progress marks against the track);
the *same file's* CSS-alias assertions, which fail if a rule is repointed at the fill tier and
which also pin the swatch and the slice to the **same** token per tone; and `check:contrast`'s
**thin fill** pass — added because adding `/isolate/data-display-progress` to that gate's route
list on its own changed nothing at all. The graphic pass wants ≤24px on both axes and the rail
pass wants a one-sided border; a progress fill is 8px (meter) or 22px (slice) tall by whatever
width its share makes it, so the sweep printed "AA clean" over a bar sitting at 1.60:1.

#### `--border` vs `--input` — decorative chrome vs control boundary (gh#315)

These two look like synonyms and are not. Keep them apart:

| Role       | What it draws                                                                                                                                                   | Contrast bar                                                                                                                                           |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--border` | Decorative chrome — table rules, card edges, section dividers, `<Separator>`                                                                                    | **None.** WCAG 2.2 SC 1.4.11 does not reach a divider; this system's dense JP grid depends on it staying quiet                                         |
| `--input`  | The boundary that **is** the control — Input, Textarea, Select, outline Button, TagInput, composite date field, topbar search, and the Switch's unchecked track | **≥ 3:1** (SC 1.4.11 Non-text Contrast) against every surface a control sits on — page, card, popover, muted/secondary panel, striped and hovered rows |

A field here has no fill of its own (`background: hsl(var(--background))`) and no shadow to speak of, so that 1px edge is the whole visual claim that you may type there. Current values: `30 7% 53%` light (3.47:1 on `--background`/`--card`, 3.18:1 on `--muted`) and `45 6% 47%` dark (4.22:1 on `--background`, 3.88:1 on `--card`/`--popover`, 3.17:1 on `--muted`).

**Re-theming rule:** a service theme that retints neutrals must move these two **independently** — setting `--input: var(--border)` re-opens the bug. `src/tokens/__tests__/input-boundary-contrast.test.ts` recomputes the ratios from `foundation.css` and fails below 3:1, and fails outright if the two roles are given the same value again.

The Switch's off-track borrows `--input` by default. If a service wants it quieter than the boundary role, override `--switch-unchecked-background` (component token, `initial`, call-site default `hsl(var(--input))`) rather than dragging `--input` back down — but whatever you set still owes 3:1 against the page and against the thumb (`--background`), or "off" stops being a visible state.

### Typography

| Owner               | Rule                                                                                            |
| ------------------- | ----------------------------------------------------------------------------------------------- |
| `base.css`          | `--font-size-xs` … `--font-size-2xl`, weights, line-heights                                     |
| `layout.css`        | Page title/subtitle, empty-state title                                                          |
| `card-layout.css`   | Banded header title override                                                                    |
| `dialog-layout.css` | Dialog title/description                                                                        |
| Components          | Use `text-sm`, `text-base` (wired to tokens) or `data-slot` CSS — never arbitrary `text-[14px]` |

Runtime scale: app `theme.css` overrides → `--font-size-sm` on root.

### Spacing / margin / gap

| App API                           | Internal owner                             |
| --------------------------------- | ------------------------------------------ |
| `<Flex direction="col" gap="md">` | `layout.css` `.ui-flex-gap-md`             |
| `<Flex gap="sm">`                 | `layout.css` `.ui-flex-gap-sm`             |
| `<PageContainer>`                 | `layout.css` `.ui-page-*`                  |
| `<Card>` slots                    | `card-layout.css`                          |
| `<Table>` cells                   | `table-layout.css` `[data-slot="table-*"]` |

**Apps:** no Tailwind `p-*`, `m-*`, `gap-*`, `space-*`.

### Icon size (gh#326)

Icon was the last geometric axis without a scale. Twenty-eight component tokens declared raw numbers, and between them they used exactly **nine values** — the vocabulary was already there, nobody had written it down. `foundation.css` now does:

| Token             | rem        | px     |                             |
| ----------------- | ---------- | ------ | --------------------------- |
| `--icon-size-2xs` | `0.625rem` | **10** | presence dot on an avatar   |
| `--icon-size-xs`  | `0.75rem`  | **12** | table sort caret, xs button |
| `--icon-size-sm`  | `0.875rem` | **14** | `size="sm"` controls        |
| `--icon-size-md`  | `1rem`     | **16** | **the default icon step**   |
| `--icon-size-lg`  | `1.25rem`  | **20** | alert / launcher glyph      |
| `--icon-size-xl`  | `1.5rem`   | **24** | upload tile, empty state    |
| `--icon-size-2xl` | `2.25rem`  | **36** | StatCard medallion          |
| `--icon-size-3xl` | `2.5rem`   | **40** | dropzone                    |
| `--icon-size-4xl` | `3rem`     | **48** | empty-state box             |

Type can sit between pixels because hinting and antialiasing carry it; a 1px-stroke glyph cannot — half a pixel of icon is a blurred icon. Every step is a whole pixel at the 16px root, and a service retunes a **step**, never a ratio.

**Not `--scaling`-multiplied.** The scale is the crisp pixel list; density is opted into by the tokens that want it — `--control-icon-size: calc(var(--icon-size-md) * var(--scaling))`. Whether a given icon tracks density stays a per-token decision.

#### Two tiers, and where the boundary sits

|            | When                                         | How                                                                   |
| ---------- | -------------------------------------------- | --------------------------------------------------------------------- |
| **Tier 1** | the value appears in **more than one place** | it earns a step on the scale; a service retunes the step once         |
| **Tier 2** | the value appears in **exactly one place**   | set that component's own token **at the call site** — never the scale |

A system with only tier 1 turns every exception into a hack (`!important`, a global override, a forked stylesheet); a system with only tier 2 loses consistency. The 6px status dot is the proof case: it will never be on an icon scale, and it is a real need.

```tsx
// Tier 2 — one instance, one arbitrary value. No !important, no :root override, no fork.
<DropdownMenuItem style={{ "--menu-icon-size": "6px" } as React.CSSProperties}>…</DropdownMenuItem>
```

```css
/* Tier 2, the theme form — scope it, don't globalise it. */
[data-tenant="acme"] [data-slot="alert-icon"] {
  --alert-dismiss-icon-size: 0.375rem;
}
```

An inline custom property wins by **inheritance proximity**, not specificity, so it beats the `:root` default without any weight games. What keeps the route open is that every icon rule in `src/styles` reads its token through `var()` with no baked literal — `src/tokens/__tests__/icon-size-scale.test.ts` asserts exactly that, and carries a shrink-only list of the rules that still bake a literal and are therefore unreachable from an app.

The three left need tokens in `components/control.css` (`.ui-otp-separator-icon`) and `components/shell.css` (`.tb-icon-btn svg`, `.tb-chip-icon`). Note that `.tb-chip-icon`'s `1.125rem` is **not** a snap case even though 18px is off the scale: it is a whole pixel, and the box is a letter medallion (`display: grid`, `place-items: center`, a radius, `color: white`), not a stroked glyph — so it wants a `scale-exempt:` marker, not the nearest step. Off-scale and off-grid are different findings; decide each on what the icon actually is.

**Before you add a step:** if a value is wanted in two places it belongs on the scale, and if it is wanted in one it does not. Adding a tenth step to serve a single call site is how a scale stops meaning anything.

### Stroke and band height (gh#324)

The census confirmed the number and rejected the diagnosis: **`width` is not one axis.** Three unrelated concerns wear the suffix.

| concern                  | example                                      | verdict                      |
| ------------------------ | -------------------------------------------- | ---------------------------- |
| thickness of a line      | `--control-border-width: 1px`                | **one vocabulary** → a scale |
| measure of a container   | `--dialog-width-default: 32rem`              | not a scale — tier 2         |
| content width of a field | `--app-setting-picker-timezone-width: 14rem` | not a scale — tier 2         |

`height` split the same way: a control, a table row, a menu item, a nav row and the app-shell top bar are one decision sharing one vocabulary; a chart plot's height and a popover's max-height are container measures chosen per surface. `size` and `offset` came out no-scale on the same test. **Naming a scale is the fix for an axis; declaring that an axis is _not_ one is the fix for the rest** — both verdicts, and the census behind them, live in `scripts/token-scale-bypass-rules.mjs` so the question is not re-asked every quarter.

#### `--stroke-*` — the thickness of a painted line

| Token               | px      |                                                         |
| ------------------- | ------- | ------------------------------------------------------- |
| `--stroke-hairline` | **1**   | every resting border, divider and rule                  |
| `--stroke-sm`       | **1.5** | avatar presence stroke                                  |
| `--stroke-md`       | **2**   | **the emphasis stroke** — focus rings, selected markers |
| `--stroke-lg`       | **3**   | the open/focus ring on toggles                          |
| `--stroke-xl`       | **4**   | Steps process ring                                      |
| `--stroke-2xl`      | **6**   | the Card accent rail                                    |

**Px, never rem.** A stroke is a _device_ line: its job is to read as one crisp rule at any type size, so it must not grow when the root font-size does. Every token that moved onto this scale was already `px` for that reason; a `rem` thickness (`--slider-track-height: 0.375rem`) is a different decision and stayed where it was. **Not `--scaling`-multiplied** either — `--scaling` is a density knob, and a 1px divider at 0.92px would blur rather than tighten.

The focus mark reads this scale too, through `--focus-ring-weight` — `var(--focus-outline-weight)`, which is `var(--stroke-hairline)` (the light 1px mark the ON state ships). `--focus-ring-width` is DERIVED from it (`weight × --focus-outline`, the on/off switch) and must never be set directly; a theme that wants a 2px focus perimeter (WCAG 2.2 SC 2.4.13) sets `--focus-ring-weight: var(--stroke-md)`. See docs/CUSTOMER-THEMING.md.

#### `--band-height-*` — the vertical extent of a horizontal band

| Token               | rem       | px     |                                          |
| ------------------- | --------- | ------ | ---------------------------------------- |
| `--band-height-xs`  | `1.5rem`  | **24** | `--control-height-xs`                    |
| `--band-height-sm`  | `1.75rem` | **28** | compact control / table row, file button |
| `--band-height-md`  | `2rem`    | **32** | **the default control and row band**     |
| `--band-height-lg`  | `2.25rem` | **36** | the canonical DXS auth control           |
| `--band-height-xl`  | `2.75rem` | **44** | the WCAG 2.2 AA touch floor (rule #24)   |
| `--band-height-2xl` | `3rem`    | **48** | the AppShell top bar                     |
| `--band-height-3xl` | `3.5rem`  | **56** | the AppShell top bar on a coarse pointer |

**This is not a replacement for `--control-height-*`, and the difference matters.** The control tier is a **runtime ladder**: it multiplies by `--scaling`, steps ±`--space-1` for `sm`/`lg`/`xs`, and `@media (pointer: coarse)` lifts the whole thing to the 44px tap floor. `--band-height-*` is the **static vocabulary the ladder is anchored on** — `--control-height-default: var(--band-height-md)` — exactly as `--font-size-base` anchors the type scale.

> Point a band token at `--control-height-*` to "reuse a step" and you silently enrol it in
> density **and** in the coarse-pointer growth. That is a geometry change, not a rename.
> `--table-row-height-default` reads `var(--band-height-md)`, not `var(--control-height-default)`,
> for precisely that reason.

That trap had already been sprung once: `--card-service-launcher-icon-size` read `var(--control-height-lg)` — a control tier sizing an _icon_ box. Invisible at the desk and wrong on a phone, where the medallion inflated 36px → 48px while the glyph inside it stayed 20px. It is `calc(var(--icon-size-2xl) * var(--scaling))` now: right axis, same value on every density, and it stops growing on touch.

#### Mis-named axes are worth renaming, with an alias

`--table-skeleton-line-height` was a **length** on the line-height axis, whose scale is a set of unitless _ratios_ — so it was never a bypass, it was a mis-named height, and while it stood the axis could not be gated at all. One rename unlocked a whole axis at zero baseline cost.

### Component Tokens

Each component owns a token file under `src/tokens/components/`. The file may only derive from foundation, semantic, or other component tokens; component CSS may only consume those component tokens.

#### Role-mirror knobs MUST be `initial` + a call-site fallback (the `:root` freeze rule)

When a component-token default is just a **role token** (`--card-background` defaults to `--card`, `--table-header-background` to `--muted`, `--checkbox-checked-background` to `--primary`, `--focus-ring-color` to `--ring` …), you may **not** write the binding at `:root`:

```css
/* ✗ WRONG — freezes at the :root role value */
:root {
  --card-background: var(--card);
}
.ui-card {
  background: hsl(var(--card-background));
}
```

CSS substitutes a `var()` at the element that **declares** it, so `--card-background` computes to `:root`'s `--card` and inherits that frozen value down. A consumer who scopes the _role_ (`[data-tenant] { --card: <dark> }` or `.dark`) overrides `--card` but **never reaches** `--card-background` — the component keeps the light `:root` value. Under a dark theme this is glaring (a frozen light card under white text → invisible text); under a light theme it hides silently.

Instead, declare the knob `initial` (a real, catalogued, guaranteed-invalid declaration — no role to freeze) and move the role default to the **call site** as a fallback, so it re-resolves live at the painting element under any scope, while an explicit theme override of the knob still wins:

```css
/* ✓ RIGHT — default re-resolves under scope; knob still overridable */
:root {
  --card-background: initial;
} /* documented default = hsl(var(--card)) */
.ui-card {
  background: hsl(var(--card-background, var(--card)));
}
```

The same rule applies to `@theme inline` (utilities re-resolve scoped roles) and to any `:root`-declared **composite** that wraps a role (e.g. a focus-ring box-shadow): read the role **directly** at the call site, never through a frozen `:root` intermediate. Pure non-colour knobs (spacing, radius, font-size) don't need this **as long as nothing re-scopes what they read** — see the two paragraphs below, which is where that qualifier cost a release.

**A DERIVED TIER FREEZES ON ITS OWN SEED — the `--font-size-*` ramp (gh#834).** The type scale is one base and one ratio, and every step used to be written `--font-size-5xl: var(--font-size-display)` / `--font-size-lg: calc(var(--font-size-base) * …)` on `:root`. Those substitute on `<html>`, so a `[data-tenant]` that re-seeds the base — the route level 3 of `docs/CUSTOMER-THEMING.md` documents, and which level 1 calls a seed — moved `text-base` and **nothing else**. Measured with `--font-size-base: 24px; --font-size-display: 80px` on a scope: 2xs/xs/sm/lg/xl/2xl/3xl/4xl/5xl all sat exactly where `:root` had left them, and a showcase whose comment read "80px hero via text-5xl" painted 54.

So every **derived** step is now an `initial` knob and the formula lives at the call site, spelled in terms of the un-derived knobs only (`--font-size-base`, `--font-size-ratio`, `--font-size-display`, `--font-size-display-ratio`) — an `initial` knob cannot be chained through, so `xl` reads `base × ratio³` rather than `lg × ratio`:

```css
:root {
  --font-size-5xl: initial;
} /* documented default = var(--font-size-display) */
[data-slot="text"][data-size="5xl"] {
  font-size: var(--font-size-5xl, var(--font-size-display));
}
```

**Every reader must carry the fallback**: a bare `var(--font-size-xs)` on an `initial` knob resolves to nothing, and the failure is silent and total. `check:frame-token-scope` is the gate that measures both halves — it fails on a step that stops following a scoped seed, and on a bare read of an empty knob.

**The same freeze bites any knob whose default is a RE-SCOPED tier, colour or not.** `--control-height` is re-scoped by `.ui-auth-shell` (44px comfortable) and by `.ui-auth-shell[data-variant="canonical"]` (36px), so a knob that mirrors it must follow the same `initial` + call-site-fallback shape:

```css
:root {
  --otp-slot-size: initial;
} /* documented default = var(--control-height) */
.ui-otp-slot {
  width: var(--otp-slot-size, var(--control-height));
}
```

Ask "is the default a token that some scope re-declares?", not "is the default a colour?".

Card primitive tokens:

| Token                      | Purpose                                                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------- |
| `--card-space-inset`       | Shared header/body/footer **inline** inset — the column all slots align to                     |
| `--card-space-shell-y`     | **Block** shell padding (header top · `solo` body top · terminal bottom) — default = the inset |
| `--card-space-header-y`    | Banded header band vertical density (feeds `--card-space-divided-y`)                           |
| `--card-space-body-y`      | Gap under the header + the body's own top padding                                              |
| `--card-space-footer-y`    | Separated footer band vertical density (feeds `--card-space-divided-y`)                        |
| `--card-space-divided-y`   | **Border-aware** symmetric top+bottom padding of a DIVIDED band                                |
| `--card-space-gap`         | In-slot stack gap (header title ↕ description)                                                 |
| `--card-accent-rail-width` | Width of the `accent` leading-edge stripe (default `6px`)                                      |
| `--card-title-font-size`   | Card title scale                                                                               |
| `--card-header-background` | Banded header background color token                                                           |
| `--card-shadow`            | Card elevation                                                                                 |

#### Border-aware vertical padding (dividers)

A card slot's vertical padding depends on **whether it carries a divider border** — set it once via a token, never hard-code padding on the slot:

- **Divided band** — a `<CardHeader banded>` (bottom border + muted band) or a `<CardFooter separated>` (top border). It reads as its own region, so it pads **symmetrically** top+bottom from `--card-space-divided-y` (which defaults to `--card-space-header-y`). One knob keeps the header- and footer-band rhythm in sync. - **Plain header** — no border; it _flows_ into the body.

So: WITH a divider → symmetric band padding; WITHOUT → asymmetric flow padding. Tune the band density once at `--card-space-divided-y`; tune the accent stripe at `--card-accent-rail-width`.

#### The two card axes are independent (gh#232)

`--card-space-inset` owns the **inline** column; `--card-space-shell-y` owns the **block** shell edges (a plain header's top, a `solo` body's top, the terminal slot's bottom). `--card-space-shell-y` is declared `initial`, so its default resolves at the call site to `--card-space-inset` — a card that sets neither, or that only re-tunes the inset (including `density="tight|cozy"`), renders exactly as before. Override `--card-space-shell-y` alone to make a card **shorter without narrowing it**:

```css
/* a short login card that keeps its 24px column */
.ui-auth-shell {
  --auth-shell-card-padding-block-compact: 14px;
}
```

That AuthShell knob is wired straight to `--card-space-shell-y` on the compact auth card; the inline column stays on `--auth-shell-compact-card-inset` and the header↔body gap on `--auth-shell-card-body-gap-compact`. Three knobs, three axes, no consumer bridge selector on `[data-slot="card-content"]`.

Example app override:

```css
:root {
  --card-space-inset: var(--space-section-active);
  --card-space-divided-y: var(--space-stack-md); /* roomier banded header / separated footer */
  --card-accent-rail-width: 4px; /* thinner accent stripe */
}
```

### Density

| App API                                                   | Owner         |
| --------------------------------------------------------- | ------------- |
| `<PageContainer density="compact\|default\|comfortable">` | `density.css` |

Retunes `--phi-unit`, `--control-height`, `--table-row-height`, `--table-cell-padding-y` for the whole subtree.

Control heights are `compact` 28px, `default` 32px, and `comfortable` 44px. Use `comfortable` for handheld/public surfaces that need the touch floor.

### Card / Table / Dialog / Alert

See `docs/SPACING.md` for Card slot matrix. Same pattern everywhere:

1. Component TSX → `data-slot` + modifier flags only
2. `*-layout.css` → all inset/gap/border-band rules
3. Governance test fails if TSX reintroduces padding utilities

## Preset components (avoid reinventing layout)

| Need            | Use                               |
| --------------- | --------------------------------- |
| KPI stat tile   | `<StatCard label value delta />`  |
| Page sections   | `<Flex direction="col" gap="md">` |
| Row of controls | `<Flex gap="sm">`                 |
| Status enum     | `<Badge status="…" />`            |
| Empty list      | `<EmptyState … />`                |

## The layer contract

Every rule this package ships sits inside a cascade layer, and **layer order beats specificity outright**. Two halves, and both have already cost a release.

### Inside the package

`@import "tailwindcss"` establishes `theme, base, components, utilities`. Component CSS lives in `@layer components`, which is **earlier** than `utilities` — so a Tailwind utility a component emits on its own element outranks the layered rule that is supposed to own the same property, and no selector can win that fight.

The documented token was dead, the narrow frame stayed at 14px, and a 5–6 character Japanese label could not fit its column measure — a WCAG 2.2 SC 1.4.10 failure that only manifests in Japanese. Two independent consumers reported it.

A **responsive re-point** that must beat such a utility goes in **`@layer godxjp-ui-responsive`**, declared straight after Tailwind in `src/styles/base.css` and therefore the last layer:

```css
@layer godxjp-ui-responsive {
  @container ui-table-collection (width < 40rem) {
    [data-collapse-below="sm"] [data-slot="table"] {
      font-size: var(--table-action-collection-font-size-compact);
    }
  }
}
```

It is reserved for `@container` / `@media` re-points. Everything static stays in `@layer components`.

### In a consumer app

**Unlayered CSS outranks every layer, including `godxjp-ui-responsive`.** So:

- Theme this package by setting **tokens** on a wrapper element (`.my-page { --table-…: 7rem; }`). - Do **not** write app selectors against package internals (`[data-slot]`, `[data-priority]`, `.ui-*`). An unlayered rule that does wins at _every_ width and kills the package's responsive re-points.

## Adding a new component

1. Add/update the component token file in `src/tokens/components/`.
2. Add `src/styles/<component>-layout.css` with `[data-slot="…"]` rules.
3. Import in `index.css`.
4. Component TSX: structure + `data-slot` only.
5. Extend `token-governance.test.ts` allowlist only for third-party adapters (calendar, cmdk).

## MCP

`godxjp_ui_guide` topic=`golden-ratio` · `tokens`
