roundel
Colour for a CLI: one output policy decided from the runtime, nine semantic tokens over util.styleText, and a theme that changes them together, contrast-checked. Zero dependencies. Drop-in paths for chalk, colorette, kleur, picocolors, supports-color and yoctocolors.
roundel is the colours a CLI carries: one output policy decided once from the runtime,
nine semantic tokens over util.styleText — error, hint, command and flag rather than
red and blue — and a theme that changes them all together, contrast-checked before it
flies. Under --json, NO_COLOR or a pipe nobody asked to colour, every token returns its
input unchanged, so captured output never carries an escape. Zero dependencies, five
subpaths, each costing only itself.
Drop-in for chalk: npx burgee migrate moves the import to roundel/chalk, chalk's API
over the same tokens, graded by chalk's own suite.
chalk gives you red; picocolors gives you red for fewer bytes. Neither gives you
error, and each decides on its own whether the terminal has colour — which is why a
program's spinner, prompt and help so often disagree.
A roundel is a flag's colours carried onto another surface — the rings on an aircraft's
wing, the London Underground sign. Identity, expressed purely in colour, on something that is
not a flag. That is what this package is for a command-line program: not red and blue,
but error, hint, command and flag, the colours that mean you, carried onto the
terminal as a theme.
Install
npm install roundel
pnpm add roundel
yarn add roundel
bun add roundelQuick start
import { fly } from 'roundel/theme';
import { command, error, hint } from 'roundel/tokens';
// Once, at startup. The runtime is yours to describe; nothing here reads `process`.
fly({}, { env: process.env, isTTY: { stdout: Boolean(process.stdout.isTTY) } });
console.error(`${error('missing --name')} ${hint('try')} ${command('greet --name ada')}`);Through a pipe with nothing asked, under NO_COLOR, or with --json, every token returns
its input unchanged. On a terminal it styles, at the level the terminal has — and on a pipe
too when the user said so with FORCE_COLOR or --color.
What is here
| Subpath | Gives you |
|---|---|
roundel/policy | outputMode(rt, { json }) → tty | pipe | json | accessible | ci and colorLevel(rt) → 0 | 1 | 2 | 3. Pure over { env, isTTY: { stdout }, argv? }; the only place in the package that reads NO_COLOR, FORCE_COLOR, COLORTERM, CLI_ACCESSIBLE, the CI vendor variables or a --color flag. |
roundel/tokens | error warn ok hint muted command flag value heading — each (s: string) => string, the identity until fly() has decided a level above 0. |
roundel/plugin | register(plugin), theme(), contributions(). A plugin is the family's one plain object; roundel keeps its tokens and ignores every key it does not understand, so the same object works on any subset of the family that is installed. |
roundel/theme | fly(theme, rt). A theme maps tokens to styleText format names (['bold', 'underline']) or a #rrggbb, and declares the ground it will be read on. Hex is truecolor at level 3 and falls back to the nearest of 256 or 16 colours below it. |
roundel/contrast | contrast(a, b), luminance(hex), AA — the WCAG 2.2 maths fly() checks with. |
roundel/terminal | interactive(rt) — whether anybody is there to type: a terminal on stdin, no CI and no agent variable (CLAUDECODE, AI_AGENT, CURSOR_AGENT, CODEX_THREAD_ID, GEMINI_CLI), with FORCE_TTY=1 as the override — and unicode(rt), is-unicode-supported's answer over { env, platform }. Not re-exported from roundel. |
roundel/import | fromBase16(scheme) and fromITerm(plist) — a theme from a Base16 scheme (its YAML or JSON text, or the object a reader made of it) or an iTerm2 .itermcolors file, contrast-checked on the way in by fly()'s own check. Data in, data out: you read the file. Not re-exported from roundel. |
roundel/chalk | chalk 6's API — chalk.red.bold(s), chalk.hex('#…'), new Chalk({ level }), chalkStderr, supportsColor, the name lists — over the tokens' emitter and the policy's level. Graded by chalk's own suite; see below. |
The policy
First match wins: json if the run asked for it; accessible if CLI_ACCESSIBLE; ci if
CI and not a TTY; pipe if not a TTY; else tty. The mode decides redraws — spinners,
progress, anything that rewrites a line — and never the colour level.
The level is chalk's, and it obeys the user's explicit instruction in any mode: NO_COLOR
wins outright; then FORCE_COLOR=0, which supports-color settles before it reads any flag,
so an explicit "colour off" is never overridden into colour on — FORCE_COLOR=0 with
--color=256 is 0, not 2; then the --color flags (--color=256, --color=16m,
--no-color, --no-colors, --color=never… both spellings, as has-flag has them) when the
caller hands the policy its argv; then FORCE_COLOR, which names an exact level
(FORCE_COLOR=2 is 2, not "2 or better") or, as true or empty, only turns colour on and
lets the environment decide it. --json is the one output the level never enters:
structured text carries no escapes.
With no instruction at all the order is supports-color's own, deliberately: a pipe is
0 — a pipe nobody asked to colour is a file or another program's stdin — and
accessible mode is 0 too, because CLI_ACCESSIBLE is itself an instruction from a
human and ANSI colour is noise to a screen reader; an explicit ask still colours either of
them. Azure Pipelines (TF_BUILD and AGENT_NAME) is the single exception on a pipe,
exactly where chalk puts it.
Once colour is being detected, on a terminal or because the run asked, TERM=dumb is the
floor, a CI run gets its vendor's level (GitHub and Gitea Actions and CircleCI at
truecolor; Travis, AppVeyor, GitLab, Buildkite, Drone and Codeship at 16), and anything else
is what TERM and COLORTERM report. So FORCE_COLOR=true on GitHub Actions gives you
truecolor logs, and nobody else's pipe changes.
One policy, one answer: no two components in a program can reach different conclusions, which is the whole point (clack #286).
The theme
fly(
{
ground: '#0a0a0a', // what the hex tokens are checked against; near-black by default
error: '#f4794a', // truecolor, checked at 4.5:1 against the ground, or fly() throws
heading: ['bold', 'underline'], // the terminal's own palette — never checked or claimed
},
runtime,
);The defaults carry the burgee brand — rock for error, juniper for ok, each in whichever
of its deep or lifted variants reads better on the declared ground — and format names for
the other seven. A hex token below 4.5:1 is refused at every level, not only on truecolor
terminals, so a theme that would not read fails in CI rather than on one laptop. The 16-
and 256-colour fallbacks are the user's terminal palette and are not checked: a number
there would be invented.
Importing a theme
import { readFileSync } from 'node:fs';
import { fromBase16, fromITerm } from 'roundel/import';
import { fly } from 'roundel/theme';
fly(fromBase16(readFileSync('gruvbox-dark-hard.yaml', 'utf8')), runtime);
fly(fromITerm(readFileSync('Dracula.itermcolors', 'utf8')), runtime);The two largest corpora of terminal palettes, read into the theme fly() takes. Each token
takes the colour its default names, from the ANSI slot both formats already agree on: error
red, warn yellow, ok green, flag cyan, value magenta, and ground the background
(Base16 base08, base0A, base0B, base0C, base0E, base00; iTerm Ansi 1, 3, 2,
6, 5, Background Color — exported as BASE16_SLOTS and ITERM_SLOTS). hint,
command and heading keep their attributes and muted keeps the terminal's grey: a
scheme's comment grey is built to recede, 2.50:1 in Default Dark.
The result is checked before it is returned, by audit() — so whatever an importer returns,
fly() accepts. A scheme that does not read is refused with an ImportError naming the slot;
for Tomorrow Night it reads
roundel/import: below 4.5:1 (WCAG AA) — error (base08) #cc6666 on #1d1f21, 4.46:1.
Every refusal carries a code — E_IMPORT_FORMAT, E_IMPORT_SLOT or
E_IMPORT_CONTRAST — and a fix. Pass { conformance: 'AAA' } to check at 7:1. No network
and no bundled corpus: the file is yours to supply.
Migrating
- import chalk from 'chalk';
+ import chalk from 'roundel/chalk';Identical. The chain (chalk.red.bold.underline(s), every modifier, the sixteen
colours and their Bright variants, backgrounds, chalk 6's underline styles and colours),
rgb/hex/ansi256 and their bg/underline forms with chalk's own downsampling at
levels 2 and 1, nesting and line-break handling byte for byte, chalk.level (get and set,
validated), new Chalk({ level }), chalkStderr, supportsColor / supportsColorStderr,
modifierNames / foregroundColorNames / backgroundColorNames / underlineColorNames /
colorNames, visible, reset, and Function.prototype on every link. ESM with a
default condition, so require('roundel/chalk') works too.
Different.
- The level is per façade.
chalk.level = 0silencesroundel/chalkand nothing else; the tokens and the theme keep reading the policy. chalk's global mutable level is why chalk and ora disagree about the same terminal, and it stops at this door. - The policy decides colour. The level is detected once at import through
colorLevel(), reading the sameNO_COLOR,FORCE_COLOR,--color, CI-vendor,TERMandCOLORTERMrules chalk does — see The policy. Every other package in the family reads the same answer, soroundel/chalkand a spinner cannot disagree about the terminal the way chalk and ora do. - No template literal.
chalk\{red x}`` was removed in chalk 5 and is not resurrected. - No emulator allow-list. chalk's per-terminal-program detection (
TERM_PROGRAM, kitty, ghostty, wezterm, TeamCity, the Windows build number) is not reproduced; a program on one of those terminals that wants colour asks for it withFORCE_COLORor--color.
- import { red, bold, createColors } from 'colorette';
+ import { red, bold, createColors } from 'roundel/colorette';Identical. colorette 2.0.20's forty-one styles as named exports, isColorSupported, and
createColors({ useColor }), with colorette's bytes: a nested close re-opens the outer style
(bold and dim re-open each other), '' and undefined style to '', and every other value
is coerced as '' + input is. Colour is colorette's own rule, decided once at import — off for a
NO_COLOR that is set at all, empty included, or --no-color; on for a set FORCE_COLOR,
--color, Windows, a terminal with a TERM that is not dumb, or CI on GitHub Actions, GitLab
or CircleCI — not the policy. With colour off every style is String, as
colorette's are. require('roundel/colorette') returns the same names require('colorette') does.
- import kleur from 'kleur';
- import { red, bold } from 'kleur/colors';
+ import kleur from 'roundel/kleur';
+ import { red, bold } from 'roundel/kleur/colors';Identical. Both of kleur 4.1.5's entries. roundel/kleur is kleur's one object: enabled
and twenty-six styles, each wrapping text in its codes or, called with nothing, starting a chain —
kleur.red().bold('x') opens in the order chained, and a held chain adds a style it lacks and
never one twice. roundel/kleur/colors is kleur's plain functions and $, nested by wrapping,
handing null and undefined back, and every input back untouched while colour is off. Both
re-open an outer style after a nested close and coerce as txt + '' does. Colour is kleur's own
rule, decided once at import and kept on the switch a caller assigns — off for a truthy
NODE_DISABLE_COLORS, a NO_COLOR that is set at all, or TERM=dumb; on for a FORCE_COLOR
other than 0 or a terminal on stdout — not the policy. require('roundel/kleur')
returns the same object require('kleur') does. A named import of a style from kleur itself
fails at load against kleur's own ES entry, and against this one too.
- import pc from 'picocolors';
+ import pc from 'roundel/picocolors';Identical. createColors(enabled), isColorSupported, and all forty-one styles, with
picocolors' bytes: each closes on its own code, a nested close re-opens the outer style
(bold and dim re-open each other), and the input is stringified as "" + input is.
Colour is decided by picocolors' own rule — not NO_COLOR or --no-color, and one of
FORCE_COLOR, --color, Windows, a stdout TTY whose TERM is not dumb, or CI — not by
the policy, because the drop-in answers what picocolors answers.
require('roundel/picocolors') returns the same object, as require('picocolors') does.
- import supportsColor from 'supports-color';
+ import supportsColor from 'roundel/supports-color';Identical. The default { stdout, stderr }, decided at import, and
createSupportsColor(stream, { sniffFlags }), each false or { level, hasBasic, has256, has16m },
in supports-color 11's order: the platform and Windows build, TeamCity, TERM_PROGRAM and the
truecolor terminals included. It answers what supports-color answers, so it is not
the policy in two places, on purpose: a numeric FORCE_COLOR outranks
--no-color (FORCE_COLOR=3 --no-color is level 3), and NO_COLOR is not read.
roundel/policy keeps refusing colour a user turned off, and a test pins both answers side by side.
- import colors, { red, bold } from 'yoctocolors';
+ import colors, { red, bold } from 'roundel/yoctocolors';Identical. yoctocolors 2.2.0's 61 functions, the 4:x underline styles, overline and the
underline colours included, as named exports and as the default namespace. Colour is yoctocolors'
own rule, decided once at import: Node's tty.WriteStream.prototype.hasColors(), which reads
FORCE_COLOR, NO_COLOR and TERM itself. It is not the policy, and with colour
off each function hands its input back untouched, as yoctocolors' does.
Or let the codemod make the change: npx burgee migrate --dry-run lists every import it would
rewrite — only drop-ins graded level with their incumbent — and npx burgee migrate makes it.
See Migrate.
Compatibility
Graded by chalk's own suite, vendored at 6.0.1 into compat-oracle and run unedited
through a generated shim: 59 of 59 tests (100.0%) on 2026-09-30, alongside the same
suite scoring 59 / 59 against real chalk in the same run.
Graded by colorette's own suite, vendored at 2.0.20: roundel/colorette passes 67 of 67
cases of the three files colorette's npm test runs, level with colorette itself, the four that
spawn a program under FORCE_COLOR, NO_COLOR, --color and --no-color included. colorette's
runner, twist, exits 0 when its cases fail, so compat-oracle reads the counts twist prints
rather than its exit code, and runs the suite in upstream CI's environment. colorette.test.ts
evaluates colorette's own index.js beside this module and requires the same value from every
style, for every pair of styles nested in each other, with colour on and off, and the same
decision in every row of colorette's rule. Bundled, the whole surface is 2,425 bytes against
colorette's 2,602 (measured).
Graded by kleur's own suite, vendored at 4.1.5: roundel/kleur and roundel/kleur/colors
pass 15 of 15 cases of the two files kleur's npm test runs, level with kleur itself. kleur's
runner is uvu -r esm, and esm does not start on Node 24, so compat-oracle runs upstream's
pinned uvu once per file with tsx where esm stood, which gives the suite what esm did — ES
module syntax in .js files, imports resolved as CommonJS resolves them — and edits none of it. kleur.test.ts and
kleur-colors.test.ts evaluate kleur's own index.mjs and colors.mjs beside these modules and
require the same value from every style, every pair chained and nested, colour on and off, and the
same decision in every row of kleur's rule, the rows of its Linux-only env.sh among them. Bundled,
the whole surface is 1,049 bytes against kleur's 1,839, and 930 against kleur/colors' 997
(measured).
Graded by picocolors' own test, vendored at 1.1.1: roundel/picocolors passes
tests/test.js, the one file picocolors' npm test runs. That file is a script of bare
assertions that stops at the first failure, so the grade is one bit, as rc's is: 1 of 1, the
same as picocolors itself. Its other file, tests/environments.js, evaluates picocolors'
source text in a vm with a fake process. Upstream does not run it, and only a single
self-contained script could pass it. Its nine environments are held instead by
picocolors.test.ts, which runs picocolors' own source and this façade under the same fake
process and requires the same answer. Bundled, the façade's whole surface is 1,607 bytes against
picocolors' 2,739; scripts/subpath-weight-lock.test.ts holds it there (measured).
Graded by supports-color's own suite, vendored at 11.0.0: roundel/supports-color passes
55 of 55 cases of test.js, level with supports-color itself. Each case rewrites the process
and re-imports the module with a query string, so compat-oracle passes the query through its
shim. The rest of the decision table is held by supports-color.test.ts, which evaluates
supports-color's own source and this module in thousands of generated environments and
requires the same answers. Bundled, the whole surface is 1,781 bytes against supports-color's 2,145 (measured).
Graded by yoctocolors' own suite, vendored at 2.2.0: roundel/yoctocolors passes 78 of 78
cases of test.js, level with yoctocolors itself, the case that spawns node fixture.js with
FORCE_COLOR=0 included. yoctocolors.test.ts evaluates yoctocolors' own base.js beside this
module and requires the same value from every style, for every pair of styles nested in each
other, with colour on and off. Bundled, the whole surface is 3,275 bytes against yoctocolors' 3,395 (measured).
The grade is re-run on every change to roundel/chalk; the current figure is generated under
Benchmarks below and published with every other drop-in on the
compatibility page.
Weight
Every subpath is a lock, not a convention. roundel/tokens reaches 3,258 bytes on disk
(its ceiling is picocolors, 3.3 KB); roundel/policy 1,972; roundel/theme 6,271;
roundel/plugin 2,812 and reaching no module at all;
roundel/contrast 1,250; roundel/terminal 878 and reaching no module; roundel/import
16,550, most of it the theme and contrast check it runs; roundel/chalk 9,311 (its ceiling is
chalk 6.0.0's own 9,370 — 6.0.1 is 9,521 — before the ansi-styles and supports-color chalk
also ships). Importing one never loads
another — the tokens never carry the theme, the theme never carries the tokens, chalk
carries neither — and sideEffects: false lets a bundler drop what a program does not use.
ESM with a default condition, so require('roundel/tokens') works from CommonJS on
Node 22.13+ (measured).
Benchmarks
Every number here is produced by npm run bench and published at burgee.interlace.tools/docs/benchmarks.
Graded by the incumbent's own test suite:
| suite | passing |
|---|---|
chalk | 59 / 59 |
colorette | 67 / 67 |
kleur | 15 / 15 |
picocolors | 1 / 1 |
supports-color | 55 / 55 |
yoctocolors | 78 / 78 |
For agents
- Captured output is plain.
outputMode(rt, { json })answersjsonunder--json, and underNO_COLORor on a pipe nobody asked to colour the level is 0 — every token returns its input unchanged, so a transcript an agent reads back never carries an escape. - The decision is a function, not a side effect.
roundel/policyis pure over{ env, isTTY, argv }, so a harness can ask the question the program asked and get the same answer. - A theme plugin can be checked before it ships.
npx roundel check ./theme.mjsvalidates a plugin against the family schema, prints what it contributes, and exits 0, 1 with a code and a fix, or 2 on a usage error. - The docs are machine-readable at roundel.interlace.tools/llms.txt and llms-full.txt.
API
The subpaths are listed under What is here; every export, with its types, is on roundel.interlace.tools.
Where it sits
Plugins register under the tokens key, against the one schema the whole family shares.
burgee, caique, controlroom, flagstaff build on it, and it builds on nothing in this family.
The family
Eleven packages, one repository, one release pipeline. Each installs and works on its own, and each
owns one job. Six of them — bellpull, closeout, linegauge, paratext, roundel, seniority
— depend on nothing; the others depend only on packages in this table, and every dependency points
one way, down the layers.
No package declares a dependency from outside the family; react and react-reconciler are
optional peers of controlroom, which npm does not install; vitest is an optional peer of
oxmutate, which npm does not install.
package-shape-lock
holds that for every manifest, and
independence-install-lock
installs each package alone and finds nothing but the family packages it declares.
Releases are published by one workflow through npm trusted publishing: no npm token is used, and
each release carries SLSA provenance, which npm view <package> dist.attestations shows.
trusted-publishing-lock
keeps both true.
| Package | What it is | Migrates from |
|---|---|---|
| burgee | The CLI framework: one declaration, every surface | commander, yargs and meow |
| roundel (this package) | Colour: one output policy, semantic tokens, a theme | chalk, colorette, kleur, picocolors, supports-color and yoctocolors |
| flagstaff | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 |
| caique | Prompts that are flags first, and never hang | @inquirer/core |
| linegauge | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi, slice-ansi, cli-truncate and widest-line |
| paratext | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img |
| seniority | Configuration precedence and discovery, with provenance | cosmiconfig, lilconfig, dotenv and rc |
| closeout | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook, restore-cursor, cli-cursor and onetime |
| bellpull | Subprocesses, and which executable actually ran | cross-spawn and which |
| controlroom | Keyboard-driven terminal screens, inline or full-screen | ink |
| oxmutate | Mutation testing on the oxc engine, inside your own Vitest | Stryker |
Every migration guide, and the family-wide compatibility and benchmarks pages, are on burgee.interlace.tools.
Contributing
Issues and pull requests are welcome at ofri-peretz/burgee; read CONTRIBUTING.md first. Report a vulnerability privately, as SECURITY.md describes — never in a public issue.
Licence
MIT © Ofri Peretz — see LICENSE.