@nadicodeai/ui 12.1.53 → 12.1.55

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.
Files changed (38) hide show
  1. package/dist/eslint/index.js +27 -69
  2. package/dist/eslint/rules/jsx-class-utility.js +2 -56
  3. package/dist/eslint/rules/no-choice-control-wrapper.js +95 -0
  4. package/dist/eslint/rules/no-comment.js +44 -0
  5. package/dist/eslint/rules/no-subject-derived-expectation.js +55 -1
  6. package/dist/eslint/strict-design-system.js +224 -0
  7. package/dist/styles/components.css +1 -1
  8. package/package.json +3 -3
  9. package/dist/.build-kind +0 -1
  10. package/dist/eslint/rules/card-row-owner.js +0 -167
  11. package/dist/eslint/rules/confirm-destructive-action.js +0 -177
  12. package/dist/eslint/rules/drawer-owns-its-edges.js +0 -66
  13. package/dist/eslint/rules/expect-chain.js +0 -60
  14. package/dist/eslint/rules/icon-control-accessible-name.js +0 -156
  15. package/dist/eslint/rules/menu-command-owner.js +0 -45
  16. package/dist/eslint/rules/no-bare-page-navigation.js +0 -46
  17. package/dist/eslint/rules/no-elevated-edge.js +0 -141
  18. package/dist/eslint/rules/no-existence-only-assertion.js +0 -89
  19. package/dist/eslint/rules/no-invalid-token-utility.js +0 -46
  20. package/dist/eslint/rules/no-mono-title-register.js +0 -73
  21. package/dist/eslint/rules/no-native-visible-control.js +0 -92
  22. package/dist/eslint/rules/no-nested-overlay.js +0 -56
  23. package/dist/eslint/rules/no-off-grid-spacing.js +0 -115
  24. package/dist/eslint/rules/no-paired-size-utility.js +0 -66
  25. package/dist/eslint/rules/no-raw-shadow.js +0 -70
  26. package/dist/eslint/rules/no-repainted-focus.js +0 -75
  27. package/dist/eslint/rules/no-resting-settings-control.js +0 -89
  28. package/dist/eslint/rules/no-route-width-override.js +0 -135
  29. package/dist/eslint/rules/no-store-cast.js +0 -46
  30. package/dist/eslint/rules/no-token-category-bypass.js +0 -53
  31. package/dist/eslint/rules/no-unpinned-vercel-functions-import.js +0 -104
  32. package/dist/eslint/rules/one-control-height.js +0 -163
  33. package/dist/eslint/rules/overlay-body-owns-scroll.js +0 -62
  34. package/dist/eslint/rules/overlay-mounts-closed.js +0 -102
  35. package/dist/eslint/rules/prefer-radio-for-few-static-options.js +0 -77
  36. package/dist/eslint/rules/settings-section-description.js +0 -45
  37. package/dist/eslint/rules/shadcn-composition.js +0 -197
  38. package/dist/eslint/rules/table-column-wraps-by-meta.js +0 -55
@@ -1,40 +1,17 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readdirSync, readFileSync } from "node:fs";
3
3
 
4
- import { menuCommandOwner } from "./rules/menu-command-owner.js";
5
- import { cardRowOwner } from "./rules/card-row-owner.js";
6
- import { confirmDestructiveAction } from "./rules/confirm-destructive-action.js";
7
- import { drawerOwnsItsEdges } from "./rules/drawer-owns-its-edges.js";
8
- import { iconControlAccessibleName } from "./rules/icon-control-accessible-name.js";
4
+ import { builtinRules } from "eslint/use-at-your-own-risk";
5
+
9
6
  import { noAppFeatureComponent } from "./rules/no-app-feature-component.js";
10
- import { noBarePageNavigation } from "./rules/no-bare-page-navigation.js";
11
- import { noStoreCast } from "./rules/no-store-cast.js";
12
- import { noElevatedEdge } from "./rules/no-elevated-edge.js";
13
- import { noExistenceOnlyAssertion } from "./rules/no-existence-only-assertion.js";
14
- import { noNestedOverlay } from "./rules/no-nested-overlay.js";
15
- import { noOffGridSpacing } from "./rules/no-off-grid-spacing.js";
16
- import { noRawShadow } from "./rules/no-raw-shadow.js";
17
- import { noRouteWidthOverride } from "./rules/no-route-width-override.js";
18
- import { preferRadioForFewStaticOptions } from "./rules/prefer-radio-for-few-static-options.js";
7
+ import { noChoiceControlWrapper } from "./rules/no-choice-control-wrapper.js";
8
+ import { noComment } from "./rules/no-comment.js";
19
9
  import { noDeprecatedZodApi } from "./rules/no-deprecated-zod-api.js";
20
- import { noMonoTitleRegister } from "./rules/no-mono-title-register.js";
21
10
  import { noPackageComponentRedefinition } from "./rules/no-package-component-redefinition.js";
22
- import { noPairedSizeUtility } from "./rules/no-paired-size-utility.js";
23
- import { noRestingSettingsControl } from "./rules/no-resting-settings-control.js";
24
- import { oneControlHeight } from "./rules/one-control-height.js";
25
- import { overlayBodyOwnsScroll } from "./rules/overlay-body-owns-scroll.js";
26
- import { overlayMountsClosed } from "./rules/overlay-mounts-closed.js";
27
- import { tableColumnWrapsByMeta } from "./rules/table-column-wraps-by-meta.js";
28
11
  import { noShadcnAppearanceOverride } from "./rules/no-shadcn-appearance-override.js";
29
12
  import { noSubjectDerivedExpectation } from "./rules/no-subject-derived-expectation.js";
30
- import { noUnpinnedVercelFunctionsImport } from "./rules/no-unpinned-vercel-functions-import.js";
31
- import { noNativeVisibleControl } from "./rules/no-native-visible-control.js";
32
- import { noRepaintedFocus } from "./rules/no-repainted-focus.js";
33
- import { noInvalidTokenUtility } from "./rules/no-invalid-token-utility.js";
34
- import { noTokenCategoryBypass } from "./rules/no-token-category-bypass.js";
35
13
  import { serverActionDiscipline } from "./rules/server-action-discipline.js";
36
- import { settingsSectionDescription } from "./rules/settings-section-description.js";
37
- import { shadcnComposition } from "./rules/shadcn-composition.js";
14
+ import { strictDesignSystemSelectors } from "./strict-design-system.js";
38
15
 
39
16
  /**
40
17
  * The plugin carries no version of its own.
@@ -73,6 +50,7 @@ function readPublishedVersion() {
73
50
  */
74
51
  export const rulesDigest = digestOf([
75
52
  "index.js",
53
+ "strict-design-system.js",
76
54
  ...readdirSync(new URL("./rules/", import.meta.url))
77
55
  .filter((name) => name.endsWith(".js") && !name.endsWith(".test.js"))
78
56
  .sort()
@@ -90,21 +68,26 @@ function digestOf(paths) {
90
68
  return hash.digest("hex").slice(0, 16);
91
69
  }
92
70
 
71
+ /*
72
+ * The strict design-system checks that a selector expresses are core
73
+ * `no-restricted-syntax`, registered here once per check under the check's own
74
+ * name, with the check's selectors as the rule's default options. A config that
75
+ * turns on one `nadicodeai/<check>` alone therefore runs that check, and
76
+ * `flat/strict` is self-contained for a consumer of `@nadicodeai/ui/eslint`.
77
+ */
78
+ const restrictedSyntax = builtinRules.get("no-restricted-syntax");
79
+ const strictSelectorRules = Object.fromEntries(
80
+ Object.entries(strictDesignSystemSelectors).map(([name, entries]) => [
81
+ name,
82
+ { ...restrictedSyntax, meta: { ...restrictedSyntax.meta, defaultOptions: entries } },
83
+ ]),
84
+ );
85
+
93
86
  export const strictDesignSystemRules = {
94
- "nadicodeai/card-row-owner": "error",
95
- "nadicodeai/drawer-owns-its-edges": "error",
96
- "nadicodeai/icon-control-accessible-name": "error",
97
- "nadicodeai/no-invalid-token-utility": "error",
98
- "nadicodeai/no-native-visible-control": "error",
99
- "nadicodeai/no-nested-overlay": "error",
100
- "nadicodeai/no-off-grid-spacing": "error",
101
- "nadicodeai/no-raw-shadow": "error",
102
- "nadicodeai/no-repainted-focus": "error",
87
+ ...Object.fromEntries(
88
+ Object.keys(strictDesignSystemSelectors).map((name) => [`nadicodeai/${name}`, "error"]),
89
+ ),
103
90
  "nadicodeai/no-shadcn-appearance-override": "error",
104
- "nadicodeai/no-token-category-bypass": "error",
105
- "nadicodeai/overlay-body-owns-scroll": "error",
106
- "nadicodeai/overlay-mounts-closed": "error",
107
- "nadicodeai/prefer-radio-for-few-static-options": "error",
108
91
  };
109
92
 
110
93
  /** @type {import('eslint').ESLint.Plugin} */
@@ -115,40 +98,15 @@ const plugin = {
115
98
  },
116
99
  configs: {},
117
100
  rules: {
118
- "card-row-owner": cardRowOwner,
119
- "menu-command-owner": menuCommandOwner,
120
- "confirm-destructive-action": confirmDestructiveAction,
121
- "drawer-owns-its-edges": drawerOwnsItsEdges,
122
- "icon-control-accessible-name": iconControlAccessibleName,
101
+ ...strictSelectorRules,
123
102
  "no-app-feature-component": noAppFeatureComponent,
124
- "no-bare-page-navigation": noBarePageNavigation,
125
- "no-store-cast": noStoreCast,
126
- "no-elevated-edge": noElevatedEdge,
127
- "no-existence-only-assertion": noExistenceOnlyAssertion,
103
+ "no-choice-control-wrapper": noChoiceControlWrapper,
104
+ "no-comment": noComment,
128
105
  "no-deprecated-zod-api": noDeprecatedZodApi,
129
- "no-mono-title-register": noMonoTitleRegister,
130
- "no-nested-overlay": noNestedOverlay,
131
- "no-off-grid-spacing": noOffGridSpacing,
132
106
  "no-package-component-redefinition": noPackageComponentRedefinition,
133
- "no-paired-size-utility": noPairedSizeUtility,
134
- "no-raw-shadow": noRawShadow,
135
- "no-resting-settings-control": noRestingSettingsControl,
136
- "no-route-width-override": noRouteWidthOverride,
137
- "prefer-radio-for-few-static-options": preferRadioForFewStaticOptions,
138
107
  "no-shadcn-appearance-override": noShadcnAppearanceOverride,
139
108
  "no-subject-derived-expectation": noSubjectDerivedExpectation,
140
- "no-native-visible-control": noNativeVisibleControl,
141
- "no-repainted-focus": noRepaintedFocus,
142
- "no-unpinned-vercel-functions-import": noUnpinnedVercelFunctionsImport,
143
- "no-invalid-token-utility": noInvalidTokenUtility,
144
- "no-token-category-bypass": noTokenCategoryBypass,
145
- "one-control-height": oneControlHeight,
146
- "overlay-body-owns-scroll": overlayBodyOwnsScroll,
147
- "overlay-mounts-closed": overlayMountsClosed,
148
- "table-column-wraps-by-meta": tableColumnWrapsByMeta,
149
109
  "server-action-discipline": serverActionDiscipline,
150
- "settings-section-description": settingsSectionDescription,
151
- "shadcn-composition": shadcnComposition,
152
110
  },
153
111
  };
154
112
 
@@ -1,8 +1,8 @@
1
1
  /*
2
- * The one className reader the plugin's class rules share.
2
+ * The className reader of no-shadcn-appearance-override.
3
3
  *
4
4
  * It reads LITERALS: string literals and template quasis anywhere inside a
5
- * `className` expression, plus the class strings a `cva()` call carries. On top
5
+ * `className` expression. On top
6
6
  * of that it takes EXACTLY ONE HOP: an identifier resolves to a same-file
7
7
  * `const` string literal, and an identifier or a `TABLE.key` lookup resolves to
8
8
  * a same-file `const` object whose string values are then read as classes. A
@@ -40,18 +40,6 @@ function findVariable(scope, name) {
40
40
  return undefined;
41
41
  }
42
42
 
43
- /** Resolve renamed component imports while keeping local JSX names available. */
44
- export function jsxComponentName(node, context) {
45
- const name = node?.name;
46
- if (name?.type === "JSXMemberExpression") return name.property.name;
47
- if (name?.type !== "JSXIdentifier") return undefined;
48
- const variable = findVariable(context.sourceCode.getScope(name), name.name);
49
- const definition = variable?.defs.find((entry) => entry.type === "ImportBinding");
50
- return definition?.node.type === "ImportSpecifier"
51
- ? (definition.node.imported.name ?? definition.node.imported.value)
52
- : name.name;
53
- }
54
-
55
43
  /**
56
44
  * The initializer of the same-file `const` an identifier names. Undefined for
57
45
  * imports, parameters, `let`/`var`, and anything declared more than once —
@@ -148,19 +136,6 @@ function utilities(node, { skipPropertyKeys = false, context } = {}) {
148
136
  return fragments.flatMap((fragment) => fragment.split(/\s+/).filter(Boolean));
149
137
  }
150
138
 
151
- /**
152
- * Every class utility written inside one node, wherever it sits. The reader for
153
- * a rule whose surface is not a JSX element — a `cn()`/`cva()` call assigned to
154
- * a `className` property, or a bare class string in a props object — which is
155
- * where this repository's composed class lists actually live.
156
- *
157
- * Pass `skipPropertyKeys` for a cva call, whose variant-map keys name variants
158
- * rather than classes.
159
- */
160
- export function nodeClassUtilities(node, context, { skipPropertyKeys = false } = {}) {
161
- return utilities(node, { skipPropertyKeys, context });
162
- }
163
-
164
139
  /**
165
140
  * The class utilities written in one `className` JSX attribute. Pass `context`
166
141
  * to take the one hop; without it the reader stays literal-only.
@@ -200,35 +175,6 @@ export function openingElementClassUtilities(node, context, includeSlots = false
200
175
  );
201
176
  }
202
177
 
203
- /**
204
- * `className` attributes plus `cva()` calls — the two places a design-token
205
- * utility is written. The token rules run over both because a cva variant map
206
- * is where a component's class strings actually live.
207
- */
208
- export function tokenUtilityVisitors(context, onUtility) {
209
- const cvaNames = new Set();
210
-
211
- return {
212
- ImportDeclaration(node) {
213
- if (node.source.value !== "class-variance-authority") return;
214
- for (const specifier of node.specifiers) {
215
- if (specifier.type === "ImportSpecifier" && specifier.imported.name === "cva") {
216
- cvaNames.add(specifier.local.name);
217
- }
218
- }
219
- },
220
- JSXAttribute(node) {
221
- for (const utility of classUtilities(node, context)) onUtility(node, utility);
222
- },
223
- CallExpression(node) {
224
- if (node.callee.type !== "Identifier" || !cvaNames.has(node.callee.name)) return;
225
- for (const utility of utilities(node, { skipPropertyKeys: true, context })) {
226
- onUtility(node, utility);
227
- }
228
- },
229
- };
230
- }
231
-
232
178
  /*
233
179
  * Package ownership as a per-rule option (#821, #822). `@nadicodeai/ui`'s own
234
180
  * source is the place the primitives are BUILT, so a rule that polices what
@@ -0,0 +1,95 @@
1
+ const CHOICE_CONTROLS = new Set(["Select", "ToggleGroup", "RadioGroup", "Tabs"]);
2
+
3
+ const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
4
+
5
+ function enclosingFunction(node) {
6
+ let current = node.parent;
7
+ while (current && !FUNCTIONS.has(current.type)) current = current.parent;
8
+ return current;
9
+ }
10
+
11
+ /** A component's own props: a destructured or typed parameter. */
12
+ const isComponent = (fn) =>
13
+ fn.params.some((param) => param.type === "ObjectPattern" || param.typeAnnotation);
14
+
15
+ /** Whether `owner` handed the element its value: the element's own component, or a callback inside it. */
16
+ function handedTo(element, owner) {
17
+ for (let fn = enclosingFunction(element); fn; fn = enclosingFunction(fn)) {
18
+ if (fn === owner) return true;
19
+ if (isComponent(fn)) return false;
20
+ }
21
+ return false;
22
+ }
23
+
24
+ /** The identifiers in a handler that invoke what they name: the handler itself, a callee, or a method's receiver. */
25
+ function invoked(handler) {
26
+ const found = [];
27
+ const visit = (node) => {
28
+ if (!node || typeof node.type !== "string") return;
29
+ if (node.type === "Identifier") {
30
+ const parent = node.parent;
31
+ const isCallee = parent?.type === "CallExpression" && parent.callee === node;
32
+ const isReceiver =
33
+ parent?.type === "MemberExpression" &&
34
+ parent.object === node &&
35
+ parent.parent?.type === "CallExpression" &&
36
+ parent.parent.callee === parent;
37
+ if (node === handler || isCallee || isReceiver) found.push(node);
38
+ }
39
+ for (const [key, value] of Object.entries(node)) {
40
+ if (key === "parent") continue;
41
+ for (const child of Array.isArray(value) ? value : [value]) {
42
+ if (child && typeof child.type === "string") visit(child);
43
+ }
44
+ }
45
+ };
46
+ visit(handler);
47
+ return found;
48
+ }
49
+
50
+ /**
51
+ * A kit choice control whose onValueChange forwards to a callback the enclosing
52
+ * component was handed. `ToolbarToggle` flattened Base UI's array into a string
53
+ * there, and "Split by" stopped working while every check stayed green.
54
+ */
55
+ export const noChoiceControlWrapper = {
56
+ meta: {
57
+ type: "problem",
58
+ docs: {
59
+ description:
60
+ "Render a kit choice control where its value is used, never behind a wrapper that forwards it.",
61
+ },
62
+ schema: [],
63
+ messages: {
64
+ wrapper:
65
+ "Render the kit's {{control}} where the value is used, with its own API: this one forwards onValueChange to \"{{name}}\", which the component was handed, and a wrapper is where the value's shape gets lost.",
66
+ },
67
+ },
68
+ create(context) {
69
+ return {
70
+ "JSXAttribute[name.name='onValueChange'] > JSXExpressionContainer"(container) {
71
+ const opening = container.parent.parent;
72
+ const control = opening.name.type === "JSXIdentifier" ? opening.name.name : null;
73
+ if (!CHOICE_CONTROLS.has(control)) return;
74
+ const handler = container.expression;
75
+ for (const identifier of invoked(handler)) {
76
+ const scope = context.sourceCode.getScope(identifier);
77
+ let variable = null;
78
+ for (let current = scope; current && !variable; current = current.upper) {
79
+ variable = current.set.get(identifier.name) ?? null;
80
+ }
81
+ const definition = variable?.defs[0];
82
+ if (definition?.type !== "Parameter") continue;
83
+ if (definition.node === handler) continue;
84
+ if (!handedTo(opening, definition.node)) continue;
85
+ context.report({
86
+ node: container,
87
+ messageId: "wrapper",
88
+ data: { control, name: identifier.name },
89
+ });
90
+ return;
91
+ }
92
+ },
93
+ };
94
+ },
95
+ };
@@ -0,0 +1,44 @@
1
+ /*
2
+ * Code says what it does in its names and its structure, and a test says what
3
+ * it proves in its name and its assertions; a comment restates one of them and
4
+ * nothing keeps it true when the code changes. A directive to a tool is not
5
+ * prose for the reader: ESLint's, Oxlint's, TypeScript's, Biome's, Prettier's,
6
+ * coverage's and Vitest's stay, and so does the marker the seam guard requires.
7
+ */
8
+
9
+ const DIRECTIVE =
10
+ /^[*/]?\s*(?:@ts-|eslint-|eslint\s+[\w@/-]+\s*:|oxlint-|biome-ignore|<reference\b|istanbul\b|c8\s|prettier-ignore|@vitest-environment|@jsx)/;
11
+
12
+ const SEAM_MARKER = /^\s*NADIA\((?:added|changed|removed)\):/;
13
+
14
+ export const noComment = {
15
+ meta: {
16
+ type: "problem",
17
+ docs: {
18
+ description:
19
+ "Disallow comments in code, but for directives to a tool and the marker the seam guard requires",
20
+ },
21
+ schema: [],
22
+ messages: {
23
+ comment:
24
+ "Code says what it does in its names and its structure, never in a comment. Rename or restructure, or remove the comment.",
25
+ },
26
+ },
27
+
28
+ create(context) {
29
+ return {
30
+ Program() {
31
+ for (const comment of context.sourceCode.getAllComments()) {
32
+ if (
33
+ comment.type === "Shebang" ||
34
+ DIRECTIVE.test(comment.value) ||
35
+ SEAM_MARKER.test(comment.value)
36
+ ) {
37
+ continue;
38
+ }
39
+ context.report({ loc: comment.loc, messageId: "comment" });
40
+ }
41
+ },
42
+ };
43
+ },
44
+ };
@@ -34,7 +34,61 @@
34
34
  */
35
35
  import { dirname, resolve, sep } from "node:path";
36
36
 
37
- import { expectAssertion } from "./expect-chain.js";
37
+ /*
38
+ * The matcher of `expect(subject).<modifiers>.<matcher>(args)` is not the
39
+ * callee's property in the general case: `not`, `resolves` and `rejects` sit
40
+ * between `expect(...)` and the matcher, so it is read by walking the member
41
+ * chain back to the `expect` call.
42
+ */
43
+ function propertyName(member) {
44
+ if (!member.computed && member.property.type === "Identifier") return member.property.name;
45
+ if (member.computed && member.property.type === "Literal") {
46
+ return typeof member.property.value === "string" ? member.property.value : undefined;
47
+ }
48
+ return undefined;
49
+ }
50
+
51
+ /** `a?.b` and `a?.()` arrive wrapped; the rules want the expression itself. */
52
+ function unwrapChain(node) {
53
+ return node?.type === "ChainExpression" ? node.expression : node;
54
+ }
55
+
56
+ function isExpectCall(node) {
57
+ if (node?.type !== "CallExpression") return false;
58
+ if (node.callee.type === "Identifier") return node.callee.name === "expect";
59
+ // `expect.soft(value)` and `expect.poll(fn)` open the same chain.
60
+ return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier"
61
+ ? node.callee.object.name === "expect"
62
+ : false;
63
+ }
64
+
65
+ /**
66
+ * Reads an assertion call. Returns `{ matcher, modifiers, subject, args }` for
67
+ * `expect(subject).<modifiers>.<matcher>(args)`, or `undefined` when the call
68
+ * is not an `expect` chain. `subject` is unwrapped of its `ChainExpression`.
69
+ */
70
+ function expectAssertion(node) {
71
+ if (node.type !== "CallExpression" || node.callee.type !== "MemberExpression") return undefined;
72
+ const matcher = propertyName(node.callee);
73
+ if (!matcher) return undefined;
74
+
75
+ const modifiers = [];
76
+ let current = node.callee.object;
77
+ while (current.type === "MemberExpression") {
78
+ const name = propertyName(current);
79
+ if (!name) return undefined;
80
+ modifiers.unshift(name);
81
+ current = current.object;
82
+ }
83
+ if (!isExpectCall(current)) return undefined;
84
+
85
+ return {
86
+ matcher,
87
+ modifiers,
88
+ subject: unwrapChain(current.arguments[0]),
89
+ args: node.arguments,
90
+ };
91
+ }
38
92
 
39
93
  const EQUALITY_MATCHERS = new Set(["toBe", "toEqual", "toStrictEqual"]);
40
94
  const TEST_FILE = /\.test\.tsx?$/;
@@ -0,0 +1,224 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ /*
4
+ * The strict design-system checks, as selector lists for core
5
+ * `no-restricted-syntax`. The plugin registers that core rule once per check,
6
+ * under the check's own name, so each check keeps its `nadicodeai/<name>` id
7
+ * and no two checks share one option list (flat config replaces a rule's
8
+ * options rather than merging them).
9
+ *
10
+ * A class check reads the string literals inside a `className` attribute and a
11
+ * `cva()` call, one class token at a time, variants included.
12
+ */
13
+
14
+ const theme = createRequire(import.meta.url)("@nadicodeai/design-system/tailwind").theme.extend;
15
+ const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
16
+
17
+ /** Any variant chain: `md:`, `group-data-x/tabs:`, `has-[[data-slot=a]:focus]:`. */
18
+ const VARIANTS = String.raw`(?:(?:[^\s:\[\]]|\[(?:[^\s\[\]]|\[[^\s\]]*\])*\])+:)*`;
19
+ export const token = (body, variants = VARIANTS) =>
20
+ String.raw`/(^|\s)${variants}!?(?:${body})!?(\s|$)/`;
21
+ export const literal = (pattern) =>
22
+ `:matches(Literal[value=${pattern}], TemplateElement[value.raw=${pattern}])`;
23
+ const CLASS_SITES =
24
+ ":matches(JSXAttribute[name.name='className'], Program:has(ImportDeclaration[source.value='class-variance-authority'] > ImportSpecifier[local.name='cva']) CallExpression[callee.name='cva'])";
25
+ // A cva variant map's keys name variants, not classes.
26
+ const classToken = (body, variants) =>
27
+ `${CLASS_SITES} ${literal(token(body, variants))}:not(Property > .key)`;
28
+ const ownClass = (body) =>
29
+ `:has(> JSXOpeningElement:has(JSXAttribute[name.name='className'] ${literal(token(body))}))`;
30
+
31
+ /** A component import from the package or the app may not be renamed: a check recognises it by its JSX name. */
32
+ export const keepName = (names, checks = "design-system lint checks") =>
33
+ names.map((name) => ({
34
+ selector: `ImportDeclaration[source.value=/^(@nadicodeai\\/ui|@\\/components|\\.)/] > ImportSpecifier[imported.name='${name}'][local.name!='${name}']`,
35
+ message: `Import ${name} under its own name: the ${checks} recognise it by that name.`,
36
+ }));
37
+
38
+ const OVERLAY_CONTENT = ["AlertDialogContent", "DialogContent", "DrawerContent", "SheetContent"];
39
+ const OVERLAY_ROOTS = [
40
+ "Dialog",
41
+ "AlertDialog",
42
+ "Drawer",
43
+ "Sheet",
44
+ "Popover",
45
+ "DropdownMenu",
46
+ "ContextMenu",
47
+ "Menubar",
48
+ "Tooltip",
49
+ "HoverCard",
50
+ "Combobox",
51
+ "ActionMenu",
52
+ ];
53
+
54
+ const card = "JSXElement[openingElement.name.name=/^(Card|\\w*Card)$/]";
55
+ const cardChild = `:matches(${card}, JSXExpressionContainer:has(> ${card}), JSXExpressionContainer:has(> :matches(ConditionalExpression, LogicalExpression):has(> ${card})))`;
56
+ const mappedCards = `JSXExpressionContainer:has(> CallExpression[callee.property.name=/^(map|from)$/]:has(${card}))`;
57
+
58
+ const bareControl =
59
+ "JSXOpeningElement[name.name=/^(Button|button)$/]:not(:has(> JSXAttribute[name.name=/^aria-label(ledby)?$/])):not(:has(> JSXSpreadAttribute)):not(JSXAttribute[name.name='render'] JSXOpeningElement)";
60
+ const onlyElements =
61
+ ":not(:matches(JSXElement, JSXFragment, Literal[value=null], Literal[value=false], Identifier[name='undefined'], ConditionalExpression, LogicalExpression))";
62
+ const rendersText = `:matches(JSXText[value=/\\S/], :matches(JSXElement, JSXFragment) > JSXExpressionContainer:not([expression.type=/^(JSXElement|JSXFragment|JSXEmptyExpression|ConditionalExpression|LogicalExpression)$/]), ConditionalExpression > ${onlyElements}:matches(.consequent, .alternate), LogicalExpression > ${onlyElements}.right)`;
63
+ const elementChild =
64
+ ":matches(JSXElement, JSXFragment, JSXExpressionContainer[expression.type=/^(JSXElement|JSXFragment|ConditionalExpression|LogicalExpression)$/])";
65
+
66
+ const overlayRoot = `JSXOpeningElement[name.name=/Dialog|Drawer|Sheet|Popover|Menu|Tooltip|HoverCard|Combobox/]:not([name.name=/(Content|Trigger|Title|Description|Header|Footer|Body|Item|Group|Label|Separator|Close|Portal|Overlay|Backdrop)$/])`;
67
+ const isTrue = ":matches([value=null], [value.expression.value=true])";
68
+
69
+ const selectItem = "JSXElement[openingElement.name.name='SelectItem']";
70
+ const noExpressionChild =
71
+ ":not(:has(> JSXExpressionContainer[expression.type!='JSXEmptyExpression']))";
72
+ const fewItems = (parent) =>
73
+ `${parent}:has(> ${selectItem} ~ ${selectItem}):not(:has(> ${selectItem} ~ ${selectItem} ~ ${selectItem} ~ ${selectItem}))${noExpressionChild}`;
74
+
75
+ const drawerSlot = "/^Drawer(Header|Body|Footer)$/";
76
+ const drawerEdge = literal(token(String.raw`border(-[tby])?(-\d+)?`));
77
+
78
+ const SPACING =
79
+ "(?:p|px|py|pt|pr|pb|pl|ps|pe|m|mx|my|mt|mr|mb|ml|ms|me|gap|gap-x|gap-y|space-x|space-y|inset|inset-x|inset-y|top|right|bottom|left|start|end)";
80
+ // The ladder rungs and the control geometry (seam, focus outline and offset, inline padding, touch target).
81
+ const ON_GRID = String.raw`(?:0|1|2|4|8|10|12|16|24|32|40|44|48|64|96|128)(?:\.0+)?`;
82
+
83
+ const typographyTokens = Object.keys(theme.fontSize).map(escape).join("|");
84
+ const colorTokens = Object.keys(theme.colors).map(escape).join("|");
85
+ const radiusValues = [...new Set(Object.values(theme.borderRadius))].map(escape).join("|");
86
+ const radiusNames = Object.keys(theme.borderRadius).map(escape).join("|");
87
+ const invalidTokenMessage =
88
+ "This class is not generated by the installed nadicode design system. Use a utility from @nadicodeai/design-system/tailwind.";
89
+
90
+ export const strictDesignSystemSelectors = {
91
+ "card-row-owner": [
92
+ {
93
+ selector: `JSXElement:not([openingElement.name.name='CardRow']):matches(${ownClass(String.raw`grid-cols-(?!1(\s|$))\S+`)}, ${ownClass("flex-row")}, ${ownClass("flex")}:not(${ownClass("flex-col")})):matches(:has(> ${cardChild} ~ ${cardChild}), :has(> ${mappedCards})) > JSXOpeningElement`,
94
+ message:
95
+ "These cards are laid side by side without the owner of that row. An alignment class or a wrapper around a card is then enough for the shortest card to stop at its content and the row to end on a ragged edge. Use CardRow from @nadicodeai/ui, which stretches its cells and makes every card fill the one it lands in (rule/card-row-equal-height).",
96
+ },
97
+ ],
98
+ "drawer-owns-its-edges": [
99
+ ...keepName(["DrawerHeader", "DrawerBody", "DrawerFooter"]),
100
+ {
101
+ selector: `JSXOpeningElement[name.name=${drawerSlot}] > JSXAttribute[name.name='className'] ${drawerEdge}`,
102
+ message:
103
+ "This border gives a drawer slot a second edge. The drawer draws the hairline between its header, its body and its footer; drop the border class (rule/drawer-owns-its-edges).",
104
+ },
105
+ {
106
+ selector: `JSXElement:has(> JSXElement[openingElement.name.name=${drawerSlot}]) > JSXOpeningElement > JSXAttribute[name.name='className'] ${drawerEdge}`,
107
+ message:
108
+ "This border draws an edge around a drawer slot. The drawer draws the hairline between its header, its body and its footer; render the slot without this wrapper (rule/drawer-owns-its-edges).",
109
+ },
110
+ ],
111
+ "icon-control-accessible-name": [
112
+ ...keepName(["Button"]),
113
+ {
114
+ selector: `JSXElement:not(:has(${rendersText})):matches(:has(> ${bareControl}:has(> JSXAttribute[name.name='size'][value.value=/^icon(-xs|-sm|-lg)?$/])), :has(> ${elementChild})) > ${bareControl}`,
115
+ message:
116
+ 'A screen reader announces "button" and nothing more for this control: its content is an icon, and an icon carries no name. Add aria-label with the action it performs, point aria-labelledby at the text that names it, or put that text in a child with the sr-only class.',
117
+ },
118
+ ],
119
+ "no-invalid-token-utility": [
120
+ {
121
+ selector: classToken(`nc-type-(?!(?:${typographyTokens})(\\s|$))\\S*`),
122
+ message: invalidTokenMessage,
123
+ },
124
+ {
125
+ selector: classToken(
126
+ `(?:bg|text|border|outline|ring|fill|stroke|decoration|accent|caret|divide|from|via|to|shadow)-nc-(?!(?:${colorTokens})(\\/\\S*)?(\\s|$))\\S+`,
127
+ ),
128
+ message: invalidTokenMessage,
129
+ },
130
+ ],
131
+ "no-native-visible-control": [
132
+ {
133
+ selector:
134
+ "JSXOpeningElement[name.name=/^(button|details|input|select|textarea)$/]:not([name.name='input']:has(> JSXAttribute[name.name='type']:matches([value.value='hidden'], [value.expression.value='hidden'], [value.expression.quasis.0.value.cooked='hidden']))):not(JSXOpeningElement > JSXAttribute[name.name='render'] > JSXExpressionContainer > JSXElement > JSXOpeningElement)",
135
+ message:
136
+ "Use the @nadicodeai/ui primitive instead of a native visible control; a <details> takes Disclosure, which draws the one disclosure.",
137
+ },
138
+ ],
139
+ "no-nested-overlay": [
140
+ ...keepName(OVERLAY_CONTENT),
141
+ {
142
+ selector: `JSXElement[openingElement.name.name=/^(${OVERLAY_CONTENT.join("|")})$/] JSXElement[openingElement.name.name=/^(${OVERLAY_CONTENT.join("|")})$/] > JSXOpeningElement`,
143
+ message:
144
+ "This overlay is rendered inside another overlay. Two overlays on one tree keep both focus traps armed, so the keyboard cannot reach the outer surface and Escape closes the wrong layer, while the scrims stack. Render the second overlay as a sibling driven by state, or replace it with an inline disclosure inside the first.",
145
+ },
146
+ ],
147
+ "no-off-grid-spacing": [
148
+ {
149
+ selector: classToken(String.raw`-?${SPACING}-\[-?(?!${ON_GRID}px\])\d+(?:\.\d+)?px\]`),
150
+ message:
151
+ "This pixel step sits off the design-system spacing ladder, so the rhythm reads as almost aligned and the value cannot follow the tokens. Use a rung: 0, nadicode-xxs (4px), nadicode-xs (8), nadicode-sm (12), nadicode-md (16), nadicode-lg (24), nadicode-xl (32), nadicode-2xl (40), nadicode-3xl (48), nadicode-4xl (64), nadicode-5xl (96), nadicode-6xl (128).",
152
+ },
153
+ ],
154
+ "no-raw-shadow": [
155
+ {
156
+ selector: classToken(
157
+ String.raw`shadow(?:-(?:2xs|xs|sm|md|lg|xl|2xl|inner|initial|\[\S*\]))?`,
158
+ ),
159
+ message:
160
+ "A stock shadow paints a light-mode drop shadow with no ring, so the surface loses its edge against the dark workspace and cannot follow the tokens. Choose the physical level instead: nc-elevation-resting, nc-elevation-raised, nc-elevation-floating, or nc-elevation-modal.",
161
+ },
162
+ ],
163
+ "no-repainted-focus": [
164
+ {
165
+ selector: classToken(
166
+ String.raw`-?ring(?:-\S*)?`,
167
+ String.raw`(?=\S*focus)${VARIANTS.slice(0, -1)}+`,
168
+ ),
169
+ message:
170
+ "Focus never paints a box-shadow: the base layer draws the offset outline. Drop this ring utility.",
171
+ },
172
+ {
173
+ selector: classToken(
174
+ String.raw`\[outline\S*|outline(?!-(?:none|hidden|ring|sidebar-ring)(?:\s|$))(?!-offset-)(?:-\S*)?`,
175
+ String.raw`(?=\S*focus)${VARIANTS.slice(0, -1)}+`,
176
+ ),
177
+ message:
178
+ "This utility repaints focus with its own width or hue. The base layer owns the focus outline; a proxy target takes nc-focus-outline plus a ring-role color.",
179
+ },
180
+ ],
181
+ "no-token-category-bypass": [
182
+ {
183
+ selector: classToken(
184
+ String.raw`rounded(?:-(?:t|r|b|l|s|e|ss|se|ee|es|tl|tr|br|bl))?-\[\s*(?:${radiusValues}|var\(--(?:nc-rounded|radius)-(?:${radiusNames})\))\s*\]`,
185
+ ),
186
+ message:
187
+ "This class spells a design-system radius by its value. Use the generated rounded-<token> utility the value belongs to.",
188
+ },
189
+ ],
190
+ "overlay-body-owns-scroll": [
191
+ ...keepName(["DialogContent", "DrawerContent"]),
192
+ {
193
+ selector: `JSXElement[openingElement.name.name=/^(DialogContent|DrawerContent)$/] JSXOpeningElement:not([name.name=/^(DialogContent|DrawerContent)$/]) > JSXAttribute[name.name='className'] ${literal(token("overflow-y-auto|overflow-y-scroll|overflow-auto"))}`,
194
+ message:
195
+ "This makes the element a second scroll container inside an overlay. The body slot owns an overlay's scroll, its shrinking and the inset that keeps focus outlines visible. Put the content in DialogBody or DrawerBody from @nadicodeai/ui and drop the overflow classes (rule/overlay-body-owns-scroll).",
196
+ },
197
+ ],
198
+ "overlay-mounts-closed": [
199
+ ...keepName(OVERLAY_ROOTS),
200
+ {
201
+ selector: `${overlayRoot} > JSXAttribute[name.name='open']${isTrue}`,
202
+ message:
203
+ "This overlay is given `open` fixed to true, so it only exists while open and neither its fade in nor its fade out runs. Keep it mounted and drive `open` from state.",
204
+ },
205
+ {
206
+ selector: `${overlayRoot} > JSXAttribute[name.name='defaultOpen']${isTrue}`,
207
+ message:
208
+ "This overlay mounts open through `defaultOpen`, so its entry never runs. Mount it closed and open it from state.",
209
+ },
210
+ {
211
+ selector: `${overlayRoot} > JSXAttribute[name.name='key'][value.type='JSXExpressionContainer']:not([value.expression.type='Literal']):not([value.expression.type='TemplateLiteral'][value.expression.expressions.length=0]):not(CallExpression[callee.property.name=/^(map|flatMap)$/] > :function JSXAttribute)`,
212
+ message:
213
+ "This overlay takes a `key` that remounts it, so an opening that changes the key mounts it already open and its fade in never runs. Keep one mounted overlay and reset its content another way, such as a key on the content inside it.",
214
+ },
215
+ ],
216
+ "prefer-radio-for-few-static-options": [
217
+ ...keepName(["SelectContent", "SelectItem"]),
218
+ {
219
+ selector: `JSXElement[openingElement.name.name='SelectContent']${noExpressionChild}:matches(${fewItems("JSXElement")}, :has(> ${fewItems("JSXElement[openingElement.name.name='SelectGroup']")})) > JSXOpeningElement`,
220
+ message:
221
+ "This Select holds two or three options fixed at author time, so a reader has to open it to learn what the alternatives are. RadioGroup shows them all at once and takes one arrow key instead of an open and an arrow. Use RadioGroup with a RadioGroupItem per option.",
222
+ },
223
+ ],
224
+ };
@@ -52,7 +52,7 @@
52
52
  * own optical ring in the current mode (design-system DESIGN.md
53
53
  * "core.elevation"). An elevated surface therefore draws no second edge:
54
54
  * never pair one of these with border-*, ring-*, or another box-shadow
55
- * (`nadicodeai/no-elevated-edge` enforces it). Declared as utilities so
55
+ * (`repo/no-elevated-edge` enforces it). Declared as utilities so
56
56
  * variant prefixes can gate them.
57
57
  */
58
58
  @utility nc-elevation-resting {