roundel 0.2.0 → 0.3.1
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/README.md +15 -0
- package/dist/chalk.js +6 -8
- package/dist/contrast.d.ts +57 -5
- package/dist/contrast.js +17 -0
- package/dist/policy.d.ts +11 -16
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +2 -0
- package/dist/schema.json +1 -1
- package/dist/theme.d.ts +64 -0
- package/dist/theme.js +83 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -176,3 +176,18 @@ colours, [flagstaff](https://www.npmjs.com/package/flagstaff) flies it, and
|
|
|
176
176
|
none requires the others.
|
|
177
177
|
|
|
178
178
|
MIT © Ofri Peretz — see [LICENSE](./LICENSE).
|
|
179
|
+
|
|
180
|
+
## Benchmarks
|
|
181
|
+
|
|
182
|
+
Every number here is produced by `npm run bench` and published at [/docs/benchmarks](/docs/benchmarks).
|
|
183
|
+
|
|
184
|
+
Graded by the incumbent's own test suite:
|
|
185
|
+
|
|
186
|
+
| suite | passing |
|
|
187
|
+
| :-- | --: |
|
|
188
|
+
| `chalk` | 58 / 58 |
|
|
189
|
+
## Where it sits
|
|
190
|
+
|
|
191
|
+
Plugins register under the `tokens` key, against the one schema the whole family shares.
|
|
192
|
+
|
|
193
|
+
`burgee` and `flagstaff` build on it, and it builds on nothing in this family.
|
package/dist/chalk.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { colorLevel } from './policy.js';
|
|
2
|
+
import { processRuntime } from './runtime.js';
|
|
2
3
|
import { sgr } from './tokens.js';
|
|
3
4
|
const MODIFIERS = {
|
|
4
5
|
reset: [0, 0],
|
|
@@ -120,16 +121,13 @@ function builder(root, chain, visible) {
|
|
|
120
121
|
},
|
|
121
122
|
});
|
|
122
123
|
}
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const detect = (stream) => colorLevel({ env: proc?.env ?? {}, argv: proc?.argv ?? [], isTTY: { stdout: proc?.[stream]?.isTTY === true } });
|
|
126
|
-
const stdoutLevel = detect('stdout');
|
|
127
|
-
const stderrLevel = detect('stderr');
|
|
124
|
+
const stdoutLevel = colorLevel(processRuntime());
|
|
125
|
+
const stderrLevel = colorLevel(processRuntime('stderr'));
|
|
128
126
|
const info = (level) => (level === 0 ? false : { level, hasBasic: true, has256: level >= 2, has16m: level === 3 });
|
|
129
127
|
function create(options = {}, detected = stdoutLevel) {
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return builder({ level
|
|
128
|
+
const { level = detected } = options;
|
|
129
|
+
checkLevel(level);
|
|
130
|
+
return builder({ level }, [], false);
|
|
133
131
|
}
|
|
134
132
|
export class Chalk {
|
|
135
133
|
constructor(options) {
|
package/dist/contrast.d.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The WCAG 2.2 contrast maths (R5)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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/policy.d.ts
CHANGED
|
@@ -13,21 +13,7 @@
|
|
|
13
13
|
* 9,370 bytes. Comments are stripped from `dist`, so the prose is free; the statements
|
|
14
14
|
* are not.
|
|
15
15
|
*/
|
|
16
|
-
|
|
17
|
-
* The slice of a runtime the policy needs. burgee's `processRuntime` satisfies it, so
|
|
18
|
-
* does a two-line literal in a test; nothing here imports a type from anywhere.
|
|
19
|
-
*/
|
|
20
|
-
export interface Runtime {
|
|
21
|
-
env: Record<string, string | undefined>;
|
|
22
|
-
isTTY: {
|
|
23
|
-
stdout: boolean;
|
|
24
|
-
};
|
|
25
|
-
/**
|
|
26
|
-
* The process arguments, when the caller owns them: `--color`, `--no-color` and
|
|
27
|
-
* `--color=…` are read here and nowhere else. A test literal leaves it out.
|
|
28
|
-
*/
|
|
29
|
-
argv?: readonly string[];
|
|
30
|
-
}
|
|
16
|
+
import { type Runtime } from './runtime.js';
|
|
31
17
|
export type OutputMode = 'tty' | 'pipe' | 'json' | 'accessible' | 'ci';
|
|
32
18
|
declare const MAX_LEVEL = 3;
|
|
33
19
|
/** chalk's levels: none, 16 colours, 256 colours, truecolor. */
|
|
@@ -90,4 +76,13 @@ export declare const flown: {
|
|
|
90
76
|
level: ColorLevel;
|
|
91
77
|
paint: Partial<Record<TokenName, Paint>>;
|
|
92
78
|
};
|
|
93
|
-
|
|
79
|
+
/**
|
|
80
|
+
* The slice of a runtime this file reads is declared in `./runtime.js`, beside the one
|
|
81
|
+
* function in the package that names the process (Y9), and re-exported here because
|
|
82
|
+
* `roundel/policy` is the subpath a caller imports it from. One declaration, two doors.
|
|
83
|
+
*
|
|
84
|
+
* The import at the top is type-only, so `verbatimModuleSyntax` erases it whole: `policy.js`
|
|
85
|
+
* reaches no new module, and the weight of every subpath that stands on it is unchanged.
|
|
86
|
+
* `subpath-isolation.test.ts` is what holds that.
|
|
87
|
+
*/
|
|
88
|
+
export type { Runtime };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The slice of the world this package needs, and the one file in it that names the process
|
|
3
|
+
* (Y9). The same seam `paratext/src/runtime.ts` and `burgee/src/runtime.ts` declare, for
|
|
4
|
+
* the same reason: `policy.ts` answers "where is this output going?" from a `Runtime`, so a
|
|
5
|
+
* test declares a terminal in two lines and a host can lie about one on purpose.
|
|
6
|
+
*
|
|
7
|
+
* The guarded cast below moved here whole from `chalk.ts`, unchanged. It is guarded because
|
|
8
|
+
* `process` is not a given where a bundle runs, and it is bound to a local because a
|
|
9
|
+
* textual lock cannot tell `proc?.env` from any other local — the exemption is recorded in
|
|
10
|
+
* burgee's `process-reference-lock.test.ts` against this file, so a reader looking for it
|
|
11
|
+
* finds it, but it is the cast and not the entry that keeps roundel honest.
|
|
12
|
+
*
|
|
13
|
+
* Nothing is cached here. `./chalk` calls `processRuntime()` twice at import because
|
|
14
|
+
* chalk's contract is "detect the terminal at import" (R6); every other caller in the
|
|
15
|
+
* family passes its own `Runtime` and never reaches this file at all.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The slice of a runtime the policy needs. burgee's `processRuntime` satisfies it, so does
|
|
19
|
+
* a two-line literal in a test; nothing here imports a type from anywhere.
|
|
20
|
+
*/
|
|
21
|
+
export interface Runtime {
|
|
22
|
+
env: Record<string, string | undefined>;
|
|
23
|
+
isTTY: {
|
|
24
|
+
stdout: boolean;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The process arguments, when the caller owns them: `--color`, `--no-color` and
|
|
28
|
+
* `--color=…` are read in `policy.ts` and nowhere else. A test literal leaves it out.
|
|
29
|
+
*/
|
|
30
|
+
argv?: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The real process. `stream` names the stream this output is going to — `chalkStderr` and
|
|
34
|
+
* `supportsColorStderr` detect against stderr, as chalk does — and its `isTTY` lands in
|
|
35
|
+
* `isTTY.stdout`, which is the policy's name for "the stream this output goes to" rather
|
|
36
|
+
* than for fd 1 in particular.
|
|
37
|
+
*
|
|
38
|
+
* `argv` defaults to empty rather than absent: `exactOptionalPropertyTypes` makes an
|
|
39
|
+
* optional `argv` mean "the key may be missing", not "the value may be undefined".
|
|
40
|
+
*/
|
|
41
|
+
export declare const processRuntime: (stream?: "stdout" | "stderr") => Runtime;
|
package/dist/runtime.js
ADDED
package/dist/schema.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json","title":"flagstaff plugin","description":"A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().","type":"object","required":["name"],"additionalProperties":true,"properties":{"name":{"type":"string","minLength":1,"description":"The plugin's name; also the prefix a host may use when two plugins contribute the same key."},"contract":{"type":"integer","minimum":1,"description":"The plugin contract this object follows. A host refuses a newer contract than it knows."},"tokens":{"type":"object","description":"A roundel theme: semantic token name to a hex colour, contrast-checked when flown.","additionalProperties":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"}},"glyphs":{"type":"object","description":"Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.","additionalProperties":{"type":"string","minLength":1}},"spinners":{"type":"object","description":"Spinner styles by name, in cli-spinners' shape plus the static projection.","additionalProperties":{"$ref":"#/$defs/spinner"}},"borders":{"type":"object","description":"Border styles a box can be drawn with, by name.","additionalProperties":{"$ref":"#/$defs/border"}},"components":{"type":"object","description":"Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.","additionalProperties":{"$ref":"#/$defs/component"}}},"$defs":{"spinner":{"type":"object","required":["frames","interval","static"],"properties":{"frames":{"type":"array","items":{"type":"string"},"minItems":1},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between frames on a terminal."},"static":{"type":"string","description":"What a pipe prints instead of the animation."}}},"component":{"type":"object","required":["static"],"properties":{"static":{"description":"(state) => string. Required: the projection every non-terminal mode prints."},"frame":{"description":"(t, state) => string. Optional: the frame at t milliseconds since hoisting."},"sample":{"type":"object","required":["running","done"],"description":"Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.","properties":{"running":{"description":"The state to open with."},"done":{"description":"The state to close with."}}},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between repaints when `frame` is given; 80 when omitted."}}},"border":{"type":"object","required":["topLeft","top","topRight","left","right","bottomLeft","bottom","bottomRight"],"description":"cli-boxes' shape exactly, so that corpus imports unchanged.","properties":{"topLeft":{"type":"string"},"top":{"type":"string"},"topRight":{"type":"string"},"left":{"type":"string"},"right":{"type":"string"},"bottomLeft":{"type":"string"},"bottom":{"type":"string"},"bottomRight":{"type":"string"}}}}}
|
|
1
|
+
{"$schema":"https://json-schema.org/draft/2020-12/schema","$id":"https://github.com/ofri-peretz/burgee/blob/main/packages/flagstaff/src/schema.json","title":"flagstaff plugin","description":"A plugin is one plain object. Everything in it is data that can be read without running it; the only functions allowed are a component's `static` (required) and `frame` (optional). A spinner or component without a static projection is refused at register().","type":"object","required":["name"],"additionalProperties":true,"properties":{"name":{"type":"string","minLength":1,"description":"The plugin's name; also the prefix a host may use when two plugins contribute the same key."},"contract":{"type":"integer","minimum":1,"description":"The plugin contract this object follows. A host refuses a newer contract than it knows."},"tokens":{"type":"object","description":"A roundel theme: semantic token name to a hex colour, contrast-checked when flown.","additionalProperties":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"propertyNames":{"enum":["error","warn","ok","hint","muted","command","flag","value","heading","ground"],"description":"roundel's ten semantic tokens, and nothing else — `roundel`'s own validate() refuses any other name."}},"glyphs":{"type":"object","description":"Symbols by meaning: `ok`, `fail`, `warn`, `info`, `running`. A plugin that ships glyphs changes every built-in that draws one.","additionalProperties":{"type":"string","minLength":1}},"spinners":{"type":"object","description":"Spinner styles by name, in cli-spinners' shape plus the static projection.","additionalProperties":{"$ref":"#/$defs/spinner"}},"borders":{"type":"object","description":"Border styles a box can be drawn with, by name.","additionalProperties":{"$ref":"#/$defs/border"}},"components":{"type":"object","description":"Components by name: `static(state)` returns the text a pipe, an agent or a screen reader gets; `frame(t, state)` is the optional animated form.","additionalProperties":{"$ref":"#/$defs/component"}},"capabilities":{"$ref":"#/$defs/capabilities"}},"$defs":{"spinner":{"type":"object","required":["frames","interval","static"],"properties":{"frames":{"type":"array","items":{"type":"string"},"minItems":1},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between frames on a terminal."},"static":{"type":"string","description":"What a pipe prints instead of the animation."}}},"component":{"type":"object","required":["static"],"properties":{"static":{"description":"(state) => string. Required: the projection every non-terminal mode prints."},"frame":{"description":"(t, state) => string. Optional: the frame at t milliseconds since hoisting."},"sample":{"type":"object","required":["running","done"],"description":"Two states to *show* this component with: `flagstaff check` and the docs gallery render `running` then `done`. Omitted, they assume `{ phase: 'running' }` and `{ phase: 'done' }` and say so in the output. The loop never reads it — a running program's state comes from the program.","properties":{"running":{"description":"The state to open with."},"done":{"description":"The state to close with."}}},"interval":{"type":"integer","minimum":1,"description":"Milliseconds between repaints when `frame` is given; 80 when omitted."}}},"border":{"type":"object","required":["topLeft","top","topRight","left","right","bottomLeft","bottom","bottomRight"],"description":"cli-boxes' shape exactly, so that corpus imports unchanged.","properties":{"topLeft":{"type":"string"},"top":{"type":"string"},"topRight":{"type":"string"},"left":{"type":"string"},"right":{"type":"string"},"bottomLeft":{"type":"string"},"bottom":{"type":"string"},"bottomRight":{"type":"string"}}},"capability":{"type":"object","required":["name","osc","when","encode","fallback"],"additionalProperties":false,"description":"One paratext capability: what it says to the terminal, when the terminal is believed to understand it, and what prints when it does not. No functions, so it travels through JSON. `encode` and `fallback` are templates: `{field}` is the field's value, `{field|base64}` is it base64-encoded, and `[ … ]` is emitted only when every field inside it has a value.","properties":{"name":{"type":"string","minLength":1,"description":"How callers name it. Registering an existing name replaces it — how a caller corrects a guess we got wrong."},"osc":{"description":"The OSC code this speaks, or 'BEL' for the bell and for protocols that are not OSC at all, such as Kitty's.","oneOf":[{"type":"integer","minimum":0},{"const":"BEL"}]},"when":{"type":"object","description":"When the terminal is believed to understand it. Every clause must hold; `termProgram` and `envAny` are ORs within themselves. Guesses — no terminal answers 'do you do OSC 1337' — and therefore data a caller can replace.","additionalProperties":false,"properties":{"tty":{"type":"boolean","description":"Refuse a pipe. Almost always true: a file that receives OSC gets control bytes in it."},"termProgram":{"type":"array","items":{"type":"string"},"description":"Any one of these TERM_PROGRAM values."},"envAny":{"type":"array","items":{"type":"string"},"description":"Any one of these environment variables merely being set, as VTE announces itself."},"term":{"type":"string","description":"An exact TERM — Kitty is xterm-kitty."}}},"encode":{"type":"string","minLength":1,"description":"The bytes, as a template, for a terminal that does understand."},"fallback":{"type":"string","description":"What to print when it does not — PRINCIPLES rule 6. '' is a legitimate answer, a window title having nothing to say in a log; absence is not, which is why this is required and may be ''."}},"examples":[{"name":"kitty-image","osc":"BEL","when":{"tty":true,"term":"xterm-kitty"},"encode":"\u001b_Ga=T,f=100;{base64}\u001b\\","fallback":"{caption}"}]},"capabilities":{"type":"object","description":"paratext capabilities by name — the OSC section of a plugin, read the way flagstaff reads `spinners`.","additionalProperties":{"$ref":"#/$defs/capability"}},"capabilityDocument":{"description":"What paratext's `check()` accepts. Two shapes, and only one of them survives 1.0.","oneOf":[{"description":"The family shape: a plugin carrying its capabilities under `capabilities`.","type":"object","required":["name","capabilities"],"properties":{"name":{"type":"string","minLength":1},"contract":{"type":"integer","minimum":1},"capabilities":{"$ref":"#/$defs/capabilities"}}},{"deprecated":true,"description":"Deprecated: one capability as the whole document, the shape paratext's schema had before this one absorbed it. Accepted for one minor release, removed at 1.0 — `check()` validates it and says so.","$ref":"#/$defs/capability"}]}}}
|
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 {
|
|
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
|
|
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,
|
|
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
|
|
71
|
-
const level = colorLevel(rt, opts);
|
|
123
|
+
export function audit(theme = {}) {
|
|
72
124
|
const ground = theme.ground ?? INK;
|
|
73
|
-
const
|
|
74
|
-
|
|
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
|
|
78
|
-
|
|
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 ${
|
|
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