---
title: "Color"
description: "Grayscale first. Color goes only where it carries meaning, and in a financial interface almost nothing does. The palette is one neutral ramp, one accent and a semantic value pair. A design that needs a sixth hue needs a rethink."
canonical: "https://lopifi.com/docs/foundations/color"
---

# Color

> Grayscale first. Color goes only where it carries meaning, and in a financial interface almost nothing does. The palette is one neutral ramp, one accent and a semantic value pair. A design that needs a sixth hue needs a rethink.

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

---

# Color

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

The palette is **one neutral ramp, one accent and a semantic value pair**. A design that needs a sixth hue needs a rethink.

---

## Why OKLCH

All tokens are authored in `oklch()`.

- **Perceptual uniformity.** `oklch(0.55 …)` looks equally light at every hue. HSL doesn't, which is why hand-tuned HSL ramps always have one step that looks off.
- **Predictable ramps.** Hold hue and chroma, vary lightness, and the ramp reads as one family.
- **Contrast is inspectable.** `L` approximates perceived lightness, so you can spot contrast failures in the token file.
- **Wide gamut.** P3 displays get more saturated accents for free, with sRGB fallback.

Ship `oklch()` directly. It's been Baseline since 2023. For legacy targets, a PostCSS plugin emits an sRGB fallback above each declaration.

---

## The neutral ramp

**Hue 265, chroma 0.002–0.012.** A cool ink, not pure gray. Pure `#000`/`#fff`/`#808080` makes a data-dense UI look unfinished. A trace of blue looks deliberate and matches how real paper and ink look.

Chroma *rises* slightly as lightness drops, because dark neutrals look flat without it.

<!-- plate:color-ramp -->

### 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

Dark mode is a separate design, not an inversion. Dark surfaces get **lighter as they rise**, the same as in light mode: in both, elevation means more light. That's physically consistent, and it's why a 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`. White text on a pure black canvas halates on OLED and makes 14px tabular figures shimmer.

---

## 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, the active nav item, selected tab, focused input ring and chart series can't *also* be accent-filled. They get neutral treatments instead: a border, a weight change, a background tint or an underline.

There are two exceptions:
- **Focus rings.** Accessibility wins. The focus ring is always `--accent` and doesn't count toward the budget.
- **Links in prose.** Body copy links are accent-colored. They don't compete for the same attention.

<!-- plate:color-accent -->

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

---

## Semantic value colors

The most important 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 */
```

`--positive` and `--negative` both sit at `L 0.52`. They're peers, and neither is louder. Amber sits higher so it's easy to tell from red at a glance, which is also why it can't be text: `--warning` is 2.95:1 on white. Amber text and glyphs use `--warning-text`. The preset maps every `text-warning` utility to it, so `bg-warning` and `border-warning` keep the fill and no call site can use the fill for 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

About 8% of men have a red-green deficiency. To a deuteranope, a red and a green at the same lightness collapse into the same gray.

> **Color is never the only signal for a value's sign.** Anywhere in this system.

Every signed value also carries a cue that isn't color:

| 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 |

The test: **switch the palette to grayscale and confirm every sign still reads.** That's a ship gate, listed in the [quality bar](https://lopifi.com/md/docs/craft/quality-bar).

<!-- plate:color-sign -->

### The Signal theme (accessibility variant)
An opt-in mapping for users who need it, plus support for the system-level `prefers-contrast` setting. Blue and amber are the standard colorblind-safe diverging pair. They differ 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);
}
```

"Green means up" is a strong enough convention that this isn't the default. It's a setting in preferences next to density, and every chart and every number must honor it.

### 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" // required, no default
// standard  → up is positive   (revenue, cash, margin)
// inverted  → up is negative   (burn, COGS, churn, DSO)
// neutral   → no color at all  (headcount, invoice count)
```

Default every metric to `standard` and your dashboard will show a cost overrun in green.

---

## Data-visualization palette

Categorical series have to be distinguishable from each other *and* from the semantic pair, so they use 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.** Group the rest into "Other." More series 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 other sign cue.
- **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. **No decorative color on a data surface.** No colored card headers, tinted table stripes or category-colored borders on every row. Save color for the exception.
3. **Backgrounds are tints, not fills.** Status pills use `*-subtle` backgrounds with `*`-colored text, not saturated fills with white text. Saturated fills are for the one primary action.
4. **No gradients** on buttons, cards or charts. One exception: an area chart's fill may fade a single hue to transparent. That keeps the line readable over gridlines, so it's there for legibility, not decoration.
5. **No glow as affordance.** A glow is never what tells you something is interactive or focused.
6. **Borders carry the structure, not color.** See [elevation](https://lopifi.com/md/docs/foundations/elevation-borders-shadows).
7. **Every token is defined for both modes when it's declared.** No token works only 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 deliberately stricter than WCAG. 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 exception inside a figure is the cents of a `figure-hero` / `figure-lg` display value: those glyphs are large text, and the integer part carries the amount at full contrast ([typography](https://lopifi.com/md/docs/foundations/typography#the-currency-symbol)). In tables, cents stay at full color.

---

## Theming for consumers

The system ships one point of view. You can 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** for theming. Change them and you've forked the system. That's fine, since you own the code, but keeping it coherent is then up to you.
