EUI applications without a browser

05 — Theme

Status: normative. Implemented by crates/eui-theme.

The server never sends a colour. It sends a role — surface.raised, text.muted, accent.base — and a scale index — space.4, text.lg — and the client resolves them against the active theme and the viewer's own mode, density and font scale. Switching to dark mode costs zero bytes, the viewer's accessibility settings cannot be overridden because the server never sees them, and the server learns nothing about the person.

Because the client resolves, the resolution algorithm is part of the protocol: two conforming clients given the same theme document and the same viewer settings MUST produce the same values, within the tolerance in §7.

1. Colour roles

A ColorRef in role space (1..=0x3FFF, see 02-wire-format.md §3.2) names one of these. The numbering is stable; ids 34..=0x3FFF are reserved and MUST be rejected. 0x4000..=0x7FFF was carved out of this range by version 6 for gradients (02 §5.3), whose stops are roles like any other colour and resolve here, so a gradient follows the viewer's mode for nothing.

idRoleidRole
1surface.base15success.on
2surface.raised16warning.base
3surface.sunken17warning.subtle
4surface.overlay18warning.on
5text.default19danger.base
6text.muted20danger.subtle
7text.inverted21danger.on
8text.disabled22info.base
9accent.base23info.subtle
10accent.hover24info.on
11accent.active25border.subtle
12accent.on26border.default
13success.base27border.strong
14success.subtle28focus.ring
idRoleidRole
29series.132series.4
30series.233series.5
31series.3

1.1 The series roles

series.1 … series.5 are the categorical ramp a chart draws with, and they exist because the status roles are not free to be borrowed: a green series reads as "good" to someone who has learned the rest of the interface. A client MUST assign them in fixed order — series.1 to the first series, series.2 to the second — and MUST NOT cycle. There is no series.6: past five, a server folds the tail into one "other" series, facets into small multiples, or encodes the sixth dimension with something that is not hue.

The ramp is chosen so that adjacent pairs stay apart for a reader with a colour vision deficiency; §4.1 gives the values and the guarantee.

2. Scales

Indices are what a StyleRecord carries. Values are device-independent pixels at cozy density and font scale 1.0; §5 says how the viewer's settings change them.

space (18 entries, index 0–17): 0, 2, 4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 96, then 6, 10, 14, 80, 128.

indexpxTailwindindexpxTailwind
00094010
120.5104812
241116416
382129624
41231361.5
516414102.5
620515143.5
7246168020
83281712832

Indices 13–17 are version 6 and are appended rather than slotted into order, because an index is what a record carries and renumbering would move every existing step under every older client. They are the steps an application written from Tailwind reached for and the scale did not have — measured over ten first-draft screens, 1.5 34 times, 2.5 14, 32 10, 3.5 8 and 20 twice (40, once, was left out). A step is an index, not a formula, so the scale is not in order and a client MUST NOT assume it is.

A server MUST NOT send an index above 12 to a session negotiated below version 6. It sends the step's fallback instead, the nearest older step with ties going down — a box that is 2 px tighter than its author meant rather than 2 px wider than the width it was given:

newpxolder session gets
1362 (4 px)
14103 (8 px)
15144 (12 px)
168011 (64 px)
1712812 (96 px)

The mapping is applied where the record is encoded, to gap, padding, margin and every Dim::Space, so the view is written once and each session is sent what it can draw (eui_proto::StyleRecord::for_protocol, and in Soli the encoder's style table). Four of the five sit exactly half-way between two older steps, which is why "ties down" is a rule and not a footnote. Without it an older client would meet an index past the end of its scale, which is an error at style definition time (below), and the whole batch with it.

radius (index 0–4): none 0, sm md/2, md, lg 2·md, full 9999, where md is the theme's radius_md (§3), 8 by default — so 4 8 16, Tailwind's rounded, rounded-lg and rounded-2xl.

text (index 0–7), as size / line-height — Tailwind's text-xs … text-4xl, size for size and line for line:

indexnamesizeline
0xs1216
1sm1420
2base1624
3lg1828
4xl2028
52xl2432
63xl3036
74xl3640

The default StyleRecord carries font_size = 2, base.

shadow (index 0–3), each up to two layers of black, painted in order, as y offset, blur, spread / opacity — Tailwind's shadow-sm, shadow-md and shadow-lg, written the way CSS writes a box-shadow:

indexnamelayer 1layer 2
0none——
1sm0 1 2 0 / 0.05—
2md0 4 6 -1 / 0.100 2 4 -2 / 0.10
3lg0 10 15 -3 / 0.100 4 6 -4 / 0.10

The spread grows the border box on every side before it is blurred, and a negative one shrinks it; the corner radius moves by the same amount, and never below zero. A negative spread is what keeps a layer under its box: the shadow shows below, where the offset carries it, and not above. 03 §2 says how a layer is painted. Enforced by the_scales_are_tailwinds (crates/eui-theme/tests/resolve.rs) and, for the painting, a_negative_spread_keeps_the_shadow_under_the_box (crates/eui-render/tests/render.rs).

motion (index 0–4), milliseconds: fast 100, base 180, slow 320, slower 560, slowest 1000, all with the easing curve cubic-bezier(0.2, 0, 0, 1).

The top of the scale is longer than any control should take, and that is deliberate: the two slowest steps are for something arriving over a distance, where the duration has to be read against how far the thing travels rather than against a person's patience. An entrance that moves a node its own height (03 §5.2) at base reads as a jump — the eye has no time to see a curve — and the same move at slowest reads as a glide. A control's hover still belongs at fast.

A curve is evaluated as CSS evaluates one: it gives y for an x, x is the fraction of the duration elapsed, and solving x for the Bézier parameter has no closed form — so a client iterates, and 0 and 1 are exact. Beside the theme's own curve a client keeps four more, chosen by what is moving rather than named on the wire, because motion that has to be specified per node is motion nobody gets right twice:

CurveControl pointsFor
standard0.2, 0, 0, 1a style change (03 §5)
decelerate0, 0, 0.2, 1something arriving (03 §5 enter)
accelerate0.4, 0, 1, 1something leaving (03 §5.1 exit)
smooth0.45, 0, 0.55, 1rest to rest — a keyboard scroll
linear0, 0, 1, 1a value that is not a movement

Something arriving decelerates rather than easing: it was already moving when it was first seen, which is what makes it read as having come from somewhere instead of having been switched on.

control heights, used by composed widgets: sm 28, md 36, lg 44.

An index past the end of a scale is an error at style definition time; the client rejects the DefStyle.

3. The theme document

A theme is an EUIT record (02-wire-format.md §7) carrying seeds, not palettes. Every colour in §1 is derived from them by §4, so a theme author chooses four hues and gets three consistent modes.

keyFieldValueDefault
1accentOKLCH seed (L, C, h)(0.511, 0.262, 277) — Tailwind's indigo-600
2surfaceOKLCH seed; only C and h are used(0.98, 0.01, 264) — Tailwind's gray
3radius_mdpx8
4density0 compact, 1 cozy, 2 comfortable1 — a preference, the viewer's own setting wins
5font_sansasset hash, or absent for the client's built-in faceabsent
6font_monoasset hash, or absentabsent

font_sans and font_mono are the static form of a DefFont on font roles 0 and 1 (02-wire-format.md §5.1): the face is an asset, fetched from the application's own origin and verified against its hash, and it replaces the client's own face for that session. A theme that names neither leaves both on the embedded faces. An application that wants more than two faces names them per style with a font role instead, and a DefFont binds each; the two mechanisms bind the same table, so the last to arrive wins.

The defaults are Tailwind UI's palette. The accent is indigo-600's own OKLCH, which lies outside sRGB; §4.4 clips its chroma to #4f39f6, the same colour a browser falls back to. Resolved in light mode, the default theme lands within a few ΔE (OKLab ×100) of the Tailwind colour each role stands in for — surface.base gray-50, surface.raised white, surface.sunken gray-100, border.subtle gray-200, border.default gray-300, text.muted gray-500, text.default gray-900, accent.base indigo-600, accent.hover indigo-500, accent.active indigo-700. border.strong stands furthest off, at ΔE 5.2 from gray-400, because §4.3 wants 3:1 of it and gray-400 is 2.5:1 on gray-50. Enforced, role by role with its tolerance, by the_default_palette_is_tailwinds_gray_and_indigo (crates/eui-theme/tests/resolve.rs), which prints both palettes.

L and C are clamped to [0, 1] and [0, 0.4]; h is taken modulo 360. Status hues are fixed by the protocol, not the theme: success 145, warning 80, danger 25, info 250. A theme cannot make danger green.

4. Resolution

All arithmetic is IEEE-754 binary64. Colour work happens in OKLab / OKLCH (Ottosson 2020); the conversion constants are in §8.

4.1 Lightness targets

For each mode the algorithm assigns a target lightness L and chroma C to every role. Hue comes from the relevant seed, or from the fixed status hues. sL, sC, sh are the surface seed's values; aL, aC, ah the accent's.

Rolelight Ldark Lhigh-contrast LChue
surface.base0.9850.190.00min(sC, 0.02)sh
surface.raised1.0000.240.05min(sC, 0.02)sh
surface.sunken0.9670.150.00min(sC, 0.02)sh
surface.overlay1.0000.270.08min(sC, 0.02)sh
text.default0.210.931.00min(sC, 0.015)sh
text.muted0.5510.720.90min(sC, 0.015)sh
text.inverted0.980.150.00min(sC, 0.015)sh
text.disabled0.650.500.70min(sC, 0.015)sh
accent.baseclamp(aL, 0.40, 0.60)clamp(aL, 0.60, 0.80)0.80aCah
accent.hoverbase + 0.06 †base + 0.06base + 0.06aCah
accent.activebase − 0.06base + 0.12base + 0.12aCah
*.base (status)0.520.720.800.14 (warning 0.12)fixed
*.subtle (status)0.950.250.150.04 / 0.06 / 0.06fixed
border.subtle0.9280.260.50min(sC, 0.02)sh
border.default0.8720.320.70min(sC, 0.02)sh
border.strong0.7070.450.90min(sC, 0.02)sh
focus.ring0.550.750.85max(aC, 0.18)ah
series.10.500.520.720.12264
series.20.500.520.800.1270
series.30.740.660.880.12170
series.40.480.500.700.12330
series.50.700.660.840.12195

The light targets of the surfaces, borders and text are the lightnesses of Tailwind's gray-50 … gray-900, so a surface seed with gray's hue resolves to gray itself (§3).

† In light mode hover lightens, as a Tailwind button does (indigo-600 to indigo-500), and active presses darker. A lighter hover can wash out the label on it, so it then steps back towards the base, 0.01 at a time, until accent.on keeps 3:1 against it — or until it is the base again. Enforced by accent_hover_and_active_step_away_from_base and, for 300 random seeds, by contrast_holds_for_hostile_seeds (crates/eui-theme/tests/resolve.rs).

The series hues are fixed, not derived from the theme's accent: a theme that retunes its accent MUST NOT retune them, because their separation is a property of the set, not of any one of them. series.1 shares the default accent's hue so that a single-series chart still looks like the product.

The series roles are not subject to §4.3. They are not text and not a control: what they owe the reader is separation from each other, which is measured as the OKLab ΔE (×100) between adjacent pairs after simulating protanopia, deuteranopia and tritanopia. Every adjacent pair clears ΔE 8 under each simulation and ΔE 15 to normal vision — worst pair 21.5 light, 15.7 dark, 12.4 high contrast under simulation. In light and dark some steps sit below 3:1 against surface.base, so a client that paints a chart from these roles MUST give identity a second carrier: a legend, direct labels, or a table view. Colour alone is never the answer to "which series is this". High contrast lifts every step above 3:1 instead, which is why its column leaves the lightness band the other two keep to.

4.2 The on roles

accent.on and each *.on are whichever of pure white (1, 0, 0) or pure black (0, 0, 0) has the greater WCAG contrast ratio against the corresponding base, computed after §4.3 has run on that base.

4.3 Contrast enforcement

Contrast is guaranteed by construction, not by a check. After the targets are assigned, and before the on roles are chosen, the algorithm adjusts lightness until every pair below meets its ratio, using WCAG 2.x relative luminance on the gamut-clipped sRGB values:

ForegroundAgainstMinimum
text.defaulteach of surface.*7.0
text.mutedeach of surface.*4.5
text.disabledsurface.base3.0
accent.base, each status .basesurface.base3.0
focus.ringsurface.base3.0
border.defaultsurface.base1.5
border.strongsurface.base3.0

To adjust a foreground against a background: move the foreground's L away from the background's L in steps of 0.01, re-clipping to gamut each step, and stop at the first step that meets the ratio, or after 60 steps. Moving away means towards 1.0 when the mean L of the backgrounds is below 0.5, else towards 0.0. A foreground checked against several backgrounds is adjusted until it meets the ratio against all of them.

The accent.hover and accent.active offsets are re-derived from the adjusted accent.base, after the on roles are chosen (§4.2), because the light hover is held to 3:1 against accent.on (§4.1 †).

4.4 Gamut clipping

An OKLCH colour outside sRGB is brought into gamut by reducing chroma alone: binary-search C in [0, C] for 16 iterations, keeping the largest value whose linear-sRGB components all lie in [0, 1]. Lightness and hue are never changed by clipping.

4.5 Output

Each role resolves to 8-bit sRGB with alpha 255, packed 0xRRGGBBAA. Quantisation is round-half-up of component × 255.

5. The viewer's axes

Applied on top of the theme, always, and never reported back to the server beyond the coarse fields in the Viewport frame.

  • Mode selects the column in §4.1.
  • Density multiplies every space entry and every control height: compact 0.8, cozy 1.0, comfortable 1.25. Results are rounded to the nearest whole pixel; space.0 stays 0.
  • Font scale multiplies every text size and line height, rounded to the nearest whole pixel. It does not touch space: an enlarged font on an unchanged grid is the behaviour a reader who enlarged the font asked for.
  • The desktop's palette. A client MAY replace the resolved colour of any role with one the viewer's desktop publishes — its background, foreground, accent and status colours — and take the palette's own light or dark as the mode. The overrides sit on top of the theme in the palette's own mode — a viewer who switches to the other mode gets the theme's own colours for it, and the desktop's again on returning, so an application's light/dark switch still switches something — and are applied after §4's resolution, so a theme's contrast guarantees do not extend to them: the desktop chose the colours, and the desktop answers for them. The server sees nothing of this but the mode. The reference client follows Omarchy on Linux (~/.local/state/omarchy/current/theme/colors.toml), live; the mapping is in eui-client/src/desktop_theme.rs.
  • The viewer's light or dark. A client MUST send the mode the viewer is actually in on the first Hello, not on a viewport that corrects it afterwards: a server renders for what the Hello said, and a client that opens light and corrects itself has already made the server draw the wrong palette once. Where the platform will not say — winit reports a theme on macOS, on Windows and in a page, and on no Linux backend — the reference client asks the desktop instead: the palette above where there is one, and otherwise color-scheme from the XDG desktop portal (org.freedesktop.appearance), which is the setting a browser answers prefers-color-scheme from. A change of mode goes to every open session, not only the one in front.

6. Literal colours

A DefColor literal bypasses all of the above and is correct for a brand mark or a data series. It is wrong for a surface, a border or body text, because it will not follow the viewer anywhere. Nothing in the protocol prevents it; the server-side linter warns.

7. Conformance tolerance

Two implementations MUST agree on every resolved colour to within ±1 per 8-bit channel, and on every resolved length exactly. The tolerance exists because cbrt and powf are not correctly rounded on every platform's libm; it is not licence to deviate in the algorithm.

8. Colour constants

OKLab, from linear sRGB (Ottosson):

l = 0.4122214708 r + 0.5363325363 g + 0.0514459929 b
m = 0.2119034982 r + 0.6806995451 g + 0.1073969566 b
s = 0.0883024619 r + 0.2817188376 g + 0.6299787005 b
l' = cbrt(l)   m' = cbrt(m)   s' = cbrt(s)
L = 0.2104542553 l' + 0.7936177850 m' − 0.0040720468 s'
a = 1.9779984951 l' − 2.4285922050 m' + 0.4505937099 s'
b = 0.0259040371 l' + 0.7827717662 m' − 0.8086757660 s'

and back:

l' = L + 0.3963377774 a + 0.2158037573 b
m' = L − 0.1055613458 a − 0.0638541728 b
s' = L − 0.0894841775 a − 1.2914855480 b
l = l'³   m = m'³   s = s'³
r =  4.0767416621 l − 3.3077115913 m + 0.2309699292 s
g = −1.2684380046 l + 2.6097574011 m − 0.3413193965 s
b = −0.0041960863 l − 0.7034186147 m + 1.7076147010 s

OKLCH is (L, C = √(a² + b²), h = atan2(b, a)) with h in degrees.

sRGB transfer: linear x ≤ 0.0031308 → 12.92 x, else 1.055 x^(1/2.4) − 0.055; inverse x ≤ 0.04045 → x / 12.92, else ((x + 0.055) / 1.055)^2.4.

WCAG relative luminance Y = 0.2126 R + 0.7152 G + 0.0722 B on linear values; contrast ratio (Y₁ + 0.05) / (Y₂ + 0.05) with the lighter colour first.

Rendered from spec/05-theme.md in the repository. The normative text is spec/; where it and a prose page disagree the spec wins, and that is a bug worth reporting. Edit this page