# AmplifyPay Design System

A complete, portable specification. Every value here is the working system — taken from
`packages/ds/src/theme.css`, `packages/ds/src/index.tsx`, `packages/ds/src/data-tools.tsx`
and `packages/ds/src/select.css`, not an approximation.

Written to be applied to another product: change three things (§21) and the rest transfers.

**Stack it assumes:** Tailwind v4 (`@theme` tokens), React, `clsx` + `tailwind-merge`.
None of that is essential — the tokens and rules are framework-free.

---

## Contents

1. [The four rules](#1-the-four-rules)
2. [Colour](#2-colour)
3. [Typography](#3-typography)
4. [Iconography](#4-iconography)
5. [Radius, shadow, shape](#5-radius-shadow-shape)
6. [Layering and motion](#6-layering-and-motion)
7. [Density tokens](#7-density-tokens)
8. [Breakpoints and spacing](#8-breakpoints-and-spacing)
9. [Buttons](#9-buttons)
10. [Cards and surfaces](#10-cards-and-surfaces)
11. [Badges and banners](#11-badges-and-banners)
12. [Fields and forms](#12-fields-and-forms)
13. [Money](#13-money)
14. [Tables and stats](#14-tables-and-stats)
15. [Charts](#15-charts)
16. [The editable grid](#16-the-editable-grid)
17. [Shell and page frame](#17-shell-and-page-frame)
18. [States and feedback](#18-states-and-feedback)
19. [Accessibility](#19-accessibility)
20. [Voice](#20-voice)
21. [Porting it](#21-porting-it)
22. [Known gaps](#22-known-gaps)
23. [The complete token block](#23-the-complete-token-block)

---

## 1. The four rules

These sit at the top of the theme file. Every other decision is downstream of them, and a
component that breaks one is wrong even if it looks good.

1. **Deep green is the brand surface.** Navigation, dark blocks, headings, links. It is the
   ground the product stands on, not an accent.
2. **Volt is the ONE accent.** It means *act here* or *money moved*. Nothing decorative is
   ever volt. One accent, spent deliberately, is what makes a primary action unmissable on a
   screen of forty numbers.
3. **Numbers are large, tabular and unmissable. Labels are small and quiet.** The figure is
   the content; the word above it is a caption for the figure.
4. **Colour never carries meaning alone — always with a word.** A red chip says
   "Name does not match", not just red.

> **Why the rules live in the file.** There were once three copies of this system. Two were
> byte-identical; the third had drifted, and the drift was invisible because each copy looked
> right on its own. The worst of it: `MoneyAmount tone="volt"` resolved to a faint tint on
> desktop and the full accent on the phone — so the token meaning *this money has moved* said
> two different things depending on the screen.

---

## 2. Colour

Four ramps. The neutral is **green-tinted on purpose**: a pure grey beside this green reads as
a different product.

### Brand — deep forest green

| Step | Hex | Used for |
|---|---|---|
| 50 | `#eef5f1` | Chip fill, selected row in a list |
| 100 | `#d3e6dc` | Avatar fill, counters on dark |
| 200 | `#a5cbba` | Secondary text on dark surfaces |
| 300 | `#6da895` | Muted text on dark |
| 400 | `#3d8570` | — |
| 500 | `#1a5a44` | Focus rings, icons, select chevron |
| 600 | `#114634` | Links, chip text, calendar glyph |
| 700 | `#0a3527` | Dark button, active nav item |
| 800 | `#06291e` | Nav rail, dark cards, toasts |
| 900 | `#052018` | Ink on volt, modal scrim |

### Volt — the single accent

| Step | Hex | Used for |
|---|---|---|
| 100 | `#eefbcf` | — |
| 200 | `#e0f8a6` | — |
| 300 | `#d5f87c` | — |
| 400 | `#cef664` | Primary button hover |
| 500 | `#c8f550` | **Primary button, money that moved, selection** |
| 600 | `#b2e22f` | Primary button active |
| 700 | `#8fbc1f` | — |

### Neutral — cool paper, green-tinted

| Step | Hex | Used for |
|---|---|---|
| 50 | `#f6f7f3` | **Page background (paper)**, read-only cells |
| 100 | `#eef0ea` | Neutral chip, ghost hover, row hairline |
| 200 | `#e2e6dd` | **Card border**, table rules, disabled fill |
| 300 | `#cbd2c7` | Field border, dashed empty-state border |
| 400 | `#99a49c` | Placeholder text, disabled text |
| 500 | `#5b6660` | Secondary body text, captions |
| 600 | `#414b46` | Table headings, computed figures |
| 700 | `#2c3531` | Ghost button text, labels |
| 800 | `#18201c` | — |
| 900 | `#0b1410` | **Body ink** |

### Semantic — used WITH a word, never alone

| Role | Text | Tint | Meaning |
|---|---|---|---|
| good | `#17805a` | `#dcf1e7` | Settled, matched, locked |
| warn | `#a2650b` | `#fbeed4` | Waiting, unverified, needs a look |
| red | `#b4362a` | `#f8e2df` | Refused, failed, destructive |
| surface | `#ffffff` | — | The raised plane. Cards sit on paper, not on white |

### The volt rule, concretely

| Do | Don't |
|---|---|
| Volt on the one button that commits the work | Volt on a heading, border, hover or icon |
| Volt on a figure that actually moved — deducted, disbursed, settled | Volt on money that is expected, owed or forecast |
| One volt element per screen | Two primary buttons competing |

### Browser chrome

```css
:root {
  color-scheme: light;
  accent-color: var(--color-brand-700);   /* checkboxes, radios, range thumbs */
}
::selection { background: var(--color-volt-500); color: var(--color-brand-900); }
```

Without `accent-color`, native controls render in the browser's default blue and read as
foreign inside the product.

---

## 3. Typography

**One typeface: Inter.** There is no second face — `--font-mono` is Inter with tabular figures
switched on. Roles are separated by weight, size and tracking instead, which is why a payslip
and a settings form feel like one product.

| Token | Size | Line height | Weight | Tracking | Used for |
|---|---|---|---|---|---|
| `hero` | `clamp(2.25rem, 9vw, 3.25rem)` | 1.02 | 700 | −0.035em | The one number a screen exists for |
| `display` | `clamp(1.75rem, 7vw, 2.5rem)` | 1.08 | 700 | −0.03em | Stat card figures |
| `h1` | 1.75rem / 28px | 1.2 | 700 | −0.022em | Page title |
| `h2` | 1.1875rem / 19px | 1.35 | 600 | −0.015em | Card and section titles |
| `body` | 0.9375rem / 15px | 1.55 | 400 | — | Everything read in sentences |
| `caption` | 0.8125rem / 13px | 1.4 | 400 | — | Hints, table cells, secondary notes |
| `label` | 0.6875rem / 11px | 1 | 600 | +0.07em | Uppercase caption above a figure |

### Why hero and display are clamped

They were fixed at `3.25rem` and `2.5rem`. A six-figure cedi amount was then wider than a
375px phone and pushed the whole page sideways — the page scrolled to reveal nothing but empty
space. **Any type size that will hold a currency figure must be fluid**, because you do not
control how large the number gets.

### Font features

```css
body { font-feature-settings: "cv11", "ss01", "ss03"; }
```

`cv11` gives the single-storey *l*, which is what stops `1` and `l` reading alike in a column
of figures.

### Three utility classes

```css
/* Quiet uppercase label above a number or a nav group. */
.label-caps {
  font-size: var(--text-label); font-weight: 600; letter-spacing: 0.07em;
  text-transform: uppercase; color: var(--color-neutral-500);
}

/* TABLE HEADERS — one step up from .label-caps, and its own token.
   They used to share it, which made a column heading as quiet as a stat
   caption; bumping it would have enlarged every stat card as a side effect. */
.table-head {
  font-size: var(--text-caption); font-weight: 600; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--color-neutral-600);
}

/* Inter with tabular figures — every money column aligns. */
.font-mono { font-feature-settings: "tnum" 1, "cv11", "ss01"; letter-spacing: -0.01em; }
```

---

## 4. Iconography

**There is no icon library.** Not lucide, not heroicons, not react-icons — every icon in the
product is a hand-inlined SVG path. That is a deliberate trade and worth understanding before
you copy it.

| | |
|---|---|
| **What it buys** | Zero dependency, zero bundle cost, every glyph tuned to the 19px it is actually rendered at, and no tree-shaking config to get wrong |
| **What it costs** | Nobody can add an icon without drawing one, and consistency is a convention rather than a guarantee — see the honest note on stroke width below |

### The one wrapper every icon goes through

```tsx
const icon = (paths: ReactNode) => (
  <svg
    className="size-[19px] shrink-0"
    viewBox="0 0 24 24"
    fill="none"
    stroke="currentColor"
    strokeWidth="1.7"
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
  >
    {paths}
  </svg>
);
```

Five rules are baked into that wrapper, and they are the whole icon system:

1. **24×24 viewBox**, always — so paths are interchangeable with any stroke-icon set you might
   lift from.
2. **`fill="none"`, `stroke="currentColor"`** — an icon takes the colour of the text beside it.
   No icon anywhere hard-codes a colour, which is why the same glyph works on the deep-green
   rail and on white.
3. **Round caps and joins**, always. This is most of why a hand-drawn set still looks like a set.
4. **`shrink-0`** — an icon in a flex row must never be squashed by a long label.
5. **`aria-hidden="true"`** — every icon in this product sits beside its own text label. None
   of them is the only carrier of meaning, so none is announced.

### Size and weight by context

| Context | Size | Stroke | Colour |
|---|---|---|---|
| Nav rail item | `size-[19px]` | 1.7 | `currentColor` (brand-100 / white when active) |
| Field affordance (select chevron, calendar) | `size-[18px]` / 1.05–1.125rem | 1.8–2 | `brand-500` / `brand-600` |
| Inside a small button or icon-button | `size-4` (16px) | 2.5 | `neutral-500`, `neutral-900` on hover |
| Back link chevron | `size-3.5` (14px) | 2.5 | `neutral-500` → `brand-700` |
| Search glyph in a field | `size-4` | 2 | `neutral-400`, `pointer-events-none` |
| Sort glyph in a table header | `size-3`, `viewBox 0 0 12 12` | — | opacity 0 → 40% on row hover, 100% when sorted |
| Password show/hide | `size-5` (20px) | 1.8 | `neutral-400` → `neutral-700` |
| Spinner | `size-4` | `border-2` | `border-current` + `border-t-transparent` |

> **Honest note on stroke width.** The codebase currently uses 1.7, 1.8, 2, 2.5 and 3 in
> different places. 1.7 for nav, 2.5 for small glyphs and 2 for mid-size is *mostly* a size
> compensation — a 14px glyph needs a heavier stroke than a 19px one to read at all — but it is
> a convention, not an enforced token. **If you port this, make it a token**
> (`--icon-stroke-sm: 2.5`, `--icon-stroke-md: 2`, `--icon-stroke-lg: 1.7`) so the compensation
> is a decision rather than an accident.

### The navigation set

Eleven glyphs, one per top-level destination, each drawn to the same 24×24 grid.

| Destination | Glyph |
|---|---|
| Home | House — roofline + body |
| Getting started | Star |
| People | Two figures, one behind the other |
| Pay runs | Rounded rect with two dots (a banknote) |
| Pay sheet | Grid — rect with one vertical and one horizontal rule |
| Advances | Cedi sign — vertical stroke through an S-curve |
| Time off | Calendar with a tick |
| Requests | Document with two text rules |
| Attendance | Clock — circle with hands at 7:30 |
| Taxes & SSNIT | Classical building — columns and pediment |
| Reports | Bar chart |

**One glyph, one concept, across the whole product.** The calendar that means *Time off* in the
nav is the same calendar that appears inside a date field. If a second meaning needs an icon,
it gets its own glyph rather than a variant of an existing one.

### Icons drawn in CSS, not markup

Three icons are not React at all — they are SVG data URIs in the stylesheet, because they
replace browser chrome that no component can reach.

```css
/* The select chevron — appearance:none removes the UA arrow, this paints ours.
   A background image rather than a sibling element, on purpose: the Select is
   used in 21 places, none of which pass layout classes, and wrapping it in a
   positioned div would change every box it sits in. A background cannot move
   anything. */
.select-field {
  appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' \
    width='20' height='20' viewBox='0 0 20 20' fill='none' stroke='%231a5a44' \
    stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E\
    %3Cpath d='m5 7.5 5 5 5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.75rem center;
  background-size: 1.125rem;
}

/* The calendar button inside date inputs — MASKED, not a background, so the
   glyph takes background-color and can animate on hover. */
input[type="date"]::-webkit-calendar-picker-indicator {
  -webkit-appearance: none;
  appearance: none;
  width: 1.15rem; height: 1.15rem;
  background-color: var(--color-brand-600);
  -webkit-mask: url("data:image/svg+xml;utf8,<svg …calendar…>") center / 100% 100% no-repeat;
  mask: url("data:image/svg+xml;utf8,<svg …calendar…>") center / 100% 100% no-repeat;
  transition: background-color 120ms ease;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
```

**Mask vs background-image is the interesting choice.** A masked icon inherits
`background-color`, so it can be recoloured on hover and disabled states with one property. A
background-image icon has its colour baked into the URI, which is why the disabled variants
need a *second* data URI with `%2399a49c` (neutral-400) in it:

```css
/* A disabled control should not advertise a brand-coloured affordance. */
.select-field:disabled { background-image: url("…stroke='%2399a49c'…"); }
```

### The brand mark, as data

The logo is **not** a component. It is a plain object with no framework import, so a React app
and a static marketing site draw the identical mark from the identical numbers.

```ts
export const LOGO = {
  viewBox: "0 0 32 32",
  size: 32,
  /** 9 on a 32 grid — reads as a rounded square at 20px and a squircle at 200px,
      which is the range this mark actually lives in. */
  radius: 9,
  background: "var(--color-brand-800)",
  stroke: "var(--color-volt-500)",
  strokeWidth: 3.2,
  /** The employer's chevron leads… */
  leadPath: "M9 9.5 13.75 16 9 22.5",
  /** …the employee's follows, lighter. Both moving forward: the handoff. */
  followPath: "M17.75 9.5 22.5 16l-4.75 6.5",
  followOpacity: 0.55,
  label: "AmplifyPay",
  wordmark: "amplifypay",
} as const;
```

- Colours are **CSS variables, not hex**, so both surfaces pick up the same theme and a dark
  surface needs no second copy of the mark. Email is the one exception and keeps a literal-hex
  version, because an email client resolves no CSS variables at all.
- **The wordmark is one colour, always.** An earlier two-tone "amplify" + "pay" split read as
  two brands stitched together.
- Lockup is `inline-flex items-center gap-2.5`, wordmark at `1.0625rem`, weight 700,
  `tracking-[-0.03em]`, lowercase.

> **Why the geometry is shared data.** There were once five copies of this SVG across four
> apps and none in the design system — so every app had to redraw the logo for itself, and the
> one app where nobody did was the staff app, the one actual employees open. It carried a text
> wordmark with a stray full stop instead, and had no mark at all on any screen.

### Third-party marks are locked

The Google sign-in button uses Google's own four-colour mark at its official proportions and is
never restyled, recoloured or fitted to the pill radius of the rest of the system. A provider's
mark is theirs; bending it to your design language makes the button look counterfeit exactly
where people are deciding whether to trust you with a password.

---

## 5. Radius, shadow, shape

| Token | Value | Used for |
|---|---|---|
| `--radius-sm` | 6px | Inline chips, small inner elements, the money highlight |
| `--radius-md` | 10px | Fields, banners, dropdown panels |
| `--radius-lg` | 16px | Cards, modals, tables, empty states |

**Buttons are not on the radius scale.** Every button, chip, badge, nav item and avatar is a
full pill (`border-radius: 9999px`).

> **Rectangles hold information. Pills are things you press.**
> The fastest affordance in the system, and it costs nothing.

**One shadow.**

```css
--shadow-lift:
  0 4px 16px -2px rgb(11 20 16 / 0.10),
  0 2px 6px -2px rgb(11 20 16 / 0.06);
```

Tinted with the ink colour rather than pure black, so it sits in the same world as the paper.
It appears on exactly four things: **modals, toasts, the mobile drawer, popover menus.**

**Cards do not have shadows.** A card is a 1px `neutral-200` border on white. Depth is
reserved for things that genuinely float above the page — border, fill, radius and shadow each
say "separate object", so spend them by role or the hierarchy flattens.

---

## 6. Layering and motion

### The z-index scale

Seven values, each with one owner. Anything that floats must take a number from this list
rather than invent one.

| Layer | `z` | What lives there |
|---|---|---|
| In-flow lift | 10 | Sticky save bar, sticky table headers |
| Grid cell chrome | 20 | Frozen column cells inside a scrolling grid |
| Shell | 30 | Nav rail, fixed mobile top bar, mobile drawer |
| Popover | 40 | Account menu, column chooser, date panel |
| Frozen header | 40 | A frozen column *inside* a sticky header — above both |
| Modal | 50 | Dialog scrim and panel |
| Toast | **60** | Above the modal on purpose — a confirmation must be visible over the thing that triggered it |
| Acting-as banner | **100** | Above everything, always. It says somebody else is inside this account and must never be obscured |

> **The two deliberate inversions.** Toasts sit above modals because a toast confirms what the
> modal just did. The impersonation banner sits above *everything* because an account somebody
> can enter silently is surveillance — the audit trail tells the story afterwards, to whoever
> thinks to read it; the person whose payroll is being altered deserves to know while it is
> happening.

### Motion

Motion in this system is almost entirely **state feedback**, not decoration. The full
inventory:

| Where | Property | Duration |
|---|---|---|
| Every button, nav item, chip, link | `transition-colors` | Tailwind default (150ms) |
| Calendar glyph hover | `background-color` | 120ms ease |
| Select chevron on open (`:open::picker-icon`) | `transform: rotate(180deg)` | 120ms ease |
| Sort glyph appearing on header hover | `transition-opacity` | default |
| Button and page spinners | `animate-spin` | 1s linear, infinite |

That is the whole list. There are **no** entrance animations, no scroll-triggered reveals, no
staggered lists, no skeleton shimmer.

**Why no skeletons.** A spinner with a label — *"Loading September 2026…"* — says what is
being fetched. A skeleton implies a shape the data may not fill, and on a payroll table the
shape is exactly what the person is waiting to learn.

> **Known gap:** the source carries no `prefers-reduced-motion` rule. The only motion is a
> colour fade and a spinner, so the exposure is small, but a port should add one:
>
> ```css
> @media (prefers-reduced-motion: reduce) {
>   *, *::before, *::after {
>     animation-duration: 0.01ms !important;
>     animation-iteration-count: 1 !important;
>     transition-duration: 0.01ms !important;
>   }
> }
> ```

---

## 7. Density tokens

A desktop table and a phone app are not the same shape. The differences that are *real* live
here. The phone app overrides these **and nothing else**.

| Token | Desktop | Phone | Controls |
|---|---|---|---|
| `--ds-field-px` | 0.875rem | — | Field horizontal padding |
| `--ds-field-py` | 0.625rem | — | Field vertical padding |
| `--ds-field-fs` | 15px | **16px** | Field font size. **Must be ≥16px on a phone** or iOS Safari zooms the page on focus |
| `--ds-field-lh` | 1.55 | — | Carried explicitly — a bare font-size utility does not bring the line height, and losing it makes every input 2px shorter than every other control on the row |
| `--ds-field-toggle-w` | 2.75rem | — | Show/hide button inside a password field |
| `--ds-label-mb` | 0.375rem | — | Gap under a label, above a hint |
| `--ds-card-p` | 1.5rem | — | Card body padding |
| `--ds-card-header-px` | 1.5rem | — | Card header horizontal padding |
| `--ds-row-gap` | 1.5rem | — | Label→value gap in a description row |
| `--ds-row-py` | 0.75rem | — | Description row vertical padding |
| `--ds-empty-py` | 3.5rem | — | Empty state vertical padding |
| `--ds-empty-measure` | 28rem | — | Max width of empty-state body copy |
| `--ds-toast-py` | 0.625rem | — | Toast vertical padding |
| `--ds-btn-sm` | 2rem / 32px | 36px | Small button min-height |
| `--ds-btn-sm-px` | 0.875rem | — | Small button horizontal padding |
| `--ds-btn-md` | 2.5rem / 40px | **44px** | Default min-height. **44px is the smallest target a thumb hits reliably** |
| `--ds-btn-lg` | 3rem / 48px | 56px | Large button min-height |

> **The rule this encodes:** if you want to fork a component, **add a token instead**. A forked
> component is one each app must remember to update, and eventually one does not.
>
> The single exception in the whole system is the Button's default variant — volt on desktop,
> deep green on the phone where volt is reserved for money — because *a default is not a
> dimension*. The phone app wraps Button to flip it.

---

## 8. Breakpoints and spacing

### Breakpoints

Two carry almost all the weight. `sm` and `lg` account for 73 of the 81 responsive utilities in
the payroll app; `md` and `xl` are used where a specific component genuinely needs a third step.

| Token | Min width | What changes |
|---|---|---|
| `sm` | 640px | Table cell padding `px-3` → `px-5`; modal padding `p-5` → `p-7`; header actions stop wrapping |
| `lg` | 1024px | Nav rail appears and the mobile top bar disappears; page padding `px-4` → `px-10` |
| `xl` | 1280px | Six-column filter rows on the widest tools |

Two hard-coded widths sit outside the scale because they are about a *capability*, not a size:

- **900px** — below this the pay sheet refuses to render and offers a link to Pay runs instead.
  A grid with a column per pay item cannot be made honest on a phone, and a cramped version
  that silently hides columns would be worse than not showing it.
- **375px** — the reference small screen. Every layout bug in this system's history was found
  at 375px: the clamped type sizes, the wrapping header actions, the narrowed table padding.

### Spacing

Tailwind's default 4px scale, used with discipline rather than extended.

| Gap | Between |
|---|---|
| `gap-1.5` / 6px | Icon and its label |
| `gap-2` / 8px | Button contents; tightly related chips |
| `gap-3` / 12px | Header action buttons; nav icon and label |
| `gap-4` / 16px | Card header title block and its actions |
| `gap-6` / 24px | Fields within a settings section; stat cards in a row |
| `mb-8` / 32px | Below a page header, before content |

**Layout does the spacing.** Sibling groups are laid out with flex or grid and `gap`, never
per-element margins that silently collapse or double.

Vertical rhythm on a page: `py-8` on small screens, `py-10` at `lg` and above, with a 1200px
max width and `px-4` / `lg:px-10` gutters.

---

## 9. Buttons

Pills, always. Each variant carries hover **and** active states, because a phone has no hover
and the most consequential button in the product must give feedback when pressed.

| Variant | Rest | Hover | Active | When |
|---|---|---|---|---|
| `primary` | volt-500 on brand-900 | volt-400 | volt-600 | The one action that commits the work. Never two on a screen |
| `dark` | brand-700 on white | brand-600 | brand-800 | A primary action sitting on volt, or on a light block where volt is taken |
| `secondary` | white, n-300 border | n-400 border, n-50 fill | n-100 | Discard, cancel, back |
| `ghost` | n-700 text only | n-100 fill | n-200 | Tertiary actions inside a dense row |
| `danger` | red-500 on white | 90% opacity | 90% opacity | Destructive and irreversible, always with a confirming word |

| Size | Min-height | Padding | Type |
|---|---|---|---|
| `sm` | `--ds-btn-sm` | `py-1.5 px-[--ds-btn-sm-px]` | caption, 600 |
| `md` | `--ds-btn-md` | `py-2 px-5` | body, 600 |
| `lg` | `--ds-btn-lg` | `py-2.5 px-7` | body, 600 |

```
/* Base, shared by every variant */
inline-flex items-center justify-center gap-2 rounded-full transition-colors
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500
focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-50

/* ONE disabled treatment for every variant — a disabled secondary button
   used to look identical to an enabled one. */
disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-400
disabled:border-neutral-200 disabled:hover:bg-neutral-200
```

### `min-height`, never `height`

Fixed heights meant a label that wrapped grew the *text* and not the button, so the words
spilled above and below the pill — seen at 375px where "Start the September 2026 run" wrapped
to three lines through a button that stayed one line tall.

**A button must never let its own content escape it:** the visible shape is what tells somebody
where to press.

### Loading is a state, not a swap

The button keeps its label and gains a 16px spinner:

```
size-4 animate-spin rounded-full border-2 border-current border-t-transparent
```

`border-current` means it inherits the variant's own text colour, so there is no per-variant
spinner to maintain. `disabled` is set by `disabled || loading`.

---

## 10. Cards and surfaces

```
Card          rounded-lg + tone
  light       bg-surface border border-neutral-200      ← the default
  dark        bg-brand-800 text-white border-brand-800
  volt        bg-volt-500 text-brand-900 border-volt-500  ← money that moved

CardHeader    border-b border-neutral-200 px-[--ds-card-header-px] py-4
CardBody      p-[--ds-card-p]
CardTitle     text-h2
```

### Stat — the card with an opinion

Quiet `.label-caps`, a `display`-size figure, one line of plain English, and an optional
**action** slot.

> A dashboard of numbers with nothing to click is a report. The same numbers each carrying the
> next step are a console.

Label and hint colours shift by tone: `light` → `neutral-500`; `dark` → `brand-200` / `brand-100`;
`volt` → `brand-700`.

---

## 11. Badges and banners

Both say what state something is in. Both obey rule four.

**Badge** — `inline-flex items-center rounded-full px-2.5 py-1 text-label uppercase`

| Tone | Fill | Text |
|---|---|---|
| `brand` | brand-50 | brand-700 |
| `neutral` | neutral-100 | neutral-600 |
| `positive` | good-100 | good-500 |
| `warn` | warn-100 | warn-500 |
| `danger` | red-100 | red-500 |
| `volt` | volt-500 | brand-900 — *money-arrived states only* |

**Banner** — `w-full rounded-md px-4 py-3 text-body`, tones `warn` (default) / `good` /
`neutral` / `danger`.

> **Never alarm-red for a normal state.** The default tone is `warn`, not danger. Red loses its
> force the moment it decorates a routine message.

### Status mapping is a lookup, not a judgement call

Keep it in one place so the same state is the same colour on every screen.

| State | Tone | Label shown |
|---|---|---|
| `DRAFT` | neutral | Draft |
| `IN_REVIEW` | warn | Waiting for approval |
| `APPROVED_1` | brand | Approved 1 of 2 |
| `APPROVED_2` | brand | Approved 2 of 2 |
| `LOCKED` | positive | Locked |
| `DISBURSED` | **volt** | Paid |
| `RECONCILED` | positive | Reconciled |
| `REJECTED` | danger | Sent back |

`DISBURSED` is the only volt badge in the product — it is money that has moved.

---

## 12. Fields and forms

One field style, **exported as a string** so an app-level control built by hand still looks like
every other field.

```ts
export const fieldClasses =
  "w-full rounded-md border border-neutral-300 bg-surface " +
  "px-[var(--ds-field-px)] py-[var(--ds-field-py)] " +
  "text-[length:var(--ds-field-fs)]/[var(--ds-field-lh)] " +
  "text-neutral-900 placeholder:text-neutral-400 " +
  "focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/15 " +
  "disabled:bg-neutral-100 disabled:text-neutral-400";
```

`Input`, `Select` (`+ select-field pr-10`), `Textarea` (`+ min-h-24`) and `PasswordInput`
(`+ pr-[--ds-field-toggle-w]`) all share it.

**Field wrapper:** `caption`-sized semibold label at `--ds-label-mb` above; optional hint in
`neutral-500` the same distance below. The hint is a `ReactNode`, not a string, because it
often carries a link.

### The select chevron is ours

Every browser draws its own `<select>` arrow in its own grey — the one control that did not look
like the product. `appearance: none` plus a brand-green SVG background removes it.

Done as a **background image, not a sibling element**, on purpose: the component is used in 21
places, none of which pass layout classes, and wrapping it in a positioned div would change
every box it sits in. A background cannot move anything.

### The open list is progressive enhancement

```css
@supports (appearance: base-select) {
  .select-field, .select-field::picker(select) { appearance: base-select; }
  .select-field::picker(select) {
    border: 1px solid var(--color-neutral-200);
    border-radius: 10px;
    background: var(--color-surface);
    box-shadow: 0 12px 32px -12px rgb(6 41 30 / 0.28);
    padding: 0.25rem;
    min-width: anchor-size(width);   /* matches the field, not the viewport */
  }
  .select-field option:checked { background: var(--color-brand-50); font-weight: 600; }
}
```

Where the browser cannot do it, nothing changes and the native menu appears. No fallback to
maintain, and no hand-built listbox that would have to re-earn keyboard support, typeahead,
screen-reader semantics and mobile behaviour the real control already has for free.

### The date *button* is ours; the date *panel* is not

The calendar glyph is masked and painted brand green. The panel Chrome opens cannot be styled
at all, so the system leaves it alone and sets `accent-color` to reach the one part that
responds. Replacing it would mean writing a date picker from scratch — keyboard navigation,
locale-aware week starts, month jumps, screen-reader announcements — and that is not worth
trading for a panel that matches our greens.

**Disabled controls drop the brand affordance:** the chevron and calendar glyph repaint to
`neutral-400`, because a brand-coloured arrow on a dead control advertises something that will
not happen.

### Save bar

Sticky to the bottom of a long form: `neutral-50/95` backdrop blur, hairline top border. It
states its own condition beside the button — *"You have unsaved changes."* / *"Everything is
saved."* — and the button is disabled until something is dirty.

---

## 13. Money

The most opinionated part of the system. These are as much logic as design.

1. **Store integers, never floats.** Money is held in the smallest unit (pesewas) and converted
   once, at the UI edge. `Math.round(cedis * 100)` is wrong in the direction that costs somebody
   money: `1.005 * 100` is `100.49999999999999`, so it rounds to 100 when the person meant 101.
   **Parse the digits of the typed string, not a float.**
2. **One formatter, built once.** `toLocaleString` constructs a fresh `Intl.NumberFormat` on
   every call — fine on a form, not fine on a grid. A thousand-row sheet formats a few thousand
   figures per render, which was tens of milliseconds of formatter construction *on every
   keystroke*.
3. **Always tabular.** `font-variant-numeric: tabular-nums` so columns align on the decimal.
4. **Volt means it moved.** Deducted, disbursed, settled. Not expected, owed or forecast.
5. **The currency symbol is part of the value**, never a separate label — from one function, so
   no screen can render money a different way.

```ts
const GHS = new Intl.NumberFormat("en-GH", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
});

export function formatMoney(pesewas: number): string {
  return `₵${GHS.format(pesewas / 100)}`;
}
```

**`MoneyAmount` tones:** `volt` → `rounded-sm bg-volt-500 px-1.5 text-brand-900`;
`good` → `text-good-500`; `danger` → `text-red-500`; `muted` → `text-neutral-500`.
Base is `font-mono font-semibold tracking-tight`.

---

## 14. Tables and stats

`TableCard` = card + optional header + horizontally scrollable table.

- **A list's own controls belong in the card header** — its search, filters, export. Loose above
  the card, a table reads as a paragraph with a grid underneath; inside the header, it reads as
  one object you are operating.
- **Headers use `.table-head`**, not `.label-caps`.
- **Cell padding narrows below `sm`:** `px-3` small, `px-5` above. Four cells at `px-5` spend
  160px on padding alone — most of a 375px phone, and why tables used to scroll sideways.
- **Money right-aligned and tabular. Text left-aligned. Nothing is centred.**
- **Twenty rows per page**, and the pager renders nothing when everything fits on one.
- **Nulls sort last in both directions** — a missing value is not a small one.
- The table lives in `overflow-x-auto` so the *page* never scrolls sideways.

---

## 15. Charts

One chart component, and its conventions are worth copying because they are unusually strict
about honesty.

| Aspect | Spec |
|---|---|
| Canvas | `viewBox="0 0 720 150"`, `class="w-full overflow-visible"` — scales to any container |
| Gridlines | Three only, at 0 / 50% / 100%, `neutral-200`, 1px |
| Bars | `brand-500`, `rx="4"` — the same 4px round-end the rest of the system uses |
| Line | `brand-600`, `fill="none"` |
| Data points | Two circles: `r=5` filled `surface`, then `r=3.5` filled `brand-600` — the white ring keeps the point readable where the line crosses a gridline |
| Labels | `neutral-700`, caption size, `tabular-nums` |
| Accent | **No volt.** A chart is analysis, not action or money that moved |

### Every chart is a described image

```tsx
<svg
  role="img"
  aria-label={`Payroll cost by month. ${months
    .map((m) => `${cycleLabel(m.cycle)}: ${formatMoney(m.cost)}`)
    .join(". ")}`}
>
```

The label is not "a bar chart of payroll cost" — it is **every value in the chart, read out**.
A screen-reader user gets the data, not a description of a picture they cannot see.

### Two rules that keep a chart truthful

- **The headline figure is stated in text above the chart**, not left to be read off an axis:
  *"Highest month ₵37,020.00"*. If the only way to learn the number is to measure a bar against
  a gridline, the chart is decoration.
- **Money in a chart uses the same `formatMoney` as the tables.** A sentence, a column and a
  chart label can never disagree about the same figure.

---

## 16. The editable grid

The most distinctive surface in the product: people down, pay items across, typed into
directly. Worth stealing wholesale for any editable data grid.

| Measure | Value | Why |
|---|---|---|
| Row height | **36px** | Fixed, so the virtualiser estimates without measuring |
| Header height | 56px | Two lines: the item name, and what it is worked out from |
| Frozen columns | 104 / 196 / 136px | Identity columns, sticky left — you always know whose row you are in |
| Entry column | 128px | Holds `₵1,000,000.00` without truncation |
| Computed column | 112–124px | Tinted `neutral-50`, right-aligned, never editable |
| Overscan | 10 rows | Virtualised: a thousand rows render about sixty |

### Provenance markers

| Marker | Spec | Means |
|---|---|---|
| Dot | 6px circle, `brand-500`, top-right | Entered for this month |
| Corner | 7px triangle, `brand-500`, top-left | Unsaved |
| Tint | `brand-50` cell fill | Unsaved value |

Both markers are `aria-hidden` with the meaning carried in the cell's title text, and both
appear in a **legend beneath the grid** — colour never alone.

### Rules that are not visual but belong here

- **Read-only columns are tinted, not greyed-out text.** Figures stay full-contrast
  `neutral-600` on `neutral-50`, because they are real numbers somebody needs to read.
- **What a cell shows and what the server holds are different things.** A standing allowance
  displays ₵700 while the server holds no entry for the month. Conflating them meant opening a
  cell and pressing Enter silently wrote ₵700 as a one-month override.
- **The editor is uncontrolled.** Keeping the typed text in React state re-rendered every
  visible cell on every keystroke — about nine hundred — so typing stuttered. The text lives in
  a ref; the editing state changes once when the editor opens and once when it closes.
- **The cursor is anchored by identity, not position.** A background refetch that reorders rows
  must not leave the editor pointing at a different person.
- **Keyboard:** type-to-edit, Enter commits and moves down, Tab across, arrows move,
  Shift+arrows extend, F2 edits, Delete clears, Escape cancels, Ctrl/Cmd+Z undo,
  Ctrl/Cmd+D fill-down, clipboard paste from a spreadsheet.

---

## 17. Shell and page frame

| Element | Spec | Notes |
|---|---|---|
| Rail width | 236px | `brand-800`, fixed, `lg:` and up only |
| Mobile bar | 56px | Fixed top, `brand-800` |
| Mobile drawer | `min(85vw, 280px)` | `shadow-lift`, scrim `brand-900/50` |
| Content max width | 1200px | Centred, `px-4` / `lg:px-10`, `py-8` / `lg:py-10` |
| Nav item | pill, `px-4 py-2.5` | `brand-100` text; active = `brand-700` fill, white, semibold |
| Nav counter | pill, `min-w-[1.375rem]` | `brand-100` on `brand-800`, `text-label` |
| Page header | `mb-8` | Title + subtitle at `max-w-2xl`; actions **wrap** |

> **Wrap, don't shrink.** The header's action slot uses `flex-wrap`, not `shrink-0`. `shrink-0`
> forbids the row from narrowing, so on a phone a pair of buttons pushed the whole page wider
> than the screen and it scrolled sideways to reveal nothing but empty space.

### Print is part of the system

```css
@media print {
  body { background: white !important; color: #0b1410 !important; font-size: 11pt; }
  aside { display: none !important; }
  [class*="shadow-"] { box-shadow: none !important; }
  @page { margin: 18mm 14mm; }
}
```

---

## 18. States and feedback

- **Loading:** centred spinner + label in a bordered card. `role="status"`.
- **Empty:** dashed `neutral-300` border, centred, body copy capped at `--ds-empty-measure`.
  **Always has an action** — if there is nothing to show, the screen's job is to say what would
  put something there.
- **Error:** `red-100` panel, `role="alert"`, message, and a **Try again** button. An error
  state with no retry is a dead end.
- **Toast:** pill, `brand-800` on white (`red-500` for failure), `shadow-lift`, bottom-centre,
  5s, inside `role="status" aria-live="polite"` so it is announced as well as shown.

**Distinguish "loading", "failed" and "nothing here".** A component that renders `null` for all
three makes a missing feature indistinguishable from a broken one — which is exactly how a
whole settings section stayed invisible for weeks.

**Three failure screens**, for the three places a throw can land: inside a page, inside the
shell, above the router. A thrown render used to be a white screen with no nav, no logo and no
way back.

### Modal

`fixed inset-0 z-50`, scrim `bg-brand-900/45 backdrop-blur-sm`, panel
`max-h-[85vh] w-full max-w-lg rounded-lg bg-surface p-5 sm:p-7 shadow-lift`.

Behaves like a dialog: focus moves in and is **trapped**, the page behind stops scrolling, Esc
closes, focus returns where it came from. Keep `onClose` in a ref — callers pass inline arrows,
so a fresh function on every keystroke would re-run the effect and jump focus back to the first
field as you type.

### Tabs

`role="tablist"` with a **roving tabindex**: arrows move between tabs, Home/End jump to the
ends, and Tab itself moves past the whole set. Counts belong in the component — a queue whose
size you can only learn by opening it is a queue nobody triages.

---

## 19. Accessibility

Not a separate concern in this system — most of it is already stated in the four rules and the
component specs. Collected here so a port can check it off.

### The rule that does the most work

**Colour never carries meaning alone.** Every status is a tone *and* a word. Every provenance
marker in the grid has a legend beneath it. This single rule removes most of the colour-blind
exposure a dashboard normally carries, and it makes the product clearer for everybody.

### Live regions

| Region | Role | Why |
|---|---|---|
| Toasts | `role="status" aria-live="polite"` | A confirmation nobody hears is not a confirmation |
| Loading states | `role="status"` | Announces the wait rather than going silent |
| Error states | `role="alert"` | Interrupts, because something needs attention |
| Acting-as banner | `role="status" aria-live="polite"` | Announced on arrival, not only visible |
| Unsaved-cell count | `aria-live="polite"` | "3 cells changed" is read as it changes |

### Focus

- **Visible on everything.** `focus-visible:ring-2 ring-brand-500 ring-offset-2` on buttons;
  `focus:ring-2 ring-brand-500/15` plus a border colour change on fields. Nothing in the
  product removes an outline without replacing it.
- **Trapped in modals**, and returned to where it came from on close. The page behind stops
  scrolling.
- **Roving tabindex on tabs** — arrows move between them, Home/End jump to the ends, and Tab
  moves past the whole set as it should.
- **The grid is one tab stop.** A thousand-row spreadsheet with a tab stop per cell is
  unnavigable; the grid takes focus once and arrow keys move the cursor inside it.

### Semantics

- `aria-selected` on tabs, `aria-controls` tying each tab to its panel, `aria-expanded` on
  popovers, `aria-readonly` on a locked grid, `aria-invalid` on a refused cell.
- `scope="col"` on every table header.
- Icons are `aria-hidden` because each sits beside its own label — *make sure that stays true
  if you add an icon-only control; it then needs an `aria-label`.*
- `<caption>` or a heading names every table.

### Targets and text

- **44px minimum touch target** on the phone (`--ds-btn-md`), 40px on desktop.
- **16px minimum font size on phone inputs** (`--ds-field-fs`), or iOS Safari zooms the page on
  focus — an accessibility problem that presents as a layout bug.
- Body text at 15px/1.55, and running measure capped (`max-w-2xl` on subtitles,
  `--ds-empty-measure` on empty states).

### Contrast

The pairs in this system that carry text were chosen to clear WCAG AA at their sizes:
`neutral-900` on `neutral-50`, `neutral-500` on white for secondary text, `brand-900` on
`volt-500` for the primary button, white on `brand-700`/`brand-800` for the rail. The semantic
tints (`good-500` on `good-100` and friends) are text-on-tint pairs, not tint-on-white.

> **Check any new pair rather than assuming it.** Volt is a light accent — white text on volt
> fails badly, which is why the primary button is `brand-900` ink and never white.

---

## 20. Voice

Words are design material.

| Write | Not |
|---|---|
| "Waiting for approval" | `IN_REVIEW` |
| "2 people have no confirmed account to be paid into." | "Validation failed: 2 records." |
| "Transport is already on this person from 2026-09-01. End the current one first." | "duplicate key value violates unique constraint" |

- **Name things the way the person says them.** "Pay sheet", "Time off" — never the table name.
- **Say what will happen, then confirm it happened.** Button says *Save*; toast says
  *"1 cell saved. Figures recomputed."*
- **An error explains the fix.** What went wrong and the next move. No apology, no blame.
- **Never show an id.** UUIDs are banned from the interface.
- **State the promise precisely.** "Kept on this device" and "Saved" are different guarantees,
  and the screen says which one it is making.
- **Numbers in prose match numbers in columns** — same formatter, so a sentence and a table
  never disagree.

---

## 21. Porting it

### Change these three, and you have a different product

1. The **brand ramp** — ten steps of your own hue.
2. The **accent ramp** and its one meaning.
3. The **wordmark** and mark geometry.

### Keep these, whatever you are building

- The four rules.
- The density tokens, and the discipline of adding a token rather than forking a component.
- The type scale, with clamped display sizes.
- Pills for actions, rectangles for information.
- One shadow, on four things.
- Semantic colour always paired with a word.
- Integers for money.

### Five things that will bite you

1. **Tailwind scans by path.** Shared components live outside the app, so without
   `@source "../../packages/ds/src"` every utility used only inside the design system is
   considered unused and stripped. It does not fail — it silently ships an unstyled product.
2. **That glob scans the whole directory.** Desktop-only components in the same folder generate
   their CSS into a phone app's stylesheet too. Splitting the data tools into their own file cut
   8KB — 23% — from an app that renders none of them, while its JavaScript did not change by a
   byte, which is exactly why nothing caught it.
3. **Teach `tailwind-merge` your scale.** A custom type scale means `cn("text-body","text-caption")`
   resolves wrongly until you extend the `font-size` class group.
4. **Class concatenation beats `cn`.** Router `activeProps.className` is appended *after*
   merging, so the inactive colour always won and the active nav tab was invisible. Anything
   that concatenates after your merge function needs the full class, not a fragment.
5. **Keep the logo as data, not a component.** A plain object with no framework import lets a
   React app and a static marketing site draw the identical mark from the identical numbers.
   Five copies across four apps is how one app ends up with no logo at all.

```ts
// cn.ts — clsx + tailwind-merge, taught about the custom type scale.
import { clsx, type ClassValue } from "clsx";
import { extendTailwindMerge } from "tailwind-merge";

const twMerge = extendTailwindMerge({
  extend: {
    classGroups: {
      "font-size": [{ text: ["hero", "display", "h1", "h2", "body", "caption", "label"] }],
    },
  },
});

export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}
```

---

## 22. Known gaps

Stated plainly, because a design system document that only lists strengths is a sales page.
These are live in the codebase this was extracted from.

1. **Icon stroke width is a convention, not a token.** 1.7 / 1.8 / 2 / 2.5 / 3 are all in use.
   Mostly size compensation, but nothing enforces it. Make it a token on a port.
2. **No `prefers-reduced-motion` rule in source.** The only motion is colour fades and a
   spinner, so exposure is small — but it should be there.
3. **Light theme only.** `color-scheme: light` and a single palette. Deliberate for a payroll
   tool used in daylight offices, and a real gap if your product is used at night. Adding one
   means a second set of token values, not a second set of components.
4. **No formal spacing scale beyond Tailwind's default.** The gaps in the Spacing table above are observed
   conventions, not named tokens. They are consistent in practice; they are not enforced.
5. **No documented breakpoint for the grid other than a hard-coded 900px.** It works, but it
   sits outside the scale.
6. **The density tokens are one-directional.** The phone app overrides eight of them; there is
   no third density (a dense "compact" table mode, say) and adding one would need the token
   list revisited rather than extended.

---

## 23. The complete token block

Copy this, substitute your hue, and the rest of the document applies unchanged.

```css
@theme {
  /* ─── Brand — the surface the product sits on. */
  --color-brand-50:  #eef5f1;
  --color-brand-100: #d3e6dc;
  --color-brand-200: #a5cbba;
  --color-brand-300: #6da895;
  --color-brand-400: #3d8570;
  --color-brand-500: #1a5a44;
  --color-brand-600: #114634;
  --color-brand-700: #0a3527;
  --color-brand-800: #06291e;
  --color-brand-900: #052018;

  /* ─── Volt — the single accent. Action, and money that has moved. */
  --color-volt-100: #eefbcf;
  --color-volt-200: #e0f8a6;
  --color-volt-300: #d5f87c;
  --color-volt-400: #cef664;
  --color-volt-500: #c8f550;
  --color-volt-600: #b2e22f;
  --color-volt-700: #8fbc1f;

  /* ─── Neutral — cool paper, tinted toward the brand. */
  --color-neutral-50:  #f6f7f3;
  --color-neutral-100: #eef0ea;
  --color-neutral-200: #e2e6dd;
  --color-neutral-300: #cbd2c7;
  --color-neutral-400: #99a49c;
  --color-neutral-500: #5b6660;
  --color-neutral-600: #414b46;
  --color-neutral-700: #2c3531;
  --color-neutral-800: #18201c;
  --color-neutral-900: #0b1410;

  /* ─── Semantic — used WITH a word, never alone. */
  --color-good-500: #17805a;
  --color-good-100: #dcf1e7;
  --color-warn-500: #a2650b;
  --color-warn-100: #fbeed4;
  --color-red-500:  #b4362a;
  --color-red-100:  #f8e2df;
  --color-surface:  #ffffff;

  /* ─── Type — one face, sans and mono alike. */
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --font-mono: "Inter", ui-sans-serif, system-ui, sans-serif;

  --text-hero: clamp(2.25rem, 9vw, 3.25rem);
  --text-hero--line-height: 1.02;
  --text-hero--font-weight: 700;
  --text-hero--letter-spacing: -0.035em;

  --text-display: clamp(1.75rem, 7vw, 2.5rem);
  --text-display--line-height: 1.08;
  --text-display--font-weight: 700;
  --text-display--letter-spacing: -0.03em;

  --text-h1: 1.75rem;
  --text-h1--line-height: 1.2;
  --text-h1--font-weight: 700;
  --text-h1--letter-spacing: -0.022em;

  --text-h2: 1.1875rem;
  --text-h2--line-height: 1.35;
  --text-h2--font-weight: 600;
  --text-h2--letter-spacing: -0.015em;

  --text-body: 0.9375rem;
  --text-body--line-height: 1.55;

  --text-caption: 0.8125rem;
  --text-caption--line-height: 1.4;

  --text-label: 0.6875rem;
  --text-label--line-height: 1;
  --text-label--font-weight: 600;
  --text-label--letter-spacing: 0.07em;

  /* ─── Radius — generous. Pills for actions, 16px for cards. */
  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;

  --shadow-lift: 0 4px 16px -2px rgb(11 20 16 / 0.10),
                 0 2px 6px -2px rgb(11 20 16 / 0.06);
}

/* ─── Density — the only values an app may override. */
:root {
  --ds-field-px: 0.875rem;
  --ds-field-py: 0.625rem;
  --ds-field-fs: var(--text-body);     /* ≥16px on phones */
  --ds-field-lh: var(--text-body--line-height);
  --ds-field-toggle-w: 2.75rem;
  --ds-label-mb: 0.375rem;
  --ds-card-p: 1.5rem;
  --ds-card-header-px: 1.5rem;
  --ds-row-gap: 1.5rem;
  --ds-row-py: 0.75rem;
  --ds-empty-py: 3.5rem;
  --ds-empty-measure: 28rem;
  --ds-toast-py: 0.625rem;
  --ds-btn-sm: 2rem;
  --ds-btn-sm-px: 0.875rem;
  --ds-btn-md: 2.5rem;
  --ds-btn-lg: 3rem;
}

html, body, #root { height: 100%; }

body {
  font-family: var(--font-sans);
  font-feature-settings: "cv11", "ss01", "ss03";
  background: var(--color-neutral-50);
  color: var(--color-neutral-900);
  -webkit-font-smoothing: antialiased;
}

:root { color-scheme: light; accent-color: var(--color-brand-700); }

::selection, input::selection, textarea::selection {
  background: var(--color-volt-500);
  color: var(--color-brand-900);
}
```

---

## The thread running through all of it

Every rule here exists because something specific went wrong once. A token said two different
things on two screens. A button let its label escape it. A number pushed a page sideways. A
feature was complete and wired to a screen nobody could find.

A design system is not a palette and a component library. It is the set of decisions you only
have to make once — **written down in the file where the next person will actually read them.**

