# roundel/chalk > Every export of roundel/chalk, with its signature and doc comment: modifierNames, foregroundColorNames, backgroundColorNames, underlineColorNames, colorNames, supportsColor and 3 more, plus 16 types. Source: https://roundel.interlace.tools/docs/api/chalk `roundel/chalk` (R6): chalk 6's public API — the chainable builder, the mutable `level`, `Chalk`, `chalkStderr`, `supportsColor` — over the tokens' emitter and the policy's level, graded by chalk's own suite vendored into `compat-oracle`. chalk's mutable `level` is honoured here and nowhere else: `chalk.level = 0` silences this façade, not the tokens, which read the policy. The template literal (`` chalk`{red x}` ``) went with chalk 5 and is not resurrected. Not one escape is written here: parameters are computed, `sgr()` in tokens emits them (R3). The SGR numbers below are chalk's tables (ansi-styles) as written; naming each would double the file the R8 ceiling measures, so the lint's magic-number rule is off here. ```ts import chalk from 'roundel/chalk'; import { modifierNames, foregroundColorNames, backgroundColorNames, … } from 'roundel/chalk'; ``` ## Constants ### default The default export, declared as `chalk`. ```ts const chalk: ChalkInstance; ``` ### backgroundColorNames ```ts const backgroundColorNames: BackgroundColorName[]; ``` ### chalkStderr ```ts const chalkStderr: ChalkInstance; ``` ### colorNames ```ts const colorNames: ColorName[]; ``` ### foregroundColorNames ```ts const foregroundColorNames: ForegroundColorName[]; ``` ### modifierNames ```ts const modifierNames: ModifierName[]; ``` ### supportsColor ```ts const supportsColor: ColorInfo; ``` ### supportsColorStderr ```ts const supportsColorStderr: ColorInfo; ``` ### underlineColorNames ```ts const underlineColorNames: UnderlineColorName[]; ``` ## Interfaces ### Chalk `new Chalk({ level })` — an instance with its own level, detected when the option is omitted. ```ts interface Chalk extends ChalkInstance { } class Chalk { constructor(options?: ChalkOptions); } ``` ### ChalkOptions ```ts interface ChalkOptions { /** `undefined` asks for the level to be detected, as omitting it does. */ readonly level?: ColorSupportLevel | undefined; } ``` ### ColorSupport ```ts interface ColorSupport { level: ColorSupportLevel; hasBasic: boolean; has256: boolean; has16m: boolean; } ``` ## Types ### BackgroundColor ```ts type BackgroundColor = BackgroundColorName; ``` ### BackgroundColorName ```ts type BackgroundColorName = `bg${Capitalize}`; ``` ### ChalkInstance ```ts type ChalkInstance = Base & { readonly [K in ModifierName | ColorName | UnderlineColorName | 'visible']: ChalkInstance; }; ``` ### Color ```ts type Color = ColorName; ``` ### ColorInfo ```ts type ColorInfo = ColorSupport | false; ``` ### ColorName ```ts type ColorName = ForegroundColorName | BackgroundColorName; ``` ### ColorSupportLevel Colour support: none, 16 colours, 256 colours, truecolor. chalk's name for the policy's level. ```ts type ColorSupportLevel = ColorLevel; ``` ### ForegroundColor ```ts type ForegroundColor = ForegroundColorName; ``` ### ForegroundColorName ```ts type ForegroundColorName = Basic | Bright; ``` ### ModifierName ```ts type ModifierName = keyof typeof MODIFIERS; ``` ### Modifiers ```ts type Modifiers = ModifierName; ``` ### Options ```ts type Options = ChalkOptions; ``` ### UnderlineColorName ```ts type UnderlineColorName = `underline${Capitalize}`; ``` --- # roundel/contrast > Every export of roundel/contrast, with its signature and doc comment: channels, luminance, contrast, reportTheme, AA, AAA and 2 more, plus 2 types. Source: https://roundel.interlace.tools/docs/api/contrast The WCAG 2.2 contrast maths (R5). It was copied from `burgee/contrast` under Y1 — "sixty lines duplicated beats a dependency arrow pointing the wrong way" — on the condition that the two copies share a test-vector file. That condition was met half way for five days: this copy was pinned to `contrast-vectors.json` and burgee's was not, which is the worse arrangement, because the pinned copy cannot drift and the unpinned one can while keeping a green suite. **#194 settled it differently and better:** the arrow was reversed, so there is one implementation and `burgee/contrast` imports this one. The vectors remain as a reference — values computed outside this file, which is the only kind that can catch a wrong constant — and `scripts/shared-vectors-lock.test.ts` keeps them read rather than kept. `fly()` uses it to refuse a truecolor token that would not read against the declared ground. Nothing here is asked about the 16- and 256-colour palettes: those are the user's terminal theme, and a number there would be invented. ```ts import { channels, luminance, contrast, … } from 'roundel/contrast'; ``` ## Functions ### channels `#abc` and `#aabbcc` both parse, to sRGB channels in 0..1. Anything else is a mistake worth throwing on. ```ts function channels(hex: string): [number, number, number]; ``` | Parameter | Type | | :-- | :-- | | `hex` | `string` | **Returns** `[number, number, number]` ### contrast The WCAG contrast ratio between two colours. Order does not matter. ```ts function contrast(a: string, b: string): number; ``` | Parameter | Type | | :-- | :-- | | `a` | `string` | | `b` | `string` | **Returns** `number` ### luminance WCAG relative luminance. ```ts function luminance(hex: string): number; ``` | Parameter | Type | | :-- | :-- | | `hex` | `string` | **Returns** `number` ### reportTheme One line per finding, aligned, for a terminal or a failing test. Mirrors `burgee/contrast`'s `report` — the same shape in both packages, because somebody reading a theme audit and a brand audit on the same day should not have to learn two layouts. Passing rows are printed too. A report that lists only failures cannot tell "nothing is wrong" from "nothing was checked", and the second is the state this package was in for the 256-colour level until 2026-09-13. ```ts function reportTheme(findings: readonly ThemeFinding[]): string; ``` | Parameter | Type | | :-- | :-- | | `findings` | `readonly ThemeFinding[]` | **Returns** `string` ## Constants ### AA The floors WCAG 2.2 sets, as ratios. ```ts const AA: { /** Body text against its background. */ readonly TEXT: 4.5; /** Large text, UI components, and meaningful parts of a graphic. */ readonly GRAPHIC: 3; }; ``` ### AAA The stricter conformance level, for a caller who needs it: low-vision users, a CLI run on a projector, a terminal in daylight, or an organisation whose accessibility policy says AAA and does not care that this is a terminal. Not the default, and not because AA is good enough. At 7:1 the 256-colour palette runs out of room fast — a great many perfectly reasonable brand colours have no readable substitute in the cube at that floor — so defaulting to AAA would refuse themes that work for almost everyone on almost every terminal. It is the caller's call, which is the only place that judgement can honestly sit. ```ts const AAA: { /** Body text against its background. */ readonly TEXT: 7; /** Large text, UI components, and meaningful parts of a graphic. */ readonly GRAPHIC: 4.5; }; ``` ### floors The floors for a conformance level, so a caller names a standard rather than a number. ```ts const floors: (level?: Conformance) => typeof AA | typeof AAA; ``` ### round2 ```ts const round2: (value: number) => number; ``` ## Interfaces ### ThemeFinding One token's verdict at one colour level, in the words somebody fixing it would use. ```ts interface ThemeFinding { /** The token name — `error`, `ok`, `command`. */ token: string; /** `truecolor` or `256`. Never `16`: those values are the user's terminal theme. */ at: 'truecolor' | '256'; /** What the terminal is actually sent at this level, which is not always the hex written. */ colour: string; ground: string; ratio: number; required: number; passes: boolean; } ``` ## Types ### Conformance Which WCAG conformance level a theme is held to. `AA` unless a caller asks for more. ```ts type Conformance = 'AA' | 'AAA'; ``` --- # roundel/import > Every export of roundel/import, with its signature and doc comment: fromBase16, fromITerm, ImportError, BASE16_SLOTS, ITERM_SLOTS, plus 3 types. Source: https://roundel.interlace.tools/docs/api/import `roundel/import` (R11) — a theme from the two largest corpora of terminal palettes: Base16 schemes and iTerm2's `.itermcolors` files. **Data in, data out.** The caller reads the file; these functions take its text (or, for Base16, the object a YAML or JSON reader already made of it) and return the object `fly()` takes. No network, no bundled corpus, no file system — the same rule `flagstaff/import` keeps for cli-spinners, because the weight of a corpus nobody asked for is what U5 is about. ```js import { readFileSync } from 'node:fs'; import { fromBase16 } from 'roundel/import'; import { fly } from 'roundel/theme'; fly(fromBase16(readFileSync('gruvbox-dark-hard.yaml', 'utf8')), runtime); ``` **Which colour each token takes.** A palette file names colours by position, not by meaning, so the mapping is the one both formats already agree on: the sixteen ANSI slots a terminal paints with. `error` is red, `warn` yellow, `ok` green, `flag` cyan and `value` magenta — the hue each token's default names — and `ground` is the background. For Base16 that is the base16-shell assignment read backwards (red ← `base08`, yellow ← `base0A`, green ← `base0B`, cyan ← `base0C`, magenta ← `base0E`, background ← `base00`); for iTerm it is `Ansi 1`, `3`, `2`, `6`, `5` and `Background Color`. Both tables are exported, so the mapping is data a reader can see rather than a choice buried in a function. **Four tokens are not imported, on purpose.** `hint`, `command` and `heading` default to `dim`, `bold` and `bold underline` — attributes, which a palette file has no opinion on, and a hex would replace them rather than colour them. `muted` defaults to `gray`, the terminal's bright black, and that slot is a scheme's comment grey, which is *designed* to recede: `base03` is 2.50:1 on its ground in Default Dark and 2.52:1 in Gruvbox dark hard, and Dracula's `Ansi 8` is 3.03:1 — importing it would refuse three well-made schemes over the one token nobody reads first. Left at their defaults, all four follow the terminal, which in a terminal themed with the same scheme is the scheme's own colour anyway. **Contrast-checked on the way in (R5), with `fly()`'s own judgement.** The theme is run through `audit()` before it is returned, truecolor and the 256-colour substitute both, so whatever these functions return `fly()` accepts, and a theme that does not read is refused here with the slot it came from named. The refusal carries the unchecked theme, for a gallery that wants to show *why*. **Refusals are named.** Anything these formats do not need is not parsed, and anything they need and cannot find is an `ImportError` with a `code` and a `fix`, the shape `PluginError` has: `E_IMPORT_FORMAT` (not a Base16 scheme or an XML plist at all), `E_IMPORT_SLOT` (a slot the mapping reads is missing or is not a colour), `E_IMPORT_CONTRAST` (it parsed, and it does not read). ```ts import { fromBase16, fromITerm, ImportError, … } from 'roundel/import'; ``` ## Functions ### fromBase16 A theme from a Base16 scheme: the YAML file's text, a JSON file's text, or the object a reader made of either. Both layouts are read — the original, with `base00`…`base0F` at the top, and the 0.11 spec's, with them under `palette`. ```ts function fromBase16(scheme: string | Record, opts?: ImportOptions): Theme; ``` | Parameter | Type | | :-- | :-- | | `scheme` | `string \| Record` | | `opts` (optional) | `ImportOptions` | **Returns** `Theme` ### fromITerm A theme from an iTerm2 `.itermcolors` file — its text, an XML property list. A binary plist is refused with the command that converts it; nothing else in the file is read. ```ts function fromITerm(plist: string, opts?: ImportOptions): Theme; ``` | Parameter | Type | | :-- | :-- | | `plist` | `string` | | `opts` (optional) | `ImportOptions` | **Returns** `Theme` ## Classes ### ImportError A refused file says what is wrong and what to do about it — `PluginError`'s shape. ```ts class ImportError extends Error { readonly code: ImportErrorCode; readonly fix: string; /** For `E_IMPORT_CONTRAST`: the theme as parsed, before the check refused it. */ readonly theme?: Theme | undefined; constructor(code: ImportErrorCode, message: string, fix: string, /** For `E_IMPORT_CONTRAST`: the theme as parsed, before the check refused it. */ theme?: Theme | undefined); } ``` ## Constants ### BASE16_SLOTS Which Base16 slot each token is read from: base16-shell's ANSI assignment, backwards. ```ts const BASE16_SLOTS: Readonly>; ``` ### ITERM_SLOTS Which `.itermcolors` key each token is read from: the ANSI slot its default names. ```ts const ITERM_SLOTS: Readonly>; ``` ## Interfaces ### ImportOptions ```ts interface ImportOptions { /** The WCAG level the theme is checked at, and carried on it for `fly()`. Default `AA`. */ conformance?: Conformance; } ``` ## Types ### ImportedToken The tokens a palette file colours, and `ground`. The other four keep their defaults. ```ts type ImportedToken = 'ground' | 'error' | 'warn' | 'ok' | 'flag' | 'value'; ``` ### ImportErrorCode ```ts type ImportErrorCode = 'E_IMPORT_FORMAT' | 'E_IMPORT_SLOT' | 'E_IMPORT_CONTRAST'; ``` --- # roundel > Every export of roundel, with its signature and doc comment: channels, luminance, contrast, reportTheme, AA, AAA and 30 more, plus 18 types. Source: https://roundel.interlace.tools/docs/api roundel — the colours a CLI carries. Re-exports only; each subpath is its own entry and costs only itself (R7), so prefer `roundel/tokens` when that is all you need. ```ts import { channels, luminance, contrast, … } from 'roundel'; ``` ## Exports Documented on the page of the entry point that declares them. | Export | Kind | Documented in | | :-- | :-- | :-- | | `audit` | function | [`roundel/theme`](/docs/api/theme#audit) | | `channels` | function | [`roundel/contrast`](/docs/api/contrast#channels) | | `colorLevel` | function | [`roundel/policy`](/docs/api/policy#colorlevel) | | `contrast` | function | [`roundel/contrast`](/docs/api/contrast#contrast) | | `contributions` | function | [`roundel/plugin`](/docs/api/plugin#contributions) | | `fly` | function | [`roundel/theme`](/docs/api/theme#fly) | | `luminance` | function | [`roundel/contrast`](/docs/api/contrast#luminance) | | `painted` | function | [`roundel/tokens`](/docs/api/tokens#painted) | | `register` | function | [`roundel/plugin`](/docs/api/plugin#register) | | `registered` | function | [`roundel/plugin`](/docs/api/plugin#registered) | | `reportTheme` | function | [`roundel/contrast`](/docs/api/contrast#reporttheme) | | `reset` | function | [`roundel/plugin`](/docs/api/plugin#reset) | | `rgb256` | function | [`roundel/theme`](/docs/api/theme#rgb256) | | `theme` | function | [`roundel/plugin`](/docs/api/plugin#theme) | | `toOklab` | function | [`roundel/theme`](/docs/api/theme#tooklab) | | `validate` | function | [`roundel/plugin`](/docs/api/plugin#validate) | | `PluginError` | class | [`roundel/plugin`](/docs/api/plugin#pluginerror) | | `AA` | const | [`roundel/contrast`](/docs/api/contrast#aa) | | `AAA` | const | [`roundel/contrast`](/docs/api/contrast#aaa) | | `command` | const | [`roundel/tokens`](/docs/api/tokens#command) | | `CONTRACT` | const | [`roundel/plugin`](/docs/api/plugin#contract) | | `error` | const | [`roundel/tokens`](/docs/api/tokens#error) | | `flag` | const | [`roundel/tokens`](/docs/api/tokens#flag) | | `floors` | const | [`roundel/contrast`](/docs/api/contrast#floors) | | `flown` | const | [`roundel/policy`](/docs/api/policy#flown) | | `heading` | const | [`roundel/tokens`](/docs/api/tokens#heading) | | `hint` | const | [`roundel/tokens`](/docs/api/tokens#hint) | | `muted` | const | [`roundel/tokens`](/docs/api/tokens#muted) | | `ok` | const | [`roundel/tokens`](/docs/api/tokens#ok) | | `outputMode` | const | [`roundel/policy`](/docs/api/policy#outputmode) | | `painter` | const | [`roundel/tokens`](/docs/api/tokens#painter) | | `round2` | const | [`roundel/contrast`](/docs/api/contrast#round2) | | `sgr` | const | [`roundel/tokens`](/docs/api/tokens#sgr) | | `UNPAINTED` | const | [`roundel/tokens`](/docs/api/tokens#unpainted) | | `value` | const | [`roundel/tokens`](/docs/api/tokens#value) | | `warn` | const | [`roundel/tokens`](/docs/api/tokens#warn) | | `Contribution` | interface | [`roundel/plugin`](/docs/api/plugin#contribution) | | `ModeOptions` | interface | [`roundel/policy`](/docs/api/policy#modeoptions) | | `Painter` | interface | [`roundel/tokens`](/docs/api/tokens#painter) | | `Plugin` | interface | [`roundel/plugin`](/docs/api/plugin#plugin) | | `Runtime` | interface | [`roundel/policy`](/docs/api/policy#runtime) | | `SgrPair` | interface | [`roundel/tokens`](/docs/api/tokens#sgrpair) | | `ThemeFinding` | interface | [`roundel/contrast`](/docs/api/contrast#themefinding) | | `ColorLevel` | type | [`roundel/policy`](/docs/api/policy#colorlevel) | | `Conformance` | type | [`roundel/contrast`](/docs/api/contrast#conformance) | | `Format` | type | [`roundel/policy`](/docs/api/policy#format) | | `Hex` | type | [`roundel/theme`](/docs/api/theme#hex) | | `OutputMode` | type | [`roundel/policy`](/docs/api/policy#outputmode) | | `Paint` | type | [`roundel/policy`](/docs/api/policy#paint) | | `PluginErrorCode` | type | [`roundel/plugin`](/docs/api/plugin#pluginerrorcode) | | `Style` | type | [`roundel/theme`](/docs/api/theme#style) | | `Theme` | type | [`roundel/theme`](/docs/api/theme#theme) | | `Token` | type | [`roundel/tokens`](/docs/api/tokens#token) | | `TokenName` | type | [`roundel/policy`](/docs/api/policy#tokenname) | --- # roundel/plugin > Every export of roundel/plugin, with its signature and doc comment: validate, register, reset, theme, contributions, registered and 2 more, plus 3 types. Source: https://roundel.interlace.tools/docs/api/plugin The plugin host for roundel's half of the contract (`plugin-contract` R1, R4, R6, R8). A plugin is one plain object shared by the whole family. This file keeps the key roundel understands — `tokens`, a theme — and **ignores every other key without complaining**, which is what makes the same object work on any subset of the family that is installed. A plugin written for flagstaff registers here and contributes its theme; its `spinners` and `components` are not roundel's business and are not an error. **Nothing here imports flagstaff, and the shape is declared rather than imported.** Types erase, so an import would cost nothing at run time — and it would still put flagstaff in roundel's dependency story, which is the one thing the family promises it does not do. Four keys declared structurally is three lines, and keeps "none requires the others" literally true instead of true-modulo-types. **A plugin cannot smuggle an unreadable colour in.** This file collects tokens; `fly()` contrast-checks them against the ground exactly as it checks a hand-written theme, and throws below 4.5:1. That is deliberately not re-implemented here: one contrast gate, in the place that already had it (roundel R5). ```ts import { validate, register, reset, … } from 'roundel/plugin'; ``` ## Functions ### contributions Every token a plugin contributed, with who won it and who it shadowed. ```ts function contributions(): Contribution[]; ``` **Returns** `Contribution[]` ### register Register a plugin. Later wins, like ESLint flat config: the array is ordered, a caller reads it top to bottom, and the last word on a token is the one nearest the program. ```ts function register(plugin: unknown): void; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `void` ### registered The plugins registered, in registration order. ```ts function registered(): readonly Plugin[]; ``` **Returns** `readonly Plugin[]` ### reset Forget every registered plugin. For tests, and for a program that re-themes at runtime. ```ts function reset(): void; ``` **Returns** `void` ### theme The theme every registered plugin adds up to, ready for `fly()`. It is *not* flown here. A plugin contributing colour must not decide when colour is decided — `fly()` is called once by the program, and calling it from a `register()` would mean the last plugin imported quietly re-flew the theme. ```ts function theme(): Theme; ``` **Returns** `Theme` ### validate Refuse a plugin that cannot contribute a theme, at the door. A misspelt token name is refused rather than ignored: a plugin whose `errror` key is silently dropped looks like it worked, and the author debugs the wrong thing. ```ts function validate(plugin: unknown): asserts plugin is Plugin; ``` | Parameter | Type | | :-- | :-- | | `plugin` | `unknown` | **Returns** `asserts plugin is Plugin` ## Classes ### PluginError A refused plugin says what is wrong and what to do about it — the family's one vocabulary. ```ts class PluginError extends Error { readonly code: PluginErrorCode; readonly fix: string; constructor(code: PluginErrorCode, message: string, fix: string); } ``` ## Constants ### CONTRACT The plugin contract version. One number for the family — the same `1` flagstaff declares, written out rather than imported for the reason in the file comment above. ```ts const CONTRACT = 1; ``` ## Interfaces ### Contribution Which plugin last contributed each token — the shadowing a `plugin check` prints. ```ts interface Contribution { token: string; value: Hex; from: string; /** Plugins that contributed this token earlier and were overridden, in order. */ shadowed: string[]; } ``` ### Plugin The keys roundel reads. Declared structurally: any object with these fields is a plugin here, whatever else it carries. ```ts interface Plugin { name: string; contract?: number; tokens?: Record; } ``` ## Types ### PluginErrorCode ```ts type PluginErrorCode = 'E_PLUGIN_SCHEMA' | 'E_PLUGIN_CONTRACT' | 'E_NO_CONTRIBUTION'; ``` --- # roundel/policy > Every export of roundel/policy, with its signature and doc comment: colorLevel, outputMode, flown, plus 7 types. Source: https://roundel.interlace.tools/docs/api/policy The output policy — one answer to "where is this output going?", read from a `Runtime`, never from `process` (R1, R2, R9 of `roundel`; U2 of the stack). Five colour libraries disagreeing about the terminal (clack #286) is what this file exists to end: every package in the family asks here, and nothing else in it reads `isTTY`, `NO_COLOR`, `FORCE_COLOR`, `CI`, `CLI_ACCESSIBLE` or a `--color` flag. It also holds the one record `fly()` writes and every token reads, because it is the only module every subpath may import (R7). The code here is written tight — ternaries where a reader might expect statements — because `./chalk` reaches this file and R8 caps that whole graph at chalk 6's own 9,370 bytes. Comments are stripped from `dist`, so the prose is free; the statements are not. ```ts import { colorLevel, outputMode, flown } from 'roundel/policy'; ``` ## Functions ### colorLevel chalk's level, obeying the user's explicit instruction in any mode (R2 revised 2026-09-08): `NO_COLOR` wins outright; then `FORCE_COLOR=0`, which supports-color settles *before* it looks at any flag, so `FORCE_COLOR=0 --color=256` is off and not 2 — an explicit "colour off" is never undone by a level flag; then the `--color` flags in `argv`, an exact `--color=256` beating a numeric `FORCE_COLOR` as chalk's own suite says; then `FORCE_COLOR`. `--json` is the one output the level never enters: structured text carries no escapes. The mode decides redraws (U2), never the level. Accessible mode defaults to 0, on the same footing as a pipe rather than a terminal: `CLI_ACCESSIBLE` is itself an explicit instruction from a human, and ANSI colour is noise to a screen reader. As with a pipe, an explicit colour ask (`FORCE_COLOR`, `--color=…`) still wins and `NO_COLOR` still beats everything. With no instruction the order is supports-color's own, and deliberately so — chalk's `level.js` asserts it, and a family that disagreed with chalk about a bare pipe would be the clack #286 bug again. A pipe is 0, because a pipe nobody asked to colour is a file or another program's stdin; the one exception is Azure Pipelines (`TF_BUILD` *and* `AGENT_NAME` — `TF_BUILD` alone is a build without an agent), which supports-color reads before it gives up on a pipe. Once colour *is* being detected — a terminal, or a run that asked — `TERM=dumb` is the floor, a `CI` run is its vendor's level (gated on `'CI' in env` as supports-color gates it, so an empty `CI=` still selects the table), and anything else is what `TERM`/`COLORTERM` report. So the CI user who exports `FORCE_COLOR=true` to get coloured logs gets their runner's colours, and nobody else's pipe changes. ponytail: supports-color also consults the platform, TEAMCITY_VERSION, TERM_PROGRAM and the emulator allow-list; R2 refuses that detection, so those stay unread. ```ts function colorLevel(rt: Runtime, opts?: ModeOptions): ColorLevel; ``` | Parameter | Type | | :-- | :-- | | `rt` | `Runtime` | | `opts` (optional) | `ModeOptions` | **Returns** `ColorLevel` ## Constants ### flown What `fly()` decided, once, for the process: the colour level and each token's paint. Every token reads it; nothing writes it but `fly()`. Until then the level is 0 and every token is the identity, so a program that never declares its runtime prints plain text. ```ts const flown: { level: ColorLevel; paint: Partial>; }; ``` ### outputMode First match wins: `json` if asked; `accessible` if `CLI_ACCESSIBLE`; `ci` if `CI` and not a TTY; `pipe` if not a TTY; else `tty`. ```ts const outputMode: (rt: Runtime, opts?: ModeOptions) => OutputMode; ``` ## Interfaces ### ModeOptions ```ts interface ModeOptions { /** Whether this run was asked for `--json`: the engine's knowledge, not the process's. */ json?: boolean; } ``` ### Runtime The slice of a runtime the policy needs. burgee's `processRuntime` satisfies it, so does a two-line literal in a test; nothing here imports a type from anywhere. ```ts interface Runtime { env: Record; isTTY: { stdout: boolean; }; /** * The process arguments, when the caller owns them: `--color`, `--no-color` and * `--color=…` are read in `policy.ts` and nowhere else. A test literal leaves it out. */ argv?: readonly string[]; } ``` ## Types ### ColorLevel chalk's levels: none, 16 colours, 256 colours, truecolor. ```ts type ColorLevel = 0 | 1 | 2 | typeof MAX_LEVEL; ``` ### Format One `util.styleText` format name: `'bold'`, `'red'`, `'redBright'`, `'dim'`… ```ts type Format = import('node:util').InspectColor; ``` ### OutputMode ```ts type OutputMode = 'tty' | 'pipe' | 'json' | 'accessible' | 'ci'; ``` ### Paint A style as a token paints it: `styleText` format names, or the SGR parameters of one foreground colour (`38;5;n` for 256 colours, `38;2;r;g;b` for truecolor), which `styleText` cannot express. ```ts type Paint = readonly Format[] | { readonly sgr: readonly number[]; }; ``` ### TokenName ```ts type TokenName = 'error' | 'warn' | 'ok' | 'hint' | 'muted' | 'command' | 'flag' | 'value' | 'heading'; ``` --- # roundel/terminal > Every export of roundel/terminal, with its signature and doc comment: unicode, AGENTS, interactive, plus 2 types. Source: https://roundel.interlace.tools/docs/api/terminal Two questions about the terminal that are not colour, and that three packages in the family were each answering by hand (R12). - **Is anybody there to type?** `caique/decide` asked `isTTY.stdin && !CI` and missed the agent case entirely: under Claude Code a prompt has a terminal and no person, and a prompt that waits there hangs the agent that ran it. burgee's own `detectAgent` knew that; caique did not ask it. - **Can it draw a tick?** `flagstaff/ora` carried is-unicode-supported whole, and `caique/inquirer` a four-condition subset that answered differently on the Linux console and on half the Windows terminals the full table names. Both read a runtime passed in, never the process (R9), and neither reaches another module, so `roundel/terminal` costs only itself. It is its own subpath rather than more of `roundel/policy` because `./chalk` stands on `policy.js` and R8 caps that whole graph at chalk 6.0.0's own bytes, with 7 of them to spare. ```ts import { unicode, AGENTS, interactive } from 'roundel/terminal'; ``` ## Functions ### unicode Whether the terminal can be expected to draw non-ASCII glyphs — is-unicode-supported 2.1.0, condition for condition. Everything but Windows is yes, except the Linux console (`TERM=linux`), whose font has no ticks; on Windows only the terminals the incumbent names. This is the one place roundel reads `TERM_PROGRAM` and the platform. The refusal in the spec is about *colour* — the level is decided by the user's instruction and `TERM`, never an emulator allow-list — and a glyph table is not a colour level. ```ts function unicode({ env, platform }: Glyphs): boolean; ``` | Parameter | Type | | :-- | :-- | | `{ env, platform }` | `Glyphs` | **Returns** `boolean` ## Constants ### AGENTS The variables whose presence says an agent is driving the process — the list `burgee/src/agent.ts` probes (N12), after `@vercel/detect-agent`. `AI_AGENT` is the generic one any agent can set. A variable joins only when it **uniquely** identifies an agent: the agent sets it, and no terminal a person types in does. `CURSOR_TRACE_ID` fails that — Cursor sets it in every integrated terminal — and taking it would stop every person in Cursor from being asked anything (D-20260930-one-interactive-rule). This is the family's one rule for "may a person be asked?": burgee's `ctx.interactive` and caique's prompts both call `interactive` below. ```ts const AGENTS: readonly ["AI_AGENT", "CLAUDECODE", "CURSOR_AGENT", "CODEX_THREAD_ID", "GEMINI_CLI"]; ``` ### interactive Whether a person can be asked something and be expected to answer. `FORCE_TTY=1` says yes outright, as it does to burgee's `detectAgent`: it is the one explicit instruction, and a caller who pipes answers into a prompt on purpose sets it. Otherwise it takes a terminal on stdin, no `CI`, and no agent variable. An agent may well have a terminal; what it does not have is a person. ```ts const interactive: ({ env, isTTY }: Terminal) => boolean; ``` ## Interfaces ### Glyphs The slice `unicode` reads: the environment and the platform, `process.platform`'s spelling. ```ts interface Glyphs { env: Record; platform?: string; } ``` ### Terminal The slice of a runtime `interactive` reads. burgee's and caique's runtimes both satisfy it. ```ts interface Terminal { env: Record; isTTY: { stdin: boolean; }; } ``` --- # roundel/theme > Every export of roundel/theme, with its signature and doc comment: rgb256, toOklab, audit, fly, plus 3 types. Source: https://roundel.interlace.tools/docs/api/theme The theme (R4, R5): one map from token to style, flown once for the whole program. A style is either a list of `util.styleText` format names — the user's own terminal palette, never checked or claimed — or a `#rrggbb`, which is truecolor at level 3 and falls back to the nearest of 256 or 16 colours below it. Every hex token is checked against the declared ground at 4.5:1 before it is flown — **and so is the 256-colour entry it degrades to**, at every level, so a theme that would not read on somebody's 256-colour terminal fails in CI on a truecolor one. Level 1 is not checked and cannot be: the basic sixteen are the user's own theme, and a ratio over them would be invented. ```ts import { rgb256, toOklab, audit, … } from 'roundel/theme'; ``` ## Functions ### audit Every token's verdict, as data — **without throwing.** `fly()` refuses a theme that does not read, which is right at startup and useless while you are choosing colours: a caller who wants to *know* should not have to catch an exception and parse its message. So the judgement lives here and `fly()` is a filter over it, which also means the refusal and the report can never disagree about what passes. Two rows per hex token — `truecolor` and `256` — because those are the two colours a terminal can actually be sent, and they are not the same colour. **No row for 16**: those values are the user's own terminal theme, so there is no ratio to report and a number there would be invented. A token given format names rather than a hex gets no row either, for the same reason: `['bold', 'red']` is the terminal's red. ```ts function audit(theme?: Theme): ThemeFinding[]; ``` | Parameter | Type | | :-- | :-- | | `theme` (optional) | `Theme` | **Returns** `ThemeFinding[]` ### fly Fly the theme: decide the colour level from the runtime once, check every hex token against the ground, and set what the tokens paint from now on. Call it at startup, with `{ json }` when the run was asked for `--json`; a later call replaces the theme. ```ts function fly(theme: Theme, rt: Runtime, opts?: ModeOptions): void; ``` | Parameter | Type | | :-- | :-- | | `theme` | `Theme` | | `rt` | `Runtime` | | `opts` (optional) | `ModeOptions` | **Returns** `void` ### rgb256 The sRGB of a 256-palette index, which is `ansi256` run backwards. Defined for 16–255 only, and that is the whole reason this check is possible: **entries 0–15 are the user's terminal theme and entries 16–255 are not.** Exported because it is the only honest way to ask "what will the terminal actually paint", which is a question a caller checking its own theme has as much right to ask as `fly()` does. The 6×6×6 cube and the 24-step grey ramp are the xterm values every terminal ships and none of them themes, so a contrast number over them is measured rather than invented — which is exactly what `contrast.ts` says cannot be done for the basic sixteen. `ansi256` never returns below 16, so every paint it produces is knowable. ```ts function rgb256(index: number): Hex; ``` | Parameter | Type | | :-- | :-- | | `index` | `number` | **Returns** `Hex` ### toOklab sRGB 0–255 to OKLab. Björn Ottosson's matrices, written as straight-line arithmetic. The first draft used `map`/`reduce` over two 9-element matrices, which read better and cost **2,766 bytes** — `./theme` grew 44% against a 6,300-byte budget, on a package whose whole claim is that each subpath is at or under the incumbent it replaces. Eighteen multiplies written out are a third of that. The matrices are not data anyone configures, so making them data bought nothing and charged for it. Exported so the coefficients are pinnable. They were not, at first: nudging the first one from 0.4122214708 to 0.4022214708 left all 253 tests green, because everything downstream computed distance with the *same* corrupted matrix and the palette is coarse enough to absorb the error. A transform can only be checked against numbers from outside it, so `theme.test.ts` holds it to the five published reference values. ```ts function toOklab(r: number, g: number, b: number): [number, number, number]; ``` | Parameter | Type | | :-- | :-- | | `r` | `number` | | `g` | `number` | | `b` | `number` | **Returns** `[number, number, number]` ## Types ### Hex ```ts type Hex = `#${string}`; ``` ### Style ```ts type Style = Hex | readonly Format[]; ``` ### Theme A theme: a style per token, the ground the hex ones are checked against, and which WCAG level to hold them to. `conformance` is the knob a team turns when AA is not enough — low vision, a projector, a policy that says AAA. It raises the floor for **both** jobs at once: the check that refuses a theme, and the search that picks the 256-colour substitute. Those two have to agree, or a caller asking for AAA gets a verdict at one standard and a colour chosen at another. ```ts type Theme = Partial> & { ground?: Hex; conformance?: Conformance; }; ``` --- # roundel/tokens > Every export of roundel/tokens, with its signature and doc comment: painted, sgr, UNPAINTED, painter, error, warn and 7 more, plus 3 types. Source: https://roundel.interlace.tools/docs/api/tokens ```ts import { painted, sgr, UNPAINTED, … } from 'roundel/tokens'; ``` ## Functions ### painted `s` through a painter. No escape inside skips the re-open pass, no line break the line pass — chalk's own shortcut. ```ts function painted({ open, close, back }: Painter, s: string): string; ``` | Parameter | Type | | :-- | :-- | | `{ open, close, back }` | `Painter` | | `s` | `string` | **Returns** `string` ## Constants ### command ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### error ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### flag ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### heading ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### hint ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### muted ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### ok ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### painter `at` with one more pair inside it. ```ts const painter: (at: Painter, p: SgrPair) => Painter; ``` ### sgr Wrap `s` in a chain of SGR pairs, outermost first, as chalk does: a close already inside `s` is followed by a re-open so a nested style survives it, and every line break closes before it and re-opens after (chalk/chalk#92). The façade computes parameters; the escape itself is emitted here and nowhere else (R3, R6). ```ts const sgr: (chain: readonly SgrPair[], s: string) => string; ``` ### UNPAINTED No pairs: `painted` hands a string back as it came. ```ts const UNPAINTED: Painter; ``` ### value ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ### warn ```ts const error: Token, warn: Token, ok: Token, hint: Token, muted: Token, command: Token, flag: Token, value: Token, heading: Token; ``` ## Interfaces ### Painter A chain's escapes, built one pair at a time as a chalk builder grows its chain: what opens it, what closes it, and each pair's close and re-open, innermost first. A builder keeps its painter, so a styled string costs one pass over the text rather than rebuilding every escape in the chain on every call — which was most of what it cost (B5). ```ts interface Painter { open: string; close: string; back: readonly (readonly [string, string])[]; } ``` ### SgrPair One SGR pair as the chalk façade composes them: the parameters that open and close it, no escape. ```ts interface SgrPair { readonly open: string; readonly close: string; } ``` ## Types ### Token ```ts type Token = (s: string) => string; ``` --- # Changelog > Every release of roundel, newest first, from its CHANGELOG.md — what changed and the pull request it came from. Source: https://roundel.interlace.tools/docs/changelog ## 0.6.2 ### Patch Changes - [#783](https://github.com/ofri-peretz/burgee/pull/783) [`08976ae`](https://github.com/ofri-peretz/burgee/commit/08976ae734f0494720e0dce06dd850bf7177766f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `ctx.interactive` is roundel's `interactive()`, the family's one rule for whether a person may be asked: a terminal on **stdin**, no `CI`, and no detected agent, with `FORCE_TTY=1` over all three. caique's prompts already ask it, so a handler and a prompt now agree about the same shell. It used to read stdout and ignore `CI`. Three things change for a handler that reads it: - Under a non-empty `CI` it is `false`, even on a pseudo-terminal. - With stdin piped it is `false`, even when stdout is a terminal. - With stdout piped and stdin a terminal (`tool deploy | tee log`) it is `true`, because an answer can still be typed. burgee loads `roundel/terminal` in a chunk of its own (`ctx.js`, 330 B), only on the path that runs a handler. Help, `--version`, `--schema`, `--mcp` and failures never load it. The root entry shrinks (35,629 → 35,437 B on disk), and so does the bundled initial load of `import { run } from 'burgee'` (24,297 → 24,278 B). `ctx.agent`, `detectAgent`, `AGENT_PROBES` and help's colour, which reads stdout, are unchanged. `runBurgee` forwards `tty` onto stdin too, so `tty: true` is still a terminal a person can answer on. The agent variables stay at five (`AI_AGENT`, `CLAUDECODE`, `CURSOR_AGENT`, `CODEX_THREAD_ID`, `GEMINI_CLI`). One joins only when it uniquely identifies an agent, so `CURSOR_TRACE_ID`, which Cursor sets in every integrated terminal, never will. roundel's `AGENTS` documents that rule, and a test pins that a person in Cursor is asked. ## 0.6.1 ### Patch Changes - [#770](https://github.com/ofri-peretz/burgee/pull/770) [`6195b99`](https://github.com/ofri-peretz/burgee/commit/6195b99344a21b4a05ab100fc38358deab229ce8) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `roundel/chalk` is faster. - A builder keeps the escapes it opens and closes with, built one pair at a time as its chain grows, instead of rebuilding them on every call. - It reads its links as kept properties rather than through a Proxy trap on every access, and it skips the re-open and line-break passes a string doesn't need. - In B5, ours ÷ chalk on the 10k-string workload went from 3.55× to 1.17×. - `roundel/tokens` exports the pieces: `painter(at, pair)`, `painted(painter, text)` and `UNPAINTED`. `sgr(chain, text)` is unchanged. - `./chalk` stays under chalk 6.0.0's own source size (9,361 of 9,370 B). ## 0.6.0 ### Minor Changes - [#760](https://github.com/ofri-peretz/burgee/pull/760) [`a115799`](https://github.com/ofri-peretz/burgee/commit/a1157991a5defddadfbea49ba8ea3bf161d4a832) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `roundel/import`: `fromBase16(scheme)` and `fromITerm(plist)` read a Base16 scheme (its YAML or JSON text, or the object a reader made of it) or an iTerm2 `.itermcolors` file into the theme `fly()` takes. `error`, `warn`, `ok`, `flag` and `value` take the ANSI hue each one's default names, and `ground` the background; the mapping is exported as `BASE16_SLOTS` and `ITERM_SLOTS`. The theme is checked by `audit()` before it is returned, so a scheme that does not read on its own background is refused with an `ImportError` naming the slot, and whatever an importer returns, `fly()` accepts. Every refusal carries a `code` (`E_IMPORT_FORMAT`, `E_IMPORT_SLOT`, `E_IMPORT_CONTRAST`) and a `fix`. No network and no bundled corpus: the file is yours to supply. Its own subpath, not re-exported from `roundel`. `roundel/chalk` is now graded by chalk 6.0.1's own suite: 59 / 59, level with chalk itself. ## 0.5.6 ### Patch Changes - [#752](https://github.com/ofri-peretz/burgee/pull/752) [`22e6dae`](https://github.com/ofri-peretz/burgee/commit/22e6dae1b71bfa478265829a1b98d20a6f9de4f7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - A plugin that declares `contract: 0` or a negative contract is now refused with `E_PLUGIN_CONTRACT`, as `schema.json`'s minimum of 1 always said. These hosts checked only that a contract was not newer than the one they know, so 0 and below registered. ## 0.5.5 ### Patch Changes - [#674](https://github.com/ofri-peretz/burgee/pull/674) [`e9f45d8`](https://github.com/ofri-peretz/burgee/commit/e9f45d85d9db5b2e3dcaa1e43f292a1281a6952a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - README: family header, badges, install, migrating, the family table. Every package README now opens the same way — lockup, tagline, one badge row in one order (npm version, downloads, Quality Gate, the package's own coverage, OpenSSF Scorecard, unpacked size, dependencies, types, Node, licence, npm provenance), a row of compatibility badges read from the graded baseline — and carries the same sections in the same order: Install for npm, pnpm, yarn and bun, Quick start, Migrating as a before/after diff, Compatibility, Benchmarks, For agents, API, and a generated table of the nine packages. Links are absolute, so they work on npm as well as GitHub. - [#710](https://github.com/ofri-peretz/burgee/pull/710) [`620fc74`](https://github.com/ofri-peretz/burgee/commit/620fc74af013834ca6b15faa2772aeb94c5013b1) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Three code paths no input could reach are removed; behaviour is unchanged. `roundel check` no longer carries a "(replaces …)" suffix it could never print, because it loads one plugin into an emptied registry. The 16-colour fallback no longer carries a bright-black branch or a lookup default, because black returns before the bright form is built and three bits index all eight names. - [#678](https://github.com/ofri-peretz/burgee/pull/678) [`088cecc`](https://github.com/ofri-peretz/burgee/commit/088ceccb7dda1cbe878950c631f49f48980dd2e2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Whether anybody is there, whether to colour, and whether a tick can be drawn are roundel's questions, and three packages answered them by hand. - `roundel/terminal` (new subpath, 878 B, reaching nothing): `interactive(rt)` — a terminal on stdin, no `CI`, and no agent variable (`CLAUDECODE`, `AI_AGENT`, `CURSOR_AGENT`, `CODEX_THREAD_ID`, `GEMINI_CLI`, exported as `AGENTS`), with `FORCE_TTY=1` as the override — and `unicode(rt)`, is-unicode-supported 2.1.0's table over `{ env, platform }`. - `caique/decide` now depends on `roundel` and asks `interactive()`. **Behaviour change:** under an agent that has a terminal — `CLAUDECODE=1` and a TTY on stdin — a missing required value is refused with a usage error naming the flag (`--x is required when nobody is there to answer`) instead of prompting and hanging the agent. `FORCE_TTY=1` now prompts even without a terminal on stdin, as it does for burgee. - `caique/inquirer`'s tick and `flagstaff/ora`'s log symbols and spinner fallback use roundel's `unicode()`. caique's copy was a four-condition subset: the Linux console (`TERM=linux`) now gets `√` rather than `✔`, and ConEmu/Cmder, Terminus, Alacritty, rxvt-unicode and JetBrains' terminal on Windows now get `✔`, as `figures` draws them. - `burgee` help colour is roundel's `colorLevel(rt) > 0`. **Behaviour changes:** `NO_COLOR` now beats `FORCE_COLOR`; `--no-color` and `--color=…` on the command line are honoured; `CLI_ACCESSIBLE` turns help colour off; and a terminal that sets no `TERM` (Windows' conhost) gets plain help unless `FORCE_COLOR`, `--color` or `COLORTERM` asks for colour. - `burgee/contrast` rounds with roundel's `round2`; no output changes. - `paratext`: the supports-color fork behind `paratext/terminal-link` is unchanged, and now held to roundel's policy by a parity test everywhere their two incumbents agree. ## 0.5.4 ### Patch Changes - [#627](https://github.com/ofri-peretz/burgee/pull/627) [`4a629a9`](https://github.com/ofri-peretz/burgee/commit/4a629a9ff1129f2ce6f0c34e7cfc1d159304d18a) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Lint with every published Interlace ESLint plugin, and fix what the upgrade surfaced. - caique: the inquirer theme merge skips `__proto__`, `constructor` and `prototype` keys, so a theme object cannot swap the merged object's prototype. - burgee: last-element reads use `.at(-1)`. - burgee, closeout, flagstaff, roundel: helpers that capture nothing from their enclosing function move to module scope. - seniority: suppression comments name the `no-dynamic-require` rule that now reports the config loader's dynamic `require`. No public API or output changes. ## 0.5.3 ### Patch Changes - [#604](https://github.com/ofri-peretz/burgee/pull/604) [`0e7b1e8`](https://github.com/ofri-peretz/burgee/commit/0e7b1e88a7021350cf689f109c7728f799be1589) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README links to its migration guides under the docs link: "Migrating from: chalk", "ora · log-update · boxen · cli-table3", and so on. That puts a path from the npm page to the guide for the library you are replacing. No code changes. ## 0.5.2 ### Patch Changes - [#474](https://github.com/ofri-peretz/burgee/pull/474) [`1955419`](https://github.com/ofri-peretz/burgee/commit/19554194342b55f8893161f894a8c2a4df1b0f21) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - burgee plugins can hook two more stages. `parse` runs before the command is resolved: it receives argv and may return a replacement, which is how an alias plugin maps `d` to `deploy`. `shutdown` runs once as the program exits, whether the command succeeded or failed. The family `schema.json` shipped in every package now describes both stages. - [#519](https://github.com/ofri-peretz/burgee/pull/519) [`77ff1cb`](https://github.com/ofri-peretz/burgee/commit/77ff1cb8e595d32bb8bddf441ae21fc61e6f247d) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - The drop-ins now export their incumbents' type names, so a TypeScript program migrates by its import alone: `roundel/chalk` gains chalk's `Color`, `ForegroundColor`, `BackgroundColor`, `Modifiers` and `Options`; `flagstaff/ora` gains `Spinner`, `PrefixTextGenerator` and `SuffixTextGenerator`; `flagstaff/boxen` gains `Options`, `CustomBorderStyle` and `Boxes`; `flagstaff/log-update`, `linegauge`, `linegauge/wrap` and `closeout/exit-hook` gain `Options`; `burgee/yargs/parser` gains `Arguments`, `Options` and `Configuration`. Types only — no runtime bytes. ## 0.5.1 ### Patch Changes - [#508](https://github.com/ofri-peretz/burgee/pull/508) [`1aae1e2`](https://github.com/ofri-peretz/burgee/commit/1aae1e2186ce88421067df5317795773419e53d0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - ` --help` and `--version` answer instead of crashing. The bin took its first argument as the plugin file to import, so `roundel --help` failed with `Cannot find module '…/--help'` and exit 1. `-h`/`--help` now print usage and exit 0, `-V`/`--version` print the version and exit 0, and any other flag where the plugin file belongs is a usage error, exit 2. ## 0.5.0 ### Minor Changes - [#507](https://github.com/ofri-peretz/burgee/pull/507) [`b8e97dc`](https://github.com/ofri-peretz/burgee/commit/b8e97dcb64772e413f0b6f9e17e063c73314d242) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Runs on Node 20 and 22, not just 24+: `engines.node` is now `^20.19.0 || >=22.13.0`. Those are the first releases where `require(esm)` loads without a warning, so the CommonJS `require()` path keeps working. Every package's test suite runs on exactly 20.19.0 and 22.13.0, on Linux, macOS and Windows. caique's prompts no longer call `Promise.withResolvers`, which Node 20 doesn't have. ## 0.4.3 ### Patch Changes - [#494](https://github.com/ofri-peretz/burgee/pull/494) [`f7f6d4b`](https://github.com/ofri-peretz/burgee/commit/f7f6d4b8e8f9d9c7010bd4c81fda4b4d106fc9f0) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each package's `homepage` and README docs link now point at its own documentation site, `https://.interlace.tools`, instead of a page on burgee's site. The old `burgee.interlace.tools/docs/packages/` URLs answer with a 301 to the new host, so nothing already linked breaks. closeout's README override example also resolves to the current release again (`npm:closeout@^0.4`; the 0.4.0 release left it at `^0.3`). ## 0.4.2 ### Patch Changes - [#465](https://github.com/ofri-peretz/burgee/pull/465) [`acf98f3`](https://github.com/ofri-peretz/burgee/commit/acf98f3e612c6d79e6c2b78a847abcd06a063cbc) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Each README now opens with the incumbent it replaces and the agent surface it serves (`--json`, an agent event, or a static projection), so npm shows both above the fold. README text only; no code changed. ## 0.4.1 ### Patch Changes - [#454](https://github.com/ofri-peretz/burgee/pull/454) [`b4584e7`](https://github.com/ofri-peretz/burgee/commit/b4584e719bc0064b294aab5ea6da1c11f698f0e9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package's npm `homepage` now points at its page on the docs site, `https://burgee.interlace.tools/docs/packages/`, and each README links it under the header. The keywords add what people and models search for: `burgee` gains `cli-framework`, `argument-parser`, `subcommands`, `json-schema`, `mcp-server`, `model-context-protocol`, `ai-agent`, `llm`, `shell-completion`, `typescript`, `zero-dependency`, `commander-alternative` and `yargs-alternative`; the other eight gain `agent`, `ai-agent`, `non-tty`, `json` and `zero-dependency` where the package does that — `zero-dependency` only on the six that install nothing at all. `burgee`'s README gains a short FAQ (commander alternative, agent use, MCP, dependencies) and states the compatibility counts the oracle holds — 1,360 / 1,360 of commander's tests and 804 / 804 of yargs' — where it had said 1,215 and 1,185. `caique`'s README no longer calls a released package pre-release. - [#442](https://github.com/ofri-peretz/burgee/pull/442) [`bdaf364`](https://github.com/ofri-peretz/burgee/commit/bdaf364f81564c1700cf1adec18f927afe6c60c9) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now lists `plugin`, `plugins` and `extensible` in its npm keywords, because every package takes plugins through one shared contract. A plugin is a plain object, validated against the `schema.json` that ships in every package, and checked with the package's own `check` command. Each package reads its own key and ignores the rest, so one object can extend any subset of the family. The [plugins page](https://github.com/ofri-peretz/burgee/blob/main/apps/docs/content/docs/plugins.mdx) has a nine-layer example that every package's `check` accepts in CI. - [#435](https://github.com/ofri-peretz/burgee/pull/435) [`7888524`](https://github.com/ofri-peretz/burgee/commit/78885245eb292cd4a40541fe09382a198c9c45cf) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` now describes every plugin host in the family. The one schema each package ships as its plugin contract used to cover only four hosts: roundel's `tokens`, flagstaff's `glyphs`, `spinners`, `borders` and `components`, paratext's `capabilities`, and linegauge's `widths`. Five hosts validated their keys in their own code, but the file an author (or a model) writes against said nothing about them. It now describes all of them: - bellpull `resolvers`, including the absolute-path rule on `paths` - caique `widgets` - closeout `handlers`, including the phases a plugin may use - seniority `sources`, including the rank bounds - burgee `commands`, `hooks` and `enforce` Where the schema can express a rule, it gives the same verdict as the host's own validator, and a test holds the two together. Function-valued fields (`static`, `run`, `read`, `handler`) are described and required, but not typed, because JSON Schema can't say "function". **flagstaff** now validates a plugin against only its own keys, not the whole family schema. It no longer refuses a plugin over another host's key, which lets one plugin object contribute to several hosts. Its entry points are also 4.7–5.9 KB lighter for it. - [#445](https://github.com/ofri-peretz/burgee/pull/445) [`dac303e`](https://github.com/ofri-peretz/burgee/commit/dac303e944e889ac4175ac38c94e4ca0f0ca5358) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every package now declares `sideEffects` truthfully, so bundlers can drop what you don't import. Six packages declared nothing, so no bundler could drop any of their modules. A named import from the root now bundles to the same bytes as the same import from its subpath: | import | before | after | | :------------------------------------ | ------: | ------: | | `import { explain } from 'seniority'` | 2,939 B | 1,067 B | | `import { decide } from 'caique'` | 1,235 B | 734 B | | `import { strip } from 'linegauge'` | 1,102 B | 940 B | | `import { once } from 'closeout'` | 353 B | 235 B | flagstaff and roundel used to declare `false`, but each ships a `check` command whose file runs when loaded. Each now lists that file, which is the true statement. paratext also lists the two modules that register its built-in capabilities when they load. ## 0.4.0 ### Minor Changes - [#421](https://github.com/ofri-peretz/burgee/pull/421) [`db3c59e`](https://github.com/ofri-peretz/burgee/commit/db3c59e3dcd373c7e6e4a057715adb173766523f) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Every plugin host has a `check` command. ```bash npx linegauge check ./my-widths.mjs npx burgee check ./my-plugin.mjs --json ``` PRINCIPLES 7 asks three things of an extension surface: the plugin is data validated against one published schema, there is a **`check` command that shows it every way it can be seen**, and the bar is measured. The first was built in all nine hosts; the second existed in `flagstaff` alone. So an author writing a plugin for any other host found out what it did by shipping it into a program — and a surface nobody can check is a surface nobody outside this repository can write against. Each command validates, registers, and shows what the host does with the plugin, in the host's own terms: linegauge measures each code point **before and after** the override, paratext shows a capability's `encode` **and** its `fallback`, roundel each token and what it replaced, caique each widget's static projection rendered with its own sample. burgee's returns a **document** rather than printing one, so `burgee check --json` is the form an agent that just wrote a plugin reads. They share one contract with the author, held identically across all nine: - a readable report, contribution by contribution, with **`ok` as the last line**; - a refusal with a code from the family's vocabulary and a `fix`, exit 1; - **`E_NO_CONTRIBUTION`** for a plugin that contributes nothing to this host — the schema allows unknown keys so one object registers everywhere, which makes a misspelled key silent, and this is how that typo tells on itself; - exit 2 with no file. Each host also gains an eval case measuring the one-turn claim, proved to discriminate before it was committed: green against a correct plugin, red against the same plugin with one field broken. ### Patch Changes - [#430](https://github.com/ofri-peretz/burgee/pull/430) [`4d1b2b3`](https://github.com/ofri-peretz/burgee/commit/4d1b2b399cff354864d1e2e843a19fde80ef1f30) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `check` now reports every refusal with its code and its fix, wherever it was raised. Some plugin files register themselves on import: they call `register()` at the top of the module and export the result. Until now, when such a file was refused, the error was thrown inside `check`'s `import()`, before the only `try` that turns a `PluginError` into `E_PLUGIN_SCHEMA: …` plus a `fix:` line. The author got the bare message on stderr, with no code and no fix. Now the whole of `check` runs inside that one handler, so every refusal comes out the same way on every host. ## 0.3.1 ### Patch Changes - [#326](https://github.com/ofri-peretz/burgee/pull/326) [`88f7ba6`](https://github.com/ofri-peretz/burgee/commit/88f7ba65e3a79ed20bf7c5bc4feae8b87684122b) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - One `Runtime` seam per package, and one file in each that names the process (Y9). `roundel/src/runtime.ts` and `flagstaff/src/runtime.ts` each declare a `Runtime` — the slice of the world that package actually needs — and a `processRuntime()` that is the only place the real process is named. Six files stop naming it: `roundel/chalk`, and flagstaff's `cli`, `ora`, `boxen`, `cursor` and `log-update`. Nothing about the ports' behaviour moved, and the shape of each seam is what holds that. roundel's returns a literal, because chalk's contract is to detect the terminal once at import; flagstaff's hands back the live process narrowed to the interface, because its incumbents read the process at call time — boxen takes `stdout.columns` every time a box is drawn, so a box drawn after a resize still uses the new width, and ora still hooks the real stream objects and still looks up `kill` when it re-signals a swallowed Ctrl+C. The compatibility rows are unchanged: chalk 58/58, ora 99/99, log-update 99/99, boxen 84/84, restore-cursor 6/6. - [#294](https://github.com/ofri-peretz/burgee/pull/294) [`3f92a60`](https://github.com/ofri-peretz/burgee/commit/3f92a6099b1b8d5d476c64405ca963d40bf9af45) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - paratext validates against the family plugin schema, with its shape under `capabilities`. paratext shipped its own `schema.json` whose root _was_ one capability, so the family had three plugin schemas where the contract says one (PRINCIPLES 14, `plugin-contract` R2). The capability shape is now `$defs/capability` of the shared file, reached through a `capabilities` key beside `spinners`, `tokens` and `components`, and `packages/*/src/schema.json` hashes to one value. flagstaff and roundel ship the same bytes: their published `./schema.json` gains the capability definitions and nothing about what they validate changes. `check()` follows the schema's `$defs/capabilityDocument` and takes either shape: - a plugin carrying its capabilities under `capabilities`, which is where they live from now on, and whose problems are reported at `capabilities.`; - **deprecated** — one capability written as the whole document, which is what a 0.2 capability file looks like. It still validates, and `check()` returns a `deprecated:` line saying to move it under `capabilities`. paratext 1.0 stops accepting it (`.sdlc/PLAN.md` D2). `refusals()` and `isDeprecation()` are exported to tell the two kinds of line apart; the lines that are not deprecations are the ones that block, and the ones `register()` throws on. `register(capability)` is unchanged: it takes one capability, not a document, so it neither reports nor accepts the document-level deprecation. - [#339](https://github.com/ofri-peretz/burgee/pull/339) [`f295630`](https://github.com/ofri-peretz/burgee/commit/f2956301d5f9dcbcac0b001b00ebaf0315891fac) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `schema.json` constrains token names, because it was promising something no host honours. `tokens` was described as any name to a `#rrggbb` colour. `roundel`'s `validate()` accepts ten semantic names — `error`, `warn`, `ok`, `hint`, `muted`, `command`, `flag`, `value`, `heading`, `ground` — and throws on everything else. So a plugin author doing exactly what their own `E_PLUGIN_SCHEMA` error tells them, comparing their object against `roundel/schema.json`, got a green from the schema and `"accent" is not a token` from `register()`. Measured 2026-09-16 with `{ accent: '[#336699](https://github.com/ofri-peretz/burgee/issues/336699)' }`. The schema now carries `propertyNames.enum`, and `scripts/plugin-contract-lock.test.ts` pins the enum and the runtime set to each other from both sides, so neither can grow a name the other does not know. Every host ships a byte-identical copy of this file (`plugin-schema-lock.test.ts` asserts it), which is why nine packages are listed. Only the key `roundel` owns is constrained: describing `widgets`, `handlers`, `sources`, `resolvers` or `commands` in a file all eight hosts share is what made _flagstaff_ start validating caique's key last time (`PluginError: plugin.widgets.later: expected object, got boolean`), and those stay in `plugin-schema-lock`'s `UNDESCRIBED` list with that reason. `linegauge` is in the list for a different change: `ceilings.json`'s R9 block now records the bar as D1's tree-inclusive ceiling — 83,538 against 170,342, a ratio of 0.4904 — and keeps the superseded `get-east-asian-width` bar beside it with the count of entries that cleared it. ## 0.3.0 ### Minor Changes - [#218](https://github.com/ofri-peretz/burgee/pull/218) [`214f6f8`](https://github.com/ofri-peretz/burgee/commit/214f6f83b16068d7dc53d79799fba03c26a3cbe2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `audit()` and `reportTheme()` — ask whether your colouring meets WCAG AA and get rows back instead of an exception. Two rows per hex token, `truecolor` and `256`, because those are the two colours a terminal can be sent; none for 16, whose values are the user's own theme. `fly()` is now a filter over `audit()`, so the refusal and the report cannot disagree. - [#218](https://github.com/ofri-peretz/burgee/pull/218) [`214f6f8`](https://github.com/ofri-peretz/burgee/commit/214f6f83b16068d7dc53d79799fba03c26a3cbe2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Colour correctness, in three parts. The contrast check now covers the 256-colour entry the terminal actually receives, not just the hex an author wrote — 167 hexes in an sRGB sweep read at truecolor and failed at 256. That substitution is chosen by nearest-in-OKLab **among entries that clear the floor**, which is perceptually closer than per-channel rounding and readable by construction rather than by luck. And `Theme.conformance` takes `'AA'` (default) or `'AAA'`, raising the floor for the check and the search together. ## 0.2.0 ### Minor Changes - [#78](https://github.com/ofri-peretz/burgee/pull/78) [`7c5eeb0`](https://github.com/ofri-peretz/burgee/commit/7c5eeb0c04a9a692db748ea7f3ccb2f3339fa5a2) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - Add `roundel/plugin`: a plugin ships a theme, and roundel reads it. `tokens` has been in the plugin schema all along, described as "a roundel theme", with nothing to read it — a plugin that shipped one was validated and then ignored. `register()` now collects them, `theme()` hands the result to `fly()`, and `contributions()` reports which plugin won each token and which it shadowed. The same plugin object works on any subset of the family: keys roundel does not understand — `glyphs`, `spinners`, `components` — are ignored, not refused. A misspelt token name _is_ refused, naming the ten valid ones, because a silently dropped `errror` looks like it worked. Registering does not fly the theme; the program still calls `fly()` once, and a plugin token below 4.5:1 throws there exactly as a hand-written one does. Nothing is imported from flagstaff — the plugin shape is declared structurally, so no package in the family requires another. The subpath reaches no module at all: 2,812 B, most of it refusal messages. ### Patch Changes - [#82](https://github.com/ofri-peretz/burgee/pull/82) [`61bd11b`](https://github.com/ofri-peretz/burgee/commit/61bd11b9a1bf1fe73dd5a6e76e0898614e988ec7) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - One door into the registry, and a `check` that grades what it actually found. `registered()` handed out the live registry behind a `Readonly` type that freezes property bindings, not the `Map`s behind them — so `registered().spinners.set(…)` put a spinner with no static projection where `spinner()` would find it, and `.clear()` removed the built-ins. It now returns a copy over the frozen objects `register()` stores, which makes U3's "a contribution without a static projection is refused at the door" a property of the code rather than advice. Registering also copies: edit your plugin object afterwards and the registry does not change. `flagstaff check` opens with a census of the contributions it found and closes with the verdict, so `ok` is never printed before the rendering that would justify it. A plugin whose keys are misspelled — the schema allows unknown keys on purpose, for the rest of the family — is now `E_NO_CONTRIBUTION` and exit 1 with the unknown keys named, rather than `ok` and exit 0. Each component block states the state it was rendered with: a component may declare `sample: { running, done }`, and without one the assumed `{ phase }` shape is said out loud instead of silently invented. A `static` that throws is `E_COMPONENT_THREW` with a fix and the modes it broke in, rather than an uncaught crash after an `ok`. roundel is bumped with it because the plugin schema is hosted in both packages and both publish it: `packages/roundel/src/schema.json` gained the same `sample` key, and `plugin-schema-lock.test.ts` requires the two to be byte-identical. Without a roundel release the copies would agree in git and disagree in the registry — the contract's own "byte-identical in every tarball" rule holding in the repository and breaking where anyone would actually read it. This is the first contract change since roundel became a plugin host, so the pairing is worth establishing now rather than after the second one. Both of those codes are now members of the exported `PluginErrorCode`, which is the union every refusal in the family comes from. They were bare string literals inside `cli.ts`, so a second host could have spelled either one its own way and nothing would have noticed — the plugin contract's "one error vocabulary" held only as long as nobody tested it. `refuse()` takes `PluginErrorCode` rather than `string`, and a repo lock reads each host's declaration out of its source and refuses any `E_…` literal that is not in it. The union is a type, so this costs no bytes on any subpath. ## 0.1.0 ### Minor Changes - [#44](https://github.com/ofri-peretz/burgee/pull/44) [`183ebc9`](https://github.com/ofri-peretz/burgee/commit/183ebc90d38c7a23afda1f923c9b147560458334) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - `roundel/chalk`: chalk 6's public API — the chainable builder with every modifier, colour, `Bright` variant, background and underline style, `rgb`/`hex`/`ansi256` (and `bg`/`underline` forms) downsampled as chalk does, the mutable `level`, `new Chalk({ level })`, `chalkStderr`, `supportsColor`, the name lists — over the tokens' one emitter and the policy's level, detected once at import. Graded by chalk's own suite vendored into compat-oracle: **58 / 58**. `roundel/tokens` gains `sgr()`, the SGR emitter the façade composes with. `roundel/policy`: `colorLevel()` now obeys the user's explicit colour instruction in any output mode, not only on a TTY (design R2, revised 2026-09-08). `NO_COLOR` still wins outright; `FORCE_COLOR` names an _exact_ level (`FORCE_COLOR=2` is 2, not "2 or better") or, as `true`/empty, only enables colour and lets `TERM`/`COLORTERM` decide; the `--color` flags are read from a new optional `argv` on the policy's runtime shape and outrank a numeric `FORCE_COLOR`. A pipe nobody asked to colour is still 0 (Azure Pipelines excepted, where chalk excepts it), but a run that _does_ ask now gets its CI vendor's level — so `FORCE_COLOR=true` on GitHub Actions gives truecolor logs. The output mode still decides redraws, and `--json` is still always 0. - [#25](https://github.com/ofri-peretz/burgee/pull/25) [`c65bad8`](https://github.com/ofri-peretz/burgee/commit/c65bad85111fd29a2c5941ea2b3b7d6034dff7ff) Thanks [@ofri-peretz](https://github.com/ofri-peretz)! - First working release: `roundel/policy` (`outputMode`, `colorLevel`), `roundel/tokens` (nine semantic tokens over `util.styleText`), `roundel/theme` (`fly()`, the burgee brand by default, hex → nearest 256/16 fallback) and `roundel/contrast` (the WCAG maths `fly()` refuses a theme with) — zero dependencies, each subpath weighed and isolated by its own lock. --- # Coming from chalk > A chalk alternative with a drop-in path: import chalk from roundel/chalk, graded 59 / 59 by chalk's own test suite — then semantic tokens that go plain on a pipe, under NO_COLOR and under --json. Source: https://roundel.interlace.tools/docs/coming-from/chalk **roundel** is a **chalk alternative** you adopt by changing one import. `roundel/chalk` implements chalk 6's API itself, over roundel's own emitter and output policy, and chalk's own test suite is the grade. ## Migrate from chalk in one import ```diff - import chalk from 'chalk'; + import chalk from 'roundel/chalk'; ``` The chain (`chalk.red.bold.underline(s)`), `rgb` / `hex` / `ansi256`, `chalk.level`, `new Chalk({ level })`, `chalkStderr` and `supportsColor` stay as they are. It is ESM with a `default` condition, so `require('roundel/chalk')` works too. ## Is roundel compatible with chalk? Graded, not claimed. chalk's own suite, vendored at 6.0.1 and unmodified apart from the import specifier, runs against `roundel/chalk` beside a control that runs it against real chalk: | | passing | rate | | :-- | --: | --: | | `roundel/chalk` | 59 / 59 | 100.0% | | chalk itself (control) | 59 / 59 | 100.0% | From [Compatibility](https://burgee.interlace.tools/docs/compatibility), which `npm run compat:page` generates from the oracle's last run; that page is the authority. What differs, by design: the level is per façade (`chalk.level = 0` silences `roundel/chalk` and nothing else), the tagged template literal chalk removed in 5 is not brought back, and chalk's per-terminal-program allow-list is not reproduced — a program on one of those terminals asks for colour with `FORCE_COLOR` or `--color`. ## What you gain over chalk chalk gives you `red`. roundel's tokens give you `error`, `hint`, `command` and `flag`, and one output policy decides — once, for every package in the family — whether they style: - **Agents and pipes get plain text.** Through a pipe nobody asked to colour, under `NO_COLOR`, or with `--json`, every token returns its input unchanged. `--json` is the one output the colour level never enters: structured text carries no escapes. - **One answer for the whole program.** `outputMode()` returns `tty`, `pipe`, `json`, `accessible` or `ci` from the runtime you pass it, so a spinner, a prompt and the help cannot disagree about the terminal the way chalk and ora can. - **A theme that is checked.** `fly()` refuses a hex token below 4.5:1 contrast against the declared ground, at every colour level, so an unreadable theme fails in CI. `roundel/chalk` is the door, and the tokens are where those properties live: move a file at a time, or never. ## When to switch from chalk - Your CLI is run by agents or in CI, and escape codes in captured output are a bug. - You want the colours to mean something (`error`, not `red`) and change together. The full package, its subpaths and its policy are on [roundel](/docs). --- # Compatibility > How roundel/chalk is graded — chalk 6.0.1's own test suite, unedited — the current grade, and the differences that remain. Source: https://roundel.interlace.tools/docs/drop-ins `roundel/chalk` is graded, not described as compatible. It is run against **chalk's own test suite**, by [compat-oracle](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/README.md), in CI. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### Compatibility | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **Passes chalk's own test suite** — `roundel/chalk` is graded by chalk 6.0.1's own tests, unedited, so changing the import keeps chalk's output. | [✓ 59 / 59 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/chalk.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/chalk/test/chalk.js) | The count is compat-oracle's baseline, the pass count the drop-in is held to. The family's [compatibility page](https://burgee.interlace.tools/docs/compatibility) is generated from the oracle's last run and is the authority for the current figure. ## How the suite is graded 1. chalk's repository is cloned at `v6.0.1` and its `test/` directory — eight files, fixtures included — copied into `packages/compat-oracle/vendor/chalk/`. chalk does not ship its tests to npm. The `PROVENANCE` file names the tag, the commit and the command that reproduces it. 2. The only edit is the import that reaches the library: it is rewritten to a shim generated per run. Assertions and fixtures are chalk's, byte for byte. 3. A **control run** points the shim at chalk itself first, which proves the harness and sets the total every rate is measured against. 4. The **target run** points the same shim at `roundel/chalk`. The suite covers the chain, every modifier and colour, `rgb`/`hex`/`ansi256` and their downsampling, nesting and line breaks, `chalk.level`, `new Chalk({ level })`, `chalkStderr`, `visible`, and `FORCE_COLOR` in a child process. ## Known differences - **`NO_COLOR` turns `roundel/chalk` off.** chalk 6.0.1 does not read it; the policy does, and the façade's level comes from the policy. - **A `--color` flag beats an ambient `FORCE_COLOR`.** In chalk, `--no-color` under `FORCE_COLOR=3` still colours; here it does not. `FORCE_COLOR=0` still beats every flag, as in chalk ([Colour levels](/docs/guides/colour-levels#where-it-differs-from-chalk)). - **No terminal allow-list.** chalk's detection of particular terminal programs is not reproduced; such a terminal that reports nothing through `TERM` or `COLORTERM` gets colour by `FORCE_COLOR` or `--color`. - **The level is per façade.** `chalk.level = 0` silences `roundel/chalk` and nothing else. - **No template literal.** chalk removed ``chalk`{red x}` `` in version 5, and it is not brought back. None of these is exercised by chalk's suite, which is why the grade is 58 of 58. ## Types and CommonJS `roundel/chalk` exports chalk 6's names — the default instance, `Chalk`, `chalkStderr`, `supportsColor`, `supportsColorStderr` and the name lists — and its types. It is ESM with a `default` condition, so `require('roundel/chalk')` returns the module whose `default` is the chalk instance. --- # FAQ > Short answers about roundel — why nothing is coloured, NO_COLOR, FORCE_COLOR, --no-color, CI logs, screen readers, contrast, chalk and CommonJS — each with where to read more. Source: https://roundel.interlace.tools/docs/faq ## Why is nothing coloured? Either the program never called `fly()` — tokens are plain until it does — or the policy decided level 0: a pipe nobody asked to colour, `NO_COLOR`, `CLI_ACCESSIBLE`, `--json`, or a terminal that reports no colour through `TERM` or `COLORTERM`. `colorLevel(rt)` tells you which answer it reached ([Colour levels](/docs/guides/colour-levels)). ## How do I get colour in a CI log? Export `FORCE_COLOR=true`. The run gets its CI vendor's level — truecolor on GitHub Actions — and nobody else's pipe changes. `FORCE_COLOR=2` pins it at 256 colours. ## `NO_COLOR` and `FORCE_COLOR` are both set. Which wins? `NO_COLOR`, always. Then `FORCE_COLOR=0`, then a `--color` flag, then any other `FORCE_COLOR`. (Node itself prints a warning when both variables are set.) ## Does `--no-color` work if my shell exports `FORCE_COLOR`? Yes, when the program hands the policy its `argv`: a flag typed for this run beats an ambient `FORCE_COLOR`. chalk does it the other way round, which is one of the differences on [Compatibility](/docs/drop-ins#known-differences). ## What does a screen-reader user get? With `CLI_ACCESSIBLE=1`, mode `accessible` — components write their text form once per state and never redraw — and colour level 0, unless they ask for colour explicitly. ## My terminal supports truecolor, but I get 256 colours. The policy reads `COLORTERM=truecolor` for level 3. chalk also recognises some terminals by name; roundel does not. Set `COLORTERM=truecolor` or pass `--color=16m`. ## Why did `fly()` throw? A hex token in the theme reads below 4.5:1 against the declared `ground`, at truecolor or at the 256-colour colour it becomes. The message names the token and the ratio; `audit()` shows every row ([Themes and contrast](/docs/guides/themes)). ## Can I keep chalk's API? Yes: `import chalk from 'roundel/chalk'`, graded by chalk's own suite. Tokens are the part that adds meaning, and you can move to them a file at a time ([Incremental migration](/docs/recipes/incremental-migration)). ## Can I use it from CommonJS? Yes, on Node 20.19+ and 22.13+: every entry is ESM with a `default` condition, so `require('roundel/tokens')` loads it through `require(esm)`. --- # Getting started > Install roundel, fly a theme once at startup, and see the same tokens in a pipe, under FORCE_COLOR, under --json, in CI and in screen-reader mode. Source: https://roundel.interlace.tools/docs/getting-started roundel is colour with one rule behind it. You write **tokens** — `error`, `hint`, `command` — instead of colour names, you **fly** a theme once at startup with a description of the process, and one **output policy** decides from that description whether anything is coloured, and how much. ## Install ```bash npm install roundel ``` It is ESM with a `default` condition, so `require('roundel/tokens')` also works from CommonJS on Node 20.19+ and 22.13+. It has no dependencies ([`shape.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/shape.test.ts) installs the packed tarball and checks). ## The runtime roundel never reads `process` itself. The policy takes a runtime — the environment, the arguments and whether stdout is a terminal — so a test can pass a literal and a program passes its own process. Every example on this site imports this file: ```js title="rt.mjs" import process from 'node:process'; /** The real process as roundel reads it: the environment, the arguments and whether stdout is a terminal. */ export const rt = { env: process.env, argv: process.argv.slice(2), isTTY: { stdout: process.stdout.isTTY === true }, }; /** `--json` on the command line. */ export const json = process.argv.includes('--json'); ``` `argv` is optional: without it the policy reads no `--color` flags. A CLI built on [burgee](https://burgee.interlace.tools/docs) already has a runtime of this shape. ## A first program ```js title="hello.mjs" import { colorLevel, outputMode } from 'roundel/policy'; import { fly } from 'roundel/theme'; import { command, error, hint } from 'roundel/tokens'; import { json, rt } from './rt.mjs'; fly({}, rt, { json }); console.log(`mode ${outputMode(rt, { json })}, level ${colorLevel(rt, { json })}`); console.log(JSON.stringify(`${error('missing --name')} ${hint('try')} ${command('greet --name ada')}`)); ``` `JSON.stringify` makes the escape codes visible. Piped, with nothing asked, nothing is coloured — a pipe nobody asked to colour is a file or another program's input: ```text title="node hello.mjs" mode pipe, level 0 "missing --name try greet --name ada" ``` Ask for colour and it comes, at the level asked for — the default theme's `error` in truecolor, `hint` dimmed, `command` bold: ```text title="FORCE_COLOR=3 node hello.mjs" mode pipe, level 3 "\u001b[38;2;244;121;74mmissing --name\u001b[39m \u001b[2mtry\u001b[22m \u001b[1mgreet --name ada\u001b[22m" ``` ```text title="node hello.mjs --color=256" mode pipe, level 2 "\u001b[38;5;209mmissing --name\u001b[39m \u001b[2mtry\u001b[22m \u001b[1mgreet --name ada\u001b[22m" ``` Under `--json` nothing is coloured even when forced, because structured output carries no escapes: ```text title="FORCE_COLOR=3 node hello.mjs --json" mode json, level 0 "missing --name try greet --name ada" ``` A CI log and a screen reader each get their own mode, and plain text: ```text title="CI=true node hello.mjs" mode ci, level 0 "missing --name try greet --name ada" ``` ```text title="CLI_ACCESSIBLE=1 node hello.mjs" mode accessible, level 0 "missing --name try greet --name ada" ``` Every output block on this site is checked: `tests/examples.test.ts` writes each titled file, runs the command in the block's title, and compares. ## Two answers | | answers | used for | | :-- | :-- | :-- | | `outputMode(rt, { json })` | `json`, `accessible`, `ci`, `pipe` or `tty` | whether to redraw — spinners, progress, prompts | | `colorLevel(rt, { json })` | `0`, `1`, `2` or `3` | how much colour, if any | The mode never decides the colour level, and the level never decides the mode. The [output policy](/docs/guides/output-policy) guide has both rules in full. ## Where next - [Guides](/docs/guides/output-policy): the output policy and `interactive()`, colour levels, tokens, themes and contrast, plugins. - [Why roundel](/docs/why-roundel): what it does that chalk does not, cell by cell, with the evidence. - [Coming from chalk](/docs/coming-from/chalk): change one import. - [API reference](/docs/api): every export of every entry point. --- # Colour levels > colorLevel() decides 0, 16, 256 or truecolor: NO_COLOR first, then FORCE_COLOR=0, the --color flags and FORCE_COLOR, then what the terminal or the CI vendor reports. Source: https://roundel.interlace.tools/docs/guides/colour-levels `colorLevel(rt, { json })` is chalk's level — `0` none, `1` sixteen colours, `2` 256, `3` truecolor — decided by the rule below, which is chalk's own apart from the places this page names. ## The rule, in order 1. **`--json`** — always 0. Structured output carries no escapes, even when forced. 2. **`NO_COLOR`** — set and not empty: 0, whatever else is set. 3. **`FORCE_COLOR=0`** or **`FORCE_COLOR=false`** — 0. An explicit "off" is settled before any flag is read, so `--color=256` cannot turn it back on. 4. **The `--color` flags**, when the runtime carries `argv`; a flag beats any `FORCE_COLOR` but 0: `--no-color`, `--no-colors`, `--color=false` and `--color=never` are 0; `--color=16m`, `--color=full` and `--color=truecolor` are 3; `--color=256` is 2; `--color` and `--colors` turn colour on and let the terminal decide how much. Flags after `--` are the program's, not these. 5. **`FORCE_COLOR`** — a number is an exact level, clamped to 3 (`FORCE_COLOR=2` is 2, not "2 or better"); `true` or empty turns colour on and lets the terminal decide. 6. **No instruction at all:** a pipe is 0, and so is `CLI_ACCESSIBLE`, because escape codes are noise to a screen reader. Azure Pipelines (`TF_BUILD` and `AGENT_NAME`) is the one pipe chalk colours, and so does this. 7. **Once colour is on**, by a terminal or by an instruction: `TERM=dumb` is the floor; a `CI` run gets its vendor's level — GitHub Actions, Gitea Actions and CircleCI truecolor, Travis, AppVeyor, GitLab, Buildkite, Drone and Codeship 16 colours; otherwise `COLORTERM=truecolor` is 3, a `TERM` ending in `-256color` is 2, and a colour-capable `TERM` is 1. ```js title="levels.mjs" import { colorLevel, outputMode } from 'roundel/policy'; const cases = [ ['a terminal, TERM=xterm-256color', { env: { TERM: 'xterm-256color' }, isTTY: { stdout: true } }], ['a pipe, TERM=xterm-256color', { env: { TERM: 'xterm-256color' }, isTTY: { stdout: false } }], ['a pipe, FORCE_COLOR=true, COLORTERM=truecolor', { env: { FORCE_COLOR: 'true', COLORTERM: 'truecolor' }, isTTY: { stdout: false } }], ['a terminal, NO_COLOR=1, --color=16m', { env: { NO_COLOR: '1' }, argv: ['--color=16m'], isTTY: { stdout: true } }], ['a terminal, FORCE_COLOR=0, --color=256', { env: { FORCE_COLOR: '0' }, argv: ['--color=256'], isTTY: { stdout: true } }], ['a terminal, --no-color', { env: { COLORTERM: 'truecolor' }, argv: ['--no-color'], isTTY: { stdout: true } }], ['GitHub Actions log, FORCE_COLOR=true', { env: { CI: 'true', GITHUB_ACTIONS: 'true', FORCE_COLOR: 'true' }, isTTY: { stdout: false } }], ['a terminal, CLI_ACCESSIBLE=1', { env: { CLI_ACCESSIBLE: '1', COLORTERM: 'truecolor' }, isTTY: { stdout: true } }], ]; for (const [name, rt] of cases) console.log(`${outputMode(rt).padEnd(10)} ${colorLevel(rt)} ${name}`); const forced = { env: { FORCE_COLOR: '3' }, isTTY: { stdout: true } }; console.log(`${outputMode(forced, { json: true }).padEnd(10)} ${colorLevel(forced, { json: true })} --json, even with FORCE_COLOR=3`); ``` ```text title="node levels.mjs" tty 2 a terminal, TERM=xterm-256color pipe 0 a pipe, TERM=xterm-256color pipe 3 a pipe, FORCE_COLOR=true, COLORTERM=truecolor tty 0 a terminal, NO_COLOR=1, --color=16m tty 0 a terminal, FORCE_COLOR=0, --color=256 tty 0 a terminal, --no-color ci 3 GitHub Actions log, FORCE_COLOR=true accessible 0 a terminal, CLI_ACCESSIBLE=1 json 0 --json, even with FORCE_COLOR=3 ``` ## Where it differs from chalk - **`NO_COLOR`.** chalk 6.0.0's colour detection does not read it; here it outranks everything. - **A flag beats an ambient `FORCE_COLOR`.** In chalk a set `FORCE_COLOR` overwrites the flag's answer, so `--no-color` on a machine that exports `FORCE_COLOR=3` still colours. Here the flag typed for this run wins — except that `FORCE_COLOR=0` is an "off", and off always wins. - **`CLI_ACCESSIBLE` and `--json`.** chalk has neither; here each is level 0 without an explicit ask, and `--json` even with one. - **No emulator allow-list.** chalk also recognises particular terminal programs — `TERM_PROGRAM`, kitty, ghostty, wezterm, TeamCity and the Windows build number. This does not: a program on one of those terminals that reports nothing through `TERM` or `COLORTERM` asks for colour with `FORCE_COLOR` or `--color`. - **The level is per façade.** Setting `chalk.level` on `roundel/chalk` changes `roundel/chalk` and nothing else; the tokens keep reading the policy. Apart from the allow-list, a differential sweep of 3,000 random environments per partition against chalk 6.0.0's own colour detection found no divergence except where a `--color` flag and `FORCE_COLOR` are both set, which is the second point above; `policy.test.ts` records it. --- # The output policy > outputMode() answers json, accessible, tty, ci or pipe from a runtime, first match wins — the one rule every package in the family asks before it redraws. Source: https://roundel.interlace.tools/docs/guides/output-policy `roundel/policy` answers two questions about a process, from a description of it you pass in: **which mode** it is in, and **how much colour** it may use. This page is the first; the second is [Colour levels](/docs/guides/colour-levels). ## `outputMode(rt, { json })` | mode | chosen when | what a well-behaved component does | | :-- | :-- | :-- | | `json` | the program passed `{ json: true }` | writes structured events, never escapes | | `accessible` | `CLI_ACCESSIBLE` is set, terminal or not | writes the text form once per state, never redraws | | `tty` | stdout is a terminal | animates and repaints in place | | `ci` | `CI` is set and stdout is not a terminal | writes the text form once per state | | `pipe` | anything else | writes the text form once per state | The first row that matches wins, so a CI job that attaches a terminal is `tty`, and `--json` beats everything. An empty variable is not set — `CI=` and `CLI_ACCESSIBLE=` are the same as leaving them out, the convention `NO_COLOR` established. ```js title="modes.mjs" import { outputMode } from 'roundel/policy'; const terminal = { isTTY: { stdout: true } }; const pipe = { isTTY: { stdout: false } }; console.log(outputMode({ ...terminal, env: {} })); console.log(outputMode({ ...pipe, env: {} })); console.log(outputMode({ ...pipe, env: { CI: 'true' } })); console.log(outputMode({ ...terminal, env: { CI: 'true' } })); console.log(outputMode({ ...terminal, env: { CLI_ACCESSIBLE: '1' } })); console.log(outputMode({ ...terminal, env: { CLI_ACCESSIBLE: '1' } }, { json: true })); console.log(outputMode({ ...pipe, env: { CI: '' } })); ``` ```text title="node modes.mjs" tty pipe ci tty accessible json pipe ``` ## Why one rule A spinner, a prompt and the help text each have to decide whether the terminal is a terminal. When each asks its own way, they disagree: one package animates into a CI log while another has already gone plain. So each package that draws asks roundel rather than the process: flagstaff's frame loop asks `outputMode`, caique's prompts ask `interactive()` (below), and burgee's help asks `colorLevel` — so a program built from them makes each decision once. `--json` is passed in rather than read from `process.argv`, because whether a run asked for structured output is the argument parser's knowledge, not the environment's. ## A pure function The policy reads only the runtime it is handed — `env`, `isTTY.stdout` and, for colour, `argv` — and never `process`. The same input always gives the same answer, so a test passes a literal and a harness driving your CLI can ask the question your program asked. ## Is anybody there to type? `roundel/terminal` answers the question a prompt has to ask before it waits: `interactive(rt)` is true only with a terminal on stdin, no `CI`, and no agent variable (`AI_AGENT`, `CLAUDECODE`, `CURSOR_AGENT`, `CODEX_THREAD_ID`, `GEMINI_CLI`). An agent may well have a terminal; what it does not have is a person. `FORCE_TTY=1` is the one override. ```js title="interactive.mjs" import { interactive } from 'roundel/terminal'; const tty = { stdin: true }; console.log(interactive({ env: {}, isTTY: tty })); console.log(interactive({ env: { CLAUDECODE: '1' }, isTTY: tty })); console.log(interactive({ env: { CI: 'true' }, isTTY: tty })); console.log(interactive({ env: {}, isTTY: { stdin: false } })); console.log(interactive({ env: { CLAUDECODE: '1', FORCE_TTY: '1' }, isTTY: tty })); ``` ```text title="node interactive.mjs" true false false false true ``` caique's prompts ask exactly this before they draw, so a prompt run by an agent is refused, naming the flag to pass, instead of waiting. The same subpath has `unicode(rt)`, whether the terminal can be expected to draw non-ASCII glyphs, which flagstaff and caique read before they draw a tick. ## The mode is not the colour The mode decides **redraws**. It never decides the colour level: a pipe may be coloured when the user says so with `FORCE_COLOR`, and a terminal may be plain under `NO_COLOR`. The one place they touch is `json`, where the level is always 0. [Colour levels](/docs/guides/colour-levels) has the rule. --- # 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. Source: https://roundel.interlace.tools/docs/guides/plugins roundel hosts one plugin key, **`tokens`**. A plugin is the family's one plain object — a `name` and whatever keys the packages it targets read — so a brand plugin can carry roundel's tokens beside flagstaff's spinners, and each package keeps the key it understands and ignores the rest. ## Writing one ```js title="brand.mjs" export default { name: 'acme-brand', tokens: { ground: '#ffffff', error: '#b3261e', ok: '#1b6e3a' }, }; ``` A token in a plugin is a `#rrggbb` colour, and `ground` says what the colours will be read on. ## Registering and flying it `register()` validates the plugin; `theme()` merges everything registered into one theme; and `fly()` flies it, contrast gate and all. Registering never flies: a plugin cannot decide when colour is decided. ```js title="brand-app.mjs" import { contributions, register, theme } from 'roundel/plugin'; import { fly } from 'roundel/theme'; import { ok } from 'roundel/tokens'; import brand from './brand.mjs'; import { rt } from './rt.mjs'; register(brand); register({ name: 'team-override', tokens: { ok: '#0a6b47' } }); fly(theme(), rt); console.log(JSON.stringify(ok('passed'))); for (const { token, value, from, shadowed } of contributions()) { console.log(`${token} ${value} from ${from}${shadowed.length > 0 ? `, over ${shadowed.join(', ')}` : ''}`); } ``` ```text title="FORCE_COLOR=3 node brand-app.mjs" "\u001b[38;2;10;107;71mpassed\u001b[39m" ground #ffffff from acme-brand error #b3261e from acme-brand ok #0a6b47 from team-override, over acme-brand ``` **Later wins**, like flat config, and `contributions()` says who won each token and whom it shadowed, so an override is never invisible. A plugin cannot smuggle an unreadable colour past the gate: `fly()` refuses a plugin's token below 4.5:1 exactly as it refuses a hand-written one. ## Refusals A plugin that would be wrong is refused at `register()` with a code and a fix, and a refused plugin does not half-register: ```js title="refusals.mjs" import { register } from 'roundel/plugin'; for (const plugin of [{ name: 'typo', tokens: { eror: '#b3261e' } }, { name: 'named', tokens: { ok: 'green' } }, { tokens: { ok: '#0a6b47' } }]) { try { register(plugin); } catch (error) { console.log(`${error.code}: ${error.message}`); } } ``` ```text title="node refusals.mjs" E_PLUGIN_SCHEMA: plugin "typo": "eror" is not a token E_PLUGIN_SCHEMA: plugin "named": token "ok" is "green" E_PLUGIN_SCHEMA: a plugin needs a name ``` A misspelt token is refused rather than silently dropped, and a plugin needs a name because a shadowed token has to be attributable. A plugin that declares a newer `contract` than this roundel knows is refused with `E_PLUGIN_CONTRACT` and the upgrade named. ## Checking before it ships `npx roundel check` loads a plugin file without registering it, validates it against the family's plugin schema, and lists what it contributes: ```text title="npx roundel check brand.mjs" acme-brand — 3 tokens ground #ffffff error #b3261e ok #1b6e3a acme-brand: ok ``` ```js title="typo.mjs" export default { name: 'typo', tokens: { eror: '#b3261e' } }; ``` ```text title="npx roundel check typo.mjs" exit="1" E_PLUGIN_SCHEMA: plugin "typo": "eror" is not a token fix: use one of error, warn, ok, hint, muted, command, flag, value, heading, ground ``` `check` is the schema, not the contrast gate. Contrast is judged by `fly()`, against the ground of the theme every registered plugin merges into, so a plugin with an unreadable colour passes `check` and is refused when the theme is flown. To see that verdict first, pass the plugin's tokens to `audit()` ([Themes and contrast](/docs/guides/themes#seeing-the-verdict)). --- # 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. Source: https://roundel.interlace.tools/docs/guides/theme-gallery Every row is a scheme file read by `roundel/import` — `fromBase16` or `fromITerm`, the functions a program calls — and nothing in the table is typed by hand. Each colour is shown with its WCAG ratio on the scheme's own background; a scheme `fly()` would refuse is listed as refused, with the slot that refused it, rather than left out. The gallery is the scheme files in [`packages/roundel/src/__fixtures__/`](https://github.com/ofri-peretz/burgee/tree/main/packages/roundel/src/__fixtures__), which are also what `import.test.ts` pins the importers against. A theme joins it by adding its file; the row is derived. | Scheme | Format | roundel | ground | error | warn | ok | flag | value | | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | :-- | | `dracula.itermcolors` | iTerm2 | flies | `#282a36` | `#ff5555` 4.53:1 | `#f1fa8c` 12.74:1 | `#50fa7b` 10.38:1 | `#8be9fd` 10.29:1 | `#ff79c6` 5.97:1 | | `gruvbox-dark-hard.yaml` | Base16 | flies | `#1d2021` | `#fb4934` 4.77:1 | `#fabd2f` 9.67:1 | `#b8bb26` 7.94:1 | `#8ec07c` 7.79:1 | `#d3869b` 5.98:1 | | `tomorrow-night.yaml` | Base16 | refused — error (base08) below 4.5:1 | `#1d1f21` | `#cc6666` 4.46:1 | `#f0c674` 10.26:1 | `#b5bd68` 8.22:1 | `#8abeb7` 7.97:1 | `#b294bb` 6.18:1 | --- # 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. Source: https://roundel.interlace.tools/docs/guides/themes `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. ```js title="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')])); ``` ```text title="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"] ``` ```text title="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: ```js title="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); } ``` ```text title="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: ```js title="report.mjs" import { reportTheme } from 'roundel/contrast'; import { audit } from 'roundel/theme'; console.log(reportTheme(audit({ ground: '#ffffff', error: '#b3261e', warn: '#ffd400' }))); ``` ```text title="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: ```js 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](/docs/guides/theme-gallery) is every scheme file the package is tested against, read this way; the [API reference](/docs/api/import) 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`. ```js title="ratio.mjs" import { AA, contrast } from 'roundel/contrast'; console.log(contrast('#767676', '#ffffff').toFixed(2)); console.log(JSON.stringify(AA)); ``` ```text title="node ratio.mjs" 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. Source: https://roundel.interlace.tools/docs/guides/tokens `roundel/tokens` exports nine functions, each `(s: string) => string`: | token | for | default style | | :-- | :-- | :-- | | `error` | what failed | the brand's rock orange, as a hex checked for contrast | | `warn` | what might | `yellow` | | `ok` | what worked | the brand's juniper green, as a hex checked for contrast | | `hint` | what to try next | `dim` | | `muted` | what matters least | `gray` | | `command` | a command to run | `bold` | | `flag` | a flag | `cyan` | | `value` | a value the user gave | `magenta` | | `heading` | a section title | `bold`, `underline` | The names say what the text **is**. What it looks like is the theme's business, so every `error` in a program changes together, and a pipe, `NO_COLOR` or `--json` turns them all off at once. ## Plain until flown A token is the identity until [`fly()`](/docs/guides/themes) has decided a level above 0. A program that never calls `fly()` — or a library that uses tokens inside a program that did not ask for colour — prints plain text: ```js title="unflown.mjs" import { error, ok } from 'roundel/tokens'; console.log(JSON.stringify(`${error('failed')} ${ok('passed')}`)); ``` ```text title="FORCE_COLOR=3 node unflown.mjs" "failed passed" ``` `FORCE_COLOR=3` changes nothing there, because nothing asked the policy. Flown, the same tokens paint at the level the policy decides: ```js title="flown.mjs" import { fly } from 'roundel/theme'; import { error, ok } from 'roundel/tokens'; import { rt } from './rt.mjs'; fly({}, rt); console.log(JSON.stringify(`${error('failed')} ${ok('passed')}`)); ``` ```text title="FORCE_COLOR=3 node flown.mjs" "\u001b[38;2;244;121;74mfailed\u001b[39m \u001b[38;2;13;148;96mpassed\u001b[39m" ``` ```text title="node flown.mjs" "failed passed" ``` ## Styling only the argument A token wraps its argument in an opening and a closing sequence and touches nothing else, and an empty string stays empty. Nested tokens close in reverse order. Only `roundel/tokens` emits an escape: `roundel/chalk` computes parameters and hands them to the same emitter, which a test asserts by reading the façade's source for an `ESC` character. ## Coming from chalk `chalk.red(s)` names a colour; `error(s)` names a meaning. The chalk API is still there, as `roundel/chalk`, graded by chalk's own suite — [Coming from chalk](/docs/coming-from/chalk). Both read the same policy, so they never disagree about the terminal. --- # roundel > The colours a CLI carries. One output policy, semantic tokens, a theme, and a chalk migration path lighter than chalk. Zero dependencies. Source: https://roundel.interlace.tools/docs 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. **roundel** is the colours a CLI carries: one output policy decided once from the runtime, nine semantic tokens over `util.styleText`, and a theme that changes them all together, contrast-checked before it flies — plus chalk's API over the same tokens, for the program that is not ready to give chalk up. Zero dependencies, five subpaths, each costing only itself. For an agent, the policy is the point: under `--json`, `NO_COLOR` or a pipe nobody asked to colour, every token returns its input unchanged, so captured output never carries an escape. 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 ```bash npm install roundel pnpm add roundel yarn add roundel bun add roundel ``` ## Quick start ```js 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 ```js 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 ```js 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 ```diff - 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 = 0` silences `roundel/chalk` and 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 same `NO_COLOR`, `FORCE_COLOR`, `--color`, CI-vendor, `TERM` and `COLORTERM` rules chalk does — see [The policy](#the-policy). Every other package in the family reads the same answer, so `roundel/chalk` and 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 with `FORCE_COLOR` or `--color`. 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](https://burgee.interlace.tools/docs/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. 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](https://burgee.interlace.tools/docs/compatibility). ## 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 20.19+ and 22.13+. ## Benchmarks Every number here is produced by `npm run bench` and published at [burgee.interlace.tools/docs/benchmarks](https://burgee.interlace.tools/docs/benchmarks). Graded by the incumbent's own test suite: | suite | passing | | :-- | --: | | `chalk` | 59 / 59 | ## For agents - **Captured output is plain.** `outputMode(rt, { json })` answers `json` under `--json`, and under `NO_COLOR` or 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/policy` is 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.mjs` validates 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](https://roundel.interlace.tools/llms.txt) and [llms-full.txt](https://roundel.interlace.tools/llms-full.txt). ## API The subpaths are listed under [What is here](#what-is-here); every export, with its types, is on [roundel.interlace.tools](https://roundel.interlace.tools/docs). ## Where it sits Plugins register under the `tokens` key, against the one schema the whole family shares. `burgee`, `caique`, `flagstaff` build on it, and it builds on nothing in this family. ## The family Ten packages, one repository, one release pipeline. A CLI on burgee declares what it is, roundel carries its colours, flagstaff flies it and caique answers back; each installs on its own, and none takes a dependency from outside the family. | Package | What it is | Replaces | | :-- | :-- | :-- | | [burgee](https://burgee.interlace.tools/docs/packages/burgee) | The CLI framework: one declaration, every surface | commander and yargs | | **roundel** (this package) | Colour: one output policy, semantic tokens, a theme | chalk | | [flagstaff](https://flagstaff.interlace.tools/docs) | The frame loop: spinners, progress, boxes and tables | ora, log-update, boxen and cli-table3 | | [caique](https://caique.interlace.tools/docs) | Prompts that are flags first, and never hang | inquirer and clack | | [linegauge](https://linegauge.interlace.tools/docs) | Measuring, wrapping, truncating and slicing styled text | string-width, wrap-ansi, strip-ansi and slice-ansi | | [paratext](https://paratext.interlace.tools/docs) | Hyperlinks, images, title, clipboard and notifications | ansi-escapes, terminal-link and term-img | | [seniority](https://seniority.interlace.tools/docs) | Configuration precedence and discovery, with provenance | cosmiconfig, dotenv and rc | | [closeout](https://closeout.interlace.tools/docs) | Exit handlers, terminal restore and a bounded shutdown | signal-exit, exit-hook and restore-cursor | | [bellpull](https://bellpull.interlace.tools/docs) | Subprocesses, and which executable actually ran | cross-spawn and which | | [controlroom](https://burgee.interlace.tools/docs/packages/controlroom) | Reserved, not usable yet — planned: full-screen, keyboard-driven terminal screens | ink, planned | Every migration guide, and the family-wide [compatibility](https://burgee.interlace.tools/docs/compatibility) and [benchmarks](https://burgee.interlace.tools/docs/benchmarks) pages, are on [burgee.interlace.tools](https://burgee.interlace.tools/docs/packages). ## Contributing Issues and pull requests are welcome at [ofri-peretz/burgee](https://github.com/ofri-peretz/burgee/issues); read [CONTRIBUTING.md](https://github.com/ofri-peretz/burgee/blob/main/CONTRIBUTING.md) first. Report a vulnerability privately, as [SECURITY.md](https://github.com/ofri-peretz/burgee/blob/main/SECURITY.md) describes — never in a public issue. ## Licence MIT © Ofri Peretz — see [LICENSE](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/LICENSE). --- # Incremental migration > Move from chalk to roundel/chalk with one import, then to semantic tokens a file at a time, with both reading the same policy throughout. Source: https://roundel.interlace.tools/docs/recipes/incremental-migration ## Step one: the import ```diff - import chalk from 'chalk'; + import chalk from 'roundel/chalk'; ``` `roundel/chalk` passes chalk 6.0.1's own suite, so every call keeps its output. What changes is underneath: the level now comes from the policy, so `NO_COLOR` is honoured and `--no-color` beats an ambient `FORCE_COLOR` ([Compatibility](/docs/drop-ins#known-differences)). `npx burgee migrate --dry-run` lists every import it would rewrite, and `npx burgee migrate` rewrites them ([Migrate](https://burgee.interlace.tools/docs/migrate)). ## Step two: meanings, a file at a time ```diff - import chalk from 'roundel/chalk'; - console.error(chalk.red(`missing ${chalk.bold('--name')}`)); + import { error, flag } from 'roundel/tokens'; + console.error(error(`missing ${flag('--name')}`)); ``` Tokens are plain until the program calls `fly()` once at startup; add that before the first file moves. From then on both styles read the same policy, so a file on `roundel/chalk` and a file on tokens never disagree about whether this terminal gets colour. ## Step three: drop chalk ```bash npm uninstall chalk npm ls chalk ``` `npm ls` shows what still pulls chalk in transitively. An `overrides` entry pointing `chalk` at `roundel` would resolve to the package root, not to `roundel/chalk`, so it cannot move those copies; they move when the packages that depend on chalk do. --- # A theme for light terminals > Declare the background your users read on, let roundel pick the brand shade that reads on it, and check the result before it ships. Source: https://roundel.interlace.tools/docs/recipes/light-terminals The default ground is near-black. On a light terminal, say so, and every hex token is checked — and the default brand tokens chosen — against white instead: ```js title="light-check.mjs" import { reportTheme } from 'roundel/contrast'; import { audit } from 'roundel/theme'; const theme = { ground: '#fdfdfd', warn: '#9a6700' }; console.log(reportTheme(audit(theme))); ``` ```text title="node light-check.mjs" pass error truecolor #a84c17 on #fdfdfd 5.56:1 (needs 4.5:1) pass error 256 #af5f00 on #fdfdfd 4.63:1 (needs 4.5:1) pass warn truecolor #9a6700 on #fdfdfd 4.79:1 (needs 4.5:1) pass warn 256 #af5f00 on #fdfdfd 4.63:1 (needs 4.5:1) pass ok truecolor #0a6b47 on #fdfdfd 6.44:1 (needs 4.5:1) pass ok 256 #005f5f on #fdfdfd 7.37:1 (needs 4.5:1) ``` `error` and `ok` were not given, so they are the brand's deep shades, the ones that read on a light ground. `warn` was given as a hex, so it is checked too; the default `yellow` would not have been, because a format name is the user's own palette. There is no way to ask a terminal what its background is, so the choice is yours. A program that serves both can read a setting of its own and fly the matching theme; the check runs on whichever it flies. --- # Plain output for agents and CI > Wire --json and --no-color through the runtime so an agent, a CI log and a user who opts out all get plain text, from one fly() at startup. Source: https://roundel.interlace.tools/docs/recipes/plain-output-for-agents An agent reading your CLI's output, a CI log and a user who typed `--no-color` all want the same thing: no escape codes. Hand the policy what it needs — the environment, the arguments and whether stdout is a terminal — and pass `{ json }` when the run asked for structured output. Nothing else in the program has to check. ```js title="status.mjs" import process from 'node:process'; import { fly } from 'roundel/theme'; import { error, ok } from 'roundel/tokens'; const argv = process.argv.slice(2); const json = argv.includes('--json'); fly({}, { env: process.env, argv, isTTY: { stdout: process.stdout.isTTY === true } }, { json }); const results = [ ['api', true], ['web', false], ]; if (json) console.log(JSON.stringify(Object.fromEntries(results))); else for (const [name, passed] of results) console.log(JSON.stringify(`${name} ${passed ? ok('passed') : error('failed')}`)); ``` ```text title="FORCE_COLOR=1 node status.mjs" "api \u001b[32mpassed\u001b[39m" "web \u001b[91mfailed\u001b[39m" ``` ```text title="FORCE_COLOR=1 node status.mjs --no-color" "api passed" "web failed" ``` ```text title="FORCE_COLOR=1 node status.mjs --json" {"api":true,"web":false} ``` (`JSON.stringify` around each line only makes the escape codes visible here.) `--no-color` wins over the `FORCE_COLOR=1` a CI configuration exported, because it was typed for this run. `--json` would be plain even if a token slipped into the structured output, because the level under `--json` is always 0. A program built on [burgee](https://burgee.interlace.tools/docs) gets all of this without the wiring: burgee hands roundel its runtime and its `--json` flag. --- # Testing coloured output > Assert what your CLI prints at each colour level by flying a literal runtime — no environment patching, no fake terminal. Source: https://roundel.interlace.tools/docs/recipes/testing-colour The policy reads only the runtime it is given, so a test flies a literal and asserts the exact bytes, at any level, on any machine, in any CI: ```js title="colours.mjs" import assert from 'node:assert/strict'; import { fly } from 'roundel/theme'; import { error } from 'roundel/tokens'; const at = (env, tty = false) => fly({ ground: '#ffffff', error: '#b3261e' }, { env, isTTY: { stdout: tty } }); at({ FORCE_COLOR: '3' }); assert.equal(error('x'), '\u001B[38;2;179;38;30mx\u001B[39m'); at({ FORCE_COLOR: '2' }); assert.equal(error('x'), '\u001B[38;5;124mx\u001B[39m'); at({ TERM: 'xterm-256color' }); assert.equal(error('x'), 'x'); at({ TERM: 'xterm-256color' }, true); assert.equal(error('x'), '\u001B[38;5;124mx\u001B[39m'); at({ NO_COLOR: '1', COLORTERM: 'truecolor' }, true); assert.equal(error('x'), 'x'); console.log('ok'); ``` ```text title="node colours.mjs" ok ``` Each `fly()` replaces the last, so the order of the cases does not matter. The same literal runtime answers `outputMode()` too, which is how a test checks that a component redraws on a terminal and writes plain lines everywhere else. --- # Why roundel > roundel against chalk, one capability per row, every cell linked to the test, grade or source that proves it. Source: https://roundel.interlace.tools/docs/why-roundel chalk styles a string, and does it well: `roundel/chalk` passes all 59 cases of chalk 6.0.1's own suite, and roundel's colour-level rule agrees with chalk's across a differential sweep except where this page says it does not. What chalk leaves to every package that uses it is the decision around the colour — whether this output is going to a terminal, a CI log, a screen reader or an agent, and what the colours mean. roundel makes that decision once, as a pure function, and gives the colours names. The table below is the whole comparison. Every mark links to its evidence: a test in this repository for ours, and for chalk the source file of chalk 6.0.1 — the version compat-oracle grades — or chalk's own test suite. `scripts/capabilities-lock.test.ts` fails the build when a cited test no longer contains the title it is cited for. ✓ yes · ◐ partial (what is missing is said) · ✗ no · — does not apply. Every cell links to its evidence: our test or grade, or the incumbent’s source at the version compat-oracle grades. ### One output policy | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **Five output modes from one rule** — `outputMode()` answers `json`, `accessible`, `ci`, `pipe` or `tty` from the runtime it is given, first match wins, so a spinner, a prompt and the help ask one question and get one answer. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ decides a colour level and nothing else; whether to redraw is left to each package](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **`NO_COLOR` turns colour off, over `FORCE_COLOR` and `--color`** — A user who sets `NO_COLOR` gets no colour, whatever else the environment or the command line asks for. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ its colour detection never reads NO_COLOR](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **`FORCE_COLOR=0` is never overridden by a `--color` flag** — An explicit "colour off" in the environment wins over `--color=256` or `--color=16m` on the command line, so off never turns into on. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✓ an environment FORCE_COLOR replaces the flag's answer before any --color level is read](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **`--color`, `--color=256` and `--no-color` flags** — The flags chalk reads are read under the same spellings, from the argv the program hands the policy. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✓ read from process.argv at import](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **`--no-color` beats an ambient `FORCE_COLOR`** — A flag typed for this run outranks a `FORCE_COLOR` the machine exports, so `--no-color` under `FORCE_COLOR=3` is no colour. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ a set FORCE_COLOR replaces the flag's answer, so --no-color under FORCE_COLOR=3 is level 3](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **An agent on a terminal is not someone to prompt** — `interactive()` answers no under `CLAUDECODE`, `CURSOR_AGENT` and the other agent variables even with a terminal on stdin, so a prompt asks the one question that tells an agent from a person. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/terminal.test.ts) | [— decides a colour level, not whether a person can answer](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **A screen-reader mode with no colour** — With `CLI_ACCESSIBLE=1` the level is 0 even on a truecolor terminal, because escape codes are noise to a screen reader; an explicit ask still colours it. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ reads no accessibility switch](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | | **`--json` output is never coloured** — Under `--json` the level is 0 even when `FORCE_COLOR` asks for colour, so structured output never carries an escape. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ has no notion of structured output; the program sets chalk.level = 0 itself](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/index.js) | | **The decision is a pure function of a runtime** — The policy reads only the environment, arguments and terminal flag it is handed, so a test or an agent harness asks the program's question and gets the program's answer. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✗ reads process.env, process.argv and the file descriptors' TTY state once, at import](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | ### Colour levels | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **Truecolor, 256 and 16 colours, downsampled** — A hex colour is sent as truecolor at level 3 and as the nearest of 256 or 16 colours below it. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/chalk.test.ts) | [✓ through its vendored ansi-styles](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/index.js) | | **A CI vendor's colour level, once colour is asked for** — GitHub Actions, Gitea Actions and CircleCI get truecolor and the other known vendors 16 colours, while a CI pipe nobody asked to colour stays plain. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/policy.test.ts) | [✓ the same vendor table](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/vendor/supports-color/index.js) | ### Tokens and themes | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **Semantic tokens instead of colour names** — A program writes `error()`, `hint()` and `command()`, and the theme decides what each looks like, so the colours mean something and change together. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/tokens.test.ts) | [✗ every colour is named at the call site](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/index.js) | | **A theme checked for WCAG contrast** — `fly()` refuses a hex token below 4.5:1 against the declared background, at every colour level, and names the token and its ratio. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/theme.test.ts) | [✗ draws any colour it is given](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/index.js) | | **Themes through a validated plugin registry** — A plugin's tokens are validated at `register()`, a misspelt token is refused rather than dropped, and the contrast gate applies to them as to a hand-written theme. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/plugin.test.ts) | [✗ has no themes to register](https://cdn.jsdelivr.net/npm/chalk@6.0.1/source/index.js) | ### Weight | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **No runtime dependencies** — Installing it adds one package and nothing else. | [✓](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/shape.test.ts) | [✓ ansi-styles and supports-color are copied inside it](https://cdn.jsdelivr.net/npm/chalk@6.0.1/package.json) | ### Compatibility | Capability | **roundel** | chalk | | :-- | :-- | :-- | | **Passes chalk's own test suite** — `roundel/chalk` is graded by chalk 6.0.1's own tests, unedited, so changing the import keeps chalk's output. | [✓ 59 / 59 of its own tests](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/baseline/chalk.json) | [✓ its own suite, the control run](https://github.com/ofri-peretz/burgee/blob/main/packages/compat-oracle/vendor/chalk/test/chalk.js) | ## Reading it - **Parity rows are here on purpose.** chalk reads the `--color` flags, keeps an explicit `FORCE_COLOR=0` off, downsamples to 256 and 16 colours, knows the CI vendors and has no dependencies. A reader would otherwise have to go and check; the cells say they match. - **chalk's cells link to chalk 6.0.1 on jsDelivr**, the release compat-oracle grades. A URL cell is not re-read by the lock, so each was checked against that file by hand — 6.0.1 changed `source/index.js` in how it joins several arguments and touched nothing a cell cites; the compatibility row, whose source is chalk's vendored suite, is checked on every run. ## What is not in the table A row goes in only when every cell of it can be proved. These were left out: - **`npx roundel check`.** It validates a theme plugin before it ships; chalk has no plugins, so there is nothing to compare it with. It is on [Theme plugins](/docs/guides/plugins). - **The per-façade level.** Setting `chalk.level` on `roundel/chalk` changes that façade and not the tokens. chalk also has separate instances (`new Chalk({ level })`), so the difference is in what the default instance is shared with, not a yes-or-no capability. - **Terminal allow-list detection.** chalk recognises kitty, ghostty, wezterm, iTerm, Apple Terminal, TeamCity and the Windows build number; roundel deliberately does not, and reads only `TERM`, `COLORTERM` and explicit instructions ([Colour levels](/docs/guides/colour-levels)). That is a difference in approach, and the row would read as a loss or a win depending on the terminal. - **Weight in bytes.** The per-subpath figures are asserted by [`weight.test.ts`](https://github.com/ofri-peretz/burgee/blob/main/packages/roundel/src/weight.test.ts) and published on [Benchmarks](https://burgee.interlace.tools/docs/benchmarks).