roundel 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ofri Peretz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,4 +1,23 @@
1
- # roundel
1
+ <p align="center">
2
+ <a href="https://github.com/ofri-peretz/burgee/tree/main/packages/roundel" target="blank">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/ofri-peretz/burgee/main/brand-assets/roundel-lockup.svg" />
5
+ <img src="https://raw.githubusercontent.com/ofri-peretz/burgee/main/brand-assets/roundel-lockup-light.svg" alt="roundel" width="360" />
6
+ </picture>
7
+ </a>
8
+ </p>
9
+
10
+ <p align="center">
11
+ The colours a CLI carries — error and hint, not red and blue.
12
+ </p>
13
+
14
+ <p align="center">
15
+ <a href="https://www.npmjs.com/package/roundel"><img src="https://img.shields.io/npm/v/roundel?style=flat-square&color=0a6b47" alt="npm version" /></a>
16
+ <a href="https://www.npmjs.com/package/roundel"><img src="https://img.shields.io/npm/dm/roundel?style=flat-square" alt="npm downloads" /></a>
17
+ <img src="https://img.shields.io/badge/runtime%20dependencies-0-0a6b47?style=flat-square" alt="Zero runtime dependencies" />
18
+ <img src="https://img.shields.io/badge/Node.js-24+-green.svg?style=flat-square" alt="Node.js 24+" />
19
+ <img src="https://img.shields.io/badge/License-MIT-blue.svg?style=flat-square" alt="License: MIT" />
20
+ </p>
2
21
 
3
22
  chalk gives you `red`; picocolors gives you `red` for fewer bytes. Neither gives you
4
23
  `error`, and each decides on its own whether the terminal has colour — which is why a
@@ -14,7 +33,11 @@ not a flag. That is what this package is for a command-line program: not `red` a
14
33
  but `error`, `hint`, `command` and `flag`, the colours that mean *you*, carried onto the
15
34
  terminal as a theme.
16
35
 
17
- ## Use
36
+ ## Start here
37
+
38
+ ```bash
39
+ npm install roundel
40
+ ```
18
41
 
19
42
  ```js
20
43
  import { fly } from 'roundel/theme';
@@ -144,6 +167,12 @@ Node ≥ 24.
144
167
  - **`roundel/import`** — `fromBase16(scheme)` and `fromITerm(plist)`: a theme from the two
145
168
  largest corpora of terminal palettes, contrast-checked on the way in.
146
169
 
147
- Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on burgee declares
148
- what it is, roundel carries its colours, flagstaff flies it, caique answers back. Each is
149
- an independent package; none requires the others.
170
+ ---
171
+
172
+ Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on
173
+ [burgee](https://www.npmjs.com/package/burgee) declares what it is, roundel carries its
174
+ colours, [flagstaff](https://www.npmjs.com/package/flagstaff) flies it, and
175
+ [caique](https://www.npmjs.com/package/caique) answers back. Each is an independent package;
176
+ none requires the others.
177
+
178
+ MIT © Ofri Peretz — see [LICENSE](./LICENSE).
@@ -1,9 +1,16 @@
1
1
  /**
2
- * The WCAG 2.2 contrast maths (R5), copied from `burgee/contrast` on purpose: sixty lines
3
- * duplicated beats a dependency arrow pointing the wrong way (U1). This copy is held to
4
- * `contrast-vectors.json` by its test; burgee's test does not read that file yet, so the
5
- * drift lock is one-sided until the follow-up lands (burgee contrast test to read roundel's
6
- * contrast-vectors.json).
2
+ * The WCAG 2.2 contrast maths (R5).
3
+ *
4
+ * It was copied from `burgee/contrast` under Y1 — "sixty lines duplicated beats a dependency
5
+ * arrow pointing the wrong way" — on the condition that the two copies share a test-vector
6
+ * file. That condition was met half way for five days: this copy was pinned to
7
+ * `contrast-vectors.json` and burgee's was not, which is the worse arrangement, because the
8
+ * pinned copy cannot drift and the unpinned one can while keeping a green suite.
9
+ *
10
+ * **#194 settled it differently and better:** the arrow was reversed, so there is one
11
+ * implementation and `burgee/contrast` imports this one. The vectors remain as a reference —
12
+ * values computed outside this file, which is the only kind that can catch a wrong constant —
13
+ * and `scripts/shared-vectors-lock.test.ts` keeps them read rather than kept.
7
14
  *
8
15
  * `fly()` uses it to refuse a truecolor token that would not read against the declared
9
16
  * ground. Nothing here is asked about the 16- and 256-colour palettes: those are the
@@ -16,9 +23,54 @@ export declare const AA: {
16
23
  /** Large text, UI components, and meaningful parts of a graphic. */
17
24
  readonly GRAPHIC: 3;
18
25
  };
26
+ /**
27
+ * The stricter conformance level, for a caller who needs it: low-vision users, a CLI run on a
28
+ * projector, a terminal in daylight, or an organisation whose accessibility policy says AAA
29
+ * and does not care that this is a terminal.
30
+ *
31
+ * Not the default, and not because AA is good enough. At 7:1 the 256-colour palette runs out
32
+ * of room fast — a great many perfectly reasonable brand colours have no readable substitute
33
+ * in the cube at that floor — so defaulting to AAA would refuse themes that work for almost
34
+ * everyone on almost every terminal. It is the caller's call, which is the only place that
35
+ * judgement can honestly sit.
36
+ */
37
+ export declare const AAA: {
38
+ /** Body text against its background. */
39
+ readonly TEXT: 7;
40
+ /** Large text, UI components, and meaningful parts of a graphic. */
41
+ readonly GRAPHIC: 4.5;
42
+ };
43
+ /** Which WCAG conformance level a theme is held to. `AA` unless a caller asks for more. */
44
+ export type Conformance = 'AA' | 'AAA';
45
+ /** The floors for a conformance level, so a caller names a standard rather than a number. */
46
+ export declare const floors: (level?: Conformance) => typeof AA | typeof AAA;
19
47
  /** `#abc` and `#aabbcc` both parse, to sRGB channels in 0..1. Anything else is a mistake worth throwing on. */
20
48
  export declare function channels(hex: string): [number, number, number];
21
49
  /** WCAG relative luminance. */
22
50
  export declare function luminance(hex: string): number;
23
51
  /** The WCAG contrast ratio between two colours. Order does not matter. */
24
52
  export declare function contrast(a: string, b: string): number;
53
+ /** One token's verdict at one colour level, in the words somebody fixing it would use. */
54
+ export interface ThemeFinding {
55
+ /** The token name — `error`, `ok`, `command`. */
56
+ token: string;
57
+ /** `truecolor` or `256`. Never `16`: those values are the user's terminal theme. */
58
+ at: 'truecolor' | '256';
59
+ /** What the terminal is actually sent at this level, which is not always the hex written. */
60
+ colour: string;
61
+ ground: string;
62
+ ratio: number;
63
+ required: number;
64
+ passes: boolean;
65
+ }
66
+ export declare const round2: (value: number) => number;
67
+ /**
68
+ * One line per finding, aligned, for a terminal or a failing test. Mirrors
69
+ * `burgee/contrast`'s `report` — the same shape in both packages, because somebody reading a
70
+ * theme audit and a brand audit on the same day should not have to learn two layouts.
71
+ *
72
+ * Passing rows are printed too. A report that lists only failures cannot tell "nothing is
73
+ * wrong" from "nothing was checked", and the second is the state this package was in for the
74
+ * 256-colour level until 2026-09-13.
75
+ */
76
+ export declare function reportTheme(findings: readonly ThemeFinding[]): string;
package/dist/contrast.js CHANGED
@@ -2,6 +2,11 @@ export const AA = {
2
2
  TEXT: 4.5,
3
3
  GRAPHIC: 3,
4
4
  };
5
+ export const AAA = {
6
+ TEXT: 7,
7
+ GRAPHIC: 4.5,
8
+ };
9
+ export const floors = (level = 'AA') => (level === 'AAA' ? AAA : AA);
5
10
  const SRGB_MAX = 255;
6
11
  const LINEAR_THRESHOLD = 0.03928;
7
12
  const LINEAR_DIVISOR = 12.92;
@@ -33,3 +38,15 @@ export function contrast(a, b) {
33
38
  const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
34
39
  return (hi + CONTRAST_OFFSET) / (lo + CONTRAST_OFFSET);
35
40
  }
41
+ const CENTS = 100;
42
+ export const round2 = (value) => Math.round(value * CENTS) / CENTS;
43
+ export function reportTheme(findings) {
44
+ if (findings.length === 0)
45
+ return "no hex tokens to check — every token is a format name, and those are the terminal's own colours";
46
+ const token = Math.max(...findings.map((f) => f.token.length));
47
+ const at = Math.max(...findings.map((f) => f.at.length));
48
+ return findings
49
+ .map((f) => `${f.passes ? 'pass' : 'FAIL'} ${f.token.padEnd(token)} ${f.at.padEnd(at)} ` +
50
+ `${f.colour} on ${f.ground} ${f.ratio.toFixed(2)}:1 (needs ${f.required}:1)`)
51
+ .join('\n');
52
+ }
package/dist/theme.d.ts CHANGED
@@ -1,10 +1,74 @@
1
+ /**
2
+ * The theme (R4, R5): one map from token to style, flown once for the whole program.
3
+ *
4
+ * A style is either a list of `util.styleText` format names — the user's own terminal
5
+ * palette, never checked or claimed — or a `#rrggbb`, which is truecolor at level 3 and
6
+ * falls back to the nearest of 256 or 16 colours below it. Every hex token is checked against
7
+ * the declared ground at 4.5:1 before it is flown — **and so is the 256-colour entry it
8
+ * degrades to**, at every level, so a theme that would not read on somebody's 256-colour
9
+ * terminal fails in CI on a truecolor one. Level 1 is not checked and cannot be: the basic
10
+ * sixteen are the user's own theme, and a ratio over them would be invented.
11
+ */
12
+ import { type Conformance, type ThemeFinding } from './contrast.js';
1
13
  import { type Format, type ModeOptions, type Runtime, type TokenName } from './policy.js';
2
14
  export type Hex = `#${string}`;
3
15
  export type Style = Hex | readonly Format[];
4
16
  /** Each token's style, and the ground the hex ones are checked against (default: near-black). */
17
+ /**
18
+ * A theme: a style per token, the ground the hex ones are checked against, and which WCAG
19
+ * level to hold them to.
20
+ *
21
+ * `conformance` is the knob a team turns when AA is not enough — low vision, a projector, a
22
+ * policy that says AAA. It raises the floor for **both** jobs at once: the check that refuses
23
+ * a theme, and the search that picks the 256-colour substitute. Those two have to agree, or a
24
+ * caller asking for AAA gets a verdict at one standard and a colour chosen at another.
25
+ */
5
26
  export type Theme = Partial<Record<TokenName, Style>> & {
6
27
  ground?: Hex;
28
+ conformance?: Conformance;
7
29
  };
30
+ /**
31
+ * The sRGB of a 256-palette index, which is `ansi256` run backwards. Defined for 16–255 only,
32
+ * and that is the whole reason this check is possible: **entries 0–15 are the user's terminal
33
+ * theme and entries 16–255 are not.** Exported because it is the only honest way to ask
34
+ * "what will the terminal actually paint", which is a question a caller checking its own
35
+ * theme has as much right to ask as `fly()` does. The 6×6×6 cube and the 24-step grey ramp are the xterm
36
+ * values every terminal ships and none of them themes, so a contrast number over them is
37
+ * measured rather than invented — which is exactly what `contrast.ts` says cannot be done for
38
+ * the basic sixteen. `ansi256` never returns below 16, so every paint it produces is knowable.
39
+ */
40
+ export declare function rgb256(index: number): Hex;
41
+ /**
42
+ * sRGB 0–255 to OKLab. Björn Ottosson's matrices, written as straight-line arithmetic.
43
+ *
44
+ * The first draft used `map`/`reduce` over two 9-element matrices, which read better and cost
45
+ * **2,766 bytes** — `./theme` grew 44% against a 6,300-byte budget, on a package whose whole
46
+ * claim is that each subpath is at or under the incumbent it replaces. Eighteen multiplies
47
+ * written out are a third of that. The matrices are not data anyone configures, so making them
48
+ * data bought nothing and charged for it.
49
+ *
50
+ * Exported so the coefficients are pinnable. They were not, at first: nudging the first one
51
+ * from 0.4122214708 to 0.4022214708 left all 253 tests green, because everything downstream
52
+ * computed distance with the *same* corrupted matrix and the palette is coarse enough to
53
+ * absorb the error. A transform can only be checked against numbers from outside it, so
54
+ * `theme.test.ts` holds it to the five published reference values.
55
+ */
56
+ export declare function toOklab(r: number, g: number, b: number): [number, number, number];
57
+ /**
58
+ * Every token's verdict, as data — **without throwing.**
59
+ *
60
+ * `fly()` refuses a theme that does not read, which is right at startup and useless while you
61
+ * are choosing colours: a caller who wants to *know* should not have to catch an exception and
62
+ * parse its message. So the judgement lives here and `fly()` is a filter over it, which also
63
+ * means the refusal and the report can never disagree about what passes.
64
+ *
65
+ * Two rows per hex token — `truecolor` and `256` — because those are the two colours a
66
+ * terminal can actually be sent, and they are not the same colour. **No row for 16**: those
67
+ * values are the user's own terminal theme, so there is no ratio to report and a number there
68
+ * would be invented. A token given format names rather than a hex gets no row either, for the
69
+ * same reason: `['bold', 'red']` is the terminal's red.
70
+ */
71
+ export declare function audit(theme?: Theme): ThemeFinding[];
8
72
  /**
9
73
  * Fly the theme: decide the colour level from the runtime once, check every hex token
10
74
  * against the ground, and set what the tokens paint from now on. Call it at startup, with
package/dist/theme.js CHANGED
@@ -1,4 +1,4 @@
1
- import { AA, channels, contrast } from './contrast.js';
1
+ import { channels, contrast, floors, round2 } from './contrast.js';
2
2
  import { colorLevel, flown, } from './policy.js';
3
3
  const ROCK = ['#a84c17', '#f4794a'];
4
4
  const JUNIPER = ['#0a6b47', '#0d9460'];
@@ -25,11 +25,16 @@ const CUBE_START = 16;
25
25
  const CUBE_ROW = 36;
26
26
  const CUBE_COL = 6;
27
27
  const GREY_START = 232;
28
+ const GREY_STEP = 10;
29
+ const HEX_BASE = 16;
30
+ const RGB_PARTS = 3;
31
+ const CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
28
32
  const GREY_STEPS = 24;
29
33
  const GREY_LOW = 8;
30
34
  const GREY_HIGH = 248;
31
35
  const GREY_SPAN = 247;
32
36
  const CUBE_WHITE = 231;
37
+ const GREY_LAST = 255;
33
38
  const BASIC_NAMES = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
34
39
  const BLUE_BIT = 2;
35
40
  const GREEN_BIT = 1;
@@ -59,26 +64,93 @@ function ansi16(r, g, b) {
59
64
  return name;
60
65
  return name === 'black' ? 'gray' : `${name}Bright`;
61
66
  }
62
- function resolve(style, level) {
67
+ export function rgb256(index) {
68
+ const hex = (r, g, b) => `#${[r, g, b].map((v) => v.toString(HEX_BASE).padStart(2, '0')).join('')}`;
69
+ if (index >= GREY_START)
70
+ return hex(...Array(RGB_PARTS).fill(GREY_LOW + (index - GREY_START) * GREY_STEP));
71
+ const n = index - CUBE_START;
72
+ const at = (i) => CUBE_LEVELS[i];
73
+ return hex(at(Math.floor(n / CUBE_ROW)), at(Math.floor((n % CUBE_ROW) / CUBE_COL)), at(n % CUBE_COL));
74
+ }
75
+ const LINEAR_THRESHOLD = 0.04045;
76
+ const LINEAR_DIVISOR = 12.92;
77
+ const GAMMA_OFFSET = 0.055;
78
+ const GAMMA_EXPONENT = 2.4;
79
+ export function toOklab(r, g, b) {
80
+ const lin = (v) => {
81
+ const c = v / SRGB_MAX;
82
+ return c <= LINEAR_THRESHOLD ? c / LINEAR_DIVISOR : ((c + GAMMA_OFFSET) / (1 + GAMMA_OFFSET)) ** GAMMA_EXPONENT;
83
+ };
84
+ const R = lin(r);
85
+ const G = lin(g);
86
+ const B = lin(b);
87
+ const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
88
+ const m = Math.cbrt(0.2119034982 * R + 0.6806995451 * G + 0.1073969566 * B);
89
+ const s = Math.cbrt(0.0883024619 * R + 0.2817188376 * G + 0.6299787005 * B);
90
+ return [
91
+ 0.2104542553 * l + 0.793_617_785 * m - 0.004_072_046_8 * s,
92
+ 1.9779984951 * l - 2.428_592_205 * m + 0.4505937099 * s,
93
+ 0.0259040371 * l + 0.7827717662 * m - 0.808_675_766 * s,
94
+ ];
95
+ }
96
+ function degrade(r, g, b, ground, floor) {
97
+ const plain = ansi256(r, g, b);
98
+ const target = toOklab(r, g, b);
99
+ let best = -1;
100
+ let bestDistance = Number.POSITIVE_INFINITY;
101
+ for (let index = CUBE_START; index <= GREY_LAST; index++) {
102
+ const candidate = rgb256(index);
103
+ if (contrast(candidate, ground) < floor)
104
+ continue;
105
+ const [cr, cg, cb] = channels(candidate).map((v) => Math.round(v * SRGB_MAX));
106
+ const [cl, ca, cb2] = toOklab(cr, cg, cb);
107
+ const distance = (target[0] - cl) ** 2 + (target[1] - ca) ** 2 + (target[2] - cb2) ** 2;
108
+ if (distance < bestDistance) {
109
+ bestDistance = distance;
110
+ best = index;
111
+ }
112
+ }
113
+ return best === -1 ? plain : best;
114
+ }
115
+ function resolve(style, level, ground, floor) {
63
116
  if (typeof style !== 'string')
64
117
  return style;
65
118
  const [r, g, b] = channels(style).map((v) => Math.round(v * SRGB_MAX));
66
119
  if (level === TRUECOLOR)
67
120
  return { sgr: [SGR_FG, SGR_RGB, r, g, b] };
68
- return level === COLORS_256 ? { sgr: [SGR_FG, SGR_256, ansi256(r, g, b)] } : [ansi16(r, g, b)];
121
+ return level === COLORS_256 ? { sgr: [SGR_FG, SGR_256, degrade(r, g, b, ground, floor)] } : [ansi16(r, g, b)];
69
122
  }
70
- export function fly(theme, rt, opts) {
71
- const level = colorLevel(rt, opts);
123
+ export function audit(theme = {}) {
72
124
  const ground = theme.ground ?? INK;
73
- const styles = TOKENS.map((name) => [name, theme[name] ?? DEFAULTS[name](ground)]);
74
- const failures = styles.flatMap(([name, style]) => {
125
+ const required = floors(theme.conformance).TEXT;
126
+ return TOKENS.flatMap((name) => {
127
+ const style = theme[name] ?? DEFAULTS[name](ground);
75
128
  if (typeof style !== 'string')
76
129
  return [];
77
- const ratio = contrast(style, ground);
78
- return ratio < AA.TEXT ? [`${name} ${style} on ${ground} is ${ratio.toFixed(2)}:1`] : [];
130
+ const [r, g, b] = channels(style).map((v) => Math.round(v * SRGB_MAX));
131
+ const at = [
132
+ ['truecolor', style],
133
+ ['256', rgb256(degrade(r, g, b, ground, required))],
134
+ ];
135
+ return at.map(([where, colour]) => {
136
+ const ratio = round2(contrast(colour, ground));
137
+ return { token: name, at: where, colour, ground, ratio, required, passes: ratio >= required };
138
+ });
79
139
  });
140
+ }
141
+ export function fly(theme, rt, opts) {
142
+ const level = colorLevel(rt, opts);
143
+ const ground = theme.ground ?? INK;
144
+ const floor = floors(theme.conformance).TEXT;
145
+ const styles = TOKENS.map((name) => [name, theme[name] ?? DEFAULTS[name](ground)]);
146
+ const written = new Map(styles);
147
+ const failures = audit(theme)
148
+ .filter((f) => !f.passes)
149
+ .map((f) => f.at === 'truecolor'
150
+ ? `${f.token} ${f.colour} on ${f.ground} is ${f.ratio.toFixed(2)}:1`
151
+ : `${f.token} ${String(written.get(f.token))} at 256 colours is ${f.colour} on ${f.ground}, ${f.ratio.toFixed(2)}:1`);
80
152
  if (failures.length > 0)
81
- throw new Error(`roundel: below ${AA.TEXT}:1 — ${failures.join('; ')}`);
153
+ throw new Error(`roundel: below ${floor}:1 (WCAG ${theme.conformance ?? 'AA'}) — ${failures.join('; ')}`);
82
154
  flown.level = level;
83
- flown.paint = Object.fromEntries(styles.map(([name, style]) => [name, resolve(style, level)]));
155
+ flown.paint = Object.fromEntries(styles.map(([name, style]) => [name, resolve(style, level, ground, floor)]));
84
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roundel",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "The colours a CLI carries. One output policy, semantic tokens, a theme, and a chalk migration path lighter than chalk. Zero dependencies.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -55,6 +55,7 @@
55
55
  "build": "tsc -p tsconfig.build.json && node ../../scripts/schema-to-dist.mjs && node ../../scripts/strip-comments.mjs dist",
56
56
  "typecheck": "tsc -p tsconfig.json --noEmit",
57
57
  "test": "vitest run --passWithNoTests",
58
+ "coverage": "vitest run --coverage.enabled",
58
59
  "lint": "eslint src"
59
60
  },
60
61
  "repository": {