Foundations
Colour
Pastel surfaces and WCAG AA are in tension. Pale hues are surfaces, not text — so every hue ships as a ten-step ramp with the text-safe boundary marked, and every pairing carries its measured ratio.
Why this page exists
A pastel palette is easy to sell and hard to ship. The colours that make the pitch deck look calm are, almost without exception, unusable for text. Most systems resolve this by quietly using a near-black for all copy and keeping the pastels decorative — which works, but means the palette is not really part of the system, it is a mood board bolted to one.
Halbton's answer is to treat the tension as the design problem rather than an inconvenience. Each hue runs ten steps. The pale end is for surfaces. The deep end is for text. The boundary between them is computed, not chosen, and it is marked on every table below.
The four layers
Tokens apply in one direction — the page, then the things on it, then the marks on those, then meaning. A token never reaches back up a layer.
01 Canvas
The page itself. One per theme, plus one recess. Never carries meaning.
| Token | Light | Dark | On | Ratio | Safe for |
|---|---|---|---|---|---|
| --ht-canvas | #FCFCFB | #111214 | — | — | base |
| --ht-canvas-sunken | #F3F3F1 | #0C0D0F | canvas | 1.08:1 1.03:1 | surface only |
02 Surface
Elevation, not decoration. Each step is deliberately half the size convention would use.
| Token | Light | Dark | On | Ratio | Safe for |
|---|---|---|---|---|---|
| --ht-surface | #FDFDFC | #17191C | canvas | 1.00:1 1.06:1 | surface only |
| --ht-surface-raised | #FFFFFF | #1E2126 | surface | 1.01:1 1.09:1 | surface only |
| --ht-scrim | rgba(28, 30, 34, 0.45) | rgba(6, 7, 9, 0.62) | — | — | base |
03 Content & border
Discrete tokens at every tier. Never an opacity modifier over a colour token — that silently produces an invalid declaration and gets dropped.
| Token | Light | Dark | On | Ratio | Safe for |
|---|---|---|---|---|---|
| --ht-content | #292F36 | #ECEFF3 | surface | 13.27:1 15.27:1 | body text |
| --ht-content-muted | #444C56 | #C8D0DA | surface | 8.54:1 11.31:1 | body text |
| --ht-content-subtle | #5F6975 | #B0BAC6 | surface | 5.48:1 8.96:1 | body text |
| --ht-border | #DCE2E9 | #444C56 | surface | 1.28:1 2.02:1 | surface only |
| --ht-border-strong | #C8D0DA | #5F6975 | surface | 1.52:1 3.15:1 | surface only |
| --ht-border-interactive | #7A8593 | #95A0AE | surface | 3.68:1 6.64:1 | UI boundary |
| --ht-focus-ring | #576690 | #A8B8E1 | surface | 5.09:1 8.15:1 | UI boundary |
04 Semantic roles
Named by meaning, never by hue. The palette can be replaced without renaming a single token.
| Token | Light | Dark | On | Ratio | Safe for |
|---|---|---|---|---|---|
| --ht-informational-surface | #E9EFFC | #3D4A6B | surface | 1.13:1 2.00:1 | surface only |
| --ht-informational-border | #C1CFF0 | #7182B0 | informational-surface | 1.35:1 2.31:1 | surface only |
| --ht-informational-content | #3D4A6B | #D7E1F9 | informational-surface | 7.62:1 6.70:1 | body text |
| --ht-positive-surface | #E5F3EA | #2D553F | surface | 1.12:1 2.08:1 | surface only |
| --ht-positive-border | #B6DAC3 | #5D9173 | positive-surface | 1.32:1 2.32:1 | surface only |
| --ht-positive-content | #2D553F | #D0E9D9 | positive-surface | 7.38:1 6.58:1 | body text |
| --ht-caution-surface | #F8EDE2 | #614427 | surface | 1.13:1 1.98:1 | surface only |
| --ht-caution-border | #E6CAAE | #A27B54 | caution-surface | 1.35:1 2.32:1 | surface only |
| --ht-caution-content | #614427 | #F2DDCA | caution-surface | 7.69:1 6.75:1 | body text |
| --ht-critical-surface | #FBEAEA | #673D40 | surface | 1.14:1 1.94:1 | surface only |
| --ht-critical-border | #EEC3C4 | #AB7275 | critical-surface | 1.36:1 2.32:1 | surface only |
| --ht-critical-content | #673D40 | #F7D9DA | critical-surface | 7.78:1 6.85:1 | body text |
| --ht-neutral-surface | #ECEFF3 | #444C56 | surface | 1.13:1 2.02:1 | surface only |
| --ht-neutral-border | #C8D0DA | #7A8593 | neutral-surface | 1.34:1 2.32:1 | surface only |
| --ht-neutral-content | #444C56 | #DCE2E9 | neutral-surface | 7.54:1 6.66:1 | body text |
The ramps
Five ramps, ten steps each. Ramps are named by hue because that is what a ramp is; semantic roles never are. That indirection is what lets the palette be replaced without renaming a single token in a consuming codebase.
periwinkle informational
Cool blue-violet. The default accent and the focus ring.
| Step | Token | Hex | On canvas | Safe for |
|---|---|---|---|---|
| 50 | --ht-ramp-periwinkle-50 | #F5F7FE | 1.04:1 17.50:1 | surface only body text |
| 100 | --ht-ramp-periwinkle-100 | #E9EFFC | 1.12:1 16.25:1 | surface only body text |
| 200 | --ht-ramp-periwinkle-200 | #D7E1F9 | 1.27:1 14.30:1 | surface only body text |
| 300 | --ht-ramp-periwinkle-300 | #C1CFF0 | 1.52:1 12.00:1 | surface only body text |
| 400 | --ht-ramp-periwinkle-400 | #A8B8E1 | 1.92:1 9.46:1 | surface only body text |
| 500 | --ht-ramp-periwinkle-500 | #8C9ECB | 2.59:1 7.02:1 | surface only body text |
| 600 | --ht-ramp-periwinkle-600 | #7182B0 | 3.70:1 4.92:1 | large text body text |
| 700 | --ht-ramp-periwinkle-700 | #576690 | 5.51:1 3.30:1 | body text large text |
| 800 | --ht-ramp-periwinkle-800 | #3D4A6B | 8.55:1 2.13:1 | body text surface only |
| 900 | --ht-ramp-periwinkle-900 | #252E46 | 13.13:1 1.39:1 | body text surface only |
mint positive
Desaturated green. Confirmation, not celebration.
| Step | Token | Hex | On canvas | Safe for |
|---|---|---|---|---|
| 50 | --ht-ramp-mint-50 | #F3F9F5 | 1.03:1 17.56:1 | surface only body text |
| 100 | --ht-ramp-mint-100 | #E5F3EA | 1.11:1 16.36:1 | surface only body text |
| 200 | --ht-ramp-mint-200 | #D0E9D9 | 1.25:1 14.58:1 | surface only body text |
| 300 | --ht-ramp-mint-300 | #B6DAC3 | 1.48:1 12.32:1 | surface only body text |
| 400 | --ht-ramp-mint-400 | #98C5AA | 1.87:1 9.72:1 | surface only body text |
| 500 | --ht-ramp-mint-500 | #7AAD8F | 2.49:1 7.31:1 | surface only body text |
| 600 | --ht-ramp-mint-600 | #5D9173 | 3.54:1 5.14:1 | large text body text |
| 700 | --ht-ramp-mint-700 | #447459 | 5.26:1 3.47:1 | body text large text |
| 800 | --ht-ramp-mint-800 | #2D553F | 8.23:1 2.21:1 | body text surface only |
| 900 | --ht-ramp-mint-900 | #183626 | 12.82:1 1.42:1 | body text surface only |
apricot caution
Warm sand. Reads as attention without the alarm of amber.
| Step | Token | Hex | On canvas | Safe for |
|---|---|---|---|---|
| 50 | --ht-ramp-apricot-50 | #FCF6F1 | 1.04:1 17.48:1 | surface only body text |
| 100 | --ht-ramp-apricot-100 | #F8EDE2 | 1.12:1 16.24:1 | surface only body text |
| 200 | --ht-ramp-apricot-200 | #F2DDCA | 1.28:1 14.25:1 | surface only body text |
| 300 | --ht-ramp-apricot-300 | #E6CAAE | 1.52:1 11.98:1 | surface only body text |
| 400 | --ht-ramp-apricot-400 | #D4B190 | 1.94:1 9.36:1 | surface only body text |
| 500 | --ht-ramp-apricot-500 | #BD9770 | 2.61:1 6.98:1 | surface only body text |
| 600 | --ht-ramp-apricot-600 | #A27B54 | 3.72:1 4.90:1 | large text body text |
| 700 | --ht-ramp-apricot-700 | #83603B | 5.52:1 3.30:1 | body text large text |
| 800 | --ht-ramp-apricot-800 | #614427 | 8.65:1 2.11:1 | body text surface only |
| 900 | --ht-ramp-apricot-900 | #3E2A14 | 13.23:1 1.37:1 | body text surface only |
rose critical
Dusty red. Deliberately not a siren — see the elevation argument.
| Step | Token | Hex | On canvas | Safe for |
|---|---|---|---|---|
| 50 | --ht-ramp-rose-50 | #FDF5F5 | 1.04:1 17.45:1 | surface only body text |
| 100 | --ht-ramp-rose-100 | #FBEAEA | 1.13:1 16.11:1 | surface only body text |
| 200 | --ht-ramp-rose-200 | #F7D9DA | 1.28:1 14.18:1 | surface only body text |
| 300 | --ht-ramp-rose-300 | #EEC3C4 | 1.54:1 11.81:1 | surface only body text |
| 400 | --ht-ramp-rose-400 | #DDA9AB | 1.97:1 9.22:1 | surface only body text |
| 500 | --ht-ramp-rose-500 | #C78E90 | 2.65:1 6.87:1 | surface only body text |
| 600 | --ht-ramp-rose-600 | #AB7275 | 3.79:1 4.81:1 | large text body text |
| 700 | --ht-ramp-rose-700 | #8B575A | 5.66:1 3.22:1 | body text large text |
| 800 | --ht-ramp-rose-800 | #673D40 | 8.82:1 2.06:1 | body text surface only |
| 900 | --ht-ramp-rose-900 | #432527 | 13.34:1 1.36:1 | body text surface only |
slate neutral
The chrome. Carries a trace of the same blue so neutrals sit in the same family rather than reading as a separate grey system.
| Step | Token | Hex | On canvas | Safe for |
|---|---|---|---|---|
| 50 | --ht-ramp-slate-50 | #F6F7F9 | 1.04:1 17.48:1 | surface only body text |
| 100 | --ht-ramp-slate-100 | #ECEFF3 | 1.12:1 16.24:1 | surface only body text |
| 200 | --ht-ramp-slate-200 | #DCE2E9 | 1.27:1 14.36:1 | surface only body text |
| 300 | --ht-ramp-slate-300 | #C8D0DA | 1.51:1 12.04:1 | surface only body text |
| 400 | --ht-ramp-slate-400 | #B0BAC6 | 1.91:1 9.53:1 | surface only body text |
| 500 | --ht-ramp-slate-500 | #95A0AE | 2.58:1 7.06:1 | surface only body text |
| 600 | --ht-ramp-slate-600 | #7A8593 | 3.65:1 5.00:1 | large text body text |
| 700 | --ht-ramp-slate-700 | #5F6975 | 5.43:1 3.35:1 | body text large text |
| 800 | --ht-ramp-slate-800 | #444C56 | 8.47:1 2.15:1 | body text surface only |
| 900 | --ht-ramp-slate-900 | #292F36 | 13.16:1 1.38:1 | body text surface only |
The text-safe boundary
Every swatch above carries a badge derived from its measured ratio, not from an intention someone recorded. The rule the badges encode:
- body text — clears 4.5:1. Safe for any copy at any size the system sets.
- large text — clears 3:1. Headings at 24px, or 18.66px bold, and nothing smaller.
- surface only — below 3:1. A fill or a rule. Never a glyph.
The boundary sits at a different step in light and dark, because the canvas it is measured against is different. That is not an inconsistency to paper over — it is the reason the mapping has to invert, which is argued in full on Dark mode.
Semantic roles
Five roles, each a surface, a border and a content colour. Named for what they mean. A role's meaning is always carried in text as well as colour — a tint alone is not a message.
| Role | Light surface | Light text | Light ratio | Dark surface | Dark text | Dark ratio |
|---|---|---|---|---|---|---|
| informational | periwinkle 100 | periwinkle 800 | 7.62:1 | periwinkle 800 | periwinkle 200 | 6.70:1 |
| positive | mint 100 | mint 800 | 7.38:1 | mint 800 | mint 200 | 6.58:1 |
| caution | apricot 100 | apricot 800 | 7.69:1 | apricot 800 | apricot 200 | 6.75:1 |
| critical | rose 100 | rose 800 | 7.78:1 | rose 800 | rose 200 | 6.85:1 |
| neutral | slate 100 | slate 800 | 7.54:1 | slate 800 | slate 200 | 6.66:1 |
Check it yourself
This runs the same function the build gate runs, reading the token values out of the live page. Switch the theme and the numbers follow, because they are measuring what is actually on screen rather than a table baked in at build time.
Overrides the token while it is a valid hex.
Overrides the token while it is a valid hex.
no colour on no colour: Enter a valid colour on both sides.
— on —
Try content-subtle on canvas, then flip the theme. Both pass — an earlier draft of this system had that pairing at 3.65:1 in light mode, and the audit that caught it is why the build now refuses any content token that does not clear 4.5:1.