roundel
Guides

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'] — are node:util's styleText names. 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.
  • ground is the background the theme will be read on: near-black, #0a0a0a, unless you say otherwise. The default error and ok pick whichever of their two brand shades reads better on it.
light.mjs
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')]));
FORCE_COLOR=3 node light.mjs
["\u001b[38;2;179;38;30mfailed\u001b[39m","\u001b[38;2;10;107;71mpassed\u001b[39m","\u001b[1m\u001b[4mSummary\u001b[24m\u001b[22m"]
FORCE_COLOR=2 node light.mjs
["\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:

unreadable.mjs
import { fly } from 'roundel/theme';

import { rt } from './rt.mjs';

try {
  fly({ ground: '#ffffff', warn: '#ffd400' }, rt);
} catch (error) {
  console.log(error.message);
}
node unreadable.mjs
roundel: below 4.5:1 (WCAG AA) — warn #ffd400 on #ffffff is 1.43:1

The 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:

report.mjs
import { reportTheme } from 'roundel/contrast';
import { audit } from 'roundel/theme';

console.log(reportTheme(audit({ ground: '#ffffff', error: '#b3261e', warn: '#ffd400' })));
node report.mjs
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.

ratio.mjs
import { AA, contrast } from 'roundel/contrast';

console.log(contrast('#767676', '#ffffff').toFixed(2));
console.log(JSON.stringify(AA));
node ratio.mjs
4.54
{"TEXT":4.5,"GRAPHIC":3}

On this page