@nadicodeai/ui 1.0.2 → 1.1.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.
Files changed (133) hide show
  1. package/AGENTS.md +13 -13
  2. package/README.md +1 -1
  3. package/dist/agents.d.ts +8 -8
  4. package/dist/components/accordion.js +1 -1
  5. package/dist/components/action-band.js +1 -1
  6. package/dist/components/agent-card.d.ts +1 -1
  7. package/dist/components/agent-visual.d.ts +1 -1
  8. package/dist/components/alert-dialog.d.ts +1 -1
  9. package/dist/components/automation-section.d.ts +2 -2
  10. package/dist/components/avatar.d.ts.map +1 -1
  11. package/dist/components/avatar.js +3 -1
  12. package/dist/components/badge.js +1 -1
  13. package/dist/components/brand-icons/apple.d.ts +1 -1
  14. package/dist/components/brand-icons/linux.d.ts +1 -1
  15. package/dist/components/brand-icons/windows.d.ts +1 -1
  16. package/dist/components/brand-icons.d.ts +4 -4
  17. package/dist/components/button-group.d.ts +1 -1
  18. package/dist/components/button.js +1 -1
  19. package/dist/components/calendar.d.ts +1 -1
  20. package/dist/components/calendar.js +1 -1
  21. package/dist/components/card.d.ts.map +1 -1
  22. package/dist/components/card.js +2 -4
  23. package/dist/components/carousel.d.ts +1 -1
  24. package/dist/components/carousel.d.ts.map +1 -1
  25. package/dist/components/chart-breakdown-table.d.ts +2 -2
  26. package/dist/components/chart-bullet-bar.js +5 -5
  27. package/dist/components/chart-date-range-control.d.ts +1 -1
  28. package/dist/components/chart-kpi-stat.d.ts +1 -1
  29. package/dist/components/chart-time-series.d.ts +1 -1
  30. package/dist/components/chart-time-series.d.ts.map +1 -1
  31. package/dist/components/chart-time-series.js +10 -9
  32. package/dist/components/chart.d.ts.map +1 -1
  33. package/dist/components/chart.js +13 -3
  34. package/dist/components/checkbox.js +1 -1
  35. package/dist/components/combobox.js +2 -2
  36. package/dist/components/command.d.ts +1 -1
  37. package/dist/components/command.d.ts.map +1 -1
  38. package/dist/components/command.js +2 -2
  39. package/dist/components/context-menu.js +1 -1
  40. package/dist/components/dashboard-shell.d.ts +1 -1
  41. package/dist/components/disclosure-row.d.ts.map +1 -1
  42. package/dist/components/disclosure-row.js +1 -3
  43. package/dist/components/dropdown-menu.js +1 -1
  44. package/dist/components/faq-item.d.ts +1 -1
  45. package/dist/components/faq-item.d.ts.map +1 -1
  46. package/dist/components/faq-item.js +2 -2
  47. package/dist/components/feedback-state.d.ts +1 -1
  48. package/dist/components/field.d.ts +1 -1
  49. package/dist/components/footer.d.ts.map +1 -1
  50. package/dist/components/footer.js +1 -1
  51. package/dist/components/form.d.ts +1 -1
  52. package/dist/components/glass-control.js +1 -1
  53. package/dist/components/hero.js +1 -1
  54. package/dist/components/input-group.d.ts +1 -1
  55. package/dist/components/input-group.js +3 -3
  56. package/dist/components/input-otp.d.ts.map +1 -1
  57. package/dist/components/input-otp.js +6 -3
  58. package/dist/components/input.js +1 -1
  59. package/dist/components/integration-flow.d.ts.map +1 -1
  60. package/dist/components/integration-flow.js +5 -5
  61. package/dist/components/item.d.ts +1 -1
  62. package/dist/components/item.js +1 -1
  63. package/dist/components/label-system.js +1 -1
  64. package/dist/components/live-demo.js +2 -2
  65. package/dist/components/menubar.d.ts +1 -1
  66. package/dist/components/menubar.js +1 -1
  67. package/dist/components/native-select.js +1 -1
  68. package/dist/components/nav-bar.d.ts.map +1 -1
  69. package/dist/components/nav-bar.js +1 -1
  70. package/dist/components/navigation-menu.d.ts.map +1 -1
  71. package/dist/components/navigation-menu.js +5 -5
  72. package/dist/components/pagination.d.ts +1 -1
  73. package/dist/components/plugin-slot-boundary.d.ts.map +1 -1
  74. package/dist/components/plugin-slot-boundary.js +1 -1
  75. package/dist/components/popover.js +1 -1
  76. package/dist/components/radio-group.js +1 -1
  77. package/dist/components/resizable.d.ts.map +1 -1
  78. package/dist/components/resizable.js +4 -2
  79. package/dist/components/scroll-area.js +1 -1
  80. package/dist/components/seam-grid.d.ts +1 -1
  81. package/dist/components/select.js +4 -4
  82. package/dist/components/settings-section.d.ts +1 -1
  83. package/dist/components/sidebar.d.ts +4 -4
  84. package/dist/components/sidebar.d.ts.map +1 -1
  85. package/dist/components/sidebar.js +6 -7
  86. package/dist/components/slider.js +1 -1
  87. package/dist/components/status-dot.js +2 -2
  88. package/dist/components/switch.js +1 -1
  89. package/dist/components/tabs.js +1 -1
  90. package/dist/components/textarea.js +1 -1
  91. package/dist/components/texture-strip.d.ts.map +1 -1
  92. package/dist/components/texture-strip.js +3 -0
  93. package/dist/components/theme-mode-switcher.d.ts +2 -2
  94. package/dist/components/toggle-group.d.ts +1 -1
  95. package/dist/components/toggle.js +1 -1
  96. package/dist/eslint/index.js +23 -17
  97. package/dist/eslint/rules/confirm-destructive-action.js +177 -0
  98. package/dist/eslint/rules/jsx-class-utility.js +162 -176
  99. package/dist/eslint/rules/no-app-feature-component.js +100 -0
  100. package/dist/eslint/rules/no-deprecated-zod-api.js +63 -15
  101. package/dist/eslint/rules/no-elevated-edge.js +144 -0
  102. package/dist/eslint/rules/no-mono-title-register.js +73 -0
  103. package/dist/eslint/rules/no-native-visible-control.js +6 -3
  104. package/dist/eslint/rules/no-package-component-redefinition.js +175 -0
  105. package/dist/eslint/rules/no-paired-size-utility.js +66 -0
  106. package/dist/eslint/rules/no-repainted-focus.js +80 -0
  107. package/dist/eslint/rules/no-resting-settings-control.js +87 -0
  108. package/dist/eslint/rules/no-shadcn-appearance-override.js +30 -56
  109. package/dist/eslint/rules/no-unpinned-vercel-functions-import.js +25 -23
  110. package/dist/eslint/rules/server-action-discipline.js +182 -0
  111. package/dist/eslint/rules/shadcn-composition.js +131 -0
  112. package/dist/hooks/index.d.ts +3 -3
  113. package/dist/index.d.ts +132 -132
  114. package/dist/internal/agent-visual/poster.d.ts +1 -1
  115. package/dist/internal/agent-visual/renderer.d.ts +1 -1
  116. package/dist/internal/assistant-ui/attachment.js +1 -1
  117. package/dist/internal/assistant-ui/follow-up-suggestions.d.ts.map +1 -1
  118. package/dist/internal/assistant-ui/follow-up-suggestions.js +5 -1
  119. package/dist/internal/assistant-ui/reasoning.d.ts +1 -1
  120. package/dist/internal/assistant-ui/tool-fallback.d.ts +1 -1
  121. package/dist/internal/assistant-ui/tool-group.d.ts +1 -1
  122. package/dist/internal/assistant-ui/tooltip-icon-button.d.ts +1 -1
  123. package/dist/styles/globals.css +37 -12
  124. package/docs/consuming-cross-repo.md +2 -2
  125. package/docs/contract.md +30 -20
  126. package/docs/migration/base-ui-migration.md +2 -2
  127. package/package.json +7 -6
  128. package/dist/eslint/rules/no-design-system-src-import.js +0 -11
  129. package/dist/eslint/rules/no-raw-color-utility.js +0 -109
  130. package/dist/eslint/rules/no-raw-focus-ring-width.js +0 -30
  131. package/dist/eslint/rules/no-raw-logo-import.js +0 -41
  132. package/dist/eslint/rules/no-ui-src-import.js +0 -10
  133. package/dist/eslint/rules/src-import-boundary.js +0 -207
@@ -0,0 +1,144 @@
1
+ /*
2
+ * An elevated surface draws its own edge. `smooth-shadow-ring-*` already
3
+ * integrates the vendor plugin's adaptive optical ring (DESIGN.md, "material
4
+ * ladder"), so a border, a second ring, or a local ring tint on the same target
5
+ * paints a second edge over it — the seam this repository keeps reintroducing
6
+ * when a stock shadcn shape is pasted next to an elevation.
7
+ *
8
+ * Standard tool checked (ADR 0057 tier 2): `better-tailwindcss`'s
9
+ * `no-restricted-classes` judges one class at a time. `border` and `ring-1` are
10
+ * legal on their own — they are wrong only beside an elevation, and only when
11
+ * both land on the same target, so `*:data-[slot=input]:border-input` under an
12
+ * elevated parent stays legal. That relationship is what this rule reads, on
13
+ * the shared className reader.
14
+ *
15
+ * Replaces the @nadicodeai/ui material-surface-contract scanner (#822), which
16
+ * built its own TypeScript AST over the package's sources to ask the same
17
+ * question. It takes no `packageOwnedPrimitives` option: the material ladder
18
+ * holds wherever an elevation is written, inside the package and out.
19
+ */
20
+ import { nodeClassUtilities } from "./jsx-class-utility.js";
21
+
22
+ const ELEVATION = /^smooth-shadow-ring-(?:xs|sm|md|lg|xl|2xl)$/;
23
+
24
+ /*
25
+ * The callees that compose a class list out of several strings. A class written
26
+ * in one argument and an elevation in another are one surface, so the call —
27
+ * not the string — is the unit this rule reviews.
28
+ */
29
+ const COMPOSERS = new Set(["cn", "clsx", "cva"]);
30
+
31
+ /** `md:hover:border` → `{ modifiers: ["md", "hover"], utility: "border" }`. */
32
+ function tailwindToken(token) {
33
+ const segments = [];
34
+ let bracketDepth = 0;
35
+ let start = 0;
36
+ for (let index = 0; index < token.length; index += 1) {
37
+ const character = token[index];
38
+ if (character === "[") bracketDepth += 1;
39
+ if (character === "]") bracketDepth = Math.max(0, bracketDepth - 1);
40
+ // A colon inside brackets belongs to an arbitrary value, not to a variant.
41
+ if (character === ":" && bracketDepth === 0) {
42
+ segments.push(token.slice(start, index));
43
+ start = index + 1;
44
+ }
45
+ }
46
+ segments.push(token.slice(start));
47
+ return { modifiers: segments.slice(0, -1), utility: segments.at(-1) ?? token };
48
+ }
49
+
50
+ /**
51
+ * What a utility paints on: the element itself, or the descendants a variant
52
+ * selects. Two utilities collide only when this matches — an elevation on the
53
+ * root and a border on `*:data-[slot=input]` are different surfaces.
54
+ */
55
+ function targetKey(modifiers) {
56
+ const descendants = modifiers.filter(
57
+ (modifier) => modifier === "*" || modifier === "**" || /^\[&(?:_|>|\s)/.test(modifier),
58
+ );
59
+ return descendants.length === 0 ? "self" : descendants.join(":");
60
+ }
61
+
62
+ /** A width of zero is the absence of an edge, which is what an elevation wants. */
63
+ function isZeroWidthUtility(utility) {
64
+ return utility === "border-0" || utility === "ring-0";
65
+ }
66
+
67
+ function edgeRule(utility) {
68
+ if (utility.startsWith("smooth-ring-")) return "ringTintOverride";
69
+ if (/^border(?:$|-)/.test(utility) && !isZeroWidthUtility(utility)) return "borderWithElevation";
70
+ if (/^ring(?:$|-)/.test(utility) && !isZeroWidthUtility(utility)) return "ringWithElevation";
71
+ return undefined;
72
+ }
73
+
74
+ /** One finding per offending edge, however many elevations it sits beside. */
75
+ function edgeViolations(classes) {
76
+ const tokens = classes.map((token) => ({ token, ...tailwindToken(token) }));
77
+ const elevations = tokens.filter(({ utility }) => ELEVATION.test(utility));
78
+ const violations = new Map();
79
+ for (const elevation of elevations) {
80
+ const elevationTarget = targetKey(elevation.modifiers);
81
+ for (const candidate of tokens) {
82
+ if (targetKey(candidate.modifiers) !== elevationTarget) continue;
83
+ const messageId = edgeRule(candidate.utility);
84
+ if (messageId && !violations.has(candidate.token)) {
85
+ violations.set(candidate.token, { messageId, shadow: elevation.token });
86
+ }
87
+ }
88
+ }
89
+ return [...violations].map(([token, finding]) => ({ token, ...finding }));
90
+ }
91
+
92
+ function isComposerCall(node) {
93
+ return (
94
+ node?.type === "CallExpression" &&
95
+ node.callee.type === "Identifier" &&
96
+ COMPOSERS.has(node.callee.name)
97
+ );
98
+ }
99
+
100
+ function insideComposerCall(node) {
101
+ for (let current = node.parent; current; current = current.parent) {
102
+ if (isComposerCall(current)) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ export const noElevatedEdge = {
108
+ meta: {
109
+ type: "problem",
110
+ docs: {
111
+ description:
112
+ "Disallow a border, ring, or ring tint on the same target as a smooth-shadow-ring-* elevation",
113
+ },
114
+ schema: [],
115
+ messages: {
116
+ borderWithElevation:
117
+ '"{{token}}" draws a hard border on the same target as {{shadow}}. An elevated surface carries the plugin\'s optical ring: drop the border, or drop the elevation.',
118
+ ringWithElevation:
119
+ '"{{token}}" draws a second ring on the same target as {{shadow}}. An elevated surface carries the plugin\'s optical ring already.',
120
+ ringTintOverride:
121
+ '"{{token}}" repaints the vendor ring beside {{shadow}}. The elevation owns its ring tint, including the dark-mode one.',
122
+ },
123
+ },
124
+ create(context) {
125
+ const report = (node, classes) => {
126
+ for (const { token, messageId, shadow } of edgeViolations(classes)) {
127
+ context.report({ node, messageId, data: { token, shadow } });
128
+ }
129
+ };
130
+
131
+ return {
132
+ CallExpression(node) {
133
+ // The outermost composer owns the surface: a nested one's strings are
134
+ // already in its list, so reviewing both would report twice.
135
+ if (!isComposerCall(node) || insideComposerCall(node)) return;
136
+ report(node, nodeClassUtilities(node, context, { skipPropertyKeys: true }));
137
+ },
138
+ Literal(node) {
139
+ if (typeof node.value !== "string" || insideComposerCall(node)) return;
140
+ report(node, node.value.split(/\s+/).filter(Boolean));
141
+ },
142
+ };
143
+ },
144
+ };
@@ -0,0 +1,73 @@
1
+ /*
2
+ * Design law 9: a title is prose, so it never renders in the mono-caps
3
+ * register. The rule is mechanical because the register is — `font-mono`
4
+ * together with `uppercase` on a slot that carries a title — and everything
5
+ * judgment-bearing stays out of it: an eyebrow, a fact label, an identifier or
6
+ * a marketing label may all be mono, and none of them is a title slot.
7
+ *
8
+ * Standard tool checked (ADR 0057 tier 2): `better-tailwindcss`'s
9
+ * `no-restricted-classes` judges one class at a time and never against the
10
+ * element it lands on, so it can ban `font-mono` everywhere or nowhere. The
11
+ * register is a PAIR of classes on a TITLE element, which is a relationship —
12
+ * so it lives here, on the shared className reader.
13
+ *
14
+ * Replaces `monoTitleRegisterViolations` in the retired portal page-grammar
15
+ * analyzers (#821).
16
+ */
17
+ import { openingElementClassUtilities } from "./jsx-class-utility.js";
18
+
19
+ // The kit's title elements plus the hand-written headings.
20
+ const TITLE_TAGS = new Set(["CardTitle", "h1", "h2", "h3", "h4"]);
21
+ const TITLE_SLOT = /-title$/;
22
+
23
+ function withoutVariants(utility) {
24
+ return utility.slice(utility.lastIndexOf(":") + 1);
25
+ }
26
+
27
+ function slotName(node) {
28
+ for (const attribute of node.attributes) {
29
+ if (attribute.type !== "JSXAttribute" || attribute.name.name !== "data-slot") continue;
30
+ const value = attribute.value;
31
+ if (value?.type === "Literal" && typeof value.value === "string") return value.value;
32
+ if (
33
+ value?.type === "JSXExpressionContainer" &&
34
+ value.expression.type === "Literal" &&
35
+ typeof value.expression.value === "string"
36
+ ) {
37
+ return value.expression.value;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+
43
+ function isTitleElement(node) {
44
+ if (node.name.type !== "JSXIdentifier") return false;
45
+ if (TITLE_TAGS.has(node.name.name)) return true;
46
+ const slot = slotName(node);
47
+ return Boolean(slot && TITLE_SLOT.test(slot));
48
+ }
49
+
50
+ export const noMonoTitleRegister = {
51
+ meta: {
52
+ type: "problem",
53
+ docs: {
54
+ description: "Disallow the retired mono-caps register on a title slot (design law 9)",
55
+ },
56
+ schema: [],
57
+ messages: {
58
+ monoTitle:
59
+ "A title never renders in the mono-caps register: drop font-mono and uppercase (design law 9).",
60
+ },
61
+ },
62
+ create(context) {
63
+ return {
64
+ JSXOpeningElement(node) {
65
+ if (!isTitleElement(node)) return;
66
+ const utilities = openingElementClassUtilities(node, context).map(withoutVariants);
67
+ if (utilities.includes("font-mono") && utilities.includes("uppercase")) {
68
+ context.report({ node, messageId: "monoTitle" });
69
+ }
70
+ },
71
+ };
72
+ },
73
+ };
@@ -1,4 +1,4 @@
1
- import { isPackageOwnedPrimitive } from "./jsx-class-utility.js";
1
+ import { packageOwnedPrimitivesSchema, skipsPackageOwnedPrimitive } from "./jsx-class-utility.js";
2
2
 
3
3
  const NATIVE_CONTROLS = new Set(["button", "input", "select", "textarea"]);
4
4
 
@@ -42,14 +42,17 @@ export const noNativeVisibleControl = {
42
42
  docs: {
43
43
  description: "Disallow visible native controls in app source; use @nadicodeai/ui primitives",
44
44
  },
45
- schema: [],
45
+ // Default "exempt": the package's own source is where the primitives that
46
+ // replace a native control are built, so a native <button> there is the
47
+ // implementation, not a bypass.
48
+ schema: [packageOwnedPrimitivesSchema],
46
49
  messages: {
47
50
  nativeVisibleControl:
48
51
  "Use the @nadicodeai/ui primitive instead of native visible <{{element}}>.",
49
52
  },
50
53
  },
51
54
  create(context) {
52
- if (isPackageOwnedPrimitive(context.filename)) {
55
+ if (skipsPackageOwnedPrimitive(context)) {
53
56
  return {};
54
57
  }
55
58
  const packagePrimitiveNames = new Set();
@@ -0,0 +1,175 @@
1
+ /*
2
+ * A route does not re-declare a component the package already ships.
3
+ *
4
+ * `@nadicodeai/ui` is the website's component surface. A route that declares
5
+ * its own `Hero`, `Footer` or `NavBar` forks a shipped component into a page,
6
+ * where the package's next change does not reach it — and the two are then
7
+ * told apart only by their import paths.
8
+ *
9
+ * The names are DERIVED from the package's build surface at lint time: the
10
+ * `types` target of its `exports` map, followed through the barrel's
11
+ * `export *` chain. There is no list here to keep in step with the package;
12
+ * a component added or removed by the package changes this rule's answer with
13
+ * no edit at all.
14
+ *
15
+ * Standard tool checked (ADR 0057 tier 2): core `no-redeclare` and
16
+ * `no-shadow` answer only inside one scope chain, and `no-restricted-syntax`
17
+ * would need every name spelled into a selector — the hand list this derivation
18
+ * exists to avoid. Deferred here from #819 for exactly that reason.
19
+ */
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { createRequire } from "node:module";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+
25
+ const PACKAGE = "@nadicodeai/ui";
26
+
27
+ /*
28
+ * TypeScript is loaded when this rule first runs, not when the plugin loads.
29
+ * The plugin's bytes are published inside `@nadicodeai/ui`, which does not
30
+ * depend on the compiler; a consumer who never enables this rule never asks
31
+ * for it, and one who does is already linting TypeScript.
32
+ */
33
+ let ts;
34
+ function typescript() {
35
+ ts ??= createRequire(import.meta.url)("typescript");
36
+ return ts;
37
+ }
38
+
39
+ /** The `.d.ts` the package's `exports` map names as its root type surface. */
40
+ function typeSurfaceEntry() {
41
+ let directory = dirname(fileURLToPath(import.meta.resolve(PACKAGE)));
42
+ for (;;) {
43
+ const manifestPath = join(directory, "package.json");
44
+ let manifest;
45
+ try {
46
+ manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
47
+ } catch {
48
+ const parent = dirname(directory);
49
+ if (parent === directory) {
50
+ throw new Error(`${PACKAGE}: no manifest above its resolved entry point`);
51
+ }
52
+ directory = parent;
53
+ continue;
54
+ }
55
+ if (manifest.name !== PACKAGE) {
56
+ throw new Error(`${manifestPath} is not ${PACKAGE}`);
57
+ }
58
+ const entry = join(directory, manifest.exports["."].types);
59
+ if (!existsSync(entry)) {
60
+ throw new Error(
61
+ `${PACKAGE} has no built type surface at ${entry}: build the package before linting.`,
62
+ );
63
+ }
64
+ return entry;
65
+ }
66
+ }
67
+
68
+ /*
69
+ * A type is not a component. `export type ChartConfig` names a shape a route
70
+ * may legitimately declare its own version of, and it cannot be imported as a
71
+ * component at all, so the derived set holds values only: type aliases,
72
+ * interfaces, and the `type` marker on an export clause are dropped.
73
+ */
74
+ function isTypeOnlyStatement(ts, statement) {
75
+ return (
76
+ ts.isTypeAliasDeclaration(statement) ||
77
+ ts.isInterfaceDeclaration(statement) ||
78
+ statement.isTypeOnly === true
79
+ );
80
+ }
81
+
82
+ function exportedNames(file, names = new Set(), seen = new Set()) {
83
+ if (seen.has(file)) return names;
84
+ seen.add(file);
85
+ const ts = typescript();
86
+ const source = ts.createSourceFile(
87
+ file,
88
+ readFileSync(file, "utf8"),
89
+ ts.ScriptTarget.Latest,
90
+ false,
91
+ ts.ScriptKind.TS,
92
+ );
93
+ for (const statement of source.statements) {
94
+ if (ts.isExportDeclaration(statement)) {
95
+ if (statement.moduleSpecifier && !statement.exportClause) {
96
+ exportedNames(
97
+ join(dirname(file), statement.moduleSpecifier.text.replace(/\.js$/, ".d.ts")),
98
+ names,
99
+ seen,
100
+ );
101
+ } else if (statement.exportClause && ts.isNamedExports(statement.exportClause)) {
102
+ if (isTypeOnlyStatement(ts, statement)) continue;
103
+ for (const element of statement.exportClause.elements) {
104
+ if (element.isTypeOnly) continue;
105
+ names.add(element.name.text);
106
+ }
107
+ }
108
+ continue;
109
+ }
110
+ const modifiers = ts.canHaveModifiers(statement) ? (ts.getModifiers(statement) ?? []) : [];
111
+ if (!modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue;
112
+ if (isTypeOnlyStatement(ts, statement)) continue;
113
+ if (ts.isVariableStatement(statement)) {
114
+ for (const declaration of statement.declarationList.declarations) {
115
+ if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text);
116
+ }
117
+ } else if (statement.name && ts.isIdentifier(statement.name)) {
118
+ names.add(statement.name.text);
119
+ }
120
+ }
121
+ return names;
122
+ }
123
+
124
+ let packageExports;
125
+
126
+ /** Read once per lint process; the built surface does not move under it. */
127
+ function packageExportNames() {
128
+ packageExports ??= exportedNames(typeSurfaceEntry());
129
+ return packageExports;
130
+ }
131
+
132
+ const COMPONENT_NAME = /^[A-Z]/;
133
+
134
+ function isFunctionLike(node) {
135
+ return (
136
+ node?.type === "ArrowFunctionExpression" ||
137
+ node?.type === "FunctionExpression" ||
138
+ node?.type === "ClassExpression" ||
139
+ node?.type === "CallExpression"
140
+ );
141
+ }
142
+
143
+ export const noPackageComponentRedefinition = {
144
+ meta: {
145
+ type: "problem",
146
+ docs: {
147
+ description: `Disallow declaring a component whose name ${PACKAGE} already exports`,
148
+ },
149
+ schema: [],
150
+ messages: {
151
+ redefined: `{{name}} is exported by ${PACKAGE}: import it instead of declaring a second one here.`,
152
+ },
153
+ },
154
+ create(context) {
155
+ const report = (node, name) => {
156
+ if (!COMPONENT_NAME.test(name) || !packageExportNames().has(name)) return;
157
+ context.report({ node, messageId: "redefined", data: { name } });
158
+ };
159
+
160
+ return {
161
+ FunctionDeclaration(node) {
162
+ if (node.id) report(node.id, node.id.name);
163
+ },
164
+ // A class is the third way to write a component; React still renders one.
165
+ ClassDeclaration(node) {
166
+ if (node.id) report(node.id, node.id.name);
167
+ },
168
+ VariableDeclarator(node) {
169
+ if (node.id.type === "Identifier" && isFunctionLike(node.init)) {
170
+ report(node.id, node.id.name);
171
+ }
172
+ },
173
+ };
174
+ },
175
+ };
@@ -0,0 +1,66 @@
1
+ /*
2
+ * A square is written once: `size-4`, never `h-4 w-4`.
3
+ *
4
+ * Standard tool checked (ADR 0057 tier 2): `better-tailwindcss`'s
5
+ * `no-restricted-classes` judges one class at a time, so it can ban `h-4`
6
+ * outright but cannot see that a `w-4` is beside it — and the pair is the whole
7
+ * point, since `h-4 w-full` is fine. The relationship lives here, on the shared
8
+ * className reader.
9
+ *
10
+ * Replaces the `paired-size` half of the retired @nadicodeai/ui
11
+ * composition-conventions scanner (#822). Stock shadcn shapes that cannot be
12
+ * written any other way carry an inline disable with the reason at the site.
13
+ */
14
+ import {
15
+ openingElementClassUtilities,
16
+ packageOwnedPrimitivesSchema,
17
+ skipsPackageOwnedPrimitive,
18
+ } from "./jsx-class-utility.js";
19
+
20
+ /** `md:h-4` → `{ variants: "md:", axis: "h", value: "4" }`, or undefined. */
21
+ function sizeUtility(utility) {
22
+ const separator = utility.lastIndexOf(":");
23
+ const variants = utility.slice(0, separator + 1);
24
+ const base = utility.slice(separator + 1);
25
+ const match = /^([hw])-(.+)$/.exec(base);
26
+ return match ? { variants, axis: match[1], value: match[2] } : undefined;
27
+ }
28
+
29
+ export const noPairedSizeUtility = {
30
+ meta: {
31
+ type: "problem",
32
+ docs: {
33
+ description: "Disallow paired h-* and w-* utilities of equal value; use size-*",
34
+ },
35
+ schema: [packageOwnedPrimitivesSchema],
36
+ messages: {
37
+ pairedSize: "Write the square once: use {{variants}}size-{{value}} instead of a h/w pair.",
38
+ },
39
+ },
40
+ create(context) {
41
+ if (skipsPackageOwnedPrimitive(context)) {
42
+ return {};
43
+ }
44
+
45
+ return {
46
+ JSXOpeningElement(node) {
47
+ const seen = new Map();
48
+ for (const utility of openingElementClassUtilities(node, context)) {
49
+ const parsed = sizeUtility(utility);
50
+ if (!parsed) continue;
51
+ const key = `${parsed.variants}${parsed.value}`;
52
+ const axes = seen.get(key) ?? new Set();
53
+ axes.add(parsed.axis);
54
+ seen.set(key, axes);
55
+ if (axes.size === 2) {
56
+ context.report({
57
+ node,
58
+ messageId: "pairedSize",
59
+ data: { variants: parsed.variants, value: parsed.value },
60
+ });
61
+ }
62
+ }
63
+ },
64
+ };
65
+ },
66
+ };
@@ -0,0 +1,80 @@
1
+ import { classUtilities } from "./jsx-class-utility.js";
2
+
3
+ /**
4
+ * Keyboard focus has one owner: the globals.css base layer draws the offset
5
+ * ink outline on every :focus-visible element (DESIGN.md "Focus ring"), and an
6
+ * invalid control takes the same geometry in the destructive hue. A component
7
+ * that paints focus with a box-shadow ring, a raw outline width, or its own
8
+ * hue forks that treatment. Standard tool checked (ADR 0057 tier 2): no
9
+ * upstream Tailwind rule models "focus variant may only suppress or restate
10
+ * the canonical outline".
11
+ *
12
+ * A focus-variant utility may only suppress the outline (outline-none,
13
+ * outline-hidden), restate the canonical treatment on a proxy target (the
14
+ * outline style/width/offset tokens plus the ring-role colors, as input-group
15
+ * does when the group carries the indicator for its inner control), or move
16
+ * non-paint properties (z-index, colors of the element itself, …).
17
+ */
18
+ const RING_BASE = /^-?ring(?:-|$)/;
19
+ const ALLOWED_OUTLINE_BASE = new Set(["outline", "outline-none", "outline-hidden"]);
20
+ const ALLOWED_OUTLINE_COLOR = /^outline-(?:ring|sidebar-ring)$/;
21
+ const OUTLINE_TOKEN_LENGTH = /^outline-\[length:var\(--outline-width-focus\)\]$/;
22
+ const OUTLINE_OFFSET = /^-?outline-offset-/;
23
+
24
+ function baseOf(utility) {
25
+ let depth = 0;
26
+ let base = utility;
27
+ for (let i = 0; i < utility.length; i += 1) {
28
+ const ch = utility[i];
29
+ if (ch === "[" || ch === "(") depth += 1;
30
+ else if (ch === "]" || ch === ")") depth -= 1;
31
+ else if (ch === ":" && depth === 0) base = utility.slice(i + 1);
32
+ }
33
+ return base;
34
+ }
35
+
36
+ function repaintedFocus(utility) {
37
+ if (!utility.includes("focus")) return undefined;
38
+ const base = baseOf(utility);
39
+ if (base === utility) return undefined;
40
+ if (RING_BASE.test(base)) return "boxShadowFocus";
41
+ if (base.startsWith("[outline")) return "customFocusOutline";
42
+ if (!base.startsWith("outline")) return undefined;
43
+ if (
44
+ ALLOWED_OUTLINE_BASE.has(base) ||
45
+ ALLOWED_OUTLINE_COLOR.test(base) ||
46
+ OUTLINE_TOKEN_LENGTH.test(base) ||
47
+ OUTLINE_OFFSET.test(base)
48
+ ) {
49
+ return undefined;
50
+ }
51
+ return "customFocusOutline";
52
+ }
53
+
54
+ export const noRepaintedFocus = {
55
+ meta: {
56
+ type: "problem",
57
+ docs: {
58
+ description:
59
+ "Keep keyboard focus on the base-layer outline: no box-shadow ring, raw width, or component hue on a focus variant",
60
+ },
61
+ schema: [],
62
+ messages: {
63
+ boxShadowFocus:
64
+ "Focus never paints a box-shadow: the base layer draws the offset outline. Drop {{utility}}.",
65
+ customFocusOutline:
66
+ "{{utility}} repaints focus with its own width or hue. The base layer owns the focus outline; a proxy target restates only the canonical tokens.",
67
+ },
68
+ },
69
+ create(context) {
70
+ return {
71
+ JSXAttribute(node) {
72
+ for (const utility of classUtilities(node)) {
73
+ const messageId = repaintedFocus(utility);
74
+ if (messageId === undefined) continue;
75
+ context.report({ node, messageId, data: { utility } });
76
+ }
77
+ },
78
+ };
79
+ },
80
+ };
@@ -0,0 +1,87 @@
1
+ /*
2
+ * The settings law, proved on the surfaces that declare they follow it.
3
+ *
4
+ * A file that renders `SettingsFactsSection` has said its settings are a
5
+ * reading surface: current values stated as facts, one affordance opening the
6
+ * editor that changes them. The regression this catches is a form control
7
+ * rendered beside those facts rather than inside the editor they summon.
8
+ *
9
+ * "Inside the editor" means inside an `ActionDialogForm`'s `fields` prop, or
10
+ * inside a `Dialog`/`Drawer` subtree for a surface that owns its own. Being a
11
+ * CHILD of `ActionDialogForm` is the opposite: children are the resting surface
12
+ * the dialog is opened from, which is exactly where a control must not be.
13
+ *
14
+ * The scope is the declaring file rather than every page, so a registry
15
+ * toolbar's search or a mobile nav select is never a violation — a guard whose
16
+ * allow-list carries live violations documents debt instead of preventing it.
17
+ *
18
+ * Standard tool checked (ADR 0057 tier 2): `no-restricted-syntax` selects one
19
+ * node by shape and cannot ask what else the file renders, nor whether an
20
+ * ancestor summoned the subtree. Both are relationships, so the rule lives
21
+ * here. Replaces `restingSettingsControlViolations` in the retired portal
22
+ * page-grammar analyzers (#821).
23
+ */
24
+
25
+ // The controls a person types or picks into. Lowercase `input` is deliberately
26
+ // absent: a hidden field carries context a summoned editor already decided, and
27
+ // it is not a control anyone rests their eyes on.
28
+ const RESTING_CONTROL_TAGS = new Set([
29
+ "Checkbox",
30
+ "Input",
31
+ "InputOTP",
32
+ "NativeSelect",
33
+ "Select",
34
+ "Switch",
35
+ "Textarea",
36
+ ]);
37
+
38
+ const FACTS_SECTION_TAG = "SettingsFactsSection";
39
+
40
+ function elementName(node) {
41
+ return node?.name?.type === "JSXIdentifier" ? node.name.name : undefined;
42
+ }
43
+
44
+ function summons(node) {
45
+ if (node.type === "JSXAttribute") return node.name.name === "fields";
46
+ if (node.type !== "JSXElement") return false;
47
+ const name = elementName(node.openingElement);
48
+ return Boolean(name && (name.startsWith("Dialog") || name.startsWith("Drawer")));
49
+ }
50
+
51
+ export const noRestingSettingsControl = {
52
+ meta: {
53
+ type: "problem",
54
+ docs: {
55
+ description:
56
+ "Disallow a form control resting beside the facts it should be summoned over, on a settings-facts surface",
57
+ },
58
+ schema: [],
59
+ messages: {
60
+ restingControl:
61
+ "<{{tag}}> rests beside its facts: put it in the editor the facts summon (an ActionDialogForm `fields` prop, or a Dialog/Drawer subtree).",
62
+ },
63
+ },
64
+ create(context) {
65
+ let declaresFacts = false;
66
+ const resting = [];
67
+
68
+ return {
69
+ JSXOpeningElement(node) {
70
+ const name = elementName(node);
71
+ if (name === FACTS_SECTION_TAG) {
72
+ declaresFacts = true;
73
+ return;
74
+ }
75
+ if (!name || !RESTING_CONTROL_TAGS.has(name)) return;
76
+ if (context.sourceCode.getAncestors(node).some(summons)) return;
77
+ resting.push({ node, name });
78
+ },
79
+ "Program:exit"() {
80
+ if (!declaresFacts) return;
81
+ for (const { node, name } of resting) {
82
+ context.report({ node, messageId: "restingControl", data: { tag: name } });
83
+ }
84
+ },
85
+ };
86
+ },
87
+ };