# Baseline — Design System

Companion to [PRODUCT.md](PRODUCT.md). Every value here is implemented in `assets/css/tokens.css` and verified by `tools/verify-contrast.mjs` (47 checks, 0 failures).

```bash
node tools/verify-contrast.mjs
```

---

## 1. The scene

Design decisions need something concrete to be answerable to. Ours:

> A 52-year-old operations manager in Johannesburg, on the edge of the bed at 06:40 with the cuff still on the arm, curtains half open. Not ill — told the numbers are "a bit high" and quietly anxious about it. Wants to know two things: *is today okay*, and *what did I do yesterday that made it worse.*

Three consequences follow directly:

1. **Measurement happens in dim rooms**, at dawn and after dark. The member app is dark.
2. **The operator works at a desk for eight hours** with dense tables. The admin console is light.
3. **Anxious but not ill.** Anything that feels like a hospital gets deleted. So does anything that takes more than a minute.

## 2. Direction — "instrument at first light"

The reference object is a precision measuring instrument — a barometer, a Braun weather station, a darkroom light meter. Not a hospital, and not a fitness tracker.

This was chosen against two reflexes:

| Reflex | What it looks like | Verdict |
|---|---|---|
| First-order — "BP app" | Mint/teal on pale cyan, rounded cards, red-amber-green gauge | Rejected. Guessable from the category name alone. |
| Second-order — "health app that isn't teal" | Neon-lime on black, Whoop/Strava energy | Rejected. The trap one tier deeper. |
| **Committed** | Neutral graphite, mono numerals reading as a readout, saturated colour reserved almost entirely for clinical state | |

The concrete expression: `128/82` set in a grotesk mono at 56px on graphite. That single element is the whole concept.

## 3. Colour

Strategy is **Restrained**. Surfaces are pure neutral — chroma exactly `0`. The mood is carried by the brand colour and the typography, never by a tinted background. There is no cream, no warm-tinted near-white, no "paper".

### Neutrals

| Token | OKLCH | Hex | Role |
|---|---|---|---|
| `--canvas` | `0.155 0 0` | `#0c0c0c` | App body |
| `--surface-1` | `0.205 0 0` | `#171717` | Cards, sheets |
| `--surface-2` | `0.245 0 0` | `#202020` | Raised, inputs |
| `--hairline` | `0.300 0 0` | `#2e2e2e` | Decorative dividers |
| `--border` | `0.530 0 0` | `#787878` | Form-control edges — clears 3:1 |
| `--ink` | `0.970 0 0` | `#f5f5f5` | Primary text · 17.9:1 |
| `--ink-2` | `0.760 0 0` | `#b1b1b1` | Secondary · 9.1:1 |
| `--ink-3` | `0.660 0 0` | `#929292` | Tertiary · 6.3:1 — **the floor** |

`--ink-3` is the lightest value permitted on any real text, including placeholders. Light grey "for elegance" is the single most common reason an interface becomes unreadable, and this product's users are 50+.

### Brand

Anchored on a seed hue of `113°` — yellow-green/olive.

| Token | OKLCH | Hex | Role |
|---|---|---|---|
| `--brand` | `0.800 0.155 113` | `#bfc840` | Mark, primary fill, focus ring, *"logged"* |
| `--brand-dim` | `0.560 0.100 113` | `#757a2e` | Inactive borders, dim states |
| `--brand-ink` | `0.450 0.098 113` | `#565a05` | The olive, readable on white (admin) |

Brand on canvas is **10.7:1**. Canvas ink on a brand fill is the same — so the primary button works in both directions without a second colour.

### The clinical ramp — and why it contains no green

The conventional green→red severity ramp fails three tests at once. It is indistinguishable under protanopia and deuteranopia, which affect roughly 8% of men — precisely the demographic that develops hypertension. It collapses in greyscale. And it burns green on "your blood pressure is fine", which is the least useful thing green could mean in an app whose whole job is getting someone to complete a daily habit.

So the ramp runs **blue → sand → amber → orange → red**: the blue↔orange axis, the one axis that survives the common colour vision deficiencies. And green is freed for its better job — **`--brand` means "you did the thing"**: streak, completed check-in, adherence, sync succeeded. It never describes a blood pressure value.

Elevated sits at hue 76 rather than 88 so it stays clearly distinct from the brand olive at 113 — on the light console both are dark yellows, and they were close enough to blur the "green means effort, not results" separation.

Hue and chroma are shared identity across both themes. **Luminance is calibrated per surface**, because a deep crimson that reads as urgent on white simply disappears into a dark canvas — the first build of this ramp failed contrast on exactly that, and only on crisis, the one state that must never be hard to read.

**Dark ramp** (member app) — sits high so even crisis clears AA:

| State | OKLCH | Hex | On canvas |
|---|---|---|---|
| Normal | `0.860 0.070 232` | `#a3daf7` | 12.9:1 |
| Elevated | `0.810 0.120 76` | `#e5b86a` | 10.6:1 |
| Stage 1 | `0.760 0.150 58` | `#f79643` | 8.8:1 |
| Stage 2 | `0.705 0.175 38` | `#f87247` | 7.0:1 |
| Crisis | `0.655 0.190 22` | `#ee5459` | 5.6:1 |

**Light ramp** (admin console) — sits low against white:

| State | OKLCH | Hex | On white | Tint fill |
|---|---|---|---|---|
| Normal | `0.530 0.070 232` | `#3e738d` | 5.0:1 | `#e9f7ff` |
| Elevated | `0.498 0.099 76` | `#835907` | 6.2:1 | `#fdf3e5` |
| Stage 1 | `0.465 0.109 58` | `#854705` | 6.6:1 | `#fff2e8` |
| Stage 2 | `0.432 0.138 38` | `#8c2a04` | 7.6:1 | `#fff1ed` |
| Crisis | `0.398 0.157 22` | `#88061c` | 9.0:1 | `#fff1f0` |

Both ramps are **monotonic in relative luminance**, so the scale survives greyscale printing and monochrome displays:

```
dark    0.646 → 0.523 → 0.420 → 0.324 → 0.252     Δ 0.07–0.12
light   0.152 → 0.121 → 0.095 → 0.072 → 0.054     Δ 0.02–0.03
```

**Colour is never the only indicator.** Every state carries a text label and a distinct glyph — `●` normal, `◐` elevated, `◑` stage 1, `◕` stage 2, `▲` crisis. A screenshot in greyscale is still readable, and so is a screenshot described aloud.

### Admin console surfaces

Content is pure white `#ffffff`; the sidebar is `oklch(0.185 0 0)` — the member app's canvas colour. The two products visibly share DNA without pretending to be the same surface.

## 4. Typography

Two families on a genuine contrast axis (humanist sans + grotesk mono), neither used in any prior POC in this directory.

- **Instrument Sans** — UI, body, labels, headings. Humanist, wide aperture, holds up at 13px.
- **Azeret Mono** — every numeral, timestamp, table figure, and the hero BP readout.

Mono numerals are not decoration. `128/82` in a grotesk mono *is* the instrument concept, and tabular figures mean a number changing from `98` to `102` doesn't shift the layout around it.

**Fixed rem scale, ratio ~1.2** — product UI is viewed at consistent DPI, so fluid `clamp()` headings that shrink inside a sidebar look worse, not better. The landing page is the one exception.

| Step | Size | Use |
|---|---|---|
| `--fs-display` | 56px | Hero BP readout (mono) |
| `--fs-xxl` | 32px | Page titles |
| `--fs-xl` | 24px | Section headings |
| `--fs-lg` | 19px | Card titles |
| `--fs-md` | 16px | Body — the mobile floor |
| `--fs-sm` | 14px | Secondary, table cells |
| `--fs-xs` | 12.5px | Labels, captions, badge text |

`font-variant-numeric: tabular-nums` wherever a number can change. Prose capped at 65–75ch; tables may run denser. Display letter-spacing never tighter than `-0.03em`.

## 5. Motion

Custom curves only — the built-in CSS easings are too weak to read as intentional.

```css
--ease-out:    cubic-bezier(0.23, 1, 0.32, 1);      /* entrances, exits */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);     /* on-screen movement */
```

| Element | Duration |
|---|---|
| Button press | 140ms |
| Tooltip, badge | 160ms |
| Screen change in phone | 220ms |
| Sheet, drawer | 280ms |

Rules that are enforced, not aspirational:

- **`ease-in` never appears.** It delays the initial movement — the exact moment the user is watching — and makes an interface feel sluggish at identical duration.
- **Nothing animates from `scale(0)`.** Entrances start at `0.96` with opacity. Nothing in the physical world appears from nothing.
- **Pressables scale to `0.97` on `:active`.** Instant confirmation that the interface heard the tap.
- **Screen changes use CSS transitions, not keyframes**, so a rapid double-tap retargets smoothly instead of restarting from zero.
- **Hover is gated** behind `@media (hover: hover) and (pointer: fine)` — touch devices fire hover on tap.
- **No orchestrated page-load sequences in product surfaces.** The user is in a task. `index.html` gets one staggered reveal; nothing else does.
- **`prefers-reduced-motion: reduce`** drops all transforms to a crossfade. Content is visible by default and revealed content is never gated behind a transition that a headless renderer would fail to fire.

The 7d/30d/90d segmented control uses the duplicated-list + `clip-path: inset()` technique: the active-state copy is clipped to the selected segment and the clip animates. Timing individual colour transitions can never produce that cleanly.

## 6. Layout

- Flexbox for one dimension, Grid for two. `repeat(auto-fit, minmax(280px, 1fr))` for breakpoint-free card grids.
- Cards only where they are genuinely the best affordance. Nested cards never.
- Responsive behaviour is **structural** — the sidebar collapses, tables scroll inside their own `overflow-x: auto`, columns re-flow. Typography does not fluidly resize.
- The page body never scrolls horizontally at any width. Wide content scrolls inside its own container.

**Semantic z-index scale** — no arbitrary `9999`:

```
--z-base 0 · --z-sticky 100 · --z-dropdown 200 · --z-overlay 300
--z-sheet 400 · --z-toast 500 · --z-tooltip 600
```

**Phone frame:** 390 × 844 with a Dynamic Island and side buttons, collapsing to full-bleed `100dvh` below 600px so the prototype is usable on an actual phone.

## 7. Components

Every interactive component ships all seven states: default, hover, focus, active, disabled, loading, error. Half a component is not a component.

- **Loading is skeletons**, not a spinner parked in the middle of content.
- **Empty states teach the interface.** "Building your baseline — 4 of 7 readings" beats "No data".
- **Icons are inline SVG** from a single hand-authored sprite via `<use href="#i-...">`. No icon library, no emoji, one visual weight throughout.
- Focus rings are 3px `--brand` at 2px offset, visible on every interactive element.

## 8. Accessibility floor

WCAG 2.1 AA is the minimum; body text reaches AAA. This is a genuine requirement for a product whose users are 50+, not a compliance box.

- Body text ≥ 4.5:1, large text ≥ 3:1, **placeholders held to the body threshold**
- Touch targets ≥ 44 × 44px
- Every input has a `<label for>`; every icon-only button has `aria-label`
- Tab order follows visual order; focus is never trapped outside a dialog
- Colour is never the sole carrier of meaning
- Charts have an accessible text summary alongside the SVG

## 9. Bans

Beyond the general rules, these are match-and-rewrite:

- Coloured side-stripe borders (`border-left` > 1px as an accent)
- Gradient text (`background-clip: text` over a gradient)
- Glassmorphism as a default surface treatment
- Tiny uppercase tracked eyebrows above every section
- Numbered section markers (`01 / 02 / 03`) as default scaffolding
- Display fonts in UI labels, buttons, or data
- Emoji standing in for icons
- Modal as the first thought — exhaust inline and progressive alternatives first
