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.
- package/README.md +2 -1
- package/bin/inkbridge.mjs +64 -9
- package/code.js +11 -11
- package/package.json +8 -2
- package/scanner/adapter-utils-regression.ts +159 -0
- package/scanner/component-scanner.ts +276 -19
- package/scanner/font-family-extract-regression.ts +113 -0
- package/scanner/framework-adapter-shadcn-regression.ts +96 -1
- package/scanner/grid-cols-extraction-regression.ts +110 -0
- package/scanner/input-range-regression.ts +217 -0
- package/scanner/jsx-prop-unresolved-regression.ts +178 -0
- package/scanner/local-const-className-regression.ts +331 -0
- package/scanner/ring-utility-regression.ts +25 -4
- package/scanner/state-classification-regression.ts +38 -0
- package/scanner/stretch-to-parent-width-regression.ts +35 -1
- package/scanner/tailwind-parser.ts +38 -2
- package/src/components/component-gen.ts +11 -151
- package/src/design-system/cva-master.ts +7 -3
- package/src/design-system/design-system.ts +8 -0
- package/src/design-system/node-helpers.ts +15 -1
- package/src/design-system/preview-builder.ts +14 -45
- package/src/design-system/state-master.ts +23 -1
- package/src/design-system/story-builder.ts +55 -5
- package/src/design-system/ui-builder.ts +116 -6
- package/src/framework-adapters/index.ts +15 -2
- package/src/framework-adapters/shadcn.ts +83 -67
- package/src/layout/deferred-layout.ts +187 -1
- package/src/layout/layout-utils.ts +2 -1
- package/src/layout/ring-utils.ts +31 -82
- package/src/render-engine-version.ts +1 -1
- package/src/tailwind/adapter-utils.ts +137 -0
- package/src/tailwind/jsx-utils.ts +9 -0
- package/src/tailwind/node-ir.ts +172 -0
- package/src/tailwind/tailwind.ts +23 -16
- package/src/tokens/tokens.ts +11 -3
- 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
|
-
|
|
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');
|