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,113 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { extractFontName } from '../src/tokens/tokens';
4
+
5
+ /**
6
+ * Regression: `extractFontName` in `src/tokens/tokens.ts` walks a
7
+ * comma-separated font stack and returns the first entry that ISN'T a
8
+ * system keyword / generic family / web-safe fallback / emoji-fallback
9
+ * font. The returned name is what's passed to `figma.loadFontAsync` —
10
+ * a wrong pick (e.g. "Apple Color Emoji" from Tailwind's default sans
11
+ * stack, or "ui-sans-serif" from a stack with no quoted font) throws
12
+ * "couldn't load font" inside the Figma sandbox.
13
+ *
14
+ * Bug that motivated this fixture: Tailwind's default sans-serif
15
+ * value is `ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji',
16
+ * 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'`. The original
17
+ * keyword set didn't include the emoji-fallback entries, so the resolver
18
+ * skipped past the three generic-family entries and picked "Apple Color
19
+ * Emoji" as the body font. The fix adds the four emoji fallbacks to
20
+ * SYSTEM_FONT_KEYWORDS.
21
+ */
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Quoted intended font in a Tailwind-style stack
25
+ // ---------------------------------------------------------------------------
26
+
27
+ assert.equal(
28
+ extractFontName('"Open Sans", ui-sans-serif, system-ui, sans-serif'),
29
+ 'Open Sans',
30
+ 'quoted family wins over generic-family fallbacks',
31
+ );
32
+
33
+ assert.equal(
34
+ extractFontName("'Inter', system-ui, sans-serif"),
35
+ 'Inter',
36
+ 'single-quoted name is unwrapped',
37
+ );
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Tailwind default sans stack — the canonical "emoji bug" case
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const tailwindDefaultSans =
44
+ "ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'";
45
+
46
+ assert.equal(
47
+ extractFontName(tailwindDefaultSans),
48
+ null,
49
+ 'stack of only generic-families + emoji-fallbacks returns null so the caller falls back',
50
+ );
51
+
52
+ // Variants of the same emoji-fallback names with/without quotes
53
+ assert.equal(extractFontName('Apple Color Emoji'), null);
54
+ assert.equal(extractFontName('"Apple Color Emoji"'), null);
55
+ assert.equal(extractFontName("'Segoe UI Emoji'"), null);
56
+ assert.equal(extractFontName('Noto Color Emoji'), null);
57
+ assert.equal(extractFontName('Segoe UI Symbol'), null);
58
+
59
+ // Case-insensitive: extractFontName lowercases before comparing
60
+ assert.equal(extractFontName('APPLE COLOR EMOJI'), null);
61
+
62
+ // Real-world: intended font wins even with the emoji fallbacks appended
63
+ assert.equal(
64
+ extractFontName(`"Open Sans", ${tailwindDefaultSans}`),
65
+ 'Open Sans',
66
+ 'quoted intended font wins over the full Tailwind default tail',
67
+ );
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Generic-family / system keywords are skipped
71
+ // ---------------------------------------------------------------------------
72
+
73
+ assert.equal(extractFontName('ui-sans-serif'), null);
74
+ assert.equal(extractFontName('system-ui'), null);
75
+ assert.equal(extractFontName('sans-serif'), null);
76
+ assert.equal(extractFontName('-apple-system'), null);
77
+ assert.equal(
78
+ extractFontName('Arial, Helvetica, sans-serif'),
79
+ null,
80
+ 'web-safe-only stacks return null — the consumer hadn\'t picked a design font',
81
+ );
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // CSS variable references — return the derived name if no intended font
85
+ // is present, but a real font name still wins
86
+ // ---------------------------------------------------------------------------
87
+
88
+ assert.equal(
89
+ extractFontName('var(--font-open-sans), ui-sans-serif, sans-serif'),
90
+ 'Open Sans',
91
+ 'var(--font-foo) derives a font name when nothing else matches',
92
+ );
93
+
94
+ assert.equal(
95
+ extractFontName('var(--font-geist-mono), monospace'),
96
+ 'Geist Mono',
97
+ );
98
+
99
+ assert.equal(
100
+ extractFontName('"Inter", var(--font-fallback), sans-serif'),
101
+ 'Inter',
102
+ 'real quoted font beats var() fallback',
103
+ );
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Edge cases
107
+ // ---------------------------------------------------------------------------
108
+
109
+ assert.equal(extractFontName(''), null);
110
+ assert.equal(extractFontName(undefined), null);
111
+ assert.equal(extractFontName(null), null);
112
+
113
+ console.log('font-family-extract-regression: ok');
@@ -382,4 +382,99 @@ function findBySlot(root: NodeIR, slot: string): Array<Extract<NodeIR, { kind: '
382
382
  assert.ok(thumbs[0].classes.includes('left-[0%]'), 'no value → thumb at 0%');
383
383
  }
384
384
 
385
- console.log('framework-adapter-shadcn-regression: PASS (3 registry + 4 leaf + 4 avatar + 5 progress + 5 slider cases)');
385
+ // ---------------------------------------------------------------------------
386
+ // (i) Radix ScrollArea — strip runtime control children
387
+ // ---------------------------------------------------------------------------
388
+ //
389
+ // `<ScrollAreaPrimitive.Root>` wraps a viewport + a scrollbar + thumb +
390
+ // corner. The scrollbar/thumb/corner only render at runtime in response
391
+ // to scroll position; in a static design-system render they show up as
392
+ // visible content blobs (a dark capsule over the viewport's content
393
+ // area, which is what greenhouse-app's ScrollArea story exposed). The
394
+ // adapter drops them so only the Viewport survives.
395
+
396
+ function scrollAreaTree(extraScrollbarClasses: string[] = []): NodeIR {
397
+ return el({}, [], [
398
+ {
399
+ kind: 'element',
400
+ tagName: 'ScrollAreaPrimitive.Root',
401
+ tagLower: 'div',
402
+ props: {},
403
+ classes: ['relative', 'overflow-hidden'],
404
+ children: [
405
+ {
406
+ kind: 'element',
407
+ tagName: 'ScrollAreaPrimitive.Viewport',
408
+ tagLower: 'div',
409
+ props: {},
410
+ classes: ['h-full', 'w-full'],
411
+ children: [el({}, ['p-3'], [])],
412
+ },
413
+ {
414
+ kind: 'element',
415
+ tagName: 'ScrollAreaPrimitive.Scrollbar',
416
+ tagLower: 'div',
417
+ props: { orientation: 'vertical' },
418
+ classes: ['flex', 'p-0.5', 'sm:bg-black/60', ...extraScrollbarClasses],
419
+ children: [{
420
+ kind: 'element',
421
+ tagName: 'ScrollAreaPrimitive.Thumb',
422
+ tagLower: 'div',
423
+ props: {},
424
+ classes: ['flex-1', 'rounded-full', 'bg-muted-foreground/40'],
425
+ children: [],
426
+ }],
427
+ },
428
+ {
429
+ kind: 'element',
430
+ tagName: 'ScrollAreaPrimitive.Corner',
431
+ tagLower: 'div',
432
+ props: {},
433
+ classes: [],
434
+ children: [],
435
+ },
436
+ ],
437
+ } as unknown as NodeIR,
438
+ ]);
439
+ }
440
+
441
+ // (i1) Scrollbar + Thumb + Corner are removed; Viewport is flattened
442
+ // (its children become Root's direct children).
443
+ {
444
+ const tree = scrollAreaTree();
445
+ const next = applyShadcnAdapter(tree);
446
+ const root = (next as { children: NodeIR[] }).children[0] as { children: NodeIR[] };
447
+ assert.equal(
448
+ root.children.length,
449
+ 1,
450
+ 'Root should have one child after Viewport flatten (the Viewport content was a single styled div)',
451
+ );
452
+ // The surviving child is the *content of* the Viewport, not the
453
+ // Viewport wrapper itself. The Viewport carried no visual styling
454
+ // worth keeping and Figma was painting it as a ghost-bordered
455
+ // duplicate of Root.
456
+ assert.notEqual(
457
+ (root.children[0] as { tagName?: string }).tagName,
458
+ 'ScrollAreaPrimitive.Viewport',
459
+ 'Viewport wrapper must be flattened — its children move up to Root',
460
+ );
461
+ assert.equal(
462
+ (root.children[0] as { tagName?: string }).tagName,
463
+ 'div',
464
+ 'the surviving child is the original Viewport content (a styled <div>)',
465
+ );
466
+ }
467
+
468
+ // (i2) Viewport content's own children are preserved unchanged.
469
+ {
470
+ const tree = scrollAreaTree();
471
+ const next = applyShadcnAdapter(tree);
472
+ const root = (next as { children: NodeIR[] }).children[0] as { children: { classes: string[] }[] };
473
+ assert.deepEqual(
474
+ root.children[0].classes,
475
+ ['p-3'],
476
+ "the inner content's class list should be preserved verbatim after flatten",
477
+ );
478
+ }
479
+
480
+ console.log('framework-adapter-shadcn-regression: PASS (3 registry + 4 leaf + 4 avatar + 5 progress + 5 slider + 2 scroll-area cases)');
@@ -0,0 +1,110 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { extractGridColumns } from '../src/layout';
4
+
5
+ /**
6
+ * Regression: `extractGridColumns` is the single source of truth for
7
+ * "how many columns does this Tailwind class list specify?" — consumed
8
+ * by both the story-bench and preview-builder reflow pipelines.
9
+ *
10
+ * The function intentionally returns `1` for plain `grid` (no explicit
11
+ * `grid-cols-N`) because Tailwind's default for `display: grid` is
12
+ * single-column flow, and the value is useful for responsive previews
13
+ * (so `sm:grid-cols-3` can flip the layout at a breakpoint). The
14
+ * downstream hazard: `applyGridColumnsIfPossible` would
15
+ * unconditionally flip the frame to `HORIZONTAL` + `WRAP` +
16
+ * `counterAxisSizingMode = AUTO`. A "1-column" reflow on a VERTICAL
17
+ * bench produces a single Hug row of children laid side-by-side
18
+ * instead of stacked.
19
+ *
20
+ * History: this bug class recurred at multiple call sites — every
21
+ * place that forwarded a `cols` value to `applyGridColumnsWithReflow`
22
+ * had to remember the `> 1` guard. preview-builder.ts:668 had the
23
+ * guard; story-builder.ts:491 didn't, so every `<div className="grid
24
+ * w-[Npx] gap-N">` story root rendered as a horizontal Hug-width row.
25
+ * The guard now lives inside `applyGridColumnsIfPossible` itself, so
26
+ * the contract is "forward whatever cols you extracted, the helper
27
+ * decides".
28
+ *
29
+ * This file locks the function's return values so the helper's
30
+ * `cols <= 1` early-return keeps its meaning. Drift in either
31
+ * direction (returning `null` for plain grid, or returning `> 1` for a
32
+ * single-column source) would either silently break responsive
33
+ * previews or re-introduce the horizontal-bench bug.
34
+ */
35
+
36
+ // Plain `grid` with no explicit columns → implicit single-column flow.
37
+ // Call sites MUST guard with `> 1` before applying a column reflow.
38
+ assert.equal(
39
+ extractGridColumns(['grid']),
40
+ 1,
41
+ 'plain `grid` → 1 (Tailwind default single-column flow)',
42
+ );
43
+ assert.equal(
44
+ extractGridColumns(['grid', 'gap-2']),
45
+ 1,
46
+ '`grid gap-N` → 1',
47
+ );
48
+ assert.equal(
49
+ extractGridColumns(['grid', 'w-[360px]', 'gap-2']),
50
+ 1,
51
+ '`grid w-[N] gap-N` → 1 (the FormField story shape)',
52
+ );
53
+ assert.equal(
54
+ extractGridColumns(['inline-grid']),
55
+ 1,
56
+ 'plain `inline-grid` → 1',
57
+ );
58
+
59
+ // Explicit `grid-cols-N` → that number, and downstream reflow IS
60
+ // expected to fire.
61
+ assert.equal(
62
+ extractGridColumns(['grid', 'grid-cols-2']),
63
+ 2,
64
+ '`grid grid-cols-2` → 2',
65
+ );
66
+ assert.equal(
67
+ extractGridColumns(['grid', 'grid-cols-3', 'gap-4']),
68
+ 3,
69
+ '`grid grid-cols-3 gap-N` → 3',
70
+ );
71
+ assert.equal(
72
+ extractGridColumns(['grid', 'grid-cols-12']),
73
+ 12,
74
+ 'multi-digit column count parsed',
75
+ );
76
+
77
+ // No grid at all → null. Call sites short-circuit without ambiguity.
78
+ assert.equal(
79
+ extractGridColumns(['flex', 'flex-col', 'gap-2']),
80
+ null,
81
+ 'flex layouts → null',
82
+ );
83
+ assert.equal(
84
+ extractGridColumns(['p-4', 'rounded-md']),
85
+ null,
86
+ 'no display utility → null',
87
+ );
88
+ assert.equal(
89
+ extractGridColumns([]),
90
+ null,
91
+ 'empty class list → null',
92
+ );
93
+ assert.equal(
94
+ extractGridColumns(undefined),
95
+ null,
96
+ 'undefined input → null',
97
+ );
98
+
99
+ // Responsive `sm:grid-cols-N` without a base `grid-cols-N` — the function
100
+ // reports the responsive column count when the BASE already has `grid`.
101
+ // (The bench renders at `base` width by default, so without an
102
+ // `availableWidth` hint, the function returns the responsive value as
103
+ // the prevailing column count.)
104
+ assert.equal(
105
+ extractGridColumns(['grid', 'sm:grid-cols-3']),
106
+ 3,
107
+ 'responsive grid-cols (no base cols) → responsive value',
108
+ );
109
+
110
+ console.log('grid-cols-extraction-regression: PASS (11 cases)');
@@ -0,0 +1,217 @@
1
+ import assert from 'node:assert/strict';
2
+
3
+ import { transformInputNodes } from '../src/tailwind/node-ir';
4
+ import type { NodeIR } from '../src/tailwind/node-ir';
5
+
6
+ /**
7
+ * Regression: `<input type="range">` is rewritten by `transformInputNodes`
8
+ * in `src/tailwind/node-ir.ts` into a synthetic 3-child tree (track +
9
+ * filled indicator + thumb). Locks:
10
+ *
11
+ * 1. The rewrite fires only for `type="range"` (other input types pass
12
+ * through untouched).
13
+ * 2. Geometry is `((value - min) / (max - min)) * 100`.
14
+ * 3. `defaultValue` is honored when `value` is missing.
15
+ * 4. `disabled` adds `opacity-50` to the wrapper.
16
+ * 5. Sizing classes from the consumer (`w-full`, paddings) survive on
17
+ * the wrapper.
18
+ *
19
+ * Why this fixture exists: a previous version of the plugin had no
20
+ * rendering path for `type="range"`. The default input branch in
21
+ * `ui-builder.ts` reads `value/defaultValue/placeholder` as text — for
22
+ * range that meant either a tiny "5" text node or, more often, an
23
+ * infinite hang as downstream layout deferred-pass solvers stalled on a
24
+ * 0-content frame with `w-full accent-primary`. The transform produces
25
+ * a real visual structure the existing pipeline renders cleanly.
26
+ */
27
+
28
+ // Minimal helpers (props are always stringified by the scanner — mirror
29
+ // that here so assertions match real-world IR input).
30
+
31
+ function inputEl(props: Record<string, string>, classes: string[] = []): NodeIR {
32
+ return {
33
+ kind: 'element',
34
+ tagName: 'input',
35
+ tagLower: 'input',
36
+ props,
37
+ classes,
38
+ children: [],
39
+ };
40
+ }
41
+
42
+ function rewrite(node: NodeIR): NodeIR {
43
+ return transformInputNodes(node);
44
+ }
45
+
46
+ function expectElement(node: NodeIR): Extract<NodeIR, { kind: 'element' }> {
47
+ assert.equal(node.kind, 'element', 'expected element node');
48
+ return node as Extract<NodeIR, { kind: 'element' }>;
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // (1) Bare `<input type="range">` with value/min/max → track + filled + thumb
53
+ // ---------------------------------------------------------------------------
54
+
55
+ {
56
+ const input = inputEl(
57
+ { type: 'range', min: '0', max: '100', value: '25' },
58
+ ['w-full', 'accent-primary'],
59
+ );
60
+ const out = rewrite(input);
61
+ const wrap = expectElement(out);
62
+
63
+ assert.equal(wrap.tagLower, 'div', 'rewrite produces a div wrapper');
64
+ // Structure mirrors shadcn Slider: track (flow child, contains filled),
65
+ // thumb (absolute sibling). Two top-level children, NOT three.
66
+ assert.equal(wrap.children.length, 2, 'wrapper has track + thumb');
67
+
68
+ // Consumer's sizing classes preserved on the wrapper; layout classes added.
69
+ assert.ok(wrap.classes.includes('w-full'), 'preserves consumer w-full');
70
+ assert.ok(wrap.classes.includes('relative'), 'wrapper is relative');
71
+ assert.ok(wrap.classes.includes('flex'), 'wrapper is flex');
72
+ assert.ok(wrap.classes.includes('items-center'), 'wrapper items-center');
73
+ assert.ok(wrap.classes.includes('h-4'), 'wrapper height set');
74
+
75
+ const [track, thumb] = wrap.children.map(expectElement);
76
+
77
+ // Track is a FLOW child (not absolute) — that's what gives the wrapper
78
+ // content to size against. Without this, all-absolute siblings collapse
79
+ // the auto-layout into a 0-content stall.
80
+ assert.ok(!track.classes.includes('absolute'), 'track is flow, not absolute');
81
+ assert.ok(track.classes.includes('w-full'), 'track full-width');
82
+ assert.ok(track.classes.includes('overflow-hidden'), 'track clips its fill');
83
+ assert.ok(track.classes.includes('bg-secondary'), 'track background neutral');
84
+
85
+ // Filled portion is a flow child of the track (not a sibling of it).
86
+ // No absolute positioning — sits at the left edge by natural flow.
87
+ assert.equal(track.children.length, 1, 'track has one filled child');
88
+ const filled = expectElement(track.children[0]);
89
+ assert.ok(!filled.classes.includes('absolute'), 'filled is flow inside track');
90
+ assert.ok(filled.classes.includes('w-[25%]'), 'filled width matches value');
91
+ assert.ok(filled.classes.includes('bg-primary'), 'filled paints accent');
92
+
93
+ // Thumb: absolute sibling positioned at 25% with -translate-x-1/2.
94
+ // No top-N — wrapper's h-4 + flex items-center centers via static position.
95
+ assert.ok(thumb.classes.includes('absolute'), 'thumb is absolute');
96
+ assert.ok(thumb.classes.includes('left-[25%]'), 'thumb at 25%');
97
+ assert.ok(thumb.classes.includes('-translate-x-1/2'), 'thumb centered on point');
98
+ assert.ok(thumb.classes.includes('rounded-full'), 'thumb circular');
99
+ assert.ok(!thumb.classes.some((c) => c.startsWith('top-')),
100
+ 'thumb has no top-N — flex items-center on wrapper handles vertical centering');
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // (2) Non-zero min — the case from greenhouse-app LeverageSlider
105
+ // ---------------------------------------------------------------------------
106
+
107
+ {
108
+ // ((5 - 1.1) / (100 - 1.1)) * 100 ≈ 3.9434...
109
+ const input = inputEl({ type: 'range', min: '1.1', max: '100', value: '5' });
110
+ const out = rewrite(input);
111
+ const wrap = expectElement(out);
112
+ const track = expectElement(wrap.children[0]);
113
+ const filled = expectElement(track.children[0]);
114
+ const thumb = expectElement(wrap.children[1]);
115
+
116
+ // Match the value the transform actually emits (two-decimal precision,
117
+ // trailing-zero strip). Locking exact percentage prevents silent
118
+ // rounding drift.
119
+ const widthClass = filled.classes.find((c) => c.startsWith('w-['));
120
+ assert.equal(widthClass, 'w-[3.94%]', `got ${widthClass}`);
121
+
122
+ const leftClass = thumb.classes.find((c) => c.startsWith('left-['));
123
+ assert.equal(leftClass, 'left-[3.94%]', `got ${leftClass}`);
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // (3) `defaultValue` honored when `value` missing
128
+ // ---------------------------------------------------------------------------
129
+
130
+ {
131
+ const input = inputEl({ type: 'range', min: '0', max: '100', defaultValue: '75' });
132
+ const out = rewrite(input);
133
+ const wrap = expectElement(out);
134
+ const track = expectElement(wrap.children[0]);
135
+ const filled = expectElement(track.children[0]);
136
+
137
+ assert.ok(filled.classes.includes('w-[75%]'), 'defaultValue drives width');
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // (4) `disabled` adds opacity-50 to wrapper
142
+ // ---------------------------------------------------------------------------
143
+
144
+ {
145
+ const input = inputEl({ type: 'range', min: '0', max: '100', value: '50', disabled: 'true' });
146
+ const out = rewrite(input);
147
+ const wrap = expectElement(out);
148
+
149
+ assert.ok(wrap.classes.includes('opacity-50'), 'disabled adds opacity-50');
150
+ }
151
+
152
+ {
153
+ // disabled="false" should NOT trigger (some libs serialize false this way).
154
+ const input = inputEl({ type: 'range', min: '0', max: '100', value: '50', disabled: 'false' });
155
+ const out = rewrite(input);
156
+ const wrap = expectElement(out);
157
+
158
+ assert.ok(!wrap.classes.includes('opacity-50'), 'disabled=false does not dim');
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // (5) Non-range inputs pass through untouched
163
+ // ---------------------------------------------------------------------------
164
+
165
+ {
166
+ const before = inputEl({ type: 'text', placeholder: 'Name' }, ['h-10']);
167
+ const after = rewrite(before);
168
+ // Identity is preserved for non-range inputs (cache-friendly).
169
+ assert.equal(after, before, 'type=text returned by reference');
170
+ }
171
+
172
+ {
173
+ const before = inputEl({ type: 'number', value: '5' }, ['h-10']);
174
+ const after = rewrite(before);
175
+ assert.equal(after, before, 'type=number returned by reference');
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // (6) Missing value defaults to 0%
180
+ // ---------------------------------------------------------------------------
181
+
182
+ {
183
+ const input = inputEl({ type: 'range', min: '0', max: '100' });
184
+ const out = rewrite(input);
185
+ const wrap = expectElement(out);
186
+ const track = expectElement(wrap.children[0]);
187
+ const filled = expectElement(track.children[0]);
188
+
189
+ assert.ok(filled.classes.includes('w-[0%]'), 'missing value → 0% fill');
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // (7) Range inside a larger tree gets rewritten in place
194
+ // ---------------------------------------------------------------------------
195
+
196
+ {
197
+ const tree: NodeIR = {
198
+ kind: 'element',
199
+ tagName: 'div',
200
+ tagLower: 'div',
201
+ props: {},
202
+ classes: ['p-4'],
203
+ children: [
204
+ inputEl({ type: 'range', min: '0', max: '100', value: '40' }, ['w-full']),
205
+ ],
206
+ };
207
+ const out = rewrite(tree);
208
+ const outer = expectElement(out);
209
+ assert.equal(outer.children.length, 1);
210
+ const slider = expectElement(outer.children[0]);
211
+ assert.equal(slider.children.length, 2, 'nested range rewritten (track + thumb)');
212
+ const track = expectElement(slider.children[0]);
213
+ const filled = expectElement(track.children[0]);
214
+ assert.ok(filled.classes.includes('w-[40%]'), 'nested range geometry correct');
215
+ }
216
+
217
+ console.log('input-range-regression: ok');