@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 +21 -0
- package/README.md +235 -0
- package/bin/taste-check.mjs +90 -0
- package/package.json +49 -0
- package/schema/config.schema.json +118 -0
- package/src/color.mjs +140 -0
- package/src/config.mjs +175 -0
- package/src/contrast.mjs +110 -0
- package/src/css.mjs +149 -0
- package/src/files.mjs +81 -0
- package/src/index.mjs +29 -0
- package/src/report.mjs +80 -0
- package/src/treatments.mjs +229 -0
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;
|