inkbridge 0.1.0-beta.20 → 0.1.0-beta.21

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 (36) hide show
  1. package/README.md +2 -1
  2. package/bin/inkbridge.mjs +64 -9
  3. package/code.js +11 -11
  4. package/package.json +8 -2
  5. package/scanner/adapter-utils-regression.ts +159 -0
  6. package/scanner/component-scanner.ts +276 -19
  7. package/scanner/font-family-extract-regression.ts +113 -0
  8. package/scanner/framework-adapter-shadcn-regression.ts +96 -1
  9. package/scanner/grid-cols-extraction-regression.ts +110 -0
  10. package/scanner/input-range-regression.ts +217 -0
  11. package/scanner/jsx-prop-unresolved-regression.ts +178 -0
  12. package/scanner/local-const-className-regression.ts +331 -0
  13. package/scanner/ring-utility-regression.ts +25 -4
  14. package/scanner/state-classification-regression.ts +38 -0
  15. package/scanner/stretch-to-parent-width-regression.ts +35 -1
  16. package/scanner/tailwind-parser.ts +38 -2
  17. package/src/components/component-gen.ts +11 -151
  18. package/src/design-system/cva-master.ts +7 -3
  19. package/src/design-system/design-system.ts +8 -0
  20. package/src/design-system/node-helpers.ts +15 -1
  21. package/src/design-system/preview-builder.ts +14 -45
  22. package/src/design-system/state-master.ts +23 -1
  23. package/src/design-system/story-builder.ts +55 -5
  24. package/src/design-system/ui-builder.ts +116 -6
  25. package/src/framework-adapters/index.ts +15 -2
  26. package/src/framework-adapters/shadcn.ts +83 -67
  27. package/src/layout/deferred-layout.ts +187 -1
  28. package/src/layout/layout-utils.ts +2 -1
  29. package/src/layout/ring-utils.ts +31 -82
  30. package/src/render-engine-version.ts +1 -1
  31. package/src/tailwind/adapter-utils.ts +137 -0
  32. package/src/tailwind/jsx-utils.ts +9 -0
  33. package/src/tailwind/node-ir.ts +172 -0
  34. package/src/tailwind/tailwind.ts +23 -16
  35. package/src/tokens/tokens.ts +11 -3
  36. package/templates/scan-components-route.ts +11 -1
@@ -0,0 +1,178 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: when a JSX attribute reads an unresolved identifier (e.g. a
7
+ * function parameter with no default), the scanner must OMIT the prop
8
+ * rather than serialise the identifier's source text as the value.
9
+ *
10
+ * History: DropdownMenuItem's body has
11
+ *
12
+ * <DropdownMenuPrimitive.Item data-inset={inset} ... />
13
+ *
14
+ * where `inset?: boolean` is destructured from props with no default. In
15
+ * the Default story the consumer never passes `inset`, so its runtime
16
+ * value is `undefined` — React would render NO `data-inset` attribute.
17
+ *
18
+ * The scanner used to fall back to `expr.getText()` when an expression
19
+ * couldn't be resolved, which serialised the identifier name `"inset"`
20
+ * as the attribute value. The plugin's variant engine then treated
21
+ * `props["data-inset"] === "inset"` as "data-inset is present", which
22
+ * activated every `data-[inset]:` utility. The visible symptom: every
23
+ * DropdownMenuItem got `data-[inset]:pl-8` (32px left padding) even when
24
+ * no item was `inset`, leaving them indented like checkbox/radio items.
25
+ *
26
+ * The fix: omit the prop when the expression doesn't resolve. Matches
27
+ * React's runtime behaviour for `data-x={undefined}` — no attribute is
28
+ * emitted, so no variant predicate matches.
29
+ *
30
+ * This file locks the contract at the boundary the variant engine reads
31
+ * from — the props object on the JSX node.
32
+ */
33
+
34
+ interface JsxNodeLike {
35
+ type: 'element' | 'text';
36
+ tagName?: string;
37
+ props?: Record<string, unknown>;
38
+ children?: JsxNodeLike[];
39
+ }
40
+
41
+ interface TestScannerView {
42
+ project: import('ts-morph').Project;
43
+ extractComponentJsxTree: (
44
+ sourceFile: import('ts-morph').SourceFile,
45
+ componentName: string
46
+ ) => JsxNodeLike | null;
47
+ }
48
+
49
+ function makeScanner(): TestScannerView {
50
+ return new ComponentScanner({
51
+ componentPaths: [],
52
+ filePattern: '*.tsx',
53
+ exclude: [],
54
+ }) as unknown as TestScannerView;
55
+ }
56
+
57
+ function fixturePath(relative: string): string {
58
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
59
+ }
60
+
61
+ function findElement(node: JsxNodeLike | null, tag: string): JsxNodeLike | null {
62
+ if (!node || node.type !== 'element') return null;
63
+ if (node.tagName === tag) return node;
64
+ for (const child of node.children || []) {
65
+ const found = findElement(child, tag);
66
+ if (found) return found;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ const scanner = makeScanner();
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Case 1: unresolved boolean-prop identifier — the DropdownMenuItem pattern.
75
+ // `inset` is a destructured parameter with no default. The data-attribute
76
+ // expression `data-inset={inset}` MUST be omitted, not serialised as
77
+ // "inset".
78
+ // ---------------------------------------------------------------------------
79
+ {
80
+ const file = scanner.project.createSourceFile(
81
+ fixturePath('unresolved-data-attr.tsx'),
82
+ `
83
+ // The className would normally include "data-[inset]:pl-8" — omitted
84
+ // from this fixture because the regression asserts on the props map,
85
+ // not on class extraction, and inlining the class string trips the
86
+ // workspace lint (suggestCanonicalClasses) for what is intentionally
87
+ // a verbatim shadcn-shaped fixture.
88
+ export function DropdownMenuItem({ inset }: { inset?: boolean }) {
89
+ return (
90
+ <div
91
+ data-slot="dropdown-menu-item"
92
+ data-inset={inset}
93
+ data-variant="default"
94
+ />
95
+ );
96
+ }
97
+ `,
98
+ { overwrite: true }
99
+ );
100
+
101
+ const tree = scanner.extractComponentJsxTree(file, 'DropdownMenuItem');
102
+ assert.ok(tree, 'DropdownMenuItem must produce a tree');
103
+ const root = findElement(tree, 'div');
104
+ assert.ok(root, 'must find the root <div>');
105
+
106
+ // Literal data-* attrs survive.
107
+ assert.equal(root.props?.['data-slot'], 'dropdown-menu-item', 'literal data-slot preserved');
108
+ assert.equal(root.props?.['data-variant'], 'default', 'literal data-variant preserved');
109
+
110
+ // The unresolved expression must NOT have been serialised as the
111
+ // identifier name. Either the key is absent, or the value is some
112
+ // explicit "absent" sentinel — never the identifier text "inset".
113
+ const insetValue = root.props?.['data-inset'];
114
+ assert.notEqual(
115
+ insetValue,
116
+ 'inset',
117
+ "data-inset must NOT serialise to the unresolved identifier's source text"
118
+ );
119
+ // Stronger contract: the key should be absent. React renders no
120
+ // attribute for `data-x={undefined}`, and that's what the variant
121
+ // engine needs to see for the predicate to NOT match.
122
+ assert.ok(
123
+ !Object.prototype.hasOwnProperty.call(root.props || {}, 'data-inset'),
124
+ 'data-inset key must be omitted when the value expression is unresolved'
125
+ );
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Case 2: unresolved string-prop identifier — same rule, different shape.
130
+ // `<input placeholder={placeholder}>` where `placeholder` is unresolved
131
+ // shouldn't end up with `placeholder="placeholder"` (the identifier name)
132
+ // in the rendered tree.
133
+ // ---------------------------------------------------------------------------
134
+ {
135
+ const file = scanner.project.createSourceFile(
136
+ fixturePath('unresolved-string-attr.tsx'),
137
+ `
138
+ export function Field({ placeholder }: { placeholder?: string }) {
139
+ return <input placeholder={placeholder} />;
140
+ }
141
+ `,
142
+ { overwrite: true }
143
+ );
144
+
145
+ const tree = scanner.extractComponentJsxTree(file, 'Field');
146
+ assert.ok(tree, 'Field must produce a tree');
147
+ const input = findElement(tree, 'input');
148
+ assert.ok(input, 'must find <input>');
149
+ assert.ok(
150
+ !Object.prototype.hasOwnProperty.call(input.props || {}, 'placeholder'),
151
+ 'placeholder key must be omitted when the value identifier is unresolved'
152
+ );
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Case 3: literal string attribute survives — sanity check that this fix
157
+ // only suppresses *unresolved* expressions, not the literal-attr path.
158
+ // ---------------------------------------------------------------------------
159
+ {
160
+ const file = scanner.project.createSourceFile(
161
+ fixturePath('literal-attr.tsx'),
162
+ `
163
+ export function LiteralAttr() {
164
+ return <div data-variant="primary" data-slot="root" />;
165
+ }
166
+ `,
167
+ { overwrite: true }
168
+ );
169
+
170
+ const tree = scanner.extractComponentJsxTree(file, 'LiteralAttr');
171
+ assert.ok(tree, 'LiteralAttr must produce a tree');
172
+ const div = findElement(tree, 'div');
173
+ assert.ok(div, 'must find <div>');
174
+ assert.equal(div.props?.['data-variant'], 'primary', 'literal data-variant survives');
175
+ assert.equal(div.props?.['data-slot'], 'root', 'literal data-slot survives');
176
+ }
177
+
178
+ console.log('jsx-prop-unresolved-regression: PASS (3 cases)');
@@ -0,0 +1,331 @@
1
+ import assert from 'node:assert/strict';
2
+ import * as path from 'node:path';
3
+ import { ComponentScanner } from './component-scanner';
4
+
5
+ /**
6
+ * Regression: function-body-local `const` declarations whose initializer
7
+ * depends on a prop value must be resolved when the identifier is
8
+ * referenced in a child JSX `className`. Without this, the common pattern
9
+ *
10
+ * const StatusPill = ({ sourceLabel }) => {
11
+ * const dotClass =
12
+ * sourceLabel === "db" ? "bg-emerald-500"
13
+ * : sourceLabel === "live" ? "bg-sky-400"
14
+ * : "bg-muted-foreground";
15
+ * return <span className={cn("h-2 w-2 rounded-full", dotClass)} />;
16
+ * };
17
+ *
18
+ * collapses to just `"h-2 w-2 rounded-full"` in the scanned output —
19
+ * the conditional color silently disappears.
20
+ *
21
+ * History: the inkbridge greenhouse-app `DataSourcesCard` shipped this
22
+ * pattern for its source-status pill. Stories passed `overallSourceLabel:
23
+ * "live"`, the runtime evaluated `dotClass = "bg-sky-400"`, but Figma
24
+ * rendered every pill with a black dot (the `bg-muted-foreground` fallback
25
+ * was being picked up incorrectly because the `cn(base, dotClass)` call
26
+ * lost the `dotClass` argument entirely).
27
+ *
28
+ * Root cause: `resolveExpressionValue` looked up identifiers in
29
+ * `propsContext` (function params), then fell back to
30
+ * `SourceFile.getVariableDeclaration` — which only searches MODULE-LEVEL
31
+ * declarations. Function-body `const`s were invisible.
32
+ *
33
+ * Fix: walk ancestor scopes (Block / CaseClause / DefaultClause) from the
34
+ * identifier site outward and resolve the matching `const`'s initializer
35
+ * against the current `propsContext`. This is the universal version of the
36
+ * pattern — applies to any local-const-derived className, not just pill
37
+ * colors.
38
+ */
39
+
40
+ interface JsxNodeLike {
41
+ type: 'element' | 'text';
42
+ tagName?: string;
43
+ content?: string;
44
+ props?: Record<string, unknown>;
45
+ children?: JsxNodeLike[];
46
+ }
47
+
48
+ interface TestScannerView {
49
+ project: import('ts-morph').Project;
50
+ extractComponentJsxTree: (
51
+ sourceFile: import('ts-morph').SourceFile,
52
+ componentName: string,
53
+ ) => JsxNodeLike | null;
54
+ }
55
+
56
+ function makeScanner(): TestScannerView {
57
+ return new ComponentScanner({
58
+ componentPaths: [],
59
+ filePattern: '*.tsx',
60
+ exclude: [],
61
+ }) as unknown as TestScannerView;
62
+ }
63
+
64
+ function fixturePath(relative: string): string {
65
+ return path.resolve(process.cwd(), 'tools/figma-plugin/scanner/__fixtures__', relative);
66
+ }
67
+
68
+ function findElement(node: JsxNodeLike | null, tag: string): JsxNodeLike | null {
69
+ if (!node || node.type !== 'element') return null;
70
+ if (node.tagName === tag) return node;
71
+ for (const child of node.children || []) {
72
+ const found = findElement(child, tag);
73
+ if (found) return found;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ function classList(node: JsxNodeLike | null): string[] {
79
+ const cls = node?.props?.className;
80
+ if (typeof cls !== 'string') return [];
81
+ return cls.split(/\s+/).filter(Boolean);
82
+ }
83
+
84
+ const scanner = makeScanner();
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Case 1: the canonical StatusPill shape. A wrapper component renders a
88
+ // local-defined inner component with a literal prop value. The inner has a
89
+ // local `const dotClass = sourceLabel === "..." ? "class" : ...` whose
90
+ // resolved class must appear in the rendered `<span>`'s className.
91
+ // ---------------------------------------------------------------------------
92
+ {
93
+ const file = scanner.project.createSourceFile(
94
+ fixturePath('local-const-classname-status-pill.tsx'),
95
+ `
96
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
97
+
98
+ const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
99
+ const dotClass =
100
+ sourceLabel === "db"
101
+ ? "bg-emerald-500"
102
+ : sourceLabel === "live"
103
+ ? "bg-sky-400"
104
+ : "bg-muted-foreground";
105
+ return <span data-slot="dot" className={cn("h-2 w-2 rounded-full", dotClass)} />;
106
+ };
107
+
108
+ export function Card() {
109
+ return <StatusPill sourceLabel="live" />;
110
+ }
111
+ `,
112
+ { overwrite: true },
113
+ );
114
+
115
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
116
+ assert.ok(tree, 'Card must produce a tree');
117
+ const dot = findElement(tree, 'span');
118
+ assert.ok(dot, 'must find the rendered <span>');
119
+ const classes = classList(dot);
120
+ assert.ok(
121
+ classes.includes('bg-sky-400'),
122
+ `dot must inherit bg-sky-400 from the "live" branch of the local-const ternary; got: ${classes.join(' ')}`,
123
+ );
124
+ assert.ok(classes.includes('h-2'), 'base utilities must survive cn() resolution');
125
+ assert.ok(classes.includes('rounded-full'), 'base utilities must survive cn() resolution');
126
+ // The non-chosen branches must NOT appear.
127
+ assert.ok(!classes.includes('bg-emerald-500'), 'losing branch "db" must not appear');
128
+ assert.ok(!classes.includes('bg-muted-foreground'), 'losing branch fallback must not appear');
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Case 2: literal pass-through with a different prop name. Verifies the
133
+ // resolver picks the right branch when a wrapper forwards a literal value
134
+ // (the StatusPill shape but a different name, to catch regressions tied to
135
+ // hard-coded identifier matching).
136
+ // ---------------------------------------------------------------------------
137
+ {
138
+ const file = scanner.project.createSourceFile(
139
+ fixturePath('local-const-classname-passthrough.tsx'),
140
+ `
141
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
142
+
143
+ const StatusPill = ({ tone }: { tone: "ok" | "warn" }) => {
144
+ const dotClass = tone === "warn" ? "bg-amber-500" : "bg-emerald-500";
145
+ return <span className={cn("dot", dotClass)} />;
146
+ };
147
+
148
+ export function Card() {
149
+ return <StatusPill tone="warn" />;
150
+ }
151
+ `,
152
+ { overwrite: true },
153
+ );
154
+
155
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
156
+ assert.ok(tree, 'Card must produce a tree');
157
+ const dot = findElement(tree, 'span');
158
+ assert.ok(dot, 'must find the rendered <span>');
159
+ const classes = classList(dot);
160
+ assert.ok(
161
+ classes.includes('bg-amber-500'),
162
+ `the "warn" branch must be selected; got: ${classes.join(' ')}`,
163
+ );
164
+ assert.ok(!classes.includes('bg-emerald-500'), 'the losing branch must not appear');
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // Case 3: forward-reference TDZ. A const declared AFTER the JSX reference
169
+ // site in the same scope MUST NOT be picked up — that would resolve a
170
+ // real TDZ error as if it succeeded, masking bugs.
171
+ // ---------------------------------------------------------------------------
172
+ {
173
+ const file = scanner.project.createSourceFile(
174
+ fixturePath('local-const-classname-tdz.tsx'),
175
+ `
176
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
177
+
178
+ export function Card() {
179
+ // Reference comes BEFORE the declaration — JS would throw at runtime.
180
+ // The scanner must not silently "fix" it.
181
+ // @ts-ignore — intentional TDZ for the test.
182
+ const out = <span className={cn("base", lateClass)} />;
183
+ const lateClass = "should-not-appear";
184
+ return out;
185
+ }
186
+ `,
187
+ { overwrite: true },
188
+ );
189
+
190
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
191
+ assert.ok(tree, 'Card must produce a tree');
192
+ const span = findElement(tree, 'span');
193
+ assert.ok(span, 'must find <span>');
194
+ const classes = classList(span);
195
+ assert.ok(
196
+ !classes.includes('should-not-appear'),
197
+ 'forward-declared const must not be resolved (TDZ semantics)',
198
+ );
199
+ assert.ok(classes.includes('base'), 'literal class survives');
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Case 4: cycle protection. `const A = B` and `const B = A` must not
204
+ // infinite-loop — both should resolve to undefined and the className
205
+ // should fall back to just the literal base.
206
+ // ---------------------------------------------------------------------------
207
+ {
208
+ const file = scanner.project.createSourceFile(
209
+ fixturePath('local-const-classname-cycle.tsx'),
210
+ `
211
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
212
+
213
+ export function Card() {
214
+ const A = B;
215
+ const B = A;
216
+ return <span className={cn("base", A)} />;
217
+ }
218
+ `,
219
+ { overwrite: true },
220
+ );
221
+
222
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
223
+ assert.ok(tree, 'Card must produce a tree');
224
+ const span = findElement(tree, 'span');
225
+ assert.ok(span, 'must find <span>');
226
+ const classes = classList(span);
227
+ assert.ok(classes.includes('base'), 'literal class survives cycle protection');
228
+ // A and B are mutually recursive — neither should resolve to a literal
229
+ // string. The cn() call should silently drop the unresolved arg.
230
+ assert.equal(classes.length, 1, `only the literal "base" should remain; got: ${classes.join(' ')}`);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Case 5: scope-locality. Two functions in the same file both have a local
235
+ // `const colorClass = ...` with different values. The resolver must pick
236
+ // the one in the enclosing scope, not bleed across functions.
237
+ // ---------------------------------------------------------------------------
238
+ {
239
+ const file = scanner.project.createSourceFile(
240
+ fixturePath('local-const-classname-scope.tsx'),
241
+ `
242
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
243
+
244
+ const Inner = () => {
245
+ const colorClass = "text-sky-500";
246
+ return <span data-which="inner" className={cn("a", colorClass)} />;
247
+ };
248
+
249
+ export function Card() {
250
+ const colorClass = "text-rose-500";
251
+ return (
252
+ <div>
253
+ <span data-which="outer" className={cn("b", colorClass)} />
254
+ <Inner />
255
+ </div>
256
+ );
257
+ }
258
+ `,
259
+ { overwrite: true },
260
+ );
261
+
262
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
263
+ assert.ok(tree, 'Card must produce a tree');
264
+
265
+ // Find the two <span>s by their data-which marker.
266
+ function findByMarker(node: JsxNodeLike | null, value: string): JsxNodeLike | null {
267
+ if (!node || node.type !== 'element') return null;
268
+ if (node.props?.['data-which'] === value) return node;
269
+ for (const child of node.children || []) {
270
+ const found = findByMarker(child, value);
271
+ if (found) return found;
272
+ }
273
+ return null;
274
+ }
275
+ const outer = findByMarker(tree, 'outer');
276
+ const inner = findByMarker(tree, 'inner');
277
+ assert.ok(outer, 'outer <span> must be present');
278
+ assert.ok(inner, 'inner <span> must be present');
279
+
280
+ const outerClasses = classList(outer);
281
+ const innerClasses = classList(inner);
282
+ assert.ok(outerClasses.includes('text-rose-500'), `outer scope wins for outer span; got: ${outerClasses.join(' ')}`);
283
+ assert.ok(!outerClasses.includes('text-sky-500'), 'inner-scope value must not bleed into outer span');
284
+ assert.ok(innerClasses.includes('text-sky-500'), `inner scope wins for inner span; got: ${innerClasses.join(' ')}`);
285
+ assert.ok(!innerClasses.includes('text-rose-500'), 'outer-scope value must not be picked when inner scope shadows');
286
+ }
287
+
288
+ // ---------------------------------------------------------------------------
289
+ // Case 6: text-children expressions. `<span>{statusText}</span>` where
290
+ // `statusText` is a local-const ternary must render the resolved text.
291
+ // History: the original DataSourcesCard fix landed for className identifiers
292
+ // but the main `buildJsxTree` flow had a separate, narrower lookup for JSX
293
+ // text-children that only checked module-level vars — so the "Live - DB"
294
+ // label inside the StatusPill was missing even after the dot color worked.
295
+ // ---------------------------------------------------------------------------
296
+ {
297
+ const file = scanner.project.createSourceFile(
298
+ fixturePath('local-const-text-children.tsx'),
299
+ `
300
+ const cn = (...args: any[]) => args.filter(Boolean).join(' ');
301
+
302
+ const StatusPill = ({ sourceLabel }: { sourceLabel: "db" | "live" | "inactive" }) => {
303
+ const statusText =
304
+ sourceLabel === "db"
305
+ ? "Live - DB"
306
+ : sourceLabel === "live"
307
+ ? "Live - API"
308
+ : "Inactive";
309
+ return <span data-slot="label">{statusText}</span>;
310
+ };
311
+
312
+ export function Card() {
313
+ return <StatusPill sourceLabel="db" />;
314
+ }
315
+ `,
316
+ { overwrite: true },
317
+ );
318
+
319
+ const tree = scanner.extractComponentJsxTree(file, 'Card');
320
+ assert.ok(tree, 'Card must produce a tree');
321
+ const span = findElement(tree, 'span');
322
+ assert.ok(span, 'must find <span>');
323
+ const textChildren = (span.children || []).filter((c) => c.type === 'text');
324
+ const combined = textChildren.map((c) => c.content ?? '').join('');
325
+ assert.ok(
326
+ combined.includes('Live - DB'),
327
+ `text child must resolve to "Live - DB" via local-const lookup; got: ${JSON.stringify(textChildren)}`,
328
+ );
329
+ }
330
+
331
+ console.log('local-const-className-regression: PASS (6 cases)');
@@ -96,11 +96,32 @@ assert.equal(parseRingColor('ring-[3px]', colorGroup), null, 'arbitrary widths a
96
96
  }
97
97
 
98
98
  {
99
- // Color without width still produces a ring at the default width (3px).
100
- // This matches Tailwind v3 behavior where `ring-primary` implies `ring`.
99
+ // Color WITHOUT an explicit width is a CSS no-op Tailwind's
100
+ // `ring-COLOR` sets `--tw-ring-color` but the ring stays
101
+ // invisible until a width utility is added. shadcn's
102
+ // invalid-input pattern relies on this:
103
+ // `aria-invalid:ring-destructive/20 focus-visible:ring-[3px]`
104
+ // → tinted ring when invalid, only visible (3px) when focused.
105
+ // Previously this helper defaulted to width 3 when ANY ring color
106
+ // was present — so the State Matrix `error` variant rendered with
107
+ // a doubled red ring outside the destructive border instead of
108
+ // just the border. Now: color-only → null (no ring).
101
109
  const ring = getRingInfoFromClasses(['ring-primary'], colorGroup);
102
- assert.ok(ring, 'color implies default width');
103
- assert.equal(ring!.width, 3, 'default 3px when color is the only signal');
110
+ assert.equal(ring, null, 'color without width → null (no ring)');
111
+ }
112
+
113
+ {
114
+ // Same contract for the shadcn invalid-input pattern: tinted color,
115
+ // no width, no visible ring.
116
+ const ring = getRingInfoFromClasses(['ring-destructive', 'border-destructive'], colorGroup);
117
+ assert.equal(ring, null, 'color + non-ring class without width → null');
118
+ }
119
+
120
+ {
121
+ // Color + explicit width DOES render the ring.
122
+ const ring = getRingInfoFromClasses(['ring-destructive', 'ring-[3px]'], colorGroup);
123
+ assert.ok(ring, 'color + arbitrary width → ring renders');
124
+ assert.equal(ring!.width, 3, '3px from `ring-[3px]`');
104
125
  }
105
126
 
106
127
  {
@@ -134,4 +134,42 @@ const scanner = makeScanner();
134
134
  );
135
135
  }
136
136
 
137
+ // ---------------------------------------------------------------------------
138
+ // Case 4: shadcn-style Label — only carries `peer-disabled:` and
139
+ // `group-data-[disabled=true]:` modifiers (passive reactions to a
140
+ // parent's state, NOT its own state axis). Must NOT be classified as
141
+ // `state` — otherwise the runtime renders it via the state-master
142
+ // path, which has no slot for the label's text children, and a story
143
+ // like `<Dialog>... <Label>Name</Label> <Input/> ...</Dialog>` ends up
144
+ // with the labels invisible. Lock this in so a future widening of the
145
+ // state-modifier list doesn't silently re-break it.
146
+ // ---------------------------------------------------------------------------
147
+ {
148
+ const file = scanner.project.createSourceFile(
149
+ fixturePath('passive-label.tsx'),
150
+ `
151
+ export function Label({ className, ...props }: any) {
152
+ return (
153
+ <label
154
+ className={
155
+ "flex items-center gap-2 text-sm leading-none font-medium select-none " +
156
+ "group-data-[disabled=true]:pointer-events-none " +
157
+ "group-data-[disabled=true]:opacity-50 " +
158
+ "peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
159
+ }
160
+ {...props}
161
+ />
162
+ );
163
+ }
164
+ `,
165
+ { overwrite: true }
166
+ );
167
+ const result = scanner.analyzeState(file, file.getFilePath());
168
+ assert.equal(
169
+ result,
170
+ null,
171
+ 'A component whose only "state" modifiers are passive `group-*:` / `peer-*:` reactions must NOT be classified as `state` — it has no own-element state axis, and the runtime would lose its text children when rendering via the state-master path.',
172
+ );
173
+ }
174
+
137
175
  console.log('state-classification-regression: ok');
@@ -110,4 +110,38 @@ assert.equal(
110
110
  'w-full suppresses stretch (the post-normalisation form of size-full)',
111
111
  );
112
112
 
113
- console.log('stretch-to-parent-width-regression: PASS (14 cases)');
113
+ // DropdownMenuItem class shape: scanner emits these wrappers as
114
+ // `kind: 'component'` with tagName `DropdownMenuPrimitive.Item`. The
115
+ // ui-builder wrapperFrame branch calls `shouldStretchToParentWidth('div',
116
+ // classes)` to mirror the kind:'element' decision — without it, items
117
+ // inside a `w-56` Content stay HUG-width. Lock the predicate so the call
118
+ // site keeps producing true for the canonical Item / Label class lists.
119
+ assert.equal(
120
+ shouldStretchToParentWidth('div', [
121
+ 'focus:bg-accent', 'focus:text-accent-foreground',
122
+ 'relative', 'flex', 'cursor-default', 'items-center', 'gap-2',
123
+ 'rounded-sm', 'px-2', 'py-1.5', 'text-sm',
124
+ 'data-[disabled]:opacity-50', 'data-[inset]:pl-8',
125
+ ]),
126
+ true,
127
+ 'DropdownMenuItem class list → stretches in vertical block-flow parent',
128
+ );
129
+ assert.equal(
130
+ shouldStretchToParentWidth('div', [
131
+ 'px-2', 'py-1.5', 'text-sm', 'font-medium', 'data-[inset]:pl-8',
132
+ ]),
133
+ true,
134
+ 'DropdownMenuLabel class list → stretches in vertical block-flow parent',
135
+ );
136
+ // DropdownMenuContent itself has `min-w-[8rem]` which IS a width signal —
137
+ // so Content does NOT stretch (the consumer's `w-56` sets its width
138
+ // instead). Lock that boundary.
139
+ assert.equal(
140
+ shouldStretchToParentWidth('div', [
141
+ 'bg-popover', 'min-w-[8rem]', 'rounded-md', 'border', 'p-1', 'shadow-md',
142
+ ]),
143
+ false,
144
+ 'DropdownMenuContent (min-w-* present) → does NOT stretch — consumer width wins',
145
+ );
146
+
147
+ console.log('stretch-to-parent-width-regression: PASS (17 cases)');