Color
Two neutral ramps, five accents, two fixed semantic colors, seventeen semantic aliases. Low-chroma warm-gray, never stark white or black.
The two layers
The system separates what a color is from what a color is for.
The ramps below are raw material: ten steps of neutral (light), the same ten inverted for dark mode, and five accent hues. No component references them directly.
The aliases underneath are the working vocabulary — --gl-color-text-primary,
--gl-color-surface-card, --gl-color-accent. Every component stylesheet
reads from these. Retheming, or switching to dark, means repointing aliases
at different values — which is exactly what the dark block does.
The neutral ramps were authored in oklch(). Every value
below is that same color converted to flat sRGB hex — not re-picked,
not rounded. The contrast figures on this page are computed from those
hex values with the standard WCAG 2.1 relative-luminance
formula, then transcribed here as fact rather than typed by eye.
Neutral
Low-chroma warm-gray, never stark white or black. Ten steps, not twelve or sixteen — the palette defines exactly these.
Low-chroma warm-gray. Not stark white or black. Ten steps, not twelve: exactly 0/50/100/150/200/300/500/600/800/900 — steps 400 and 700 do not exist.
-
n-0
-
n-50
-
n-100
-
n-150
-
n-200
-
n-300
-
n-500
-
n-600
-
n-800
-
n-900
The same low-chroma warm-gray scale, inverted for dark mode. Same ten step numbers as `neutral`; `0` is the darkest step here (the dark-theme page background) rather than the lightest.
-
nd-0
-
nd-50
-
nd-100
-
nd-150
-
nd-200
-
nd-300
-
nd-500
-
nd-600
-
nd-800
-
nd-900
Accent
One swappable accent per project — never two in the same view. Signal Teal is the default; the other four are the sanctioned menu a project can switch to.
-
teal (default)
Default. Neutral, technical.
-
clay
Warm, archival.
-
ink-blue
Sober, utilitarian.
-
ochre
Dry, papery.
-
plum
Quiet, personal.
All five clear AA (4.5:1) for white text at their own value, but Ochre is
the tightest of the five at roughly 4.6:1 — closer to the floor than the
others (Ink Blue and Plum both clear 8.5:1). That is a real property of a
dry, papery ochre and is not being rounded away; a project choosing Ochre
as its accent should re-check any place it sets small white text directly
on the accent fill, rather than through --gl-color-accent-fg
on a full-size Button.
Semantic aliases
Both themes, side by side. The left half of each chip is the light value and the right half is the dark one.
Surfaces
Backgrounds, from the page itself up to a card sitting on it.
| Token | Light | Dark | Use for |
|---|---|---|---|
--gl-color-surface-page
|
neutral.0 |
neutral_dark.0 |
The page itself. Nothing sits behind it. |
--gl-color-surface-card
|
neutral.50 |
neutral_dark.50 |
Cards, dialogs, toasts — anything that reads as a sheet on the page. |
--gl-color-surface-fill
|
neutral.100 |
neutral_dark.100 |
Recessed fill — a hovered row, a segmented-tab track, a photo placeholder. |
--gl-color-surface-fill-2
|
neutral.150 |
neutral_dark.150 |
A second, deeper fill step — the diagonal-grain stripe inside an empty photo slot. |
Borders
Two weights. Hairlines do most of the elevation work in this system, so there was never a reason for a third.
| Token | Light | Dark | Use for |
|---|---|---|---|
--gl-color-border-hairline
|
neutral.200 |
neutral_dark.200 |
Card edges, row dividers, dialog dividers. The default edge everywhere. |
--gl-color-border-default
|
neutral.300 |
neutral_dark.300 |
Input and button borders — anything a reader has to recognize as a control. |
Text
Three levels of emphasis, deliberately not four. Past three, a reader stops perceiving a hierarchy and starts perceiving noise.
| Token | Light | Dark | Use for |
|---|---|---|---|
--gl-color-text-primary
|
neutral.900 |
neutral_dark.900 |
Body copy, headings, and every control label. |
--gl-color-text-secondary
|
neutral.600 |
neutral_dark.600 |
Supporting copy, hints, mono meta values, type-label's color. |
--gl-color-text-tertiary
|
neutral.500 |
neutral_dark.500 |
Placeholders and de-emphasized metadata only — never body text. This step is intentionally sub-AA against the page; see the caveat on the Color page. |
Accent
One swappable accent per project, held constant across a whole view. `-ink` and `-soft` re-derive from `--gl-color-accent` via `color-mix()`, so retheming the accent retones both automatically.
| Token | Light | Dark | Use for |
|---|---|---|---|
--gl-color-accent
|
accent.teal |
accent.teal |
Primary buttons, links, focus affordance, the active tab underline. Never two accents in one view. |
--gl-color-accent-fg
|
#ffffff |
#ffffff |
Label and icon color on a filled accent surface. Fixed white, not a ramp reference. |
--gl-color-accent-ink
|
color-mix(in oklch, var(--gl-color-accent) 78%, black) |
color-mix(in oklch, var(--gl-color-accent) 78%, black) |
Text sitting on an `-soft` accent tint — an IconButton's active state, an accent avatar's initials. |
--gl-color-accent-soft
|
color-mix(in oklch, var(--gl-color-accent) 12%, white) |
color-mix(in oklch, var(--gl-color-accent) 12%, white) |
The tint behind an accent Badge, a toggled IconButton, an accent avatar chip. |
Status
Exactly two states, both fixed — they read the same in every project and are never swapped the way the accent is.
| Token | Light | Dark | Use for |
|---|---|---|---|
--gl-color-success
|
#3f7a4a |
#3f7a4a |
Success text and icons — a completed task's check, a saved-state badge. |
--gl-color-success-bg
|
color-mix(in oklch, var(--gl-color-success) 12%, white) |
color-mix(in oklch, var(--gl-color-success) 12%, white) |
Tint behind a success Badge. Mixed against literal white, so it stays constant across themes. |
--gl-color-danger
|
#ad3a2c |
#ad3a2c |
Overdue/destructive text, the Toast undo action, a danger Button's fill. |
--gl-color-danger-bg
|
color-mix(in oklch, var(--gl-color-danger) 10%, white) |
color-mix(in oklch, var(--gl-color-danger) 10%, white) |
Tint behind a danger Badge. Mixed against literal white, so it stays constant across themes. |
Contrast that has to hold
Color is the one part of this system that is not arbitrary: a ratio is a fact about two colors and a pair of eyes, and it does not care what the palette wants. The pairings below are fixed facts about the token values above, computed once with the standard WCAG 2.1 relative-luminance formula and checked by hand rather than by a build-time test.
Text — 4.5:1 minimum
WCAG 2.1 SC 1.4.3 (normal-size text)
| Pairing | Where it shows up | Light | Dark |
|---|---|---|---|
color-text-primary
|
Body copy | 18.21 | 15.86 |
color-text-secondary
|
Supporting copy | 6.24 | 7.26 |
color-accent-fg
|
The label on a primary or dark-variant Button. | 6.14 | 6.14 |
color-success
|
Success text on a light backdrop — proxied by color-accent-fg | 5.13 | 5.13 |
color-danger
|
Overdue/destructive text on a light backdrop | 6.14 | 6.14 |
Two things this contract deliberately does not assert.
Text tertiary — --gl-color-text-tertiary
(neutral.500) is roughly 3.46:1 against the page in light mode, under the
4.5:1 floor. That step is documented as muted-faint text — placeholders
and de-emphasized metadata, never body copy — so this is a real,
intentional property of the palette, not a bug to patch by darkening the
step.
Dividers — --gl-color-border-hairline and
--gl-color-border-default sit well under the 3:1 that WCAG
1.4.11 asks of a meaningful boundary. That's deliberate: a bounded
surface in this system reads by surface contrast, not a border, so these
tokens are reserved for dividers inside a surface (a CardRow, a table
rule) — never a boundary around one — and this system never claims a
3:1 bar for them, so this contract does not invent one.
Rules
- Never write a hex outside
assets/css/tokens.css. If a component needs a color that does not exist yet, add the alias. - One accent per project, never two in a view. Swapping
--gl-color-accentre-derives-inkand-softautomatically viacolor-mix(). --gl-color-successand--gl-color-dangerare fixed. They read the same in every project and are never swapped the way the accent is.