Themes and contrast
fly() sets what each token paints: format names from the terminal's palette, or a hex checked against WCAG 4.5:1 on the declared background, at every colour level.
fly(theme, rt, { json }) decides the colour level from the runtime once, checks the theme,
and sets what the tokens paint from then on. Call it at startup; a later call replaces the
theme, and a refused one leaves the previous theme flying.
A theme
A theme maps any of the nine tokens to a style, plus a ground and, optionally, a
conformance level:
- Format names —
['bold', 'underline'],['yellow']— arenode:util'sstyleTextnames. They are the user's own terminal palette, so they are never checked and never claimed to pass anything. - A hex colour —
'#b3261e'— is sent as truecolor at level 3, and as the nearest readable of 256 or the nearest of 16 below that. groundis the background the theme will be read on: near-black,#0a0a0a, unless you say otherwise. The defaulterrorandokpick whichever of their two brand shades reads better on it.
import { fly } from 'roundel/theme';
import { error, heading, ok } from 'roundel/tokens';
import { rt } from './rt.mjs';
fly({ ground: '#ffffff', error: '#b3261e', heading: ['bold', 'underline'] }, rt);
console.log(JSON.stringify([error('failed'), ok('passed'), heading('Summary')]));["\u001b[38;2;179;38;30mfailed\u001b[39m","\u001b[38;2;10;107;71mpassed\u001b[39m","\u001b[1m\u001b[4mSummary\u001b[24m\u001b[22m"]["\u001b[38;5;124mfailed\u001b[39m","\u001b[38;5;23mpassed\u001b[39m","\u001b[1m\u001b[4mSummary\u001b[24m\u001b[22m"]ok was not given, so it kept its default — the deep juniper, because that one reads on white.
The contrast gate
Every hex token is checked against the ground at WCAG 2.2 AA, 4.5:1, and so is the 256-colour colour it will become. A theme that would not read is refused, naming each token and its ratio:
import { fly } from 'roundel/theme';
import { rt } from './rt.mjs';
try {
fly({ ground: '#ffffff', warn: '#ffd400' }, rt);
} catch (error) {
console.log(error.message);
}roundel: below 4.5:1 (WCAG AA) — warn #ffd400 on #ffffff is 1.43:1The check runs at every level, level 0 included: it is about what the theme declares, not about this terminal, so an unreadable theme fails in CI rather than on the one laptop that has truecolor. The 256-colour substitute is chosen to pass — the nearest palette entry that still clears the floor, not simply the nearest.
{ conformance: 'AAA' } raises the floor to 7:1. It is not the default because at 7:1 many
reasonable brand colours have no readable 256-colour substitute left.
Seeing the verdict
audit(theme) returns the same judgement as data, without throwing — two rows per hex token,
truecolor and 256 — and reportTheme() from roundel/contrast prints it:
import { reportTheme } from 'roundel/contrast';
import { audit } from 'roundel/theme';
console.log(reportTheme(audit({ ground: '#ffffff', error: '#b3261e', warn: '#ffd400' })));pass error truecolor #b3261e on #ffffff 6.54:1 (needs 4.5:1)
pass error 256 #af0000 on #ffffff 7.44:1 (needs 4.5:1)
FAIL warn truecolor #ffd400 on #ffffff 1.43:1 (needs 4.5:1)
pass warn 256 #af5f00 on #ffffff 4.71:1 (needs 4.5:1)
pass ok truecolor #0a6b47 on #ffffff 6.55:1 (needs 4.5:1)
pass ok 256 #005f5f on #ffffff 7.49:1 (needs 4.5:1)There is no row for sixteen colours: those are the user's terminal theme, and a ratio there
would be invented. fly() and audit() share one judgement, so the report and the refusal
can never disagree.
The maths
roundel/contrast exports what the gate uses: contrast(a, b), luminance(hex), and the
floors AA and AAA.
import { AA, contrast } from 'roundel/contrast';
console.log(contrast('#767676', '#ffffff').toFixed(2));
console.log(JSON.stringify(AA));4.54
{"TEXT":4.5,"GRAPHIC":3}Tokens
Nine semantic tokens — error, warn, ok, hint, muted, command, flag, value, heading — each the identity until fly() decides a level, so a program that never flies prints plain text.
Theme plugins
A plugin contributes tokens as #rrggbb colours under the family's one plugin shape; register() validates it, theme() merges it, later wins, and fly() holds it to the same contrast gate.