@nxds/eslint-plugin 0.6.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.
@@ -32,6 +36,22 @@ Enforces `.claude/rules/extract-inline-handlers.md`: inline JSX handler props (`
32
36
 
33
37
  Enforces `.claude/rules/code-comments.md`: JSDoc blocks must not carry `@default` or `@example` tags. The docs read prop defaults from code and usage from `docs` stories, so a tag is a second copy that can drift. Line comments and plain block comments are not checked. Wired in the root `eslint.config.js` for `packages/react/src` and `packages/vue/src`.
34
38
 
39
+ ### `@nxds/no-react-19-only`
40
+
41
+ Reports React 19-only APIs in `packages/react`, the source `packages/react-18` is generated from. The generator adds `forwardRef` and changes nothing else, so a React 19-only call would compile there and then break on React 18. It reports:
42
+
43
+ | Pattern | Use instead |
44
+ | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
45
+ | `use`, `useActionState`, `useOptimistic`, `useEffectEvent`, `Activity`, `captureOwnerStack` from `react` | An API React 18.3 has |
46
+ | `useFormStatus`, `requestFormReset`, `preload*`, `preinit*`, `preconnect`, `prefetchDNS` from `react-dom` | An API React 18.3 has |
47
+ | `<SomeContext value={…}>` | `<SomeContext.Provider value={…}>` |
48
+ | A ref callback that returns a cleanup | The node in state, with setup and teardown in an effect |
49
+ | `useDeferredValue(value, initialValue)` | One argument |
50
+ | A function passed to `action` or `formAction` | `onSubmit` or `onClick` |
51
+ | `<title>` outside an `<svg>`, `<meta>`, `<link>`, `<style precedence>` | Metadata the app's framework renders |
52
+
53
+ Wired in the root `eslint.config.js` for `packages/react/src` only. It is not in `nxdsComponentConfig`, because a React 19 app may use these APIs.
54
+
35
55
  ## Source of truth
36
56
 
37
57
  The token-layer rules are self-contained: the canonical step set is `src/canonical-step-set.json`. The component rules map 1:1 to the `.claude/rules/*.md` cited above — the rule code plus those docs are the spec. Every rule has RuleTester coverage in `__tests__/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nxds/eslint-plugin",
3
- "version": "0.6.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
  }
package/src/index.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire } from 'node:module';
3
3
  import canonicalSpacingSteps from './rules/canonical-spacing-steps.js';
4
4
  import noJsdocDataTags from './rules/no-jsdoc-data-tags.js';
5
5
  import noMultiStatementJsxHandler from './rules/no-multi-statement-jsx-handler.js';
6
+ import noReact19Only from './rules/no-react-19-only.js';
6
7
  import noRenderPropTypes from './rules/no-render-prop-types.js';
7
8
  import nxClassConventions from './rules/nx-class-conventions.js';
8
9
 
@@ -18,6 +19,7 @@ const plugin = {
18
19
  'no-render-prop-types': noRenderPropTypes,
19
20
  'no-multi-statement-jsx-handler': noMultiStatementJsxHandler,
20
21
  'no-jsdoc-data-tags': noJsdocDataTags,
22
+ 'no-react-19-only': noReact19Only,
21
23
  },
22
24
  };
23
25
 
@@ -0,0 +1,280 @@
1
+ // `packages/react-18` is generated from `packages/react` by wrapping each
2
+ // component in `forwardRef`, and nothing else changes. So `packages/react`
3
+ // must not use anything React 18 lacks: the generated copy would compile and
4
+ // then break at runtime. This rule reports the React 19-only APIs and
5
+ // behaviours an nxds component could reach for.
6
+
7
+ const REACT_ONLY_19 = new Set([
8
+ 'use',
9
+ 'useActionState',
10
+ 'useOptimistic',
11
+ 'useEffectEvent',
12
+ 'Activity',
13
+ 'captureOwnerStack',
14
+ 'cache',
15
+ 'cacheSignal',
16
+ ]);
17
+
18
+ const REACT_DOM_ONLY_19 = new Set([
19
+ 'useFormStatus',
20
+ 'requestFormReset',
21
+ 'preload',
22
+ 'preloadModule',
23
+ 'preinit',
24
+ 'preinitModule',
25
+ 'preconnect',
26
+ 'prefetchDNS',
27
+ ]);
28
+
29
+ const ONLY_19_BY_SOURCE = {
30
+ react: REACT_ONLY_19,
31
+ 'react-dom': REACT_DOM_ONLY_19,
32
+ };
33
+
34
+ const FUNCTION_TYPES = new Set([
35
+ 'ArrowFunctionExpression',
36
+ 'FunctionExpression',
37
+ 'FunctionDeclaration',
38
+ ]);
39
+
40
+ function isFunction(node) {
41
+ return node != null && FUNCTION_TYPES.has(node.type);
42
+ }
43
+
44
+ /** Whether `fn` returns a function from its own body, not a nested one. */
45
+ function returnsFunction(fn) {
46
+ if (
47
+ fn.type === 'ArrowFunctionExpression' &&
48
+ fn.body.type !== 'BlockStatement'
49
+ ) {
50
+ return isFunction(fn.body);
51
+ }
52
+ let found = false;
53
+ const visit = (node) => {
54
+ if (found || node == null || typeof node.type !== 'string') return;
55
+ if (node.type === 'ReturnStatement') {
56
+ if (isFunction(node.argument)) found = true;
57
+ return;
58
+ }
59
+ if (isFunction(node)) return;
60
+ for (const [key, value] of Object.entries(node)) {
61
+ if (key === 'parent') continue;
62
+ if (Array.isArray(value)) value.forEach(visit);
63
+ else if (value && typeof value === 'object') visit(value);
64
+ }
65
+ };
66
+ visit(fn.body);
67
+ return found;
68
+ }
69
+
70
+ /** The name a call's callee ends in: `useCallback` for `React.useCallback`. */
71
+ function calleeName(call) {
72
+ const { callee } = call;
73
+ if (callee.type === 'Identifier') return callee.name;
74
+ if (
75
+ callee.type === 'MemberExpression' &&
76
+ callee.property.type === 'Identifier'
77
+ ) {
78
+ return callee.property.name;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** The function an identifier names, when it is declared in reach. */
84
+ function functionNamed(context, identifier) {
85
+ const scope = context.sourceCode.getScope(identifier);
86
+ let variable = null;
87
+ for (let current = scope; current && !variable; current = current.upper) {
88
+ variable = current.set.get(identifier.name) ?? null;
89
+ }
90
+ const definition = variable?.defs[0];
91
+ if (!definition) return null;
92
+ if (definition.node.type === 'FunctionDeclaration') return definition.node;
93
+ if (definition.node.type !== 'VariableDeclarator') return null;
94
+ const init = definition.node.init;
95
+ if (isFunction(init)) return init;
96
+ if (init?.type === 'CallExpression' && calleeName(init) === 'useCallback') {
97
+ return isFunction(init.arguments[0]) ? init.arguments[0] : null;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ function expressionFunction(context, expression) {
103
+ if (isFunction(expression)) return expression;
104
+ if (expression.type === 'Identifier')
105
+ return functionNamed(context, expression);
106
+ return null;
107
+ }
108
+
109
+ function attributeExpression(attribute) {
110
+ const { value } = attribute;
111
+ return value?.type === 'JSXExpressionContainer' ? value.expression : null;
112
+ }
113
+
114
+ function hasAncestorElement(node, name) {
115
+ for (let current = node.parent; current; current = current.parent) {
116
+ if (
117
+ current.type === 'JSXElement' &&
118
+ current.openingElement.name.type === 'JSXIdentifier' &&
119
+ current.openingElement.name.name === name
120
+ ) {
121
+ return true;
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ export default {
128
+ meta: {
129
+ type: 'problem',
130
+ docs: {
131
+ description:
132
+ 'Disallow React 19-only APIs in source that packages/react-18 is generated from.',
133
+ },
134
+ schema: [],
135
+ messages: {
136
+ api: '`{{name}}` is React 19 only, so the generated packages/react-18 breaks. Use an API React 18.3 has.',
137
+ contextProvider:
138
+ 'Rendering a context as a provider is React 19 only. Render `<{{name}}.Provider value={…}>`.',
139
+ refCleanup:
140
+ 'A ref callback that returns a cleanup is React 18-incompatible: React 18 ignores the cleanup and calls the ref with `null`. Keep the node in state and set up and tear down in an effect.',
141
+ deferredInitialValue:
142
+ "`useDeferredValue`'s initial value is React 19 only. Pass one argument.",
143
+ formAction:
144
+ 'A function `{{name}}` is React 19 only. Handle the form in `onSubmit` or the button in `onClick`.',
145
+ documentMetadata:
146
+ '`<{{name}}>` is hoisted into the document head by React 19 only. React 18 renders it in place.',
147
+ },
148
+ },
149
+ create(context) {
150
+ // Local names bound to the `react` / `react-dom` namespace or default import.
151
+ const namespaces = new Map();
152
+ const contexts = new Set();
153
+
154
+ function reportMember(node, source, name) {
155
+ if (ONLY_19_BY_SOURCE[source]?.has(name)) {
156
+ context.report({ node, messageId: 'api', data: { name } });
157
+ }
158
+ }
159
+
160
+ return {
161
+ ImportDeclaration(node) {
162
+ const source = node.source.value;
163
+ if (!(source in ONLY_19_BY_SOURCE)) return;
164
+ for (const specifier of node.specifiers) {
165
+ if (specifier.type === 'ImportSpecifier') {
166
+ const imported =
167
+ specifier.imported.name ?? specifier.imported.value;
168
+ reportMember(specifier, source, imported);
169
+ } else {
170
+ namespaces.set(specifier.local.name, source);
171
+ }
172
+ }
173
+ },
174
+
175
+ MemberExpression(node) {
176
+ if (
177
+ node.object.type !== 'Identifier' ||
178
+ node.property.type !== 'Identifier'
179
+ ) {
180
+ return;
181
+ }
182
+ const source = namespaces.get(node.object.name);
183
+ if (source) reportMember(node.property, source, node.property.name);
184
+ },
185
+
186
+ JSXMemberExpression(node) {
187
+ if (node.object.type !== 'JSXIdentifier') return;
188
+ const source = namespaces.get(node.object.name);
189
+ if (source) reportMember(node.property, source, node.property.name);
190
+ },
191
+
192
+ VariableDeclarator(node) {
193
+ if (
194
+ node.id.type === 'Identifier' &&
195
+ node.init?.type === 'CallExpression' &&
196
+ calleeName(node.init) === 'createContext'
197
+ ) {
198
+ contexts.add(node.id.name);
199
+ }
200
+ },
201
+
202
+ CallExpression(node) {
203
+ if (
204
+ calleeName(node) === 'useDeferredValue' &&
205
+ node.arguments.length > 1
206
+ ) {
207
+ context.report({ node, messageId: 'deferredInitialValue' });
208
+ }
209
+ },
210
+
211
+ JSXOpeningElement(node) {
212
+ if (node.name.type !== 'JSXIdentifier') return;
213
+ const { name } = node.name;
214
+ const attributes = node.attributes.filter(
215
+ (attribute) => attribute.type === 'JSXAttribute'
216
+ );
217
+
218
+ const isContext = contexts.has(name) || /^[A-Z]\w*Context$/.test(name);
219
+ if (
220
+ isContext &&
221
+ attributes.some((attribute) => attribute.name.name === 'value')
222
+ ) {
223
+ context.report({
224
+ node,
225
+ messageId: 'contextProvider',
226
+ data: { name },
227
+ });
228
+ }
229
+
230
+ if (name === 'meta' || name === 'link') {
231
+ context.report({
232
+ node,
233
+ messageId: 'documentMetadata',
234
+ data: { name },
235
+ });
236
+ }
237
+ if (name === 'title' && !hasAncestorElement(node.parent, 'svg')) {
238
+ context.report({
239
+ node,
240
+ messageId: 'documentMetadata',
241
+ data: { name },
242
+ });
243
+ }
244
+ if (
245
+ name === 'style' &&
246
+ attributes.some((attribute) => attribute.name.name === 'precedence')
247
+ ) {
248
+ context.report({
249
+ node,
250
+ messageId: 'documentMetadata',
251
+ data: { name },
252
+ });
253
+ }
254
+
255
+ for (const attribute of attributes) {
256
+ const attributeName = attribute.name.name;
257
+ const expression = attributeExpression(attribute);
258
+ if (!expression) continue;
259
+
260
+ if (attributeName === 'ref') {
261
+ const fn = expressionFunction(context, expression);
262
+ if (fn && returnsFunction(fn)) {
263
+ context.report({ node: attribute, messageId: 'refCleanup' });
264
+ }
265
+ }
266
+ if (
267
+ (attributeName === 'action' || attributeName === 'formAction') &&
268
+ expressionFunction(context, expression)
269
+ ) {
270
+ context.report({
271
+ node: attribute,
272
+ messageId: 'formAction',
273
+ data: { name: attributeName },
274
+ });
275
+ }
276
+ }
277
+ },
278
+ };
279
+ },
280
+ };
@@ -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
  }
@@ -129,14 +239,14 @@ function isDocsSpecimenPage(filename) {
129
239
  const RAW_TYPOGRAPHY_EXCEPTIONS = [
130
240
  {
131
241
  filename:
132
- /(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
242
+ /(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]avatar[/\\]avatar\.tsx$/,
133
243
  className:
134
244
  'nx:flex nx:size-full nx:items-center nx:justify-center nx:rounded-[inherit] nx:bg-muted nx:text-foreground nx:font-medium nx:leading-none',
135
245
  messageIds: new Set(['rawFontWeight', 'rawLineHeight']),
136
246
  },
137
247
  {
138
248
  filename:
139
- /(?:^|[/\\])packages[/\\]react[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
249
+ /(?:^|[/\\])packages[/\\]react(?:-18)?[/\\]src[/\\]components[/\\]chart[/\\]chart\.tsx$/,
140
250
  className: 'nx:text-foreground nx:font-mono nx:font-medium nx:tabular-nums',
141
251
  messageIds: new Set(['rawFontWeight']),
142
252
  },
@@ -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
  };