@nxds/eslint-plugin 0.7.0 → 0.8.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
@@ -14,12 +14,16 @@ Requires `jsonc-eslint-parser`. Wired in the root `eslint.config.js` to target s
14
14
 
15
15
  Enforces the `nx:` Tailwind-class conventions from `.claude/rules/shadcn-divergences.md` on class strings (string + single-quasi template literals): correct prefix order (`nx:` before every modifier, not `hover:nx:…`), no banned `accent` token, complete semantic token paths (`-background` / `-foreground` / `-subtle`), and no raw primitive colors (`nx:bg-blue-500`). Ported from the former `.claude/hooks/lint-nx-prefix.mjs` so the checks run in `pnpm lint` and the pre-commit hook. Wired for `packages/react/src/**` and `apps/**` `.tsx`.
16
16
 
17
+ Icons keep one size at every density, so the rule reports an icon sized from spacing. That is a numeric spacing step (`size-4`, `h-3`, `w-3`) or a value that reads a spacing variable, such as `size-[var(--nx-spacing-8)]`. It checks a variant whose subject is the `svg`, such as `[&_svg]:size-4` or `[&>svg:not(…)]:h-3`, and the `className` of an `Icon*` or `*Icon` JSX element. `has-[>svg]:size-8` and `[&:has(svg)]:size-9` size the box around the icon, so they pass. Use `size-icon-glyph-xs`, `-sm`, `-default`, `-lg` or `-xl`. They follow the UI scale. Stories are checked, because the docs pages copy their code. The docs app is not checked, and neither are Vue template `class` attributes.
18
+
17
19
  One check reads a whole class-string scope rather than a single literal: `nx:transition-colors` beside a ring-painting `focus-visible:outline-*` class fades the focus ring in, because Tailwind expands `transition-colors` to a list carrying `outline-color`. Only a width or a colour paints a ring, so `outline-none`, `outline-hidden`, `outline-0`, `outline-offset-*` and the style keywords (`outline-solid` / `dashed` / `dotted` / `double`) are exempt. Use `nx:transition-control` / `nx:transition-field` instead — see `/foundations/focus`.
18
20
 
19
21
  **What counts as one scope.** One class string; one call to `cva`, `cn`, `clsx` or `cx` — base, array elements and `variants` values together; or one `[…].join(' ')`, which collapses an array literal into a single attribute. That **over-approximates**: two sibling `variants` values of the same key are mutually exclusive and never share an element, and the rule reports the pair anyway. Over-reporting is the safe direction here, and the shape every field surface is written in — a base string plus a ring in a later array element — needs the call-wide scope to be caught at all.
20
22
 
21
23
  Two things fall outside a scope and pass: an array that is never joined, whose elements are per-item class strings; and a string that only meets the rest at a call site — a shared `const`, an argument handed to another function, or a callback body the enclosing call takes with it. The four composer names must be written plainly, and `join` is the one member expression the walk reads, on an array literal receiver — so an aliased or chained composer (`twMerge`, `utils.cn`, a renamed import, `[…].filter(Boolean).join(' ')`) is invisible to it. Specimen surfaces are exempt from this check: the docs pages under `apps/docs/app/_pages`, where a pairing is the subject of the prose. Stories and the rest of the docs app are checked.
22
24
 
25
+ A second scope check covers icon line width. A class list that sizes `svg` children, such as `[&_svg]:size-…` or `[&>svg]:h-…`, must also include `nx:icon-stroke`, so every stroked icon draws the line for its size, from `--nx-icon-stroke-base` and `--nx-icon-stroke-ratio`. It must size them with a `size-icon-glyph-*` class, because `size-*`, `h-*` and `w-*` leave `--nx-icon-size` unset. Stories and the docs app are not checked.
26
+
23
27
  ### `@nxds/no-render-prop-types`
24
28
 
25
29
  Enforces `.claude/rules/composition-over-render-props.md`: component props must not be typed as render callbacks (`(...) => ReactNode`) or component references (`ComponentType` / `FC` / `ElementType`). Event-handler-named props (`on*`) are exempt. Use `children` / named `ReactNode` slots or per-mode components instead. Third-party-mandated shapes (e.g. recharts) opt out with a scoped `eslint-disable` + reason.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxds/eslint-plugin",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "ESLint rules that enforce nxds conventions in copied components.",
6
6
  "keywords": [
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "$comment": "Union of every px value shipped across packages/core/tokens/semantic/spacing-{vega,lyra,maia,mira,nova,luma,sera}.json. Regenerate via `pnpm --filter @nxds/eslint-plugin refresh:canonical-set`. The shipped union may exceed the originally-intended scale; reconciliation tracked separately.",
3
3
  "values": [
4
- 0, 2, 4, 6, 8, 9, 10, 11, 12, 14, 15, 16, 18, 20, 22, 24, 26, 28, 30, 32,
5
- 34, 36, 38, 40, 42, 44, 46, 48, 52, 56, 60, 64, 68, 76, 80, 84, 92, 96, 100,
6
- 108, 112, 116, 120, 124, 128, 132, 140, 144, 148, 156, 160, 164, 172, 176,
7
- 180, 188, 192, 196, 204, 208, 210, 212, 220, 224, 228, 236, 240, 244, 252,
8
- 256, 260, 284, 288, 292, 316, 320, 324, 380, 384, 388
4
+ 0, 2, 4, 6, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 25,
5
+ 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62,
6
+ 64, 66, 68, 70, 76, 78, 80, 82, 84, 86, 92, 94, 96, 98, 100, 102, 108, 110,
7
+ 112, 114, 116, 118, 124, 126, 128, 130, 132, 134, 140, 142, 144, 146, 148,
8
+ 150, 156, 158, 160, 162, 164, 166, 172, 174, 176, 178, 180, 182, 188, 190,
9
+ 192, 194, 196, 198, 204, 206, 208, 210, 212, 214, 220, 222, 224, 226, 228,
10
+ 230, 236, 238, 240, 242, 244, 246, 252, 254, 256, 258, 260, 262, 284, 286,
11
+ 288, 290, 292, 294, 316, 318, 320, 322, 324, 326, 380, 382, 384, 386, 388,
12
+ 390
9
13
  ]
10
14
  }
@@ -35,6 +35,88 @@ export const RAW_LETTER_SPACINGS = [
35
35
  'widest',
36
36
  ];
37
37
 
38
+ // A size from spacing follows density: a numeric spacing step (`size-4`), or an
39
+ // arbitrary value that reads a spacing variable
40
+ // (`size-[var(--nx-spacing-8)]`, `size-(--nx-spacing-8)`). Glyph tokens
41
+ // (`size-icon-glyph-sm`) and other arbitrary values (`size-[24px]`) do not.
42
+ // A leading `!` is Tailwind's important modifier.
43
+ const SPACING_SIZE = /^!?(?:size|h|w)-(?:\d|[[(].*--nx-spacing-)/;
44
+
45
+ // Any size utility: a spacing step, a glyph token or an arbitrary value.
46
+ const ANY_SIZE = /^!?(?:size|h|w)-/;
47
+
48
+ // A fixed size that leaves `--nx-icon-size` unset, such as `size-[24px]`. A size
49
+ // from spacing reports `densityIconSize` instead.
50
+ const PLAIN_SIZE = /^!?(?:size|h|w)-(?!icon-glyph-|\d|[[(].*--nx-spacing-)/;
51
+
52
+ // An `Icon*` or `*Icon` JSX element, such as `IconX` or `CheckIcon`.
53
+ const ICON_ELEMENT_NAME = /^Icon(?![a-z])|Icon$/;
54
+
55
+ /** The `nx:` classes in a class string. */
56
+ function nxClasses(raw) {
57
+ return raw.split(/\s+/).filter((token) => token.startsWith('nx:'));
58
+ }
59
+
60
+ /** Splits a class at the `:` separators outside `[…]` and `(…)`. */
61
+ function splitVariants(className) {
62
+ const parts = [];
63
+ let depth = 0;
64
+ let start = 0;
65
+ for (let i = 0; i < className.length; i++) {
66
+ const char = className[i];
67
+ if (char === '[' || char === '(') depth++;
68
+ else if (char === ']' || char === ')') depth--;
69
+ else if (char === ':' && depth === 0) {
70
+ parts.push(className.slice(start, i));
71
+ start = i + 1;
72
+ }
73
+ }
74
+ parts.push(className.slice(start));
75
+ return parts;
76
+ }
77
+
78
+ /** A selector without its `[…]` and `(…)` groups. */
79
+ function withoutGroups(selector) {
80
+ let depth = 0;
81
+ let kept = '';
82
+ for (const char of selector) {
83
+ if (char === '[' || char === '(') depth++;
84
+ else if (char === ']' || char === ')') depth--;
85
+ else if (depth === 0) kept += char;
86
+ }
87
+ return kept;
88
+ }
89
+
90
+ // An arbitrary variant whose subject is the `svg`, such as `[&_svg]` or
91
+ // `[&>svg:not(…)]`. In `has-[>svg]` and `[&:has(svg)]` the subject is the box
92
+ // around the icon.
93
+ function selectsSvg(variant) {
94
+ if (!variant.startsWith('[') || !variant.endsWith(']')) return false;
95
+ const compounds = withoutGroups(variant.slice(1, -1)).split(/[_>+~]/);
96
+ return /^svg(?![\w-])/.test(compounds.at(-1));
97
+ }
98
+
99
+ /** Whether an `nx:` class sizes an `svg` with a utility that `size` matches. */
100
+ function sizesSvg(token, size) {
101
+ const variants = splitVariants(token.slice('nx:'.length));
102
+ const utility = variants.pop();
103
+ return size.test(utility) && variants.some(selectsSvg);
104
+ }
105
+
106
+ function sizesSvgFromSpacing(raw) {
107
+ return nxClasses(raw).some((token) => sizesSvg(token, SPACING_SIZE));
108
+ }
109
+
110
+ function sizesFromSpacing(raw) {
111
+ return nxClasses(raw).some((token) =>
112
+ SPACING_SIZE.test(splitVariants(token).at(-1))
113
+ );
114
+ }
115
+
116
+ function isIconElement(name) {
117
+ return name.type === 'JSXIdentifier' && ICON_ELEMENT_NAME.test(name.name);
118
+ }
119
+
38
120
  const NAMED_VARIANT = String.raw`@?[\w-]+(?:\[[^\s]+?\])?(?:\/[\w-]+)?:`;
39
121
  const ARBITRARY_VARIANT = String.raw`\[[^\s]+?\]:`;
40
122
  const CHILD_VARIANT = String.raw`\*{1,2}:`;
@@ -72,21 +154,27 @@ const CHECKS = [
72
154
  re: new RegExp(
73
155
  `nx:${NX_MODIFIER_CHAIN}font-(?:${RAW_FONT_WEIGHTS.join('|')})\\b`
74
156
  ),
75
- runtimeOnly: true,
157
+ skipFile: isExampleFile,
76
158
  },
77
159
  {
78
160
  messageId: 'rawLineHeight',
79
161
  re: new RegExp(
80
162
  `nx:${NX_MODIFIER_CHAIN}leading-(?:${RAW_LINE_HEIGHTS.join('|')}|\\d+|\\[)`
81
163
  ),
82
- runtimeOnly: true,
164
+ skipFile: isExampleFile,
165
+ },
166
+ {
167
+ messageId: 'densityIconSize',
168
+ test: sizesSvgFromSpacing,
169
+ // Stories are checked: the docs pages copy their code into apps.
170
+ skipFile: isDocsFile,
83
171
  },
84
172
  {
85
173
  messageId: 'rawLetterSpacing',
86
174
  re: new RegExp(
87
175
  `nx:${NX_MODIFIER_CHAIN}tracking-(?:${RAW_LETTER_SPACINGS.join('|')}|\\[)`
88
176
  ),
89
- runtimeOnly: true,
177
+ skipFile: isExampleFile,
90
178
  },
91
179
  ];
92
180
 
@@ -105,6 +193,27 @@ function fadesItsFocusRing(scope) {
105
193
  );
106
194
  }
107
195
 
196
+ // A class list that sizes its `svg` children draws the icon, so it sets the
197
+ // icon's line with `nx:icon-stroke` too.
198
+ function leavesIconUnstroked(scope) {
199
+ const classes = nxClasses(scope);
200
+ if (classes.includes('nx:icon-stroke')) return false;
201
+ return classes.some((token) => sizesSvg(token, ANY_SIZE));
202
+ }
203
+
204
+ // `size-icon-glyph-*` sets `--nx-icon-size`, the size the line is drawn for. Any
205
+ // other size must set the variable itself, under the same variants.
206
+ function sizesIconWithoutLine(scope) {
207
+ const classes = nxClasses(scope);
208
+ return classes.some((token) => {
209
+ if (!sizesSvg(token, PLAIN_SIZE)) return false;
210
+ const variants = splitVariants(token).slice(0, -1).join(':');
211
+ return !classes.some((other) =>
212
+ other.startsWith(`${variants}:[--nx-icon-size:`)
213
+ );
214
+ });
215
+ }
216
+
108
217
  function isStoryFile(filename) {
109
218
  return /\.stories\.[jt]sx?$/.test(filename);
110
219
  }
@@ -113,8 +222,9 @@ function isDocsFile(filename) {
113
222
  return /(?:^|[/\\])apps[/\\]docs[/\\]/.test(filename);
114
223
  }
115
224
 
116
- // What the runtime-only checks skip: stories and the docs app quote utilities
117
- // as specimens and prose, and the docs app styles its own chrome off-system.
225
+ // What the raw typography and stroke checks skip: stories and the docs app
226
+ // quote utilities as specimens and prose, and the docs app styles its own
227
+ // chrome off-system.
118
228
  function isExampleFile(filename) {
119
229
  return isStoryFile(filename) || isDocsFile(filename);
120
230
  }
@@ -157,10 +267,7 @@ function isRawTypographyException(check, filename, raw) {
157
267
  }
158
268
 
159
269
  function shouldRunCheck(check, filename, raw) {
160
- if (!check.runtimeOnly) {
161
- return true;
162
- }
163
- if (isExampleFile(filename)) {
270
+ if (check.skipFile?.(filename)) {
164
271
  return false;
165
272
  }
166
273
  return !isRawTypographyException(check, filename, raw);
@@ -169,12 +276,12 @@ function shouldRunCheck(check, filename, raw) {
169
276
  function matchedMessageIds(raw, filename) {
170
277
  const matched = new Set();
171
278
  for (const check of CHECKS) {
172
- const { messageId, re } = check;
173
279
  if (!shouldRunCheck(check, filename, raw)) {
174
280
  continue;
175
281
  }
176
- if (re.test(raw)) {
177
- matched.add(messageId);
282
+ const matches = check.test ? check.test(raw) : check.re.test(raw);
283
+ if (matches) {
284
+ matched.add(check.messageId);
178
285
  }
179
286
  }
180
287
  return matched;
@@ -255,7 +362,7 @@ export default {
255
362
  type: 'problem',
256
363
  docs: {
257
364
  description:
258
- 'Enforce nx: Tailwind class conventions — correct prefix order, no banned `accent` token, complete semantic token paths, no raw primitive colors, no raw named font-size utilities, and no ring-fading `transition-colors` on a focus-ring surface.',
365
+ 'Enforce nx: Tailwind class conventions — correct prefix order, no banned `accent` token, complete semantic token paths, no raw primitive colors, no raw named font-size utilities, no icon sized from spacing, no ring-fading `transition-colors` on a focus-ring surface, and no sized `svg` children without `nx:icon-stroke`.',
259
366
  },
260
367
  schema: [],
261
368
  messages: {
@@ -275,8 +382,14 @@ export default {
275
382
  'Raw Tailwind line-height utility — let a typography composite own line-height instead of `nx:leading-*`.',
276
383
  rawLetterSpacing:
277
384
  'Raw Tailwind letter-spacing utility — use a typography composite such as `nx:typography-label-caps` instead of `nx:tracking-*`.',
385
+ densityIconSize:
386
+ 'Icon size from spacing — a numeric `size-*`, `h-*` or `w-*`, or one that reads a `--nx-spacing-*` variable, on an `svg` selector (`[&_svg]:size-4`) or on an `Icon*` / `*Icon` element follows density. Use `size-icon-glyph-xs`, `-sm`, `-default`, `-lg` or `-xl` (12, 14, 16, 24 or 32px at the Default UI scale).',
278
387
  ringFadingTransition:
279
388
  'Ring-fading transition — `nx:transition-colors` includes `outline-color`, so this focus ring fades in instead of landing with the keypress. Use `nx:transition-control` (or `nx:transition-field` on a field surface).',
389
+ unstrokedIcon:
390
+ 'Unstroked icon — this class list sizes its `svg` children (`[&_svg]:size-icon-glyph-default`), so add `nx:icon-stroke` to draw stroked icons at the icon line width.',
391
+ plainIconSize:
392
+ 'Plain icon size — size `svg` children with a `size-icon-glyph-*` class, so the line matches the icon. `size-*`, `h-*` and `w-*` leave `--nx-icon-size` unset, so set it on the same selector for a size off the scale.',
280
393
  },
281
394
  },
282
395
  create(context) {
@@ -290,14 +403,22 @@ export default {
290
403
  }
291
404
 
292
405
  function reportScope(node) {
293
- if (isDocsSpecimenPage(filename) || isInsideClassCall(node)) {
406
+ if (isInsideClassCall(node)) {
294
407
  return;
295
408
  }
296
409
  const collected = [];
297
410
  collectClassStrings(node, visitorKeys, collected);
298
- if (fadesItsFocusRing(collected.join(' '))) {
411
+ const scope = collected.join(' ');
412
+ if (!isDocsSpecimenPage(filename) && fadesItsFocusRing(scope)) {
299
413
  context.report({ node, messageId: 'ringFadingTransition' });
300
414
  }
415
+ if (isExampleFile(filename)) return;
416
+ if (leavesIconUnstroked(scope)) {
417
+ context.report({ node, messageId: 'unstrokedIcon' });
418
+ }
419
+ if (sizesIconWithoutLine(scope)) {
420
+ context.report({ node, messageId: 'plainIconSize' });
421
+ }
301
422
  }
302
423
 
303
424
  return {
@@ -327,6 +448,24 @@ export default {
327
448
  }
328
449
  reportScope(node);
329
450
  },
451
+ JSXOpeningElement(node) {
452
+ if (isDocsFile(filename) || !isIconElement(node.name)) {
453
+ return;
454
+ }
455
+ const className = node.attributes.find(
456
+ (attribute) =>
457
+ attribute.type === 'JSXAttribute' &&
458
+ attribute.name.name === 'className'
459
+ );
460
+ if (!className?.value) {
461
+ return;
462
+ }
463
+ const collected = [];
464
+ collectClassStrings(className.value, visitorKeys, collected);
465
+ if (collected.some(sizesFromSpacing)) {
466
+ context.report({ node: className, messageId: 'densityIconSize' });
467
+ }
468
+ },
330
469
  };
331
470
  },
332
471
  };