# Color

> Grayscale first. Color is added only where it carries meaning, and in a financial interface almost nothing does.

Source: https://lopifi.com/docs/foundations/03-color
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Color

Grayscale first. Color is added only where it carries meaning, and in a financial interface almost nothing does.

The whole palette is **one neutral ramp, one accent, and a semantic value pair**. That's it. If a design needs a sixth hue, it needs a rethink instead.

---

## Why OKLCH

All tokens are authored in `oklch()`.

- **Perceptual uniformity.** `oklch(0.55 …)` is the same apparent lightness at every hue. In HSL it is not, which is why hand-tuned HSL ramps always have one step that looks wrong.
- **Predictable ramps.** Hold hue and chroma, vary lightness, get a ramp that reads as one family.
- **Contrast is inspectable.** The `L` value approximates perceived lightness, so contrast failures are visible in the token file.
- **Wide gamut.** P3 displays get more saturated accents for free, with sRGB fallback.

Fallback: ship `oklch()` directly. Baseline since 2023; for legacy targets a PostCSS plugin emits an sRGB fallback declaration above each.

---

## The neutral ramp

**Hue 265, chroma 0.002–0.012.** A cool ink, not a pure grey. Pure `#000`/`#fff`/`#808080` in a data-dense UI reads as unfinished; a whisper of blue reads as considered and matches how paper-white and ink actually look.

Chroma *increases* slightly as lightness decreases — dark neutrals need more chroma to avoid looking flat.

### Light mode

```css
--canvas:            oklch(0.985 0.002 265);  /* page background — NOT white */
--surface:           oklch(1.000 0.000 000);  /* cards — pure white, lifted above canvas */
--surface-sunken:    oklch(0.970 0.003 265);  /* wells, code, inactive tabs, table zebra */
--surface-hover:     oklch(0.975 0.003 265);
--surface-selected:  oklch(0.960 0.010 258);  /* accent-tinted */

--border-subtle:     oklch(0.935 0.004 265);  /* dividers INSIDE a surface */
--border:            oklch(0.900 0.005 265);  /* surface edges */
--border-strong:     oklch(0.820 0.006 265);  /* emphasis, selected, input hover */

--text-primary:      oklch(0.210 0.012 265);  /* 15.8:1 on surface */
--text-secondary:    oklch(0.480 0.010 265);  /*  7.4:1 — labels, metadata */
--text-tertiary:     oklch(0.580 0.008 265);  /*  4.6:1 — THE FLOOR for meaningful text */
--text-disabled:     oklch(0.720 0.006 265);  /*  2.6:1 — never used for meaning */
--text-inverse:      oklch(0.985 0.002 265);
```

### Dark mode

Not an inversion — a separate design. Dark surfaces get **lighter as they rise**, which is the opposite of light mode, where surfaces get lighter as they rise too. (Both modes: elevation = more light. That's physically consistent and it's why naive inversion feels wrong.)

```css
--canvas:            oklch(0.170 0.008 265);
--surface:           oklch(0.205 0.009 265);
--surface-raised:    oklch(0.240 0.010 265);  /* popovers, dialogs */
--surface-sunken:    oklch(0.150 0.008 265);
--surface-hover:     oklch(0.235 0.010 265);
--surface-selected:  oklch(0.265 0.020 258);

--border-subtle:     oklch(0.265 0.010 265);
--border:            oklch(0.310 0.011 265);
--border-strong:     oklch(0.400 0.012 265);

--text-primary:      oklch(0.970 0.003 265);
--text-secondary:    oklch(0.720 0.008 265);
--text-tertiary:     oklch(0.600 0.008 265);  /* floor: 4.6:1 on --surface */
--text-disabled:     oklch(0.450 0.008 265);
```

> **Dark mode is never pure black.** `oklch(0.17)` ≈ `#1c1c22`. Pure black canvas with white text causes halation on OLED and makes 14px tabular figures shimmer. Ask any trader who's stared at a terminal.

---

## The accent

**One.** `--accent`. Ink blue.

```css
--accent:            oklch(0.500 0.155 258);  /* ≈ #2b5fd9 */
--accent-hover:      oklch(0.450 0.155 258);
--accent-active:     oklch(0.410 0.150 258);
--accent-subtle:     oklch(0.960 0.025 258);  /* tinted backgrounds */
--accent-border:     oklch(0.880 0.060 258);
--accent-fg:         oklch(0.995 0.000 000);  /* text ON accent */

/* dark mode */
--accent:            oklch(0.650 0.150 258);
--accent-subtle:     oklch(0.270 0.045 258);
--accent-fg:         oklch(0.170 0.010 258);
```

### The one-accent law
> **One accent event per view.** Spend it on the single most important action.

If a screen has a primary "Send payment" button, then the active nav item, the selected tab, the focused input ring, and the chart series may not *also* be accent-filled. They get neutral treatments: a border, a weight change, a background tint, an underline.

The exceptions — the only ones:
- **Focus rings.** Accessibility overrides aesthetics. The focus ring is always `--accent` and it does not count toward the budget.
- **Links in prose.** Body copy links are accent-colored. They're not competing for the same attention.

### Accent is never a data color
Charts, category tags, and account colors do not use `--accent`. If everything can be blue, blue stops meaning "press this".

---

## Semantic value colors

The most consequential decision in a finance palette.

```css
--positive:          oklch(0.520 0.130 155);  /* deep teal-green */
--positive-fg:       oklch(0.995 0 0);
--positive-subtle:   oklch(0.950 0.035 155);
--positive-flash:    oklch(0.900 0.070 155);  /* live tick background */

--negative:          oklch(0.520 0.190 025);  /* deep rose-red */
--negative-fg:       oklch(0.995 0 0);
--negative-subtle:   oklch(0.955 0.030 025);
--negative-flash:    oklch(0.905 0.070 025);

--warning:           oklch(0.680 0.150 075);  /* amber — pending, review, expiring: fills and borders */
--warning-subtle:    oklch(0.960 0.045 075);
--warning-text:      oklch(0.520 0.115 070);  /* amber as text and glyphs: ≥ 4.96:1, --warning-subtle included */

--info:              var(--accent);
--neutral-value:     var(--text-secondary);   /* unchanged / zero delta */
```

Both `--positive` and `--negative` sit at `L 0.52` — they are *peers*, neither shouts louder. Amber sits higher because it must be distinguishable from red at a glance — which is also why it cannot be text: `--warning` is 2.95:1 on white. Text and glyphs in amber use `--warning-text`, and the preset maps every `text-warning` utility to it, so `bg-warning` and `border-warning` keep the fill while no call site can set the fill as copy. In dark mode `--warning-text` is `--warning` itself (≥ 6.28:1), and `--negative` steps up to `L 0.69` so it reads ≥ 4.8:1 on `--negative-subtle`.

### The red/green problem, and the actual fix

Roughly 8% of men have a red-green deficiency. Two colors at the same lightness with red/green hues converge to the same grey for a deuteranope. So:

> **Color is never the only signal for a value's sign.** Never. Not once, anywhere in this system.

Every signed value ships with a redundant, non-chromatic cue:

| Cue | Where |
|---|---|
| **Explicit sign glyph** — `+` prefix on positive deltas, `−` (U+2212, not a hyphen) on negatives | `Delta`, all KPI changes |
| **Accounting parentheses** — `(1,234.00)` | Statements, ledgers, tables |
| **Directional arrow** — `↑` / `↓` at 12px | `Delta`, `Trend` |
| **Position** — above/below a zero baseline | Charts |
| **Pattern** — 45° hatch fill on negative bars | Charts, waterfalls |

Test: **turn the palette to greyscale and confirm every sign is still readable.** That's the ship gate, and it's in the [quality bar](../craft/03-quality-bar.md).

### The Signal theme (accessibility variant)
An opt-in alternate mapping for users who tell us they need it, plus a system-level respect for `prefers-contrast`. Blue/amber is the canonical colorblind-safe divergent pair — different in hue *and* lightness.

```css
[data-value-palette="signal"] {
  --positive: oklch(0.500 0.130 250);   /* blue */
  --negative: oklch(0.520 0.130 055);   /* amber-orange, deep enough to set figures: ≥ 4.98:1 */
  --positive-subtle: oklch(0.955 0.030 250);
  --negative-subtle: oklch(0.960 0.045 060);
}
```

The convention "green = up" is strong enough that we don't make this the default. It is a setting, surfaced in preferences next to density, and it must be honored by every chart as well as every number.

### Sign is a domain decision, not a color decision
`--negative` means **"the number is below zero"**, not "this is bad". A negative *expense* variance is good news. Components take a `polarity` prop:

```ts
polarity?: "standard" | "inverted" | "neutral"
// standard  → up is positive   (revenue, cash, margin)
// inverted  → up is negative   (burn, COGS, churn, DSO)
// neutral   → no color at all  (headcount, invoice count)
```

Defaulting every metric to `standard` is how dashboards end up cheerfully green about a cost overrun.

---

## Data-visualization palette

Categorical series need to be distinguishable from each other *and* from the semantic pair. So they live in a different lightness band and avoid hues 155 and 025.

```css
--viz-1: oklch(0.550 0.140 258);  /* blue      — always series 1 */
--viz-2: oklch(0.650 0.120 195);  /* cyan      */
--viz-3: oklch(0.620 0.130 090);  /* olive     */
--viz-4: oklch(0.580 0.140 320);  /* magenta   */
--viz-5: oklch(0.680 0.110 045);  /* sand      */
--viz-6: oklch(0.480 0.100 230);  /* deep blue */
--viz-7: oklch(0.720 0.090 140);  /* sage      */
--viz-8: oklch(0.420 0.080 300);  /* plum      */
```

Rules:
- **Max 6 categorical series.** Beyond that, group into "Other" — this is a data problem, not a palette problem.
- **Sequential scales** (heatmaps, calendar) interpolate a single hue's lightness in OKLCH: `oklch(0.97 0.02 258)` → `oklch(0.40 0.16 258)`.
- **Diverging scales** (variance) use blue→neutral→amber, never red→green, because a diverging scale has no redundant sign cue available.
- **Series order is stable across renders.** A category that's `viz-3` on the dashboard is `viz-3` in the drill-down. Assign by a stable key hash, never by array index.

---

## Application rules

1. **Grayscale first.** Build every component with zero color. Add color only where removing it loses information. Most components ship with none.
2. **Never color as decoration on a data surface.** Colored card headers, tinted table stripes, category-colored borders on every row — all forbidden. Color is bandwidth; spend it on the exception.
3. **Backgrounds are tint, not fill.** Status pills use `*-subtle` backgrounds with `*` -colored text, not saturated fills with white text. Saturated fills are reserved for the single primary action.
4. **No gradients.** None. Not on buttons, not on cards, not on charts. One exception: an area chart's fill may fade a single hue to transparent, because that's a legibility device (it keeps the line readable over gridlines), not decoration.
5. **No glow as affordance.** A glow may never be the thing that says "this is interactive" or "this is focused".
6. **Borders carry the structure, not color.** See [elevation](04-elevation-borders-shadows.md).
7. **Every token is defined for both modes at declaration.** No token exists that's only valid in light mode.

---

## Contrast requirements

| Content | Minimum | Standard |
|---|---|---|
| Body text, all figures | 4.5:1 | WCAG AA |
| Text ≥ 18.66px bold or ≥ 24px | 3:1 | AA large |
| **Any figure representing money** | **7:1** | AAA — *our own rule* |
| Icons conveying meaning | 3:1 | AA non-text |
| Borders that define an input | 3:1 | AA non-text |
| Focus ring vs. adjacent | 3:1 | AA non-text |
| Chart series vs. background | 3:1 | |
| Decorative borders/dividers | none | |

The 7:1 rule for money is deliberate and stricter than the standard. A misread figure in a finance product is a material error, and `--text-primary` at 15.8:1 costs nothing.

**Never** use `--text-tertiary` for a monetary value. It's for labels, timestamps, and units — which is why the currency symbol may use it. The one sanctioned exception inside a figure is the cents of a `figure-hero` / `figure-lg` display value, where the glyphs are large text and the integer part carries the amount at full contrast ([typography](05-typography.md#the-currency-symbol)); in tables the cents stay at full colour.

---

## Theming for consumers

The system ships one point of view. Consumers may override exactly two things without breaking it:

```css
:root {
  --accent: oklch(0.50 0.16 25);   /* your brand hue */
  --font-display: "Your Serif";    /* your editorial face */
}
```

The neutral ramp, semantic values, spacing, radius and motion tokens are **not** theming surface. If you change those you have forked the system, which is fine — you own the code — but you're on your own for coherence.
