@josueavalosjim/taste-check 0.1.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 Josue Avalos Jimenez
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 ADDED
@@ -0,0 +1,235 @@
1
+ # taste-check
2
+
3
+ Two deterministic checks for a design system you already have. It computes WCAG
4
+ contrast ratios from your own custom properties, and flags class names and
5
+ literal values that are not on your own approved list.
6
+
7
+ It has no opinion about which colours you use or which classes are allowed.
8
+ You supply both.
9
+
10
+ ```bash
11
+ npx @josueavalosjim/taste-check --config tastecheck.config.json
12
+ ```
13
+
14
+ Installed as a dependency, the command is just `taste-check`:
15
+
16
+ ```bash
17
+ npm i -D @josueavalosjim/taste-check
18
+ ```
19
+
20
+ ```
21
+ contrast ok, 10 pairs across 2 themes
22
+ ok 17.76:1 needs 4.5 light --text-strong on --surface body text
23
+ ok 5.32:1 needs 4.5 light --text-quiet on --surface the translucent one
24
+ ok 4.10:1 needs 3.0 light --hairline on --surface borders
25
+
26
+ treatments FAILED
27
+ FAIL src/Promo.jsx:7 class "promo-huge" on <div> is not approved
28
+ FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
29
+ ```
30
+
31
+ Zero runtime dependencies. Node 22 or newer.
32
+
33
+ ## Why this exists
34
+
35
+ Most quality tooling checks compliance and has no point of view. axe tells you
36
+ an element fails 4.5:1. It cannot tell you that your borders answer to 3:1
37
+ while your captions answer to 4.5:1, because that distinction is yours, not the
38
+ spec's.
39
+
40
+ Design systems drift in a specific way: a value gets hardcoded because the
41
+ token did not quite fit, a class gets invented because nobody knew the approved
42
+ one existed. Written rules do not stop it. A rule in a stylesheet comment is
43
+ enforced by people re-reading stylesheets, and nobody re-reads a stylesheet
44
+ while writing markup. So the rules get a check that runs instead.
45
+
46
+ ## What counts as a failure
47
+
48
+ Each of these exits 1 rather than passing quietly:
49
+
50
+ - a pair naming a token that does not exist
51
+ - a file pattern matching no files
52
+ - a theme whose scopes resolve no tokens
53
+ - a colour value the parser does not understand
54
+ - an unknown key in the config, which is usually a typo doing nothing
55
+
56
+ A check that cannot fail is worse than no check, because it goes green and gets
57
+ quoted as evidence.
58
+
59
+ ## The contrast check
60
+
61
+ Give it your custom properties, describe your themes, and list what must clear
62
+ what.
63
+
64
+ ```json
65
+ {
66
+ "contrast": {
67
+ "tokens": ["styles/tokens.css"],
68
+ "themes": [
69
+ { "name": "light", "scopes": [":root"] },
70
+ { "name": "dark", "scopes": [":root", "[data-theme=\"dark\"]"] }
71
+ ],
72
+ "pairs": [
73
+ { "fg": "--text", "bg": "--surface", "min": 4.5, "label": "body text" },
74
+ { "fg": "--hairline", "bg": "--surface", "min": 3.0, "themes": ["light"] }
75
+ ]
76
+ }
77
+ }
78
+ ```
79
+
80
+ Passing pairs are printed with their ratio, not just failing ones, so you can
81
+ see which pair is one background nudge away from dropping under its floor.
82
+
83
+ `min` has no default. WCAG's 4.5 and 3.0 are documented here and never assumed
84
+ in code, because the floor for a decorative hairline and the floor for body
85
+ text are different decisions and both are yours.
86
+
87
+ A theme is an ordered list of scopes and later scopes win, which is the cascade
88
+ for equal specificity. Declarations inside an at-rule are ignored unless a
89
+ scope opts in:
90
+
91
+ ```json
92
+ { "name": "dark-system", "scopes": [":root", { "selector": ":root", "atRule": "prefers-color-scheme: dark" }] }
93
+ ```
94
+
95
+ Without that rule a `@media (prefers-color-scheme: dark)` block containing
96
+ `:root` would overwrite the light theme, and the light checks would silently
97
+ measure against colours the light theme never paints.
98
+
99
+ Two details make the numbers match a browser rather than approximate it.
100
+ Translucent foregrounds are composited over their background before measuring,
101
+ so `rgb(0 0 0 / 0.58)` on white scores 5.32:1 and not 21:1. And `var()`
102
+ indirection is followed within the theme, so a token pointing at another token
103
+ resolves to the value that theme actually uses.
104
+
105
+ A background that resolves to a translucent colour is refused rather than
106
+ measured. There is no page here to look through, so name the opaque surface
107
+ behind it instead.
108
+
109
+ ## The treatments check
110
+
111
+ Give it your markup and your approved list.
112
+
113
+ ```json
114
+ {
115
+ "treatments": {
116
+ "files": ["src/**/*.jsx"],
117
+ "elements": ["a", "button", "div", "span", "p"],
118
+ "approvedClasses": ["card", "card__link", "button"],
119
+ "allowPrefixes": ["u-"],
120
+ "approvedValues": ["0", "100%"]
121
+ }
122
+ }
123
+ ```
124
+
125
+ It reports two things: a class name that is not approved and matches no allowed
126
+ prefix, and a literal colour or length hardcoded into an inline `style`.
127
+
128
+ The parsing is a scanner, not a regex, and that is the whole reason it works. The obvious implementation is `/<a\b[^>]*?className="([^"]*)"/`, which
129
+ finds almost nothing while looking like it passes:
130
+
131
+ - `[^>]*` cannot cross `onClick={() => x}`. It stops at the arrow's own `>`,
132
+ and the tag is skipped in silence.
133
+ - `title="a > b"` ends the tag early for the same reason.
134
+ - A `className` built as an expression is not a quoted string at all.
135
+
136
+ So tags are found by walking the source with quote and brace depth tracked, and
137
+ class names are collected from every string literal inside a `className`
138
+ expression. A ternary contributes both of its branches on purpose, because the
139
+ question is whether a class can appear at all. Over-reporting is the safer
140
+ direction to be wrong in.
141
+
142
+ Template literal holes are read into rather than blanked, so a class written
143
+ inside `` `card ${on ? 'card--on' : ''}` `` is seen.
144
+
145
+ ## Config
146
+
147
+ Point your editor at `schema/config.schema.json` for completion and inline
148
+ docs. Paths inside a config resolve against the config file, so it can be run
149
+ from anywhere.
150
+
151
+ | Key | Meaning |
152
+ | --- | --- |
153
+ | `contrast.tokens` | CSS files holding the custom properties. Later files override earlier ones. |
154
+ | `contrast.themes[].scopes` | Selectors applied in order, later winning. A string, or `{ selector, atRule }`. |
155
+ | `contrast.pairs[].fg` / `.bg` | A token name, or a literal colour. `bg` must resolve to something opaque. |
156
+ | `contrast.pairs[].min` | The floor this pair must clear. Required. |
157
+ | `contrast.pairs[].themes` | Limit a pair to some themes. Omit to check it everywhere. |
158
+ | `treatments.files` | Markup to scan. Supports `dir/*.ext` and any-depth `**` patterns. |
159
+ | `treatments.elements` | Element names to scan. Omit or use `["*"]` for all of them. |
160
+ | `treatments.approvedClasses` | Every class allowed to appear. |
161
+ | `treatments.allowPrefixes` | Prefixes that are always allowed, as a deliberate escape hatch. |
162
+ | `treatments.approvedValues` | Literal values allowed inside inline styles. |
163
+
164
+ ```
165
+ taste-check [options]
166
+
167
+ -c, --config <path> Config file (default: tastecheck.config.json)
168
+ --only <name> Run one check: contrast or treatments
169
+ --json Machine-readable output
170
+ --version Print the version
171
+ ```
172
+
173
+ Exit code is 1 if any check fails, 0 if every check ran and passed.
174
+
175
+ ## What this does not do
176
+
177
+ Read this before trusting a green run.
178
+
179
+ **It reads tokens, not a rendered page.** This is the real limit. The check it
180
+ was ported from ran in a browser and read colours off `getComputedStyle`,
181
+ because a token file cannot tell you what is actually painted behind an
182
+ element. An overlay, an ancestor background or a colour set inline by a
183
+ component are all invisible here. What this gives you is that the
184
+ values in your token file relate to each other the way you said they should. It
185
+ does not prove what a visitor sees.
186
+
187
+ **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits, `rgb()` and
188
+ `rgba()` in both the comma and the space syntax, and `white` / `black` /
189
+ `transparent`. `hsl()`, `oklch()` and `color-mix()` are not parsed yet, and a
190
+ value it cannot parse is a failure rather than a skip, so you will hear about
191
+ it immediately.
192
+
193
+ **There is no specificity resolution.** Scopes apply in the order you list
194
+ them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
195
+ you want.
196
+
197
+ **Component indirection is invisible.** Classes applied by a helper
198
+ function, a `clsx` call importing names from elsewhere, or CSS-in-JS are
199
+ invisible to it.
200
+
201
+ ## Future directions
202
+
203
+ Not built. Written down so the shape is clear.
204
+
205
+ **A fresh-eyes checklist hook.** The deterministic checks here cover what can
206
+ be measured. The judgment half of design review cannot be, and the useful
207
+ pattern for it is a fresh context: a separate model call that sees a screenshot
208
+ and a checklist, and nothing else. Judging in the same context that produced
209
+ the work is unreliable, because the reasoning that justified a choice is still
210
+ sitting there to justify it again. The plan is a plugin hook that takes your
211
+ screenshot command and your checklist file and reports back in the same format
212
+ as the checks above. The checklist stays yours: a shipped one would just be
213
+ somebody else's taste.
214
+
215
+ **A runtime mode**, closing the gap named above by measuring `getComputedStyle`
216
+ in a real browser, as an optional peer dependency so the core stays free of one.
217
+
218
+ **YAML configs**, once there is a reason to take on a parser.
219
+
220
+ **More colour formats**, `hsl()` first.
221
+
222
+ ## Development
223
+
224
+ ```bash
225
+ npm test
226
+ ```
227
+
228
+ 34 tests. Most of them plant a violation into a fixture that was passing a
229
+ moment earlier and demand it gets caught: a token darkened below its floor, an
230
+ unapproved class added to a clean file, a class buried in a template literal
231
+ hole, a file pattern pointed at nothing.
232
+
233
+ ## License
234
+
235
+ MIT
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * taste-check CLI.
4
+ *
5
+ * Exits 1 on any failure and 0 only when every check ran and passed. "Ran" is
6
+ * load-bearing: a config that matches no files, names a token that does not
7
+ * exist, or scopes a pair to a theme that is not defined is a failure here,
8
+ * not a quiet skip.
9
+ */
10
+ import { load } from '../src/config.mjs';
11
+ import { run } from '../src/index.mjs';
12
+ import { failed, toJson, toText } from '../src/report.mjs';
13
+
14
+ const USAGE = `taste-check
15
+
16
+ taste-check [options]
17
+
18
+ Options:
19
+ -c, --config <path> Config file (default: tastecheck.config.json)
20
+ --only <name> Run one check: contrast or treatments
21
+ --json Machine-readable output
22
+ -h, --help This
23
+ --version Print the version
24
+
25
+ Exit code is 1 if any check fails, 0 if every check ran and passed.`;
26
+
27
+ function parseArgs(argv) {
28
+ const options = { config: 'tastecheck.config.json', only: null, json: false };
29
+ for (let i = 0; i < argv.length; i += 1) {
30
+ const arg = argv[i];
31
+ const next = () => {
32
+ const value = argv[i + 1];
33
+ if (value === undefined || value.startsWith('-')) throw new Error(`${arg} needs a value`);
34
+ i += 1;
35
+ return value;
36
+ };
37
+ if (arg === '-h' || arg === '--help') return { help: true };
38
+ else if (arg === '--version') return { version: true };
39
+ else if (arg === '-c' || arg === '--config') options.config = next();
40
+ else if (arg === '--only') {
41
+ options.only = next();
42
+ if (options.only !== 'contrast' && options.only !== 'treatments') {
43
+ throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
44
+ }
45
+ } else if (arg === '--json') options.json = true;
46
+ else throw new Error(`unknown option "${arg}"`);
47
+ }
48
+ return options;
49
+ }
50
+
51
+ const die = (message) => {
52
+ console.error(`taste-check: ${message}`);
53
+ process.exit(1);
54
+ };
55
+
56
+ let options;
57
+ try {
58
+ options = parseArgs(process.argv.slice(2));
59
+ } catch (error) {
60
+ die(`${error.message}\n\n${USAGE}`);
61
+ }
62
+
63
+ if (options.help) {
64
+ console.log(USAGE);
65
+ process.exit(0);
66
+ }
67
+ if (options.version) {
68
+ const { version } = JSON.parse(
69
+ await import('node:fs').then((fs) =>
70
+ fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
71
+ ),
72
+ );
73
+ console.log(version);
74
+ process.exit(0);
75
+ }
76
+
77
+ const loaded = load(options.config);
78
+ if (!loaded.ok) {
79
+ console.error(`taste-check: ${options.config} could not be used:\n`);
80
+ for (const error of loaded.errors) console.error(` ${error}`);
81
+ process.exit(1);
82
+ }
83
+
84
+ const results = run(loaded.config, loaded.dir, { only: options.only });
85
+ if (!results.length) {
86
+ die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or treatments'} check.`);
87
+ }
88
+
89
+ console.log(options.json ? toJson(results) : toText(results));
90
+ process.exit(failed(results) ? 1 : 0);
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@josueavalosjim/taste-check",
3
+ "version": "0.1.0",
4
+ "description": "Deterministic design-system checks: WCAG contrast over your own tokens, and a one-off value linter over your own approved list. No palette, no class list, no opinions shipped.",
5
+ "keywords": [
6
+ "accessibility",
7
+ "cli",
8
+ "contrast",
9
+ "design-system",
10
+ "design-tokens",
11
+ "lint",
12
+ "wcag"
13
+ ],
14
+ "homepage": "https://github.com/josueavalosjim/taste-check#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/josueavalosjim/taste-check/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/josueavalosjim/taste-check.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Josue Avalos Jimenez",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./src/index.mjs",
27
+ "./package.json": "./package.json"
28
+ },
29
+ "bin": {
30
+ "taste-check": "bin/taste-check.mjs"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "src",
35
+ "schema",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "scripts": {
40
+ "test": "node --test \"test/**/*.test.mjs\""
41
+ },
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "dependencies": {}
49
+ }
@@ -0,0 +1,118 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://github.com/josueavalosjim/taste-check/schema/config.schema.json",
4
+ "title": "taste-check config",
5
+ "description": "Checks are driven entirely by this file. The tool ships no palette, no class list and no contrast floors.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "minProperties": 1,
9
+ "anyOf": [{ "required": ["contrast"] }, { "required": ["treatments"] }],
10
+ "properties": {
11
+ "$schema": { "type": "string" },
12
+ "contrast": {
13
+ "type": "object",
14
+ "description": "WCAG contrast ratios computed from your own custom properties.",
15
+ "additionalProperties": false,
16
+ "required": ["tokens", "themes", "pairs"],
17
+ "properties": {
18
+ "tokens": {
19
+ "type": "array",
20
+ "minItems": 1,
21
+ "description": "CSS files holding the custom properties. Paths resolve against this config file. Later files override earlier ones.",
22
+ "items": { "type": "string" }
23
+ },
24
+ "themes": {
25
+ "type": "array",
26
+ "minItems": 1,
27
+ "description": "Each theme is an ordered list of scopes. Later scopes win, which is the cascade for equal specificity. There is no specificity resolution.",
28
+ "items": {
29
+ "type": "object",
30
+ "additionalProperties": false,
31
+ "required": ["name", "scopes"],
32
+ "properties": {
33
+ "name": { "type": "string", "minLength": 1 },
34
+ "scopes": {
35
+ "type": "array",
36
+ "minItems": 1,
37
+ "items": {
38
+ "oneOf": [
39
+ { "type": "string", "description": "A selector, matched exactly against one entry in a rule's selector list." },
40
+ {
41
+ "type": "object",
42
+ "additionalProperties": false,
43
+ "required": ["selector"],
44
+ "properties": {
45
+ "selector": { "type": "string", "minLength": 1 },
46
+ "atRule": {
47
+ "type": "string",
48
+ "description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Without this, at-rule declarations are ignored."
49
+ }
50
+ }
51
+ }
52
+ ]
53
+ }
54
+ }
55
+ }
56
+ }
57
+ },
58
+ "pairs": {
59
+ "type": "array",
60
+ "minItems": 1,
61
+ "description": "What must clear what. A pair naming a token that does not exist is a failure, never a skip.",
62
+ "items": {
63
+ "type": "object",
64
+ "additionalProperties": false,
65
+ "required": ["fg", "bg", "min"],
66
+ "properties": {
67
+ "fg": { "type": "string", "minLength": 1, "description": "A token name like --text, or a literal colour." },
68
+ "bg": { "type": "string", "minLength": 1, "description": "As fg, but it must resolve to an opaque colour." },
69
+ "min": { "type": "number", "exclusiveMinimum": 0, "description": "The floor this pair must clear. No default: you decide what the rule is." },
70
+ "label": { "type": "string", "description": "Why this pair matters. Printed with the result." },
71
+ "themes": {
72
+ "type": "array",
73
+ "minItems": 1,
74
+ "description": "Limit the pair to these themes. Omit to check it in all of them.",
75
+ "items": { "type": "string" }
76
+ }
77
+ }
78
+ }
79
+ }
80
+ }
81
+ },
82
+ "treatments": {
83
+ "type": "object",
84
+ "description": "Class names and literal values in markup, against a list you supply.",
85
+ "additionalProperties": false,
86
+ "required": ["files"],
87
+ "properties": {
88
+ "files": {
89
+ "type": "array",
90
+ "minItems": 1,
91
+ "description": "Markup to scan. A pattern matching nothing is a failure, never a clean run.",
92
+ "items": { "type": "string" }
93
+ },
94
+ "elements": {
95
+ "type": "array",
96
+ "minItems": 1,
97
+ "description": "Element names to scan. Use [\"*\"] or omit for every element.",
98
+ "items": { "type": "string" }
99
+ },
100
+ "approvedClasses": {
101
+ "type": "array",
102
+ "description": "Every class name allowed to appear. Anything else is reported.",
103
+ "items": { "type": "string" }
104
+ },
105
+ "allowPrefixes": {
106
+ "type": "array",
107
+ "description": "Class name prefixes that are always allowed, for a deliberate escape hatch.",
108
+ "items": { "type": "string" }
109
+ },
110
+ "approvedValues": {
111
+ "type": "array",
112
+ "description": "Literal values allowed in inline styles. Everything else that looks like a colour or a length is reported.",
113
+ "items": { "type": "string" }
114
+ }
115
+ }
116
+ }
117
+ }
118
+ }
package/src/color.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Colour parsing and the WCAG contrast arithmetic.
3
+ *
4
+ * The maths here is the standard sRGB relative-luminance formula, but two
5
+ * details are worth stating because they are the reason a naive contrast
6
+ * checker disagrees with a browser:
7
+ *
8
+ * 1. Alpha is composited, not ignored. A token like `rgb(0 0 0 / 0.58)` is
9
+ * not a 21:1 black; it is whatever it becomes over the ground behind it.
10
+ * Measuring the raw value is measuring a colour that is never painted.
11
+ *
12
+ * 2. A value this parser does not understand is an error, not a skip. A
13
+ * skipped pair would be reported as a pass, so an unsupported colour
14
+ * format would quietly shrink the suite instead of failing it.
15
+ *
16
+ * Everything returns a result object rather than throwing, so the caller can
17
+ * attach its own context (which token, which theme) to the failure.
18
+ */
19
+
20
+ /** The three named colours that actually turn up in token files. */
21
+ const NAMED = {
22
+ white: [255, 255, 255, 1],
23
+ black: [0, 0, 0, 1],
24
+ transparent: [0, 0, 0, 0],
25
+ };
26
+
27
+ /** Formats deliberately not supported in v1, named so the error is useful. */
28
+ const KNOWN_UNSUPPORTED = ['hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color-mix', 'color'];
29
+
30
+ const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)%?$/;
31
+
32
+ const ok = (rgba) => ({ ok: true, rgba });
33
+ const err = (reason) => ({ ok: false, reason });
34
+
35
+ /** A channel token to 0-255, or null if it is not a number. */
36
+ function channel(token) {
37
+ if (!NUMBER.test(token)) return null;
38
+ const n = parseFloat(token);
39
+ return token.endsWith('%') ? (n / 100) * 255 : n;
40
+ }
41
+
42
+ /** An alpha token to 0-1, or null if it is not a number. */
43
+ function alpha(token) {
44
+ if (!NUMBER.test(token)) return null;
45
+ const n = parseFloat(token);
46
+ return token.endsWith('%') ? n / 100 : n;
47
+ }
48
+
49
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
50
+
51
+ /**
52
+ * Parse a CSS colour into [r, g, b, a] with r/g/b in 0-255 and a in 0-1.
53
+ *
54
+ * Supported: #rgb, #rgba, #rrggbb, #rrggbbaa; rgb()/rgba() in both the legacy
55
+ * comma syntax and the modern space syntax with a slash alpha; and the three
56
+ * named colours above. Anything else fails loudly.
57
+ */
58
+ export function parseColor(input) {
59
+ if (typeof input !== 'string') return err('not a string');
60
+ const text = input.trim();
61
+ if (!text) return err('empty value');
62
+
63
+ const named = NAMED[text.toLowerCase()];
64
+ if (named) return ok([...named]);
65
+
66
+ if (text.startsWith('#')) {
67
+ const hex = text.slice(1);
68
+ if (!/^[0-9a-fA-F]+$/.test(hex)) return err(`"${text}" is not a valid hex colour`);
69
+ const wide = hex.length === 6 || hex.length === 8;
70
+ const short = hex.length === 3 || hex.length === 4;
71
+ if (!wide && !short) return err(`"${text}" is not a 3, 4, 6 or 8 digit hex colour`);
72
+ const size = wide ? 2 : 1;
73
+ const at = (i) => {
74
+ const part = hex.slice(i * size, i * size + size);
75
+ return parseInt(short ? part + part : part, 16);
76
+ };
77
+ const hasAlpha = hex.length === 4 || hex.length === 8;
78
+ return ok([at(0), at(1), at(2), hasAlpha ? at(3) / 255 : 1]);
79
+ }
80
+
81
+ const fn = text.match(/^([a-zA-Z-]+)\s*\(([\s\S]*)\)$/);
82
+ if (fn) {
83
+ const name = fn[1].toLowerCase();
84
+ if (name !== 'rgb' && name !== 'rgba') {
85
+ const hint = KNOWN_UNSUPPORTED.includes(name)
86
+ ? `${name}() is not supported in v1. Convert the token to hex or rgb(), or open an issue.`
87
+ : `${name}() is not a colour function this tool understands`;
88
+ return err(`"${text}": ${hint}`);
89
+ }
90
+ // Both syntaxes at once: `rgb(1, 2, 3, .5)` and `rgb(1 2 3 / .5)`. Split on
91
+ // the slash first so a modern alpha is never mistaken for a fourth channel.
92
+ const [head, ...tail] = fn[2].split('/');
93
+ if (tail.length > 1) return err(`"${text}" has more than one slash`);
94
+ const parts = head.trim().split(/[,\s]+/).filter(Boolean);
95
+ const alphaToken = tail.length ? tail[0].trim() : parts[3];
96
+ if (parts.length < 3) return err(`"${text}" needs three colour channels`);
97
+ if (tail.length === 0 && parts.length > 4) return err(`"${text}" has too many channels`);
98
+ if (tail.length === 1 && parts.length !== 3) return err(`"${text}" has too many channels`);
99
+ const rgb = [channel(parts[0]), channel(parts[1]), channel(parts[2])];
100
+ if (rgb.some((c) => c === null)) return err(`"${text}" has a channel that is not a number`);
101
+ let a = 1;
102
+ if (alphaToken !== undefined) {
103
+ const parsed = alpha(alphaToken);
104
+ if (parsed === null) return err(`"${text}" has an alpha that is not a number`);
105
+ a = parsed;
106
+ }
107
+ return ok([...rgb.map((c) => clamp(c, 0, 255)), clamp(a, 0, 1)]);
108
+ }
109
+
110
+ if (text.includes('var(')) {
111
+ return err(`"${text}" still contains var(). The token it points at was not found.`);
112
+ }
113
+ return err(`"${text}" is not a colour this tool can parse`);
114
+ }
115
+
116
+ /** Composite a translucent foreground over an opaque ground. */
117
+ export function composite(fg, bg) {
118
+ return [0, 1, 2].map((i) => fg[3] * fg[i] + (1 - fg[3]) * bg[i]);
119
+ }
120
+
121
+ /** Relative luminance, per WCAG 2.x. */
122
+ export function luminance([r, g, b]) {
123
+ const lin = (c) => {
124
+ const v = c / 255;
125
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
126
+ };
127
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
128
+ }
129
+
130
+ /**
131
+ * The contrast ratio of a foreground against an opaque ground, with the
132
+ * foreground composited over that ground first.
133
+ */
134
+ export function contrastRatio(fg, bg) {
135
+ const over = composite(fg, bg);
136
+ const [hi, lo] = [luminance(over), luminance(bg)].sort((a, b) => b - a);
137
+ return (hi + 0.05) / (lo + 0.05);
138
+ }
139
+
140
+ export const isOpaque = (rgba) => rgba[3] > 0.999;