@josueavalosjim/taste-check 0.1.0 → 0.2.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
@@ -50,6 +50,8 @@ Each of these exits 1 rather than passing quietly:
50
50
  - a pair naming a token that does not exist
51
51
  - a file pattern matching no files
52
52
  - a theme whose scopes resolve no tokens
53
+ - a single scope inside a theme that selects nothing, even when the theme has
54
+ tokens from its other scopes
53
55
  - a colour value the parser does not understand
54
56
  - an unknown key in the config, which is usually a typo doing nothing
55
57
 
@@ -85,16 +87,24 @@ in code, because the floor for a decorative hairline and the floor for body
85
87
  text are different decisions and both are yours.
86
88
 
87
89
  A theme is an ordered list of scopes and later scopes win, which is the cascade
88
- for equal specificity. Declarations inside an at-rule are ignored unless a
89
- scope opts in:
90
+ for equal specificity.
91
+
92
+ `@layer` is transparent. A `:root` inside `@layer tokens` resolves exactly as a
93
+ top-level `:root` does, because a layer changes cascade priority rather than
94
+ whether the declarations apply at all.
95
+
96
+ Conditional at-rules are different. `@media`, `@supports`, `@container` and
97
+ `@scope` only apply when their condition holds, so their declarations are
98
+ ignored unless a scope opts in by name:
90
99
 
91
100
  ```json
92
101
  { "name": "dark-system", "scopes": [":root", { "selector": ":root", "atRule": "prefers-color-scheme: dark" }] }
93
102
  ```
94
103
 
95
- Without that rule a `@media (prefers-color-scheme: dark)` block containing
96
- `:root` would overwrite the light theme, and the light checks would silently
97
- measure against colours the light theme never paints.
104
+ Without that, a `@media (prefers-color-scheme: dark)` block containing `:root`
105
+ would overwrite the light theme, and the light checks would measure against
106
+ colours the light theme never paints. You can also name a layer this way to
107
+ narrow a scope to it, but you should never need to just to see your tokens.
98
108
 
99
109
  Two details make the numbers match a browser rather than approximate it.
100
110
  Translucent foregrounds are composited over their background before measuring,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Deterministic design-system checks: WCAG contrast over your own tokens, and a one-off value linter over your own approved list. No palette, no class list, no opinions shipped.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -45,7 +45,7 @@
45
45
  "selector": { "type": "string", "minLength": 1 },
46
46
  "atRule": {
47
47
  "type": "string",
48
- "description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Without this, at-rule declarations are ignored."
48
+ "description": "Opt into declarations nested in a matching at-rule prelude, for example \"prefers-color-scheme: dark\". Conditional at-rules (@media, @supports, @container, @scope) are ignored without this. Grouping at-rules like @layer are always transparent, so you only name one to narrow a scope to it."
49
49
  }
50
50
  }
51
51
  }
package/src/contrast.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import { readFileSync } from 'node:fs';
20
20
  import { contrastRatio, isOpaque, parseColor } from './color.mjs';
21
- import { parseDeclarations, resolveScopes, resolveValue } from './css.mjs';
21
+ import { parseDeclarations, resolveScopes, resolveValue, unmatchedScopes } from './css.mjs';
22
22
  import { expand, label } from './files.mjs';
23
23
 
24
24
  /** A token name, or a literal colour, resolved to rgba for one theme. */
@@ -62,6 +62,20 @@ export function runContrast(config, cwd) {
62
62
  continue;
63
63
  }
64
64
 
65
+ // A scope that selects nothing is not a smaller theme, it is a typo. The
66
+ // theme above still has tokens, so nothing else here would notice.
67
+ const dead = unmatchedScopes(decls, theme.scopes);
68
+ if (dead.length) {
69
+ for (const scope of dead) {
70
+ const shown = typeof scope === 'string' ? scope : JSON.stringify(scope);
71
+ problems.push(
72
+ `theme "${theme.name}": the scope ${shown} matched no declaration. ` +
73
+ `Every token it was meant to contribute is coming from another scope instead.`,
74
+ );
75
+ }
76
+ continue;
77
+ }
78
+
65
79
  for (const pair of pairs) {
66
80
  if (pair.themes && !pair.themes.includes(theme.name)) continue;
67
81
  const where = `${pair.fg} on ${pair.bg}`;
package/src/css.mjs CHANGED
@@ -13,11 +13,31 @@
13
13
  * relies on `.a.b` beating `.b`, list the scopes in the order you want them
14
14
  * applied and the result is the one you asked for.
15
15
  *
16
- * Declarations inside an at-rule are ignored unless a scope opts into that
17
- * at-rule by name. Without that rule, a `@media (prefers-color-scheme: dark)`
18
- * block containing `:root` would silently overwrite the light theme, and the
19
- * light theme would be checked against colours it never paints.
16
+ * At-rules split into two kinds, and they are treated differently because
17
+ * they mean different things.
18
+ *
19
+ * A CONDITIONAL at-rule (`@media`, `@supports`, `@container`, `@scope`) only
20
+ * applies when its condition holds, so its declarations are ignored unless a
21
+ * scope opts into it by name. Without that, a
22
+ * `@media (prefers-color-scheme: dark)` block containing `:root` would
23
+ * overwrite the light theme, and light would be checked against colours it
24
+ * never paints.
25
+ *
26
+ * A GROUPING at-rule, `@layer` above all, always applies. It changes cascade
27
+ * priority, not whether the declarations exist. So it is transparent here: a
28
+ * `:root` inside `@layer tokens` resolves exactly as a top-level `:root`
29
+ * would. A scope can still name a layer to narrow to it, but nobody should
30
+ * have to write that just to see their own tokens.
31
+ *
32
+ * Layer order is not modelled. Within the scopes a theme lists, the last
33
+ * declaration still wins, same as everywhere else here.
34
+ */
35
+
36
+ /**
37
+ * At-rules whose contents are conditional, and so must be opted into.
38
+ * Anything else wrapping a rule is grouping, and is looked straight through.
20
39
  */
40
+ const CONDITIONAL = /^@(media|supports|container|scope|document)\b/;
21
41
 
22
42
  /** Blank out comments, keeping every offset and newline so lines stay true. */
23
43
  function blankComments(css) {
@@ -104,22 +124,42 @@ function selectorMatches(list, scope) {
104
124
  * The token table for one theme: a Map of `--name` to { value, selector }.
105
125
  * Scopes are applied in order, later winning.
106
126
  */
127
+ /** The declarations one scope selects, in source order. */
128
+ function declsForScope(decls, scope) {
129
+ const selector = typeof scope === 'string' ? scope : scope.selector;
130
+ const atRule = typeof scope === 'string' ? null : (scope.atRule ?? null);
131
+ return decls.filter((d) => {
132
+ if (!selectorMatches(d.selector, selector)) return false;
133
+ if (atRule === null) {
134
+ // Only a conditional wrapper hides a declaration by default. A grouping
135
+ // one like @layer does not.
136
+ return !d.atRules.some((a) => CONDITIONAL.test(a));
137
+ }
138
+ return d.atRules.some((a) => a.includes(atRule));
139
+ });
140
+ }
141
+
107
142
  export function resolveScopes(decls, scopes) {
108
143
  const table = new Map();
109
144
  for (const scope of scopes) {
110
- const selector = typeof scope === 'string' ? scope : scope.selector;
111
- const atRule = typeof scope === 'string' ? null : (scope.atRule ?? null);
112
- for (const d of decls) {
113
- if (!selectorMatches(d.selector, selector)) continue;
114
- if (atRule === null) {
115
- if (d.atRules.length) continue;
116
- } else if (!d.atRules.some((a) => a.includes(atRule))) continue;
117
- table.set(d.prop, d);
118
- }
145
+ for (const d of declsForScope(decls, scope)) table.set(d.prop, d);
119
146
  }
120
147
  return table;
121
148
  }
122
149
 
150
+ /**
151
+ * The scopes in a theme that selected nothing at all.
152
+ *
153
+ * A theme can resolve plenty of tokens while one of its scopes is quietly
154
+ * dead: write `[data-theme="dark"]` when the stylesheet says
155
+ * `:root[data-theme="dark"]` and the base scope still fills the table, so the
156
+ * dark theme gets measured against the light values and reports a pass. The
157
+ * numbers look right, they are just the wrong theme's numbers.
158
+ */
159
+ export function unmatchedScopes(decls, scopes) {
160
+ return scopes.filter((scope) => declsForScope(decls, scope).length === 0);
161
+ }
162
+
123
163
  /**
124
164
  * Follow `var(--other)` indirection to a concrete value.
125
165
  *