@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 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. taste-check measures those and fails
7
- your build on them, either from your token file or from a page it renders in a
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 ships no design rules of its own. Not a palette, not a class list, not
67
- a contrast floor, not a checklist. You supply all of it.
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. taste-check ships none, and there is a test asserting
288
- the framing mentions no design vocabulary at all, because a rule that arrives
289
- inside a tool is somebody else's taste with the tool's authority behind it.
290
- Checklist lines are list items in your file; a heading or a paragraph is prose
291
- and is not judged.
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 treatments
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 supplies none on purpose and runs
459
- in CI with an exit code instead.
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
- 34 tests. Most of them plant a violation into a fixture that was passing a
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 nothing.
643
+ hole, a fabricated token hidden in a ternary, a file pattern pointed at
644
+ nothing.
525
645
 
526
646
  ## License
527
647
 
@@ -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 treatments
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 (options.only !== 'contrast' && options.only !== 'treatments') {
96
- throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
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 === '--emit') options.emit = true;
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
- if (options.command !== 'check' && !loaded.config[options.command]) {
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 treatments'} check.`);
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.9.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. A checklist that
82
- arrived with a tool is somebody else's taste wearing the tool's authority.
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('the config must define at least one of "contrast", "treatments", "runtime" or "judge"');
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 contains no design rules and never will,
22
- * because a shipped checklist is just somebody else's taste wearing the
23
- * authority of a tool.
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: 'treatments/unscannable',
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
+ }
@@ -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: lineOf(source, at) });
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)) {