@josueavalosjim/taste-check 0.9.0 → 0.11.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/README.md +135 -15
- package/bin/taste-check.mjs +84 -8
- package/package.json +1 -1
- package/schema/config.schema.json +61 -0
- package/skills/taste-check-judge/SKILL.md +8 -2
- package/src/checklist.mjs +147 -0
- package/src/config.mjs +22 -2
- package/src/index.mjs +5 -0
- package/src/judge.mjs +6 -3
- package/src/report.mjs +4 -0
- package/src/sarif.mjs +4 -2
- package/src/tokens.mjs +128 -0
- package/src/treatments.mjs +2 -3
package/README.md
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
Design review in CI, with a line down the middle of it.
|
|
4
4
|
|
|
5
5
|
Some of design review is measurable. Contrast ratios, values that should have
|
|
6
|
-
been tokens, class names nobody approved.
|
|
7
|
-
your build on them, either from your token
|
|
8
|
-
real browser.
|
|
6
|
+
been tokens, tokens that were never real, class names nobody approved.
|
|
7
|
+
taste-check measures those and fails your build on them, either from your token
|
|
8
|
+
file or from a page it renders in a real browser.
|
|
9
9
|
|
|
10
10
|
The rest is judgment, and it cannot be measured. For that it sends your
|
|
11
11
|
screenshots and your checklist to a model, and reports what comes back as an
|
|
@@ -32,6 +32,9 @@ contrast ok, 10 pairs across 2 themes
|
|
|
32
32
|
treatments FAILED
|
|
33
33
|
FAIL src/Promo.jsx:7 class "promo-huge" on <div> is not approved
|
|
34
34
|
FAIL src/Promo.jsx:8 inline value "#ff0055" on <a> is a one-off. Use a token, or add it to approvedValues.
|
|
35
|
+
|
|
36
|
+
tokens FAILED
|
|
37
|
+
FAIL src/Promo.jsx:8 "--color-primary-500" is referenced here but declared in no token file. Check the name, or add it to allow.
|
|
35
38
|
```
|
|
36
39
|
|
|
37
40
|
```bash
|
|
@@ -63,15 +66,18 @@ which is fine for advice and disqualifying for a build gate. So the measured
|
|
|
63
66
|
half here gates, the judged half does not, and the tool will not let you
|
|
64
67
|
confuse one for the other by accident.
|
|
65
68
|
|
|
66
|
-
It also
|
|
67
|
-
|
|
69
|
+
It also enforces no rule it wrote. Not a palette, not a class list, not a
|
|
70
|
+
contrast floor. It will scaffold you a starter checklist, and that file lands
|
|
71
|
+
in your repo: what runs is the copy you edited, never its own.
|
|
68
72
|
|
|
69
73
|
## What counts as a failure
|
|
70
74
|
|
|
71
75
|
Each of these exits 1 rather than passing quietly:
|
|
72
76
|
|
|
73
77
|
- a pair naming a token that does not exist
|
|
78
|
+
- markup referencing a token that is declared nowhere
|
|
74
79
|
- a file pattern matching no files
|
|
80
|
+
- a token file that declares no custom properties at all
|
|
75
81
|
- a theme whose scopes resolve no tokens
|
|
76
82
|
- a single scope inside a theme that selects nothing, even when the theme has
|
|
77
83
|
tokens from its other scopes
|
|
@@ -181,6 +187,55 @@ direction to be wrong in.
|
|
|
181
187
|
Template literal holes are read into rather than blanked, so a class written
|
|
182
188
|
inside `` `card ${on ? 'card--on' : ''}` `` is seen.
|
|
183
189
|
|
|
190
|
+
## The tokens check
|
|
191
|
+
|
|
192
|
+
Give it the stylesheet that declares your tokens and the markup that uses them.
|
|
193
|
+
It reports every `var(--x)` in your markup where `--x` is declared nowhere.
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{
|
|
197
|
+
"tokens": {
|
|
198
|
+
"declaredIn": ["src/styles/tokens.css"],
|
|
199
|
+
"files": ["src/**/*.jsx"],
|
|
200
|
+
"allow": ["--scroll-progress"],
|
|
201
|
+
"allowPrefixes": ["--radix-"]
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
The failure it is built for is a token name that was never real:
|
|
207
|
+
`var(--color-primary-500)` in a system that says `--brand-action-bg`. A name
|
|
208
|
+
like that is not a typo. It is what gets written when the code is produced
|
|
209
|
+
without the token file open, which is most of what a model does, and it looks
|
|
210
|
+
correct in review because it looks like a token.
|
|
211
|
+
|
|
212
|
+
**It scans markup, not stylesheets, and that is deliberate.** See
|
|
213
|
+
[where it sits](#where-it-sits-next-to-other-tools). Stylelint already owns the
|
|
214
|
+
CSS side and does it better.
|
|
215
|
+
|
|
216
|
+
For markup the scan is the whole file rather than the tags, because `var(--`
|
|
217
|
+
means one thing everywhere it appears. So a reference in an object declared
|
|
218
|
+
above the `return`, in a styled-components template, or in a constant in
|
|
219
|
+
another module is found, and nesting comes free: both names in
|
|
220
|
+
`var(--a, var(--b))` are checked, not only the outer one.
|
|
221
|
+
|
|
222
|
+
`allow` and `allowPrefixes` are the escape hatch, and you will need them. A
|
|
223
|
+
custom property set from JavaScript with `setProperty`, or one owned by a
|
|
224
|
+
library's stylesheet, is legitimately absent from your token file.
|
|
225
|
+
|
|
226
|
+
Two things it does not treat as failures. A bare `--x` written where you meant
|
|
227
|
+
`var(--x)` is stylelint's `custom-property-no-missing-var-function`, not this.
|
|
228
|
+
And a run that finds no references at all is reported in the summary rather
|
|
229
|
+
than failed, because a codebase that keeps its styling in stylesheets has none
|
|
230
|
+
and is not broken:
|
|
231
|
+
|
|
232
|
+
```
|
|
233
|
+
tokens ok, 1 token file, 84 declared, 41 files scanned, 0 references
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
If that zero is a surprise, your `files` pattern is pointed at the wrong tree.
|
|
237
|
+
A pattern matching no files at all is a failure, as everywhere else here.
|
|
238
|
+
|
|
184
239
|
## Measuring the rendered page
|
|
185
240
|
|
|
186
241
|
The contrast check above reads your token file, which tells you what a colour
|
|
@@ -284,11 +339,13 @@ told what changed, answer every line, prefer unsure to a guess, do not be
|
|
|
284
339
|
agreeable, and name the specific thing you are looking at. That part is method
|
|
285
340
|
and it is the same for everyone.
|
|
286
341
|
|
|
287
|
-
The checklist is yours
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
342
|
+
The checklist is yours, and there is a test asserting the framing mentions no
|
|
343
|
+
design vocabulary at all, because a rule that arrives at the moment of judgment
|
|
344
|
+
carries the tool's authority rather than its author's. Checklist lines are list
|
|
345
|
+
items in your file; a heading or a paragraph is prose and is not judged.
|
|
346
|
+
|
|
347
|
+
Starting from nothing is its own problem, so there is a scaffold and a linter
|
|
348
|
+
for the lines you write. Both are in the next section.
|
|
292
349
|
|
|
293
350
|
### Letting an agent carry the call
|
|
294
351
|
|
|
@@ -336,6 +393,48 @@ exits 1 whatever `failOn` says:
|
|
|
336
393
|
Without that split, "the judge did not run" and "the judge found nothing" print
|
|
337
394
|
the same thing.
|
|
338
395
|
|
|
396
|
+
## Starting a checklist
|
|
397
|
+
|
|
398
|
+
An empty file is the hardest part of the judge, so:
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
taste-check checklist --new
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
It writes a starter into your repo. Six lines, and every one is about whether
|
|
405
|
+
the screen is broken rather than whether it is good, because that is the only
|
|
406
|
+
kind of line that is not somebody's taste. It refuses to overwrite a file that
|
|
407
|
+
exists, since the moment it lands it is yours.
|
|
408
|
+
|
|
409
|
+
That is a scaffold and not a default, and the difference is the whole reason it
|
|
410
|
+
is allowed to exist here. A default judges every user who never opened the file
|
|
411
|
+
against opinions they did not choose. A scaffold is a file you edited and
|
|
412
|
+
committed, and taste-check never reads its own copy. eslint is the same shape:
|
|
413
|
+
it ships rules and no default configuration, and `--init` writes you one.
|
|
414
|
+
|
|
415
|
+
```bash
|
|
416
|
+
taste-check checklist --lint
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
This checks your lines and never their content. It has no opinion about what
|
|
420
|
+
you ask for, only about whether asking it produces a verdict:
|
|
421
|
+
|
|
422
|
+
```
|
|
423
|
+
checklist, 3 to look at
|
|
424
|
+
unfalsifiable design-checklist.md:12 "Does it feel premium and modern"
|
|
425
|
+
"premium", "modern", "feel" describes a feeling rather than
|
|
426
|
+
something visible. A judge answers unsure, every run.
|
|
427
|
+
not-in-a-still design-checklist.md:13 "Is the hover animation smooth"
|
|
428
|
+
"animation", "hover" cannot be seen in a screenshot.
|
|
429
|
+
measurable design-checklist.md:14 "Body copy hits at least 4.5:1"
|
|
430
|
+
this asks for a number. Put it in a contrast pair instead.
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
It also flags a line asking two things at once, since one verdict cannot answer
|
|
434
|
+
both. "The primary action reads as the primary action" passes: it is an opinion,
|
|
435
|
+
a strong one, and none of the linter's business, because it can be settled by
|
|
436
|
+
looking.
|
|
437
|
+
|
|
339
438
|
## Config
|
|
340
439
|
|
|
341
440
|
Point your editor at `schema/config.schema.json` for completion and inline
|
|
@@ -371,6 +470,10 @@ it is the way it is.
|
|
|
371
470
|
| `treatments.approvedClasses` | Every class allowed to appear. |
|
|
372
471
|
| `treatments.allowPrefixes` | Prefixes that are always allowed, as a deliberate escape hatch. |
|
|
373
472
|
| `treatments.approvedValues` | Literal values allowed inside inline styles. |
|
|
473
|
+
| `tokens.declaredIn` | Stylesheets declaring the tokens. Every custom property in them counts, in any scope. |
|
|
474
|
+
| `tokens.files` | Markup to scan for `var()` references. |
|
|
475
|
+
| `tokens.allow` | Names that need no declaration, for ones set from JavaScript. Must start with `--`. |
|
|
476
|
+
| `tokens.allowPrefixes` | Name prefixes that are always allowed, for a family a library owns. |
|
|
374
477
|
| `judge.checklist` | Your checklist file. List items are judged, prose is not. |
|
|
375
478
|
| `judge.shots` | Screenshots to hand the judge. Matching nothing is a failure. |
|
|
376
479
|
| `judge.shotCommand` | Optional command run first to produce those screenshots. |
|
|
@@ -388,7 +491,7 @@ taste-check runtime [options] Measure contrast on a rendered page
|
|
|
388
491
|
taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
389
492
|
|
|
390
493
|
-c, --config <path> Config file (default: tastecheck.config.json)
|
|
391
|
-
--only <name> Run one check: contrast or
|
|
494
|
+
--only <name> Run one check: contrast, treatments or tokens
|
|
392
495
|
--format <kind> text (default), json, or sarif
|
|
393
496
|
--json Alias for --format json
|
|
394
497
|
--version Print the version
|
|
@@ -448,6 +551,16 @@ tool, run axe.
|
|
|
448
551
|
**stylelint, eslint.** General code quality, with an enormous rule ecosystem.
|
|
449
552
|
Nothing here replaces them.
|
|
450
553
|
|
|
554
|
+
**stylelint for tokens in CSS specifically.** Its core
|
|
555
|
+
`no-unknown-custom-properties` reports a `var()` naming a custom property
|
|
556
|
+
nothing declares, and its `referenceFiles` option covers the case that matters,
|
|
557
|
+
where the tokens live in one file and the uses live in another. That is the
|
|
558
|
+
same check taste-check runs, done first-party, so run stylelint on your
|
|
559
|
+
stylesheets. taste-check only scans markup, where stylelint does not go: its
|
|
560
|
+
CSS-in-JS syntax is deprecated, and a `style={{ color: open ? 'var(--a)' :
|
|
561
|
+
'var(--b)' }}` prop is a JavaScript object literal that no CSS parser reads at
|
|
562
|
+
all.
|
|
563
|
+
|
|
451
564
|
**@lapidist/design-lint.** More thorough than taste-check on the token and
|
|
452
565
|
component side: it parses properly rather than scanning, knows about
|
|
453
566
|
frameworks, autofixes, and manages deprecations. If enforcing tokens in code is
|
|
@@ -455,8 +568,9 @@ the whole of your problem, it is the better fit.
|
|
|
455
568
|
|
|
456
569
|
**Checklist Design and similar agent skills.** They arrive with a hundred or
|
|
457
570
|
more published checklists and review conversationally. If you want good
|
|
458
|
-
opinions supplied, take theirs. taste-check
|
|
459
|
-
|
|
571
|
+
opinions supplied, take theirs, and they are good. taste-check scaffolds six
|
|
572
|
+
lines about whether a screen is broken and expects the rest to be yours, which
|
|
573
|
+
is more work and a different bargain.
|
|
460
574
|
|
|
461
575
|
What is left, and the reason this exists: nothing above draws a line between
|
|
462
576
|
the part that can gate a build and the part that cannot. The linters have no
|
|
@@ -490,6 +604,11 @@ within one channel unit.
|
|
|
490
604
|
them. If your tokens rely on `.a.b` beating `.b`, list the scopes in the order
|
|
491
605
|
you want.
|
|
492
606
|
|
|
607
|
+
**It does not lint your stylesheets.** The tokens check reads your CSS to learn
|
|
608
|
+
what is declared and then never looks at it again. A `var()` in a `.css` file
|
|
609
|
+
naming nothing is not reported here. That is stylelint's
|
|
610
|
+
`no-unknown-custom-properties`, named above.
|
|
611
|
+
|
|
493
612
|
**Component indirection is invisible.** Classes applied by a helper
|
|
494
613
|
function, a `clsx` call importing names from elsewhere, or CSS-in-JS are
|
|
495
614
|
invisible to it.
|
|
@@ -518,10 +637,11 @@ publisher over OIDC, so no npm token is stored in the repository and every
|
|
|
518
637
|
release carries a provenance attestation. For a minor or major bump, run
|
|
519
638
|
`npm version minor` or `npm version major` and `git push --follow-tags`.
|
|
520
639
|
|
|
521
|
-
|
|
640
|
+
171 tests. Most of them plant a violation into a fixture that was passing a
|
|
522
641
|
moment earlier and demand it gets caught: a token darkened below its floor, an
|
|
523
642
|
unapproved class added to a clean file, a class buried in a template literal
|
|
524
|
-
hole, a file pattern pointed at
|
|
643
|
+
hole, a fabricated token hidden in a ternary, a file pattern pointed at
|
|
644
|
+
nothing.
|
|
525
645
|
|
|
526
646
|
## License
|
|
527
647
|
|
package/bin/taste-check.mjs
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
* exist, or scopes a pair to a theme that is not defined is a failure here,
|
|
8
8
|
* not a quiet skip.
|
|
9
9
|
*/
|
|
10
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { resolve } from 'node:path';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
12
13
|
|
|
13
14
|
import { load } from '../src/config.mjs';
|
|
15
|
+
import { STARTER, lintChecklist } from '../src/checklist.mjs';
|
|
16
|
+
import { checklistEntries } from '../src/judge.mjs';
|
|
14
17
|
import { gradeVerdict, judge, prepareJudge, run, runtime } from '../src/index.mjs';
|
|
15
18
|
import { failed, toJson, toText } from '../src/report.mjs';
|
|
16
19
|
import { toSarif } from '../src/sarif.mjs';
|
|
@@ -20,10 +23,15 @@ const USAGE = `taste-check
|
|
|
20
23
|
taste-check [options] Run the deterministic checks over your files
|
|
21
24
|
taste-check runtime [options] Measure contrast on a rendered page
|
|
22
25
|
taste-check judge [options] Ask a fresh-eyes judge about your screenshots
|
|
26
|
+
taste-check checklist --new Write a starter checklist you then own
|
|
27
|
+
taste-check checklist --lint Check your checklist lines can be answered
|
|
23
28
|
|
|
24
29
|
Options:
|
|
25
30
|
-c, --config <path> Config file (default: tastecheck.config.json)
|
|
26
|
-
--only <name> Run one check: contrast or
|
|
31
|
+
--only <name> Run one check: contrast, treatments or tokens
|
|
32
|
+
--new [path] checklist only: scaffold a starter, refusing to
|
|
33
|
+
overwrite one that exists
|
|
34
|
+
--lint checklist only: report lines a judge cannot answer
|
|
27
35
|
--emit judge only: print the prompt and stop, for an agent
|
|
28
36
|
to carry to a model itself
|
|
29
37
|
--verdict <path> judge only: grade a reply from a file, or - for stdin
|
|
@@ -48,6 +56,16 @@ is already up. It measures what is actually painted, compositing every
|
|
|
48
56
|
background layer behind an element rather than stopping at the first opaque
|
|
49
57
|
one, and it can put the page into a state first.
|
|
50
58
|
|
|
59
|
+
checklist --new scaffolds; it does not default. The file it writes is yours,
|
|
60
|
+
and taste-check never reads its own copy of it. The six lines in it are about
|
|
61
|
+
whether a screen is broken rather than whether it is good, because that is the
|
|
62
|
+
only kind of line that is not somebody's taste.
|
|
63
|
+
|
|
64
|
+
checklist --lint is about the form of your lines and never their content. It
|
|
65
|
+
reports a line a judge will answer "unsure" to every run, a line asking two
|
|
66
|
+
things at once, a line about something a still image cannot show, and a line
|
|
67
|
+
asking for a number that belongs in a contrast pair instead.
|
|
68
|
+
|
|
51
69
|
An agent can carry the model call instead of a shell command. --emit prints
|
|
52
70
|
the prompt and the images; the agent asks a fresh context and pipes the JSON
|
|
53
71
|
back to --verdict -, which checks it against the checklist the same way. Run
|
|
@@ -68,10 +86,12 @@ function parseArgs(argv) {
|
|
|
68
86
|
emit: false,
|
|
69
87
|
verdict: null,
|
|
70
88
|
skill: false,
|
|
89
|
+
lint: false,
|
|
90
|
+
scaffold: null,
|
|
71
91
|
};
|
|
72
92
|
// One positional, and only in first position, so a stray argument is an
|
|
73
93
|
// error rather than something silently ignored.
|
|
74
|
-
if (argv[0] === 'judge' || argv[0] === 'runtime') {
|
|
94
|
+
if (argv[0] === 'judge' || argv[0] === 'runtime' || argv[0] === 'checklist') {
|
|
75
95
|
options.command = argv[0];
|
|
76
96
|
argv = argv.slice(1);
|
|
77
97
|
}
|
|
@@ -92,11 +112,16 @@ function parseArgs(argv) {
|
|
|
92
112
|
else if (arg === '-c' || arg === '--config') options.config = next();
|
|
93
113
|
else if (arg === '--only') {
|
|
94
114
|
options.only = next();
|
|
95
|
-
if (
|
|
96
|
-
throw new Error(`--only takes "contrast" or "
|
|
115
|
+
if (!['contrast', 'treatments', 'tokens'].includes(options.only)) {
|
|
116
|
+
throw new Error(`--only takes "contrast", "treatments" or "tokens", not "${options.only}"`);
|
|
97
117
|
}
|
|
98
118
|
} else if (arg === '--json') options.format = 'json';
|
|
99
|
-
else if (arg === '--
|
|
119
|
+
else if (arg === '--lint') options.lint = true;
|
|
120
|
+
else if (arg === '--new') {
|
|
121
|
+
// The path is optional, so peek rather than demanding a value.
|
|
122
|
+
const value = argv[i + 1];
|
|
123
|
+
options.scaffold = value !== undefined && !value.startsWith('-') ? (i += 1, value) : true;
|
|
124
|
+
} else if (arg === '--emit') options.emit = true;
|
|
100
125
|
else if (arg === '--skill') options.skill = true;
|
|
101
126
|
else if (arg === '--verdict') options.verdict = next();
|
|
102
127
|
else if (arg === '--format') {
|
|
@@ -138,6 +163,26 @@ if (options.skill) {
|
|
|
138
163
|
process.exit(0);
|
|
139
164
|
}
|
|
140
165
|
|
|
166
|
+
// checklist --new is the one command that has to work before a config exists,
|
|
167
|
+
// because not having anything yet is the situation it is for.
|
|
168
|
+
if (options.command === 'checklist' && options.scaffold) {
|
|
169
|
+
const named = typeof options.scaffold === 'string' ? options.scaffold : null;
|
|
170
|
+
const fromConfig = (() => {
|
|
171
|
+
const loadedForPath = load(options.config);
|
|
172
|
+
return loadedForPath.ok ? loadedForPath.config.judge?.checklist : null;
|
|
173
|
+
})();
|
|
174
|
+
const target = resolve(named ?? fromConfig ?? 'design-checklist.md');
|
|
175
|
+
if (existsSync(target)) {
|
|
176
|
+
die(`${target} already exists. It is yours now, so this will not overwrite it.`);
|
|
177
|
+
}
|
|
178
|
+
writeFileSync(target, STARTER);
|
|
179
|
+
const lines = checklistEntries(STARTER).length;
|
|
180
|
+
console.log(`wrote ${target}`);
|
|
181
|
+
console.log(`${lines} lines, all of them about whether a screen is broken. The ones worth`);
|
|
182
|
+
console.log('having are the ones you add. Run taste-check checklist --lint after editing.');
|
|
183
|
+
process.exit(0);
|
|
184
|
+
}
|
|
185
|
+
|
|
141
186
|
for (const flag of ['emit', 'verdict']) {
|
|
142
187
|
if (options[flag] && options.command !== 'judge') {
|
|
143
188
|
die(`--${flag} applies to \`taste-check judge\`, not to ${options.command}`);
|
|
@@ -155,11 +200,42 @@ if (!loaded.ok) {
|
|
|
155
200
|
if (options.command !== 'check' && options.only) {
|
|
156
201
|
die(`--only applies to the deterministic checks, not to ${options.command}`);
|
|
157
202
|
}
|
|
203
|
+
if (options.lint && options.command !== 'checklist') {
|
|
204
|
+
die('--lint applies to `taste-check checklist`, not to ' + options.command);
|
|
205
|
+
}
|
|
158
206
|
|
|
159
|
-
|
|
207
|
+
// checklist is the exception: its file lives under judge.checklist rather than
|
|
208
|
+
// in a block of its own, and it says so itself below.
|
|
209
|
+
if (options.command !== 'check' && options.command !== 'checklist' && !loaded.config[options.command]) {
|
|
160
210
|
die(`${options.config} defines no "${options.command}" block.`);
|
|
161
211
|
}
|
|
162
212
|
|
|
213
|
+
if (options.command === 'checklist') {
|
|
214
|
+
if (!options.lint) die('taste-check checklist takes --new or --lint');
|
|
215
|
+
const path = loaded.config.judge?.checklist;
|
|
216
|
+
if (!path) die(`${options.config} defines no judge.checklist to lint.`);
|
|
217
|
+
let entries;
|
|
218
|
+
try {
|
|
219
|
+
entries = checklistEntries(readFileSync(resolve(loaded.dir, path), 'utf8'));
|
|
220
|
+
} catch {
|
|
221
|
+
die(`cannot read the checklist at ${path}`);
|
|
222
|
+
}
|
|
223
|
+
if (!entries.length) {
|
|
224
|
+
die(`${path} has no checklist lines in it. Lines are list items ("- ..." or "1. ...").`);
|
|
225
|
+
}
|
|
226
|
+
const findings = lintChecklist(entries);
|
|
227
|
+
if (!findings.length) {
|
|
228
|
+
console.log(`checklist ok, ${entries.length} ${entries.length === 1 ? 'line' : 'lines'} a judge can answer`);
|
|
229
|
+
process.exit(0);
|
|
230
|
+
}
|
|
231
|
+
console.log(`checklist, ${findings.length} to look at`);
|
|
232
|
+
for (const f of findings) {
|
|
233
|
+
console.log(` ${f.kind.padEnd(16)}${path}:${f.line} ${JSON.stringify(f.text)}`);
|
|
234
|
+
console.log(` ${' '.repeat(16)}${f.detail}`);
|
|
235
|
+
}
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
163
239
|
// --emit stops before any model is involved, so it has no findings to report
|
|
164
240
|
// and no exit code to earn. It still refuses to hand back a prompt when the
|
|
165
241
|
// screenshots or the checklist are missing.
|
|
@@ -192,7 +268,7 @@ else if (options.command === 'runtime') results = await runtime(loaded.config, l
|
|
|
192
268
|
else results = run(loaded.config, loaded.dir, { only: options.only });
|
|
193
269
|
|
|
194
270
|
if (!results.length) {
|
|
195
|
-
die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast or
|
|
271
|
+
die(`nothing to run. ${options.config} defines no ${options.only ?? 'contrast, treatments or tokens'} check.`);
|
|
196
272
|
}
|
|
197
273
|
|
|
198
274
|
if (options.format === 'sarif') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@josueavalosjim/taste-check",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"accessibility",
|
|
@@ -17,6 +17,11 @@
|
|
|
17
17
|
"treatments"
|
|
18
18
|
]
|
|
19
19
|
},
|
|
20
|
+
{
|
|
21
|
+
"required": [
|
|
22
|
+
"tokens"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
20
25
|
{
|
|
21
26
|
"required": [
|
|
22
27
|
"runtime"
|
|
@@ -260,6 +265,62 @@
|
|
|
260
265
|
}
|
|
261
266
|
}
|
|
262
267
|
},
|
|
268
|
+
"tokens": {
|
|
269
|
+
"type": "object",
|
|
270
|
+
"description": "Custom properties referenced by var() in markup, against the tokens the system declares.",
|
|
271
|
+
"additionalProperties": false,
|
|
272
|
+
"required": [
|
|
273
|
+
"declaredIn",
|
|
274
|
+
"files"
|
|
275
|
+
],
|
|
276
|
+
"properties": {
|
|
277
|
+
"declaredIn": {
|
|
278
|
+
"type": "array",
|
|
279
|
+
"minItems": 1,
|
|
280
|
+
"description": "Stylesheets that declare the tokens. Every custom property declared anywhere in them counts, in any scope or at-rule.",
|
|
281
|
+
"items": {
|
|
282
|
+
"type": "string"
|
|
283
|
+
}
|
|
284
|
+
},
|
|
285
|
+
"files": {
|
|
286
|
+
"type": "array",
|
|
287
|
+
"minItems": 1,
|
|
288
|
+
"description": "Markup to scan for var() references. A pattern matching nothing is a failure, never a clean run.",
|
|
289
|
+
"items": {
|
|
290
|
+
"type": "string"
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
"allow": {
|
|
294
|
+
"type": "array",
|
|
295
|
+
"description": "Custom property names that are fine to reference without being declared, for ones set from JavaScript or by a third-party stylesheet.",
|
|
296
|
+
"items": {
|
|
297
|
+
"type": "string",
|
|
298
|
+
"pattern": "^--"
|
|
299
|
+
}
|
|
300
|
+
},
|
|
301
|
+
"allowPrefixes": {
|
|
302
|
+
"type": "array",
|
|
303
|
+
"description": "Name prefixes that are always allowed, for a whole family a library owns.",
|
|
304
|
+
"items": {
|
|
305
|
+
"type": "string"
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
"$comment": {
|
|
309
|
+
"oneOf": [
|
|
310
|
+
{
|
|
311
|
+
"type": "string"
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
"type": "array",
|
|
315
|
+
"items": {
|
|
316
|
+
"type": "string"
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
],
|
|
320
|
+
"description": "Ignored. Somewhere to write down why this config, or this pair, is the way it is."
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
263
324
|
"judge": {
|
|
264
325
|
"type": "object",
|
|
265
326
|
"description": "A fresh-eyes review of your screenshots against your checklist, run by a model command you supply. Run with `taste-check judge`. Verdicts are advisory unless failOn says otherwise.",
|
|
@@ -78,5 +78,11 @@ old screenshots is not a verdict on the new ones.
|
|
|
78
78
|
## What this skill does not contain
|
|
79
79
|
|
|
80
80
|
Any design rules. The checklist is the user's file, named in their config, and
|
|
81
|
-
if it is empty then this reports nothing and that is correct.
|
|
82
|
-
|
|
81
|
+
if it is empty then this reports nothing and that is correct.
|
|
82
|
+
|
|
83
|
+
`taste-check checklist --new` will scaffold a starter into their repo, six
|
|
84
|
+
lines about whether a screen is broken rather than whether it is good. Once it
|
|
85
|
+
is written it is theirs, and what gets judged is whatever they edited it into.
|
|
86
|
+
Do not add lines to it yourself, and do not judge against a rule that is not in
|
|
87
|
+
it. A rule that arrives at the moment of judgment, from the tool or from you,
|
|
88
|
+
is somebody else's taste wearing borrowed authority.
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starting a checklist, and checking that the lines in one can be answered.
|
|
3
|
+
*
|
|
4
|
+
* Neither of these says what to value. The starter contains only lines about
|
|
5
|
+
* whether a screen is broken, which is not taste, and the linter is about the
|
|
6
|
+
* form of a line rather than its content.
|
|
7
|
+
*
|
|
8
|
+
* ── Why a starter at all, given this ships no design rules ──────────────
|
|
9
|
+
*
|
|
10
|
+
* There is a real difference between a tool that defaults to a checklist and a
|
|
11
|
+
* tool that writes one into your repo once. A default arrives with the tool's
|
|
12
|
+
* authority at the moment of judgment: every user who never opened the file is
|
|
13
|
+
* being judged against opinions they did not choose. A scaffold is a file you
|
|
14
|
+
* own, edited and committed by you, and what runs is yours. The tool never
|
|
15
|
+
* reads its own copy.
|
|
16
|
+
*
|
|
17
|
+
* eslint is the precedent. It ships rules and no default configuration, and
|
|
18
|
+
* `--init` writes you one. Nobody thinks eslint has opinions about their code
|
|
19
|
+
* until they enable some.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The starter.
|
|
24
|
+
*
|
|
25
|
+
* Six lines, and every one of them is about whether the screen is broken
|
|
26
|
+
* rather than whether it is good. That is deliberate and the header says so:
|
|
27
|
+
* these are the ones that are not anybody's taste, which means the lines worth
|
|
28
|
+
* having are the ones you add.
|
|
29
|
+
*/
|
|
30
|
+
export const STARTER = `# Design checklist
|
|
31
|
+
|
|
32
|
+
Lines here are judged one at a time against a screenshot, and each gets one
|
|
33
|
+
verdict: pass, fail, or unsure.
|
|
34
|
+
|
|
35
|
+
Everything below is about whether the screen is broken. None of it is taste,
|
|
36
|
+
which is the only reason it could ship inside a tool. The lines worth having
|
|
37
|
+
are the ones you add, and they should be the specific things your work gets
|
|
38
|
+
wrong, in your words.
|
|
39
|
+
|
|
40
|
+
Two things make a line answerable. It has to be settleable by looking, so
|
|
41
|
+
"nothing is cut off" works where "looks expensive" comes back unsure every
|
|
42
|
+
run. And it has to ask one thing, because a line joining two claims cannot be
|
|
43
|
+
answered by a single verdict.
|
|
44
|
+
|
|
45
|
+
Anything you can measure belongs in a contrast pair or a runtime target
|
|
46
|
+
instead, where the answer is a number rather than an opinion.
|
|
47
|
+
|
|
48
|
+
Note that only list items are judged. Everything above this is prose and is
|
|
49
|
+
ignored, so there is room here to write down why these lines and not others.
|
|
50
|
+
|
|
51
|
+
Run \`taste-check checklist --lint\` after editing.
|
|
52
|
+
|
|
53
|
+
- Nothing important is cut off at an edge of the frame
|
|
54
|
+
- Every piece of text is legible against what is directly behind it
|
|
55
|
+
- Nothing overlaps something it was not meant to overlap
|
|
56
|
+
- Nothing is obviously misaligned with the things beside it
|
|
57
|
+
- Nothing is still showing a loading, empty, or placeholder state
|
|
58
|
+
- Nothing appears twice that was meant to appear once
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
/** Words that describe a feeling rather than something visible in an image. */
|
|
62
|
+
const UNFALSIFIABLE = [
|
|
63
|
+
'premium', 'modern', 'clean', 'professional', 'polished', 'elegant',
|
|
64
|
+
'beautiful', 'delightful', 'intuitive', 'slick', 'crisp', 'tasteful',
|
|
65
|
+
'cohesive', 'harmonious', 'balanced', 'pleasing', 'appealing', 'nice',
|
|
66
|
+
'good', 'bad', 'ugly', 'feel', 'feels', 'vibe', 'aesthetic',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/** Things a still image cannot show. */
|
|
70
|
+
const NOT_IN_A_STILL = [
|
|
71
|
+
'animation', 'animate', 'transition', 'hover', 'scroll', 'scrolling',
|
|
72
|
+
'load time', 'performance', 'sound', 'audio', 'click', 'tap', 'drag',
|
|
73
|
+
'keyboard', 'focus ring', 'video plays',
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
const MEASURABLE = /\b\d+(\.\d+)?\s*(:\s*1|px|rem|em|%|pt)\b|\bcontrast ratio\b|\b\d+:\d+\b/i;
|
|
77
|
+
const COMPOUND = /\b(and|or)\b/i;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check a single line for whether a judge could answer it.
|
|
81
|
+
*
|
|
82
|
+
* Never about content. A line can ask for anything at all; these findings are
|
|
83
|
+
* about whether asking it produces a verdict or a shrug.
|
|
84
|
+
*/
|
|
85
|
+
export function lintLine(text) {
|
|
86
|
+
const findings = [];
|
|
87
|
+
const lower = text.toLowerCase();
|
|
88
|
+
const words = text.trim().split(/\s+/);
|
|
89
|
+
|
|
90
|
+
const vague = UNFALSIFIABLE.filter((w) => new RegExp(`\\b${w}\\b`, 'i').test(lower));
|
|
91
|
+
if (vague.length) {
|
|
92
|
+
findings.push({
|
|
93
|
+
kind: 'unfalsifiable',
|
|
94
|
+
detail:
|
|
95
|
+
`"${vague.join('", "')}" describes a feeling rather than something visible. ` +
|
|
96
|
+
`A judge answers unsure, every run.`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const unseen = NOT_IN_A_STILL.filter((w) => lower.includes(w));
|
|
101
|
+
if (unseen.length) {
|
|
102
|
+
findings.push({
|
|
103
|
+
kind: 'not-in-a-still',
|
|
104
|
+
detail: `"${unseen.join('", "')}" cannot be seen in a screenshot. The judge only gets images.`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (MEASURABLE.test(text)) {
|
|
109
|
+
findings.push({
|
|
110
|
+
kind: 'measurable',
|
|
111
|
+
detail:
|
|
112
|
+
'this asks for a number. Put it in a contrast pair or a runtime target, ' +
|
|
113
|
+
'where the answer is measured rather than judged.',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Only compound when both halves look like claims, so "black and white" or
|
|
118
|
+
// "cut off at the top or bottom" are left alone.
|
|
119
|
+
if (COMPOUND.test(text)) {
|
|
120
|
+
// Three words a side, so "black and white" and "the top or bottom" are
|
|
121
|
+
// left alone while two real claims are caught.
|
|
122
|
+
const halves = text.split(/\b(?:and|or)\b/i).map((h) => h.trim().split(/\s+/).filter(Boolean).length);
|
|
123
|
+
if (halves.length > 1 && halves.every((n) => n >= 3)) {
|
|
124
|
+
findings.push({
|
|
125
|
+
kind: 'compound',
|
|
126
|
+
detail: 'two claims in one line. One verdict cannot answer both, so split it.',
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (words.length < 4) {
|
|
132
|
+
findings.push({ kind: 'too-short', detail: 'too short to say what is being asked.' });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Lint every line of a checklist, keeping the line numbers. */
|
|
139
|
+
export function lintChecklist(entries) {
|
|
140
|
+
const findings = [];
|
|
141
|
+
for (const entry of entries) {
|
|
142
|
+
for (const finding of lintLine(entry.text)) {
|
|
143
|
+
findings.push({ ...finding, line: entry.line, text: entry.text });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return findings;
|
|
147
|
+
}
|
package/src/config.mjs
CHANGED
|
@@ -151,6 +151,19 @@ function validateTreatments(treatments, errors) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
+
function validateTokens(tokens, errors) {
|
|
155
|
+
rejectUnknown(tokens, ['declaredIn', 'files', 'allow', 'allowPrefixes'], 'tokens', errors);
|
|
156
|
+
stringArray(tokens.declaredIn, 'tokens.declaredIn', errors);
|
|
157
|
+
stringArray(tokens.files, 'tokens.files', errors);
|
|
158
|
+
stringArray(tokens.allow, 'tokens.allow', errors, { required: false });
|
|
159
|
+
stringArray(tokens.allowPrefixes, 'tokens.allowPrefixes', errors, { required: false });
|
|
160
|
+
for (const name of tokens.allow ?? []) {
|
|
161
|
+
if (typeof name === 'string' && !name.startsWith('--')) {
|
|
162
|
+
errors.push(`tokens.allow has "${name}", which is not a custom property name. Names start with "--".`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
154
167
|
function validateJudge(judge, errors) {
|
|
155
168
|
rejectUnknown(judge, ['checklist', 'shots', 'shotCommand', 'command', 'failOn'], 'judge', errors);
|
|
156
169
|
if (typeof judge.checklist !== 'string' || !judge.checklist) {
|
|
@@ -246,15 +259,18 @@ function validateRuntime(runtime, errors) {
|
|
|
246
259
|
export function validate(config) {
|
|
247
260
|
const errors = [];
|
|
248
261
|
if (!isPlainObject(config)) return ['the config must be a JSON object'];
|
|
249
|
-
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'judge', 'runtime'], 'the config', errors);
|
|
262
|
+
rejectUnknown(config, ['$schema', 'contrast', 'treatments', 'tokens', 'judge', 'runtime'], 'the config', errors);
|
|
250
263
|
|
|
251
264
|
if (
|
|
252
265
|
config.contrast === undefined &&
|
|
253
266
|
config.treatments === undefined &&
|
|
267
|
+
config.tokens === undefined &&
|
|
254
268
|
config.judge === undefined &&
|
|
255
269
|
config.runtime === undefined
|
|
256
270
|
) {
|
|
257
|
-
errors.push(
|
|
271
|
+
errors.push(
|
|
272
|
+
'the config must define at least one of "contrast", "treatments", "tokens", "runtime" or "judge"',
|
|
273
|
+
);
|
|
258
274
|
}
|
|
259
275
|
if (config.contrast !== undefined) {
|
|
260
276
|
if (isPlainObject(config.contrast)) validateContrast(config.contrast, errors);
|
|
@@ -264,6 +280,10 @@ export function validate(config) {
|
|
|
264
280
|
if (isPlainObject(config.treatments)) validateTreatments(config.treatments, errors);
|
|
265
281
|
else errors.push('treatments must be an object');
|
|
266
282
|
}
|
|
283
|
+
if (config.tokens !== undefined) {
|
|
284
|
+
if (isPlainObject(config.tokens)) validateTokens(config.tokens, errors);
|
|
285
|
+
else errors.push('tokens must be an object');
|
|
286
|
+
}
|
|
267
287
|
if (config.judge !== undefined) {
|
|
268
288
|
if (isPlainObject(config.judge)) validateJudge(config.judge, errors);
|
|
269
289
|
else errors.push('judge must be an object');
|
package/src/index.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export { runContrast } from './contrast.mjs';
|
|
6
6
|
export { runTreatments } from './treatments.mjs';
|
|
7
|
+
export { runTokens, varRefs } from './tokens.mjs';
|
|
7
8
|
export { runJudge, prepareJudge, gradeVerdict, buildPrompt, checklistLines, checklistEntries, extractJson } from './judge.mjs';
|
|
8
9
|
export { runRuntime } from './runtime.mjs';
|
|
9
10
|
export { connect, findBrowser } from './cdp.mjs';
|
|
@@ -15,6 +16,7 @@ export { openTags, classesOf } from './treatments.mjs';
|
|
|
15
16
|
|
|
16
17
|
import { runContrast } from './contrast.mjs';
|
|
17
18
|
import { runTreatments } from './treatments.mjs';
|
|
19
|
+
import { runTokens } from './tokens.mjs';
|
|
18
20
|
import { runJudge } from './judge.mjs';
|
|
19
21
|
import { runRuntime } from './runtime.mjs';
|
|
20
22
|
|
|
@@ -34,6 +36,9 @@ export function run(config, cwd, { only = null } = {}) {
|
|
|
34
36
|
if (config.treatments && (!only || only === 'treatments')) {
|
|
35
37
|
results.push(runTreatments(config.treatments, cwd));
|
|
36
38
|
}
|
|
39
|
+
if (config.tokens && (!only || only === 'tokens')) {
|
|
40
|
+
results.push(runTokens(config.tokens, cwd));
|
|
41
|
+
}
|
|
37
42
|
return results;
|
|
38
43
|
}
|
|
39
44
|
|
package/src/judge.mjs
CHANGED
|
@@ -18,9 +18,12 @@
|
|
|
18
18
|
* every line, prefer unsure to a guess, quote what you actually see. That part
|
|
19
19
|
* is method and it is portable.
|
|
20
20
|
*
|
|
21
|
-
* The CHECKLIST is yours. taste-check
|
|
22
|
-
*
|
|
23
|
-
*
|
|
21
|
+
* The CHECKLIST is yours. taste-check enforces no rule it wrote: what runs is
|
|
22
|
+
* the file in your repo. `checklist --new` will scaffold you a starting point,
|
|
23
|
+
* and the distinction is not a dodge, it is the difference between a default
|
|
24
|
+
* and a scaffold. A default judges every user who never opened the file
|
|
25
|
+
* against opinions they did not choose. A scaffold is a file you edited and
|
|
26
|
+
* committed, and this never reads its own copy of it.
|
|
24
27
|
*
|
|
25
28
|
* ── Where the determinism line falls ────────────────────────────────────
|
|
26
29
|
*
|
package/src/report.mjs
CHANGED
|
@@ -54,6 +54,10 @@ function judgeLines(result) {
|
|
|
54
54
|
export const linesFor = (result) => {
|
|
55
55
|
if (result.name === 'contrast' || result.name === 'runtime') return contrastLines(result);
|
|
56
56
|
if (result.name === 'judge') return judgeLines(result);
|
|
57
|
+
// tokens and treatments both report {file, line, message} failures, so they
|
|
58
|
+
// render the same way. Named rather than left to the fallthrough: a check
|
|
59
|
+
// that renders by accident is a check that stops rendering by accident.
|
|
60
|
+
if (result.name === 'tokens') return treatmentLines(result);
|
|
57
61
|
return treatmentLines(result);
|
|
58
62
|
};
|
|
59
63
|
|
package/src/sarif.mjs
CHANGED
|
@@ -32,6 +32,8 @@ const RULES = [
|
|
|
32
32
|
['treatments/unapproved-class', 'error', 'A class name that is not on the approved list.'],
|
|
33
33
|
['treatments/one-off-value', 'error', 'A literal colour or length hardcoded into an inline style.'],
|
|
34
34
|
['treatments/unscannable', 'error', 'No markup matched, so nothing was checked.'],
|
|
35
|
+
['tokens/undefined', 'error', 'Markup references a custom property that no token file declares.'],
|
|
36
|
+
['tokens/unscannable', 'error', 'The tokens check could not run: no token file, no markup, or a token file that declares nothing.'],
|
|
35
37
|
['runtime/below-floor', 'error', 'A target on the rendered page is under the ratio it was given.'],
|
|
36
38
|
['runtime/unmeasurable', 'error', 'A target could not be measured: no element, nothing rendered, an edge with no width, or no browser.'],
|
|
37
39
|
['judge/verdict', 'note', 'A checklist line the judge did not pass. Advisory: a model verdict is not reproducible.'],
|
|
@@ -118,7 +120,7 @@ function resultsFor(check, ctx) {
|
|
|
118
120
|
return out;
|
|
119
121
|
}
|
|
120
122
|
|
|
121
|
-
if (check.name === 'treatments') {
|
|
123
|
+
if (check.name === 'treatments' || check.name === 'tokens') {
|
|
122
124
|
for (const failure of check.failures ?? []) {
|
|
123
125
|
out.push({
|
|
124
126
|
ruleId: failure.rule,
|
|
@@ -130,7 +132,7 @@ function resultsFor(check, ctx) {
|
|
|
130
132
|
}
|
|
131
133
|
for (const problem of check.problems ?? []) {
|
|
132
134
|
out.push({
|
|
133
|
-
ruleId:
|
|
135
|
+
ruleId: `${check.name}/unscannable`,
|
|
134
136
|
key: problem,
|
|
135
137
|
level: 'error',
|
|
136
138
|
message: { text: problem },
|
package/src/tokens.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The undefined-token check: every `var(--x)` in markup, against the tokens
|
|
3
|
+
* the system actually declares.
|
|
4
|
+
*
|
|
5
|
+
* ── Why markup and not stylesheets ──────────────────────────────────────
|
|
6
|
+
*
|
|
7
|
+
* Stylelint's core `no-unknown-custom-properties` already does this for CSS,
|
|
8
|
+
* and its `referenceFiles` option covers the case that matters, where tokens
|
|
9
|
+
* are declared in one file and consumed in another. Rebuilding that would be
|
|
10
|
+
* a worse copy of a first-party rule.
|
|
11
|
+
*
|
|
12
|
+
* What nothing covers is markup. Stylelint's CSS-in-JS syntax is deprecated,
|
|
13
|
+
* and `style={{ color: open ? 'var(--a)' : 'var(--b)' }}` is not CSS-in-JS
|
|
14
|
+
* anyway: it is a JavaScript object literal that no CSS parser reads. That is
|
|
15
|
+
* the gap, and it is the one a model writing plausible off-system code falls
|
|
16
|
+
* into, reaching for `--color-primary-500` when the system says `--brand-ink`.
|
|
17
|
+
*
|
|
18
|
+
* ── Why a plain scan and not the tag walker ─────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `treatments.mjs` walks tags because a class name is only a class name in an
|
|
21
|
+
* attribute, and a bare word elsewhere is noise. `var(--` is not like that.
|
|
22
|
+
* The string is unambiguous wherever it appears, so scanning the whole file
|
|
23
|
+
* costs nothing in false positives and picks up references the tag walker
|
|
24
|
+
* cannot see: a styled-components template, an object of styles declared above
|
|
25
|
+
* the return, a constant in a helper module.
|
|
26
|
+
*
|
|
27
|
+
* Nesting comes free. `var(--a, var(--b))` matches twice, so both names are
|
|
28
|
+
* checked rather than only the outer one.
|
|
29
|
+
*/
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { lineAt, parseDeclarations } from './css.mjs';
|
|
32
|
+
import { expand, label } from './files.mjs';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The name is `[\w-]+` for the same reason `resolveValue` in css.mjs uses it:
|
|
36
|
+
* escapes and non-ASCII in a custom property name are legal and vanishingly
|
|
37
|
+
* rare, and inventing a fuller grammar here would only disagree with the
|
|
38
|
+
* resolver next door.
|
|
39
|
+
*/
|
|
40
|
+
const VAR_REF = /var\(\s*(--[\w-]+)\s*(,?)/g;
|
|
41
|
+
|
|
42
|
+
const count = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Every custom property referenced through `var()`, with the offset of the
|
|
46
|
+
* name and whether the reference carries a fallback.
|
|
47
|
+
*
|
|
48
|
+
* A bare `--x` written outside a `var()` is deliberately not a reference. That
|
|
49
|
+
* is a different mistake, and stylelint's `custom-property-no-missing-var-function`
|
|
50
|
+
* is the rule for it.
|
|
51
|
+
*/
|
|
52
|
+
export function varRefs(source) {
|
|
53
|
+
const found = [];
|
|
54
|
+
for (const m of source.matchAll(VAR_REF)) {
|
|
55
|
+
found.push({ name: m[1], index: m.index + m[0].indexOf(m[1]), fallback: m[2] === ',' });
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Every custom property name declared anywhere in the token files. */
|
|
61
|
+
function declaredNames(files) {
|
|
62
|
+
const names = new Set();
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
for (const decl of parseDeclarations(readFileSync(file, 'utf8'))) names.add(decl.prop);
|
|
65
|
+
}
|
|
66
|
+
return names;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function runTokens(config, cwd) {
|
|
70
|
+
const failures = [];
|
|
71
|
+
const problems = [];
|
|
72
|
+
const { declaredIn, files: patterns, allow = [], allowPrefixes = [] } = config;
|
|
73
|
+
|
|
74
|
+
const quoted = (list) => list.map((p) => `"${p}"`).join(', ');
|
|
75
|
+
const tokenFiles = expand(declaredIn, cwd);
|
|
76
|
+
const files = expand(patterns, cwd);
|
|
77
|
+
|
|
78
|
+
if (!tokenFiles.length) problems.push(`no token files matched ${quoted(declaredIn)}`);
|
|
79
|
+
const declared = declaredNames(tokenFiles);
|
|
80
|
+
if (tokenFiles.length && !declared.size) {
|
|
81
|
+
problems.push(
|
|
82
|
+
`${count(tokenFiles.length, 'token file')} matched ${quoted(declaredIn)}, ` +
|
|
83
|
+
`but none of them declares a custom property. Every reference would read as undefined.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (!files.length) problems.push(`no markup files matched ${quoted(patterns)}`);
|
|
87
|
+
|
|
88
|
+
// Without a trustworthy declared set there is nothing to compare against,
|
|
89
|
+
// and reporting every reference in the codebase as undefined would bury the
|
|
90
|
+
// one line that says why.
|
|
91
|
+
if (problems.length) return { name: 'tokens', failures, problems, summary: '' };
|
|
92
|
+
|
|
93
|
+
const allowed = new Set(allow);
|
|
94
|
+
let references = 0;
|
|
95
|
+
|
|
96
|
+
for (const file of files) {
|
|
97
|
+
const source = readFileSync(file, 'utf8');
|
|
98
|
+
for (const { name, index, fallback } of varRefs(source)) {
|
|
99
|
+
references += 1;
|
|
100
|
+
if (declared.has(name)) continue;
|
|
101
|
+
if (allowed.has(name)) continue;
|
|
102
|
+
if (allowPrefixes.some((p) => name.startsWith(p))) continue;
|
|
103
|
+
failures.push({
|
|
104
|
+
rule: 'tokens/undefined',
|
|
105
|
+
file: label(file, cwd),
|
|
106
|
+
line: lineAt(source, index),
|
|
107
|
+
subject: name,
|
|
108
|
+
message:
|
|
109
|
+
`"${name}" is referenced here but declared in no token file.` +
|
|
110
|
+
(fallback ? ' It has a fallback, so it degrades rather than breaks.' : '') +
|
|
111
|
+
' Check the name, or add it to allow.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// `references` is in the summary rather than a failure on purpose. A zero
|
|
117
|
+
// can mean a glob pointing at the wrong tree, but it can equally mean a
|
|
118
|
+
// codebase that keeps its styling in stylesheets, which is not a fault. The
|
|
119
|
+
// wrong-glob case that can be told apart is the one above: no files matched.
|
|
120
|
+
return {
|
|
121
|
+
name: 'tokens',
|
|
122
|
+
failures,
|
|
123
|
+
problems,
|
|
124
|
+
summary:
|
|
125
|
+
`${count(tokenFiles.length, 'token file')}, ${declared.size} declared, ` +
|
|
126
|
+
`${count(files.length, 'file')} scanned, ${count(references, 'reference')}`,
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/treatments.mjs
CHANGED
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* positive is a conversation while a false negative is the bug shipping.
|
|
21
21
|
*/
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { lineAt } from './css.mjs';
|
|
23
24
|
import { expand, label } from './files.mjs';
|
|
24
25
|
|
|
25
26
|
/** Literal value shapes worth flagging when they are not on the list. */
|
|
@@ -178,8 +179,6 @@ function inlineValues(attrs) {
|
|
|
178
179
|
return found;
|
|
179
180
|
}
|
|
180
181
|
|
|
181
|
-
const lineOf = (source, index) => source.slice(0, index).split('\n').length;
|
|
182
|
-
|
|
183
182
|
export function runTreatments(config, cwd) {
|
|
184
183
|
const failures = [];
|
|
185
184
|
const problems = [];
|
|
@@ -202,7 +201,7 @@ export function runTreatments(config, cwd) {
|
|
|
202
201
|
|
|
203
202
|
for (const file of files) {
|
|
204
203
|
const source = readFileSync(file, 'utf8');
|
|
205
|
-
const where = (at) => ({ file: label(file, cwd), line:
|
|
204
|
+
const where = (at) => ({ file: label(file, cwd), line: lineAt(source, at) });
|
|
206
205
|
|
|
207
206
|
for (const tag of openTags(source, elements)) {
|
|
208
207
|
for (const { name, at } of classesOf(tag.attrs)) {
|