Skip to content
milan.swiss Halbton v0.1.0
Concept project

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.

2 tokens. Ratios are measured against each token's own intended backdrop, in the active theme.
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.

3 tokens. Ratios are measured against each token's own intended backdrop, in the active theme.
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.

7 tokens. Ratios are measured against each token's own intended backdrop, in the active theme.
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.

15 tokens. Ratios are measured against each token's own intended backdrop, in the active theme.
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.

Ratios are measured against the page canvas in the active theme. Text-safe from step 70050 downwards.
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.

Ratios are measured against the page canvas in the active theme. Text-safe from step 70050 downwards.
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.

Ratios are measured against the page canvas in the active theme. Text-safe from step 70050 downwards.
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.

Ratios are measured against the page canvas in the active theme. Text-safe from step 70050 downwards.
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.

Ratios are measured against the page canvas in the active theme. Text-safe from step 70050 downwards.
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.

Each role's text colour against its own panel fill, both themes. The step numbers are the inversion: surfaces move deep, text moves pale.
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.