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.
Importing a theme
A theme does not have to be written by hand. roundel/import reads the two largest corpora
of terminal palettes — Base16 schemes and iTerm2 .itermcolors presets — into the object
fly() takes:
import { readFileSync } from 'node:fs';
import { fromBase16, fromITerm } from 'roundel/import';
fly(fromBase16(readFileSync('gruvbox-dark-hard.yaml', 'utf8')), runtime);
fly(fromITerm(readFileSync('Dracula.itermcolors', 'utf8')), runtime);Each of error, warn, ok, flag and value takes the ANSI hue its default names — red,
yellow, green, cyan, magenta — and ground takes the background; hint, command,
heading and muted keep their defaults. The imported theme goes through audit() before it
is returned, so a scheme that would not read is refused on the way in with an ImportError
naming the slot, and whatever is returned, fly() accepts. The
theme gallery is every scheme file the package is tested against,
read this way; the API reference has every refusal and its fix.
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 gallery
Base16 schemes and iTerm2 presets read by roundel/import: the colour each token takes, its contrast on the scheme's own background, and whether roundel flies it.